Compare commits
No commits in common. "a559e92d3e96bd882003b193b1511ee9f2da46c4" and "532fc4ec230e2e9f5866dc46275e0e5f4e42f1a3" have entirely different histories.
a559e92d3e
...
532fc4ec23
|
@ -10,7 +10,6 @@
|
||||||
"gnudb",
|
"gnudb",
|
||||||
"newfn",
|
"newfn",
|
||||||
"RDONLY",
|
"RDONLY",
|
||||||
"cdparanoia",
|
|
||||||
"TTITLE"
|
"TTITLE"
|
||||||
]
|
]
|
||||||
}
|
}
|
13
Dockerfile
13
Dockerfile
|
@ -7,17 +7,24 @@ RUN true \
|
||||||
&& sed -i 's/main$/main contrib non-free/' /etc/apt/sources.list \
|
&& sed -i 's/main$/main contrib non-free/' /etc/apt/sources.list \
|
||||||
&& apt-get -y update \
|
&& apt-get -y update \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends -y install \
|
&& DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends -y install \
|
||||||
|
ffmpeg \
|
||||||
|
handbrake-cli libavcodec-extra \
|
||||||
|
abcde eyed3 \
|
||||||
|
glyrc setcd eject \
|
||||||
dvdbackup \
|
dvdbackup \
|
||||||
libdvd-pkg libdvdcss2 \
|
libdvd-pkg libdvdcss2 \
|
||||||
handbrake-cli libavcodec-extra \
|
|
||||||
cd-discid cdparanoia lame \
|
|
||||||
python3 \
|
python3 \
|
||||||
python3-slugify \
|
cowsay \
|
||||||
&& true
|
&& true
|
||||||
RUN dpkg-reconfigure libdvd-pkg
|
RUN dpkg-reconfigure libdvd-pkg
|
||||||
|
|
||||||
RUN true \
|
RUN true \
|
||||||
&& DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends -y install \
|
&& DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends -y install \
|
||||||
|
lame \
|
||||||
|
busybox \
|
||||||
|
jq \
|
||||||
|
procps \
|
||||||
|
moreutils \
|
||||||
cowsay
|
cowsay
|
||||||
|
|
||||||
COPY src/* /app/
|
COPY src/* /app/
|
||||||
|
|
57
README.md
57
README.md
|
@ -11,23 +11,9 @@ and then re-encode the content to a compressed format.
|
||||||
|
|
||||||
At the time I'm writing this README, it will:
|
At the time I'm writing this README, it will:
|
||||||
|
|
||||||
* Rip audio CDs, look them up in cddb, encode them to VBR MP3, then tag them.
|
* ~~Rip audio CDs, look them up in cddb, encode them to VBR MP3, then tag them.~~ A rewrite broke this; I plan to fix it soon.
|
||||||
* It also writes a shell script you can modify to quickly change the tags, since this is a pretty common thing to want to do.
|
|
||||||
* Rip video DVDs, transcode them to mkv
|
* Rip video DVDs, transcode them to mkv
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
The requirements are fairly light: a few CD tools, cdparanoia, HandBrakeCLI, and some
|
|
||||||
DVD libraries.
|
|
||||||
|
|
||||||
Most notably, you do *not* need a relational database (SQLite, Postgres, MySQL).
|
|
||||||
You just need a file system.
|
|
||||||
|
|
||||||
For a complete list of requirements,
|
|
||||||
look at the [Dockerfile](Dockerfile)
|
|
||||||
to see what Debian packages it installs.
|
|
||||||
|
|
||||||
|
|
||||||
## How To Run This
|
## How To Run This
|
||||||
|
|
||||||
You need a place to store your stuff.
|
You need a place to store your stuff.
|
||||||
|
@ -41,8 +27,9 @@ Mine is `/srv/ext/incoming`.
|
||||||
-v /srv/ext/incoming:/incoming \
|
-v /srv/ext/incoming:/incoming \
|
||||||
registry.gitlab.com/dartcatcher/media-sucker/media-sucker
|
registry.gitlab.com/dartcatcher/media-sucker/media-sucker
|
||||||
|
|
||||||
I can't get it to work with docker swarm,
|
I can't get it to work with docker swarm.
|
||||||
which doesn't support `--device`.
|
Presumably some magic is happening with `--device`.
|
||||||
|
It probably has something to do with selinux.
|
||||||
|
|
||||||
Stick a video DVD or audio CD in,
|
Stick a video DVD or audio CD in,
|
||||||
and the drive should spin up for a while,
|
and the drive should spin up for a while,
|
||||||
|
@ -52,14 +39,9 @@ or a new directory of `.mp3` files (for audio).
|
||||||
|
|
||||||
You can watch what it's doing at http://localhost:8080/
|
You can watch what it's doing at http://localhost:8080/
|
||||||
|
|
||||||
|
|
||||||
## A note on filenames and tags
|
## A note on filenames and tags
|
||||||
|
|
||||||
This program does the absolute minimum to try and tag your media properly.
|
This program does the absolute minimum to try and tag your media properly.
|
||||||
Partly because I'm a lazy programmer,
|
|
||||||
but mostly because the computer can only guess at things that you,
|
|
||||||
the operator,
|
|
||||||
can just read off the box.
|
|
||||||
|
|
||||||
For DVDs, that means reading the "title" stored on the DVD,
|
For DVDs, that means reading the "title" stored on the DVD,
|
||||||
which I've seen vary from very helpful (eg. "Barbie A Fashion Fairytale")
|
which I've seen vary from very helpful (eg. "Barbie A Fashion Fairytale")
|
||||||
|
@ -73,10 +55,13 @@ so CDDB takes the length of every track in seconds and tries to match that
|
||||||
against something a user has uploaded in the past.
|
against something a user has uploaded in the past.
|
||||||
This is wrong a whole lot of the time.
|
This is wrong a whole lot of the time.
|
||||||
|
|
||||||
But the end result in almost every case is that you're going to have to
|
If CDDB can't find a match for an audio CD,
|
||||||
rename the movie file, or re-tag the audio files.
|
this program will append the datestamp of the rip to the album name,
|
||||||
This is why you get a `tag.sh` file with every audio CD rip.
|
in the hopes that you can remember about what time you put each CD in the drive.
|
||||||
|
So for stuff like multi-CD audiobooks, that's pretty helpful.
|
||||||
|
|
||||||
|
But the end result in almost every case is that you're going to have to
|
||||||
|
manually edit the metadata.
|
||||||
|
|
||||||
## Answers
|
## Answers
|
||||||
|
|
||||||
|
@ -84,23 +69,35 @@ I'm skipping the part where I make up questions I think people might have.
|
||||||
|
|
||||||
### Why I Wrote This
|
### Why I Wrote This
|
||||||
|
|
||||||
The automatic-ripping-machine looks really badass.
|
The `automatic-ripping-machine` looks really badass.
|
||||||
But after multiple attempts across multiple months
|
But after multiple attempts across multiple months
|
||||||
to get it running,
|
to get it running,
|
||||||
I decided it would probably be faster just to write my own.
|
I decided it would probably be faster just to write my own.
|
||||||
|
|
||||||
media-sucker isn't as cool as the automatic-ripping-machine.
|
This isn't as cool as the aumomatic-ripping-machine.
|
||||||
But, at least for me,
|
But, at least for me,
|
||||||
it's more useful,
|
it's a lot more functional,
|
||||||
in that I can get it to actually do something.
|
in that it actually does something.
|
||||||
|
|
||||||
### Why You Should Run This
|
### Why You Should Run This
|
||||||
|
|
||||||
The only reason I can think of that anybody would want to use this is if they,
|
The only reason I can think of that anybody would want to use this is if they,
|
||||||
like me,
|
like me,
|
||||||
are too dumb to get the automatic-ripping-machine to work.
|
are too dumb to get the `automatic-ripping-machine` to work.
|
||||||
|
|
||||||
### What Kind Of Hardware I Use
|
### What Kind Of Hardware I Use
|
||||||
|
|
||||||
I run it on a Raspberry Pi 4,
|
I run it on a Raspberry Pi 4,
|
||||||
with a Samsung DVD drive from the stone age.
|
with a Samsung DVD drive from the stone age.
|
||||||
|
|
||||||
|
|
||||||
|
## Parting note
|
||||||
|
|
||||||
|
As of 2022-08-22, large sections of this code were written under COVID brain-fog.
|
||||||
|
This means it's going to look a lot like a 13-year-old wrote it.
|
||||||
|
|
||||||
|
I hope one day to clean it up a bit,
|
||||||
|
but it's working fairly well,
|
||||||
|
despite the mess.
|
||||||
|
Please don't judge me for the organization of things.
|
||||||
|
Judge bizarro universe Neale instead.
|
||||||
|
|
|
@ -1,54 +0,0 @@
|
||||||
# Web Server
|
|
||||||
|
|
||||||
There is one web server,
|
|
||||||
which provides static content,
|
|
||||||
and a single entrypoint for dynamic state information.
|
|
||||||
|
|
||||||
The static content is some HTML and JavaScript,
|
|
||||||
which the browser runs to pull the dynamic state,
|
|
||||||
and update the page with current status of everything.
|
|
||||||
|
|
||||||
|
|
||||||
# Workers
|
|
||||||
|
|
||||||
There are at least two Workers:
|
|
||||||
a Reader and an Encoder.
|
|
||||||
Each Worker runs in its own thread,
|
|
||||||
and can do its job without interfering with another Worker.
|
|
||||||
|
|
||||||
## Readers
|
|
||||||
|
|
||||||
Readers monitor a device for media.
|
|
||||||
Right now, those devices are always CD-ROM drives.
|
|
||||||
As soon as media is inserted,
|
|
||||||
a MediaHandler is created to scan and then copy it.
|
|
||||||
|
|
||||||
## Encoders
|
|
||||||
|
|
||||||
Encoders wait for jobs to show up,
|
|
||||||
and then they re-invoke a MediaHandler to encode everything in that job.
|
|
||||||
|
|
||||||
|
|
||||||
# MediaHandlers
|
|
||||||
|
|
||||||
MediaHandlers have a work directory,
|
|
||||||
where they store all their stuff.
|
|
||||||
They have the following stages of execution:
|
|
||||||
|
|
||||||
1. *scan* the media to figure out its title, list of tracks, and other metadata
|
|
||||||
2. *copy* the media to the work directory
|
|
||||||
3. *encode* the work directory into the desired format (eg. MP3, MKV)
|
|
||||||
4. *clean* the work directory
|
|
||||||
|
|
||||||
Before each step,
|
|
||||||
state is read out of the work directory.
|
|
||||||
|
|
||||||
During each step,
|
|
||||||
a MediaHandler continually updates its Worker with a completion percentage.
|
|
||||||
This is passed up to the Web Server's dynamic state.
|
|
||||||
|
|
||||||
After each step,
|
|
||||||
a MediaHandler updates its state,
|
|
||||||
which is stored on disk.
|
|
||||||
The only way to communicate state between execution stages is by writing to disk.
|
|
||||||
This provides some tolerance of job interruption, power loss, etc.
|
|
128
src/cd.py
128
src/cd.py
|
@ -13,7 +13,7 @@ SECOND = 1
|
||||||
MINUTE = 60 * SECOND
|
MINUTE = 60 * SECOND
|
||||||
HOUR = 60 * MINUTE
|
HOUR = 60 * MINUTE
|
||||||
|
|
||||||
def scan(state, device):
|
def read(device, status):
|
||||||
# Get disc ID
|
# Get disc ID
|
||||||
p = subprocess.run(
|
p = subprocess.run(
|
||||||
[
|
[
|
||||||
|
@ -23,9 +23,8 @@ def scan(state, device):
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
discid = p.stdout.strip()
|
discid = p.stdout
|
||||||
state["discid"] = discid
|
status["discid"] = discid
|
||||||
cddb_id = discid.split()[0]
|
|
||||||
|
|
||||||
# Look it up in cddb
|
# Look it up in cddb
|
||||||
email = os.environ.get("EMAIL") # You should really set this variable, tho
|
email = os.environ.get("EMAIL") # You should really set this variable, tho
|
||||||
|
@ -43,23 +42,22 @@ def scan(state, device):
|
||||||
# We're expected to be automatic here,
|
# We're expected to be automatic here,
|
||||||
# so just use the first one.
|
# so just use the first one.
|
||||||
for k in ("title", "artist", "genre", "year", "tracks"):
|
for k in ("title", "artist", "genre", "year", "tracks"):
|
||||||
state[k] = disc[k]
|
status[k] = disc[k]
|
||||||
else:
|
else:
|
||||||
|
now = time.strftime("%Y-%m-%dT%H%M%S")
|
||||||
num_tracks = int(discid.split()[1])
|
num_tracks = int(discid.split()[1])
|
||||||
state["title"] = "Unknown CD - %s" % cddb_id
|
status["title"] = "Unknown CD - %s" % now
|
||||||
state["tracks"] = ["Track %02d" % (i+1) for i in range(num_tracks)]
|
status["tracks"] = [""] * num_tracks
|
||||||
|
|
||||||
|
def rip(device, status, directory):
|
||||||
def copy(state, device, directory):
|
|
||||||
# cdparanoia reports completion in samples
|
# cdparanoia reports completion in samples
|
||||||
# use discid duration to figure out total number of samples
|
# use discid duration to figure out total number of samples
|
||||||
duration = int(state["discid"].split()[-1]) * SECOND # disc duration in seconds
|
duration = int(status["discid"].split()[-1]) * SECOND # disc duration in seconds
|
||||||
total_samples = duration * (75 / SECOND) * 1176 # 75 sectors per second, 1176 samples per sector
|
total_samples = duration * (75 / SECOND) * 1176 # 75 sectors per second, 1176 samples per sector
|
||||||
state["total_samples"] = total_samples
|
|
||||||
|
|
||||||
track_num = 1
|
track_num = 1
|
||||||
for track_name in state["tracks"]:
|
for track_name in status["tracks"]:
|
||||||
logging.debug("Ripping track %d of %d", track_num, len(state["tracks"]))
|
logging.debug("Ripping track %d of %d", track_num, len(status["tracks"]))
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
[
|
[
|
||||||
"cdparanoia",
|
"cdparanoia",
|
||||||
|
@ -77,110 +75,56 @@ def copy(state, device, directory):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith("##: -2"):
|
if line.startswith("##: -2"):
|
||||||
samples = int(line.split()[-1])
|
samples = int(line.split()[-1])
|
||||||
yield samples / total_samples
|
status["complete"] = samples / total_samples
|
||||||
|
|
||||||
track_num += 1
|
track_num += 1
|
||||||
|
|
||||||
|
def encode(status, directory):
|
||||||
def encode(state, directory):
|
# Encode the tracks
|
||||||
track_num = 1
|
track_num = 1
|
||||||
total_tracks = len(state["tracks"])
|
for track_name in status["tracks"]:
|
||||||
durations = [int(d) for d in state["discid"].split()[2:-1]]
|
|
||||||
total_duration = sum(durations)
|
|
||||||
encoded_duration = 0
|
|
||||||
|
|
||||||
tag_script = io.StringIO()
|
|
||||||
tag_script.write("#! /bin/sh\n")
|
|
||||||
tag_script.write("\n")
|
|
||||||
tag_script.write("ALBUM=%s\n" % state["title"])
|
|
||||||
tag_script.write("ARTIST=%s\n" % state.get("artist", ""))
|
|
||||||
tag_script.write("GENRE=%s\n" % state.get("genre", ""))
|
|
||||||
tag_script.write("YEAR=%s\n" % state.get("year", ""))
|
|
||||||
tag_script.write("\n")
|
|
||||||
|
|
||||||
for track_name in state["tracks"]:
|
|
||||||
logging.debug("Encoding track %d (%s)" % (track_num, track_name))
|
|
||||||
duration = durations[track_num-1]
|
|
||||||
argv = [
|
argv = [
|
||||||
"lame",
|
"lame",
|
||||||
"--brief",
|
|
||||||
"--nohist",
|
|
||||||
"--disptime", "1",
|
|
||||||
"--preset", "standard",
|
"--preset", "standard",
|
||||||
"--tl", state["title"],
|
"-tl", status["title"],
|
||||||
"--tn", "%d/%d" % (track_num, total_tracks),
|
"--tn", "%d/%d" % (track_num, len(status["tracks"])),
|
||||||
]
|
]
|
||||||
tag_script.write("id3v2")
|
if status["artist"]:
|
||||||
tag_script.write(" --album \"$ALBUM\"")
|
argv.extend(["-ta", status["artist"]])
|
||||||
tag_script.write(" --artist \"$ARTIST\"")
|
if status["genre"]:
|
||||||
tag_script.write(" --genre \"$GENRE\"")
|
argv.extend(["-tg", status["genre"]])
|
||||||
tag_script.write(" --year \"$YEAR\"")
|
if status["year"]:
|
||||||
if state.get("artist"):
|
argv.extend(["-ty", status["year"]])
|
||||||
argv.extend(["--ta", state["artist"]])
|
|
||||||
if state.get("genre"):
|
|
||||||
argv.extend(["--tg", state["genre"]])
|
|
||||||
if state.get("year"):
|
|
||||||
argv.extend(["--ty", state["year"]])
|
|
||||||
if track_name:
|
if track_name:
|
||||||
argv.extend(["--tt", track_name])
|
argv.extend(["-tt", track_name])
|
||||||
tag_script.write(" --song \"%s\"" % track_name)
|
outfn = "%d - %s.mp3" % (track_num, track_name)
|
||||||
outfn = "%02d - %s.mp3" % (track_num, track_name)
|
|
||||||
else:
|
else:
|
||||||
outfn = "%02d.mp3" % track_num
|
outfn = "%d.mp3" % track_num
|
||||||
argv.append("track%02d.cdda.wav" % track_num)
|
argv.append("track%02d.cdda.wav" % track_num)
|
||||||
argv.append(outfn)
|
argv.append(outfn)
|
||||||
tag_script.write("\\\n ")
|
|
||||||
tag_script.write(" --track %d/%d" % (track_num, total_tracks))
|
|
||||||
tag_script.write(" \"%s\"\n" % outfn)
|
|
||||||
|
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
argv,
|
argv,
|
||||||
cwd = directory,
|
cwd = directory,
|
||||||
stderr = subprocess.PIPE,
|
stdin = subprocess.PIPE,
|
||||||
encoding = "utf-8",
|
encoding = "utf-8",
|
||||||
)
|
)
|
||||||
for line in p.stderr:
|
p.communicate(input=track_name)
|
||||||
line = line.strip()
|
|
||||||
if "%)" in line:
|
|
||||||
p = line.split("(")[1]
|
|
||||||
p = p.split("%")[0]
|
|
||||||
pct = int(p) / 100
|
|
||||||
yield (encoded_duration + (duration * pct)) / total_duration
|
|
||||||
|
|
||||||
encoded_duration += duration
|
|
||||||
track_num += 1
|
track_num += 1
|
||||||
|
|
||||||
with open(os.path.join(directory, "tag.sh"), "w") as f:
|
|
||||||
f.write(tag_script.getvalue())
|
|
||||||
|
|
||||||
|
|
||||||
def clean(state, directory):
|
|
||||||
for fn in os.listdir(directory):
|
|
||||||
if fn.endswith(".wav"):
|
|
||||||
os.remove(os.path.join(directory, fn))
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import pprint
|
import pprint
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
status = {}
|
||||||
|
read("/dev/sr0", status)
|
||||||
|
pprint.pprint(status)
|
||||||
|
|
||||||
state = {}
|
directory = os.path.join(".", status["title"])
|
||||||
scan(state, "/dev/sr0")
|
|
||||||
pprint.pprint(state)
|
|
||||||
|
|
||||||
directory = os.path.join(".", state["title"])
|
|
||||||
os.makedirs(directory, exist_ok=True)
|
os.makedirs(directory, exist_ok=True)
|
||||||
with open(os.path.join(directory, "state.json"), "w") as f:
|
rip("/dev/sr0", status, directory)
|
||||||
json.dump(f, state)
|
pprint.pprint(status)
|
||||||
|
|
||||||
for pct in copy(state, "/dev/sr0", directory):
|
encode(status, directory)
|
||||||
sys.stdout.write("Copying: %3d%%\r" % (pct*100))
|
pprint.pprint(status)
|
||||||
pprint.pprint(state)
|
|
||||||
|
|
||||||
for pct in encode(state, directory):
|
|
||||||
sys.stdout.write("Encoding: %3d%%\r" % (pct*100))
|
|
||||||
pprint.pprint(state)
|
|
||||||
|
|
||||||
# vi: sw=4 ts=4 et ai
|
# vi: sw=4 ts=4 et ai
|
||||||
|
|
276
src/dvd.py
276
src/dvd.py
|
@ -10,151 +10,165 @@ SECOND = 1
|
||||||
MINUTE = 60 * SECOND
|
MINUTE = 60 * SECOND
|
||||||
HOUR = 60 * MINUTE
|
HOUR = 60 * MINUTE
|
||||||
|
|
||||||
def collect(collection, track):
|
class Copier:
|
||||||
newCollection = []
|
def __init__(self, device, status):
|
||||||
for t in collection:
|
self.device = device
|
||||||
if t["length"] == track["length"]:
|
self.status = status
|
||||||
# If the length is exactly the same,
|
self.scan()
|
||||||
# assume it's the same track,
|
|
||||||
# and pick the one with the most stuff.
|
def collect(self, track):
|
||||||
if len(track["audio"]) < len(t["audio"]):
|
newCollection = []
|
||||||
return collection
|
for t in self.collection:
|
||||||
elif len(track["subp"]) < len(t["subp"]):
|
if t["length"] == track["length"]:
|
||||||
return collection
|
# If the length is exactly the same,
|
||||||
newCollection.append(t)
|
# assume it's the same track,
|
||||||
newCollection.append(track)
|
# and pick the one with the most stuff.
|
||||||
return newCollection
|
if len(track["audio"]) < len(t["audio"]):
|
||||||
|
return
|
||||||
|
elif len(track["subp"]) < len(t["subp"]):
|
||||||
|
return
|
||||||
|
newCollection.append(t)
|
||||||
|
newCollection.append(track)
|
||||||
|
self.collection = newCollection
|
||||||
|
|
||||||
|
def scan(self):
|
||||||
|
self.status["state"] = "scanning"
|
||||||
|
|
||||||
|
self.collection = []
|
||||||
|
p = subprocess.run(
|
||||||
|
[
|
||||||
|
"lsdvd",
|
||||||
|
"-Oy",
|
||||||
|
"-x",
|
||||||
|
self.device,
|
||||||
|
],
|
||||||
|
encoding="utf-8",
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
lsdvd = eval(p.stdout[8:]) # s/^lsdvd = //
|
||||||
|
title = lsdvd["title"]
|
||||||
|
if title in ('No', 'unknown'):
|
||||||
|
title = lsdvd["provider_id"]
|
||||||
|
if title == "$PACKAGE_STRING":
|
||||||
|
title = "DVD"
|
||||||
|
now = time.strftime("%Y-%m-%dT%H%M%S")
|
||||||
|
title = "%s %s" % (title, now)
|
||||||
|
|
||||||
|
# Go through all the tracks, looking for the largest referenced sector.
|
||||||
|
max_sector = 0
|
||||||
|
max_length = 0
|
||||||
|
tracks = lsdvd["track"]
|
||||||
|
for track in tracks:
|
||||||
|
max_length = max(track["length"], max_length)
|
||||||
|
for cell in track["cell"]:
|
||||||
|
max_sector = max(cell["last_sector"], max_sector)
|
||||||
|
if max_sector == 0:
|
||||||
|
logging.info("Media size = 0; aborting")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Make a guess about what's on this DVD.
|
||||||
|
# We will categories into three types:
|
||||||
|
# * A feature, which has one track much longer than any other
|
||||||
|
# * A collection of shows, which has several long tracks, more or less the same lengths
|
||||||
|
# * Something else
|
||||||
|
for track in tracks:
|
||||||
|
if track["length"] / max_length > 0.80:
|
||||||
|
self.collect(track)
|
||||||
|
if (max_length < 20 * MINUTE) and (len(self.collection) < len(track) * 0.6):
|
||||||
|
self.collection = tracks
|
||||||
|
|
||||||
|
self.status["title"] = title
|
||||||
|
self.status["size"] = max_sector * 2048 # DVD sector size = 2048
|
||||||
|
self.status["tracks"] = [(t["ix"], t["length"]) for t in self.collection]
|
||||||
|
|
||||||
|
|
||||||
def scan(state, device):
|
def copy(self, directory):
|
||||||
p = subprocess.run(
|
self.status["state"] = "copying"
|
||||||
[
|
|
||||||
"lsdvd",
|
|
||||||
"-Oy",
|
|
||||||
"-x",
|
|
||||||
device,
|
|
||||||
],
|
|
||||||
encoding="utf-8",
|
|
||||||
capture_output=True,
|
|
||||||
)
|
|
||||||
lsdvd = eval(p.stdout[8:]) # s/^lsdvd = //
|
|
||||||
title = lsdvd["title"]
|
|
||||||
if title in ('No', 'unknown'):
|
|
||||||
title = lsdvd["provider_id"]
|
|
||||||
if title == "$PACKAGE_STRING":
|
|
||||||
now = time.strftime(r"%Y-%m-%dT%H:%M:%S")
|
|
||||||
title = "DVD %s" % (title, now)
|
|
||||||
|
|
||||||
# Go through all the tracks, looking for the largest referenced sector.
|
|
||||||
max_sector = 0
|
|
||||||
max_length = 0
|
|
||||||
tracks = lsdvd["track"]
|
|
||||||
for track in tracks:
|
|
||||||
max_length = max(track["length"], max_length)
|
|
||||||
for cell in track["cell"]:
|
|
||||||
max_sector = max(cell["last_sector"], max_sector)
|
|
||||||
if max_sector == 0:
|
|
||||||
logging.info("Media size = 0; aborting")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Make a guess about what's on this DVD.
|
|
||||||
# We will categories into three types:
|
|
||||||
# * A feature, which has one track much longer than any other
|
|
||||||
# * A collection of shows, which has several long tracks, more or less the same lengths
|
|
||||||
# * Something else
|
|
||||||
collection = []
|
|
||||||
for track in tracks:
|
|
||||||
if track["length"] / max_length > 0.80:
|
|
||||||
collection = collect(collection, track)
|
|
||||||
if (max_length < 20 * MINUTE) and (len(collection) < len(track) * 0.6):
|
|
||||||
collection = tracks
|
|
||||||
|
|
||||||
state["title"] = title
|
|
||||||
state["size"] = max_sector * 2048 # DVD sector size = 2048
|
|
||||||
state["tracks"] = [(t["ix"], t["length"]) for t in collection]
|
|
||||||
|
|
||||||
def copy(state, device, directory):
|
|
||||||
p = subprocess.Popen(
|
|
||||||
[
|
|
||||||
"dvdbackup",
|
|
||||||
"--input=" + device,
|
|
||||||
"--name=" + state["title"],
|
|
||||||
"--mirror",
|
|
||||||
"--progress",
|
|
||||||
],
|
|
||||||
encoding="utf-8",
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.STDOUT,
|
|
||||||
cwd=directory,
|
|
||||||
)
|
|
||||||
totalBytes = titleSize = lastTitleSize = 0
|
|
||||||
progressRe = re.compile(r"^Copying.*([0-9.]+)/[0-9.]+ (MiB|KiB)")
|
|
||||||
for line in p.stdout:
|
|
||||||
line = line.strip()
|
|
||||||
m = progressRe.search(line)
|
|
||||||
if m and m[2] == "MiB":
|
|
||||||
titleSize = float(m[1]) * 1024 * 1024
|
|
||||||
elif m and m[2] == "KiB":
|
|
||||||
titleSize = float(m[1]) * 1024
|
|
||||||
if titleSize < lastTitleSize:
|
|
||||||
totalBytes += lastTitleSize
|
|
||||||
lastTitleSize = titleSize
|
|
||||||
yield (totalBytes + titleSize) / state["size"]
|
|
||||||
|
|
||||||
|
|
||||||
def encode(state, directory):
|
|
||||||
title = state["title"]
|
|
||||||
logging.info("encoding: %s (%s)" % (title, directory))
|
|
||||||
|
|
||||||
total_length = sum(t[1] for t in state["tracks"])
|
|
||||||
finished_length = 0
|
|
||||||
for track, length in state["tracks"]:
|
|
||||||
outfn = "%s-%d.mkv" % (title, track)
|
|
||||||
tmppath = os.path.join(directory, outfn)
|
|
||||||
outpath = os.path.join(directory, "..", outfn)
|
|
||||||
p = subprocess.Popen(
|
p = subprocess.Popen(
|
||||||
[
|
[
|
||||||
"nice",
|
"dvdbackup",
|
||||||
"HandBrakeCLI",
|
"--input=" + self.device,
|
||||||
"--json",
|
"--name=" + self.status["title"],
|
||||||
"--input", "%s/%s/VIDEO_TS" % (directory, state["title"]),
|
"--mirror",
|
||||||
"--output", tmppath,
|
"--progress",
|
||||||
"--title", str(track),
|
|
||||||
"--native-language", "eng",
|
|
||||||
"--markers",
|
|
||||||
"--loose-anamorphic",
|
|
||||||
"--all-subtitles",
|
|
||||||
"--all-audio",
|
|
||||||
"--aencoder", "copy",
|
|
||||||
"--audio-copy-mask", "aac,ac3,mp3",
|
|
||||||
"--audio-fallback", "aac",
|
|
||||||
],
|
],
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=None,
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=directory,
|
||||||
)
|
)
|
||||||
|
totalBytes = titleSize = lastTitleSize = 0
|
||||||
# HandBrakeCLI spits out sort of JSON.
|
progressRe = re.compile(r"^Copying.*([0-9.]+)/[0-9.]+ (MiB|KiB)")
|
||||||
# But Python has no built-in way to stream JSON objects.
|
|
||||||
# Hence this kludge.
|
|
||||||
progressRe = re.compile(r'^"Progress": ([0-9.]+),')
|
|
||||||
for line in p.stdout:
|
for line in p.stdout:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
m = progressRe.search(line)
|
m = progressRe.search(line)
|
||||||
if m:
|
if m and m[2] == "MiB":
|
||||||
progress = float(m[1])
|
titleSize = float(m[1]) * 1024 * 1024
|
||||||
yield (finished_length + progress*length) / total_length
|
elif m and m[2] == "KiB":
|
||||||
|
titleSize = float(m[1]) * 1024
|
||||||
finished_length += length
|
if titleSize < lastTitleSize:
|
||||||
os.rename(
|
totalBytes += lastTitleSize
|
||||||
src=tmppath,
|
lastTitleSize = titleSize
|
||||||
dst=outpath,
|
self.status["complete"] = (totalBytes + titleSize) / self.status["size"]
|
||||||
)
|
|
||||||
logging.info("Finished track %d; length %d" % (track, length))
|
|
||||||
|
|
||||||
|
|
||||||
def clean(state, directory):
|
class Encoder:
|
||||||
os.removedirs(directory)
|
def __init__(self, basedir, status):
|
||||||
|
self.basedir = basedir
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def encode(self, obj):
|
||||||
|
title = obj["title"]
|
||||||
|
logging.info("encoding: %s (%s)" % (title, self.basedir))
|
||||||
|
|
||||||
|
total_length = sum(t[1] for t in obj["tracks"])
|
||||||
|
finished_length = 0
|
||||||
|
for track, length in obj["tracks"]:
|
||||||
|
outfn = "%s-%d.mkv" % (title, track)
|
||||||
|
tmppath = os.path.join(self.basedir, outfn)
|
||||||
|
outpath = os.path.join(self.basedir, "..", outfn)
|
||||||
|
p = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"nice",
|
||||||
|
"HandBrakeCLI",
|
||||||
|
"--json",
|
||||||
|
"--input", "%s/VIDEO_TS" % self.basedir,
|
||||||
|
"--output", tmppath,
|
||||||
|
"--title", str(track),
|
||||||
|
"--native-language", "eng",
|
||||||
|
"--markers",
|
||||||
|
"--loose-anamorphic",
|
||||||
|
"--all-subtitles",
|
||||||
|
"--all-audio",
|
||||||
|
"--aencoder", "copy",
|
||||||
|
"--audio-copy-mask", "aac,ac3,mp3",
|
||||||
|
"--audio-fallback", "aac",
|
||||||
|
],
|
||||||
|
encoding="utf-8",
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# HandBrakeCLI spits out sort of JSON.
|
||||||
|
# But Python has no built-in way to stream JSON objects.
|
||||||
|
# Hence this kludge.
|
||||||
|
progressRe = re.compile(r'^"Progress": ([0-9.]+),')
|
||||||
|
for line in p.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
m = progressRe.search(line)
|
||||||
|
if m:
|
||||||
|
progress = float(m[1])
|
||||||
|
complete = (finished_length + progress*length) / total_length
|
||||||
|
self.status["complete"] = complete
|
||||||
|
|
||||||
|
finished_length += length
|
||||||
|
os.rename(
|
||||||
|
src=tmppath,
|
||||||
|
dst=outpath,
|
||||||
|
)
|
||||||
|
logging.info("Finished track %d; length %d" % (track, length))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import pprint
|
import pprint
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
#! /usr/bin/python3
|
#! /usr/bin/python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import subprocess
|
import subprocess
|
||||||
import glob
|
import glob
|
||||||
import os
|
import os
|
||||||
|
@ -12,47 +13,40 @@ import re
|
||||||
import logging
|
import logging
|
||||||
import dvd
|
import dvd
|
||||||
import cd
|
import cd
|
||||||
import traceback
|
|
||||||
import worker
|
|
||||||
|
|
||||||
class Encoder(worker.Worker):
|
class Encoder(threading.Thread):
|
||||||
def __init__(self, directory=None):
|
def __init__(self, directory=None, **kwargs):
|
||||||
self.status = {}
|
self.status = {}
|
||||||
self.directory = directory
|
self.directory = directory
|
||||||
return super().__init__(directory)
|
return super().__init__(**kwargs)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while True:
|
||||||
wait = True
|
wait = True
|
||||||
self.status = {"type": "encoder", "state": "idle"}
|
self.status = {"type": "encoder", "state": "idle"}
|
||||||
for fn in glob.glob(self.workdir("*", "sucker.json")):
|
for fn in glob.glob(os.path.join(self.directory, "*", "sucker.json")):
|
||||||
directory = os.path.dirname(fn)
|
fdir = os.path.dirname(fn)
|
||||||
state = self.read_state(directory)
|
with open(fn) as f:
|
||||||
try:
|
obj = json.load(f)
|
||||||
self.encode(directory, state)
|
self.encode(fdir, obj)
|
||||||
except Exception as e:
|
|
||||||
logging.error("Error encoding %s: %s" % (directory, e))
|
|
||||||
logging.error(traceback.format_exc())
|
|
||||||
wait = False
|
wait = False
|
||||||
if wait:
|
if wait:
|
||||||
time.sleep(12)
|
time.sleep(12)
|
||||||
|
|
||||||
def encode(self, directory, state):
|
def encode(self, fdir, obj):
|
||||||
self.status["state"] = "encoding"
|
self.status["state"] = "encoding"
|
||||||
self.status["title"] = state["title"]
|
self.status["title"] = obj["title"]
|
||||||
|
if obj["type"] == "audio":
|
||||||
if state["video"]:
|
self.encode_audio(fdir, obj)
|
||||||
media = dvd
|
|
||||||
else:
|
else:
|
||||||
media = cd
|
self.encode_video(fdir, obj)
|
||||||
|
shutil.rmtree(fdir)
|
||||||
|
|
||||||
|
def encode_audio(self, fdir, obj):
|
||||||
|
cd.encode(obj, fdir)
|
||||||
|
|
||||||
logging.info("Encoding %s (%s)" % (directory, state["title"]))
|
def encode_video(self, fdir, obj):
|
||||||
for pct in media.encode(state, directory):
|
enc = dvd.Encoder(fdir, self.status)
|
||||||
self.status["complete"] = pct
|
enc.encode(obj)
|
||||||
|
|
||||||
media.clean(state, directory)
|
|
||||||
self.clear_state(directory)
|
|
||||||
|
|
||||||
logging.info("Finished encoding")
|
|
||||||
|
|
||||||
# vi: sw=4 ts=4 et ai
|
# vi: sw=4 ts=4 et ai
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
#! /usr/bin/python3
|
#! /usr/bin/python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
@ -8,10 +9,8 @@ import fcntl
|
||||||
import traceback
|
import traceback
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import slugify
|
|
||||||
import dvd
|
import dvd
|
||||||
import cd
|
import cd
|
||||||
import worker
|
|
||||||
|
|
||||||
CDROM_DRIVE_STATUS = 0x5326
|
CDROM_DRIVE_STATUS = 0x5326
|
||||||
CDS_NO_INFO = 0
|
CDS_NO_INFO = 0
|
||||||
|
@ -29,21 +28,25 @@ CDS_DATA_2 = 102
|
||||||
CDROM_LOCKDOOR = 0x5329
|
CDROM_LOCKDOOR = 0x5329
|
||||||
CDROM_EJECT = 0x5309
|
CDROM_EJECT = 0x5309
|
||||||
|
|
||||||
class Reader(worker.Worker):
|
class Reader(threading.Thread):
|
||||||
def __init__(self, device, directory):
|
def __init__(self, device, directory=None, **kwargs):
|
||||||
super().__init__(directory)
|
|
||||||
self.device = device
|
self.device = device
|
||||||
self.status["type"] = "reader"
|
self.directory = directory
|
||||||
self.status["device"] = device
|
self.status = {
|
||||||
|
"type": "reader",
|
||||||
|
"state": "idle",
|
||||||
|
"device": self.device,
|
||||||
|
}
|
||||||
self.complete = 0
|
self.complete = 0
|
||||||
self.staleness = 0
|
self.staleness = 0
|
||||||
self.drive = None
|
self.drive = None
|
||||||
logging.info("Starting reader on %s" % self.device)
|
logging.info("Starting reader on %s" % self.device)
|
||||||
|
return super().__init__(**kwargs)
|
||||||
|
|
||||||
def reopen(self):
|
def reopen(self):
|
||||||
if (self.staleness > 15) or not self.drive:
|
if (self.staleness > 15) or not self.drive:
|
||||||
if self.drive:
|
if self.drive:
|
||||||
os.close(self.drive)
|
self.drive.close()
|
||||||
self.drive = None
|
self.drive = None
|
||||||
try:
|
try:
|
||||||
self.drive = os.open(self.device, os.O_RDONLY | os.O_NONBLOCK)
|
self.drive = os.open(self.device, os.O_RDONLY | os.O_NONBLOCK)
|
||||||
|
@ -66,22 +69,24 @@ class Reader(worker.Worker):
|
||||||
rv = fcntl.ioctl(self.drive, CDROM_DISC_STATUS)
|
rv = fcntl.ioctl(self.drive, CDROM_DISC_STATUS)
|
||||||
try:
|
try:
|
||||||
if rv == CDS_AUDIO:
|
if rv == CDS_AUDIO:
|
||||||
self.handle(False)
|
self.handle_audio()
|
||||||
elif rv in [CDS_DATA_1, CDS_DATA_2]:
|
elif rv in [CDS_DATA_1, CDS_DATA_2]:
|
||||||
self.handle(True)
|
self.handle_data()
|
||||||
else:
|
else:
|
||||||
logging.info("Can't handle disc type %d" % rv)
|
logging.info("Can't handle disc type %d" % rv)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Error in disc handler: %s" % e)
|
logging.error("Error in disc handler: %s" % e)
|
||||||
logging.error(traceback.format_exc())
|
logging.error(traceback.format_exc())
|
||||||
self.eject()
|
self.eject()
|
||||||
elif rv in (CDS_TRAY_OPEN, CDS_NO_DISC, CDS_DRIVE_NOT_READ):
|
elif rv in (CDS_TRAY_OPEN, CDS_NO_DISC):
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
else:
|
else:
|
||||||
logging.info("CDROM_DRIVE_STATUS: %d (%s)" % (rv, CDS_STR[rv]))
|
logging.info("CDROM_DRIVE_STATUS: %d (%s)" % (rv, CDS_STR[rv]))
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
|
|
||||||
def eject(self):
|
def eject(self):
|
||||||
|
self.status["state"] = "ejecting"
|
||||||
|
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
try:
|
try:
|
||||||
fcntl.ioctl(self.drive, CDROM_LOCKDOOR, 0)
|
fcntl.ioctl(self.drive, CDROM_LOCKDOOR, 0)
|
||||||
|
@ -91,28 +96,32 @@ class Reader(worker.Worker):
|
||||||
logging.error("Ejecting: %v" % e)
|
logging.error("Ejecting: %v" % e)
|
||||||
time.sleep(i * 5)
|
time.sleep(i * 5)
|
||||||
|
|
||||||
def handle(self, video):
|
# XXX: rename this to something like "write_status"
|
||||||
self.status["video"] = video
|
def finished(self, **kwargs):
|
||||||
|
self.status["state"] = "finished read"
|
||||||
|
fn = os.path.join(self.directory, self.status["title"], "sucker.json")
|
||||||
|
newfn = fn + ".new"
|
||||||
|
with open(newfn, "w") as fout:
|
||||||
|
json.dump(obj=self.status, fp=fout)
|
||||||
|
os.rename(src=newfn, dst=fn)
|
||||||
|
|
||||||
|
def handle_audio(self):
|
||||||
|
self.status["video"] = False
|
||||||
|
|
||||||
self.status["state"] = "reading"
|
self.status["state"] = "reading"
|
||||||
|
cd.read(self.device, self.status)
|
||||||
state = {}
|
|
||||||
state["video"] = video
|
|
||||||
if video:
|
|
||||||
media = dvd
|
|
||||||
else:
|
|
||||||
media = cd
|
|
||||||
|
|
||||||
media.scan(state, self.device)
|
|
||||||
self.status["title"] = state["title"]
|
|
||||||
subdir = slugify.slugify(state["title"])
|
|
||||||
workdir = self.workdir(subdir)
|
|
||||||
os.makedirs(workdir, exist_ok=True)
|
|
||||||
|
|
||||||
|
directory = os.path.join(self.directory, status["title"])
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
self.status["state"] = "copying"
|
self.status["state"] = "copying"
|
||||||
for pct in media.copy(state, self.device, workdir):
|
cd.copy(self.device, self.status, self.directory)
|
||||||
self.status["complete"] = pct
|
self.finished() # XXX: rename this to something like "write_status"
|
||||||
|
|
||||||
self.write_state(subdir, state)
|
|
||||||
|
|
||||||
|
def handle_data(self):
|
||||||
|
self.status["video"] = True
|
||||||
|
src = dvd.Copier(self.device, self.status)
|
||||||
|
src.copy(self.directory)
|
||||||
|
self.finished()
|
||||||
|
|
||||||
# vi: sw=4 ts=4 et ai
|
# vi: sw=4 ts=4 et ai
|
||||||
|
|
|
@ -7,17 +7,17 @@ import time
|
||||||
import os
|
import os
|
||||||
|
|
||||||
class Statuser(threading.Thread):
|
class Statuser(threading.Thread):
|
||||||
def __init__(self, workers, directory):
|
def __init__(self, workers, directory=None, **kwargs):
|
||||||
self.workers = workers
|
self.workers = workers
|
||||||
self.directory = directory
|
self.directory = directory
|
||||||
self.status = {}
|
self.status = {}
|
||||||
super().__init__(daemon=True)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while True:
|
while True:
|
||||||
self.status["finished"] = {
|
self.status["finished"] = {
|
||||||
"video": glob.glob(os.path.join(self.directory, "*.mkv")),
|
"video": glob.glob(os.path.join(self.directory, "*.mkv")),
|
||||||
"audio": glob.glob(os.path.join(self.directory, "*/*.mp3")),
|
"audio": glob.glob(os.path.join(self.directory, "*/*/*.mp3")),
|
||||||
}
|
}
|
||||||
self.status["workers"] = [w.status for w in self.workers]
|
self.status["workers"] = [w.status for w in self.workers]
|
||||||
time.sleep(12)
|
time.sleep(12)
|
||||||
|
|
|
@ -33,9 +33,13 @@ def main():
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
readers = [reader.Reader(d, args.incoming) for d in args.drive]
|
readers = []
|
||||||
encoders = [encoder.Encoder(args.incoming) for i in range(1)]
|
for d in args.drive:
|
||||||
st = statuser.Statuser(readers + encoders, args.incoming)
|
readers.append(reader.Reader(d, directory=args.incoming, daemon=True))
|
||||||
|
encoders = []
|
||||||
|
for i in range(1):
|
||||||
|
encoders.append(encoder.Encoder(directory=args.incoming, daemon=True))
|
||||||
|
st = statuser.Statuser(readers + encoders, directory=args.incoming, daemon=True)
|
||||||
|
|
||||||
[w.start() for w in readers + encoders]
|
[w.start() for w in readers + encoders]
|
||||||
st.start()
|
st.start()
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import threading
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
class Worker(threading.Thread):
|
|
||||||
def __init__(self, directory, **kwargs):
|
|
||||||
self.directory = directory
|
|
||||||
self.status = {
|
|
||||||
"state": "idle",
|
|
||||||
}
|
|
||||||
|
|
||||||
kwargs["daemon"] = True
|
|
||||||
return super().__init__(**kwargs)
|
|
||||||
|
|
||||||
def workdir(self, *path):
|
|
||||||
return os.path.join(self.directory, *path)
|
|
||||||
|
|
||||||
def write_state(self, subdir, state):
|
|
||||||
logging.debug("Writing state: %s" % repr(state))
|
|
||||||
statefn = self.workdir(subdir, "sucker.json")
|
|
||||||
newstatefn = statefn + ".new"
|
|
||||||
with open(newstatefn, "w") as f:
|
|
||||||
json.dump(state, f)
|
|
||||||
os.rename(newstatefn, statefn)
|
|
||||||
|
|
||||||
def read_state(self, subdir):
|
|
||||||
with open(self.workdir(subdir, "sucker.json")) as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
def clear_state(self, subdir):
|
|
||||||
os.unlink(self.workdir(subdir, "sucker.json"))
|
|
Loading…
Reference in New Issue