backpack-backcrack 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
backcrack/disc.py ADDED
@@ -0,0 +1,178 @@
1
+ """Find discs sitting in optical drives, of any kind, and tell them
2
+ apart. All macOS-specific (diskutil, drutil, mount) - the disc hardware layer
3
+ is the one part of this pipeline that doesn't travel to another OS for free.
4
+ """
5
+ import hashlib
6
+ import re
7
+ import subprocess
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from backcrack import config as cfg
12
+
13
+
14
+ # Optical drive speeds are quoted in multiples of DVD 1x.
15
+ DVD_1X_BPS = 1_385_000
16
+
17
+
18
+ def raw(dev: str) -> str:
19
+ return "/dev/r" + dev.removeprefix("/dev/")
20
+
21
+
22
+ def cooked(dev: str) -> str:
23
+ return "/dev/" + dev.removeprefix("/dev/").removeprefix("r")
24
+
25
+
26
+ def optical_mounts():
27
+ """Yields (device, mountpoint) for every mounted UDF/ISO9660 volume:
28
+ DVD-Video, Blu-ray, and any other data disc. Audio CDs never mount a
29
+ filesystem, so they never show up here - see audio_discs below.
30
+ """
31
+ try:
32
+ out = subprocess.run(["mount"], capture_output=True, text=True, timeout=10).stdout
33
+ except subprocess.TimeoutExpired:
34
+ return
35
+ for line in out.splitlines():
36
+ m = re.match(r"^(\S+) on (.+) \((udf|cd9660)[,)]", line)
37
+ if m:
38
+ yield m.group(1), m.group(2)
39
+
40
+
41
+ def audio_discs():
42
+ """Yields device paths for every whole-disk carrying a CD_DA (audio)
43
+ partition. diskutil lists these even though nothing gets mounted.
44
+ """
45
+ try:
46
+ out = subprocess.run(["diskutil", "list"], capture_output=True, text=True, timeout=10).stdout
47
+ except subprocess.TimeoutExpired:
48
+ return
49
+ dev = None
50
+ for line in out.splitlines():
51
+ m = re.match(r"^(/dev/disk\S+)", line)
52
+ if m:
53
+ dev = m.group(1)
54
+ elif "CD_DA" in line and dev:
55
+ yield dev
56
+
57
+
58
+ def pending_discs():
59
+ """Yields (device, kind, label) for every disc worth ripping. kind is
60
+ "video" or "audio". label is the volume name for video. For audio it is
61
+ only the device name: reading the CD's id here, on every poll, would
62
+ disturb a rip already running in that drive, so rip_audio_disc names
63
+ the CD with audio_label() once it holds the drive's lock.
64
+ """
65
+ for dev, mnt in optical_mounts():
66
+ yield dev, "video", Path(mnt).name
67
+ for dev in audio_discs():
68
+ yield dev, "audio", Path(dev).name
69
+
70
+
71
+ def cd_discid(rdev: str) -> list:
72
+ """cd-discid's fields for the CD in `rdev` (freedb id, track count,
73
+ offsets..., seconds), or [] if it isn't installed or can't read it."""
74
+ if not cfg.CD_DISCID:
75
+ return []
76
+ try:
77
+ return subprocess.run([cfg.CD_DISCID, rdev], capture_output=True, text=True, timeout=15).stdout.split()
78
+ except (OSError, subprocess.TimeoutExpired):
79
+ return []
80
+
81
+
82
+ def cd_toc(rdev: str):
83
+ """The CD's table of contents as MusicBrainz wants it: (first track, last
84
+ track, lead-out, [track offsets]) in sectors, or None. Uses cd-discid's
85
+ exact --musicbrainz output; an older cd-discid only gives the length in
86
+ whole seconds, so the lead-out is then approximate (MusicBrainz's toc
87
+ lookup still finds near matches)."""
88
+ if not cfg.CD_DISCID:
89
+ return None
90
+ try:
91
+ out = subprocess.run([cfg.CD_DISCID, "--musicbrainz", rdev], capture_output=True, text=True, timeout=15).stdout.split()
92
+ except (OSError, subprocess.TimeoutExpired):
93
+ out = []
94
+ nums = [int(x) for x in out if x.isdigit()]
95
+ if len(nums) >= 3 and nums[0] == len(nums) - 2: # count, offsets..., lead-out
96
+ return 1, nums[0], nums[-1], nums[1:-1]
97
+ fields = cd_discid(rdev) # freedb: id, count, offsets..., seconds
98
+ if len(fields) < 3 or not fields[1].isdigit():
99
+ return None
100
+ n = int(fields[1])
101
+ try:
102
+ offsets = [int(x) for x in fields[2:2 + n]]
103
+ return 1, n, int(fields[2 + n]) * 75, offsets
104
+ except (ValueError, IndexError):
105
+ return None
106
+
107
+
108
+ def musicbrainz_discid(toc) -> str:
109
+ """MusicBrainz's disc id for a (first, last, lead-out, offsets) toc: the
110
+ SHA-1 of the toc in fixed-width hex, base64 with MusicBrainz's alphabet."""
111
+ import base64
112
+ first, last, leadout, offsets = toc
113
+ frames = [leadout] + list(offsets) + [0] * (99 - len(offsets))
114
+ text = f"{first:02X}{last:02X}" + "".join(f"{f:08X}" for f in frames)
115
+ digest = base64.b64encode(hashlib.sha1(text.encode()).digest()).decode()
116
+ return digest.translate(str.maketrans("+/=", "._-"))
117
+
118
+
119
+ def audio_label(rdev: str):
120
+ """A name for the CD in `rdev` that stays the same whichever drive it is
121
+ in: "AudioCD-<n>tracks-<id>". The id is cd-discid's, or a hash of
122
+ cdparanoia's track table when cd-discid isn't installed. None if
123
+ neither can read the disc.
124
+ """
125
+ fields = cd_discid(rdev)
126
+ if len(fields) >= 2 and fields[1].isdigit():
127
+ return f"AudioCD-{fields[1]}tracks-{fields[0]}"
128
+ if not cfg.CDPARANOIA:
129
+ return None
130
+ try:
131
+ toc = subprocess.run([cfg.CDPARANOIA, "-Q", "-d", rdev], capture_output=True, text=True, timeout=30).stderr
132
+ except (OSError, subprocess.TimeoutExpired):
133
+ return None
134
+ tracks = [line.split() for line in toc.splitlines() if re.match(r"^\s*\d+\.\s+\d+", line)]
135
+ if not tracks:
136
+ return None
137
+ digest = hashlib.sha1(" ".join(t[1] for t in tracks).encode()).hexdigest()[:8]
138
+ return f"AudioCD-{len(tracks)}tracks-{digest}"
139
+
140
+
141
+ _DRV_RE = re.compile(r'^DRV:(\d+),2,\d+,\d+,"([^"]*)","([^"]*)","([^"]*)"')
142
+
143
+
144
+ def mkv_drives(timeout: int = 60) -> list:
145
+ """(index, drive name, disc label, raw device) for every drive MakeMKV
146
+ sees with a disc loaded."""
147
+ try:
148
+ out = subprocess.run([cfg.MKVCON, "-r", "--cache=1", "info", "disc:9999"],
149
+ capture_output=True, text=True, timeout=timeout).stdout
150
+ except (OSError, subprocess.TimeoutExpired):
151
+ return []
152
+ return [m.groups() for m in map(_DRV_RE.match, out.splitlines()) if m]
153
+
154
+
155
+ # MakeMKV drive indices move between insertions, so never cache one.
156
+ # Enumeration touches every drive, so it's serialised behind a lock to avoid
157
+ # disturbing a rip already running in the other drive. video_ts mode only.
158
+ def mkv_index_for(dev: str):
159
+ want = raw(dev)
160
+ lockdir = cfg.STATE / "enum.lock"
161
+ tries = 0
162
+ while True:
163
+ try:
164
+ lockdir.mkdir()
165
+ break
166
+ except FileExistsError:
167
+ tries += 1
168
+ if tries > 90:
169
+ break
170
+ time.sleep(2)
171
+ try:
172
+ drives = mkv_drives()
173
+ finally:
174
+ try:
175
+ lockdir.rmdir()
176
+ except OSError:
177
+ pass
178
+ return next((idx for idx, _, _, device in drives if device == want), None)
backcrack/diskspeed.py ADDED
@@ -0,0 +1,129 @@
1
+ """Measure sustained read throughput of every loaded optical
2
+ drive. It's a raw block read, so any disc works. Speeds are shown in MB/s and
3
+ in DVD "x" units (1x = 1,385,000 bytes/s), so a CD or Blu-ray reads as a
4
+ multiple of DVD 1x, not of its own format's 1x. Reports live progress and
5
+ detects a stalled read instead of hanging silently.
6
+
7
+ diskspeed
8
+
9
+ Put the SAME disc in each drive in turn so the comparison is fair.
10
+ Results accumulate in RESULTS (default ~/diskspeed.txt). READ_MB, SKIP_MB,
11
+ STALL_S and DISC_BYTES (the size behind the minutes-per-disc estimate,
12
+ default a 7 GB DVD) are settings like any other.
13
+ """
14
+ import os
15
+ import re
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import time
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+
24
+ from backcrack import config as cfg
25
+ from backcrack.disc import DVD_1X_BPS, cooked, mkv_drives
26
+
27
+ READ_MB, SKIP_MB, STALL_S, DISC_BYTES, RESULTS = cfg.READ_MB, cfg.SKIP_MB, cfg.STALL_S, cfg.DISC_BYTES, cfg.RESULTS
28
+
29
+
30
+ def hr() -> None:
31
+ print("-" * 70)
32
+
33
+
34
+ def main() -> None:
35
+ if not Path(cfg.MKVCON).exists():
36
+ print(f"makemkvcon not found at {cfg.MKVCON}. See `backcrack doctor`.", file=sys.stderr)
37
+ sys.exit(1)
38
+
39
+ disc_drives = mkv_drives(timeout=30)
40
+ if not disc_drives:
41
+ print("No discs loaded. Insert a disc in each drive and re-run.", file=sys.stderr)
42
+ sys.exit(1)
43
+
44
+ sudo = []
45
+ first_raw = disc_drives[0][3]
46
+ if subprocess.run(["dd", f"if={first_raw}", "of=/dev/null", "bs=2048", "count=1"],
47
+ capture_output=True).returncode != 0:
48
+ print("Raw device reads need admin rights - password prompt incoming.")
49
+ if subprocess.run(["sudo", "-v"]).returncode != 0:
50
+ sys.exit(1)
51
+ sudo = ["sudo"]
52
+
53
+ print(f"\nReading {READ_MB}MB from each drive starting at {SKIP_MB}MB.")
54
+ print("Progress prints every 5s. Ctrl-C is safe at any point.")
55
+
56
+ for idx, name, label, raw in disc_drives:
57
+ short = name[:40]
58
+ hr()
59
+ print(short)
60
+ print(f" disc: {label or '<unlabelled>'} device: {raw}")
61
+
62
+ subprocess.run(["diskutil", "unmountDisk", cooked(raw)], capture_output=True)
63
+
64
+ tmp = tempfile.NamedTemporaryFile(prefix="diskspeed", delete=False)
65
+ tmp.close()
66
+ with open(tmp.name, "wb") as errf:
67
+ proc = subprocess.Popen(
68
+ [*sudo, "dd", f"if={raw}", "of=/dev/null", "bs=1m", f"count={READ_MB}", f"skip={SKIP_MB}"],
69
+ stderr=errf,
70
+ )
71
+
72
+ last_bytes = stalled = quiet = 0
73
+ while proc.poll() is None:
74
+ time.sleep(5)
75
+ try:
76
+ proc.send_signal(signal.SIGINFO)
77
+ except (AttributeError, ProcessLookupError):
78
+ pass
79
+ time.sleep(1)
80
+ text = Path(tmp.name).read_text(errors="ignore")
81
+ lines = [l for l in text.splitlines() if "bytes transferred" in l]
82
+ b = 0
83
+ if lines:
84
+ m = re.match(r"^(\d+) bytes", lines[-1])
85
+ if m:
86
+ b = int(m.group(1))
87
+
88
+ if b > last_bytes:
89
+ last_bytes, quiet = b, 0
90
+ m = re.search(r"in ([\d.]+) secs", lines[-1])
91
+ t = float(m.group(1)) if m else 0
92
+ if t > 0:
93
+ print(f" {b/1e6:6.0f} MB {b/1e6/t:6.2f} MB/s {b/DVD_1X_BPS/t:4.1f}x")
94
+ else:
95
+ quiet += 6
96
+ if quiet >= STALL_S:
97
+ print(f" STALLED at {last_bytes // 1_000_000}MB - no progress for {quiet}s, aborting")
98
+ proc.terminate() # SIGTERM, which sudo passes on to dd
99
+ stalled = 1
100
+ break
101
+ print(f" ...no progress for {quiet}s")
102
+ proc.wait()
103
+
104
+ text = Path(tmp.name).read_text(errors="ignore")
105
+ os.unlink(tmp.name)
106
+ final = [l for l in text.splitlines() if "bytes transferred" in l]
107
+ bps = None
108
+ if final:
109
+ m = re.search(r"\((\d+) bytes/sec\)", final[-1])
110
+ if m:
111
+ bps = int(m.group(1))
112
+
113
+ if stalled or bps is None:
114
+ print(" RESULT: no clean measurement (likely bad/protected sectors - try SKIP_MB=4000)")
115
+ continue
116
+
117
+ out_line = (f"{short:<42} {label or '?':<20} {bps/1e6:6.2f} MB/s {bps/DVD_1X_BPS:4.1f}x "
118
+ f"~{(DISC_BYTES/bps)/60:.0f} min/disc")
119
+ print(f" RESULT: {out_line}")
120
+ with open(RESULTS, "a") as f:
121
+ f.write(f"{datetime.now():%Y-%m-%d %H:%M:%S} {out_line}\n")
122
+
123
+ hr()
124
+ print(f"Saved to {RESULTS}")
125
+ print("Now swap that disc into the other drive and re-run to compare like with like.")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
backcrack/encd.py ADDED
@@ -0,0 +1,42 @@
1
+ """Encode worker. Video jobs go through HandBrake, sorting the
2
+ main feature and special features into separate folders; audio jobs get
3
+ compressed losslessly. Start it once and leave it; idle is normal. Safe to
4
+ stop and restart - finished files are never redone.
5
+
6
+ encd
7
+ """
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from backcrack import config as cfg
12
+ from backcrack.encode import audio_ext, process_job
13
+ from backcrack.common import log
14
+
15
+
16
+ def main() -> None:
17
+ cfg.make_library_dirs()
18
+ if not Path(cfg.HBCLI).exists():
19
+ print(f"WARNING: HandBrakeCLI not found at {cfg.HBCLI} - video jobs will fail. See `backcrack doctor`.")
20
+ if audio_ext() != cfg.AUDIO_FORMAT:
21
+ need = "flac or ffmpeg" if cfg.AUDIO_FORMAT == "flac" else "ffmpeg"
22
+ print(f"WARNING: {need} not found - audio tracks will be copied as .wav. See `backcrack doctor`.")
23
+
24
+ classes = ", ".join(f"{c.name} RF={c.quality}->{c.folder}/" for c in cfg.DURATION_CLASSES)
25
+ print(f"Video: {cfg.VIDEO_ENCODER} preset={cfg.ENCODER_PRESET} {classes} ({cfg.ENCODE_JOBS} at a time)")
26
+ print(f"Audio: {audio_ext()} -> encoded/")
27
+ print("Idle until ripd finishes a disc - that's normal.")
28
+ log(cfg.ENCLOG, "encd started")
29
+
30
+ try:
31
+ while True:
32
+ jobfiles = [p for p in cfg.QUEUE.iterdir() if p.is_file()]
33
+ for jobfile in jobfiles:
34
+ process_job(jobfile)
35
+ if not jobfiles:
36
+ time.sleep(15)
37
+ except KeyboardInterrupt:
38
+ print("\nencoder stopped - queue kept in", cfg.QUEUE)
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
backcrack/encode.py ADDED
@@ -0,0 +1,198 @@
1
+ """Turn a ripped disc's source/ into encoded/ (and extras/ for
2
+ video). Shared by encd.py.
3
+ """
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from backcrack import config as cfg
8
+ from backcrack.common import log, notify, classify_class, hb_titles, file_duration
9
+
10
+
11
+ PART = ".part" # an encode in progress is written as <stem>.part<suffix>
12
+
13
+
14
+ def _encode_to(out: Path, cmd_for, log_path: Path) -> int:
15
+ """Runs cmd_for(tmp) to write `out` under a temporary name, renaming it to
16
+ `out` only once the command succeeds, so an interrupted encode never
17
+ leaves a file that looks finished."""
18
+ out.parent.mkdir(parents=True, exist_ok=True)
19
+ tmp = out.with_name(out.stem + PART + out.suffix)
20
+ with open(log_path, "ab") as logf:
21
+ rc = subprocess.run(cmd_for(tmp), stdout=logf, stderr=subprocess.STDOUT, timeout=None).returncode
22
+ if rc == 0 and tmp.exists():
23
+ tmp.replace(out)
24
+ else:
25
+ tmp.unlink(missing_ok=True)
26
+ log(cfg.ENCLOG, f"FAIL {out.name} (rc={rc}) - see {log_path}")
27
+ return rc
28
+
29
+
30
+ def encode_video_one(src: str, title, out: Path, quality: str) -> int:
31
+ def cmd_for(tmp: Path) -> list:
32
+ return [
33
+ cfg.HBCLI, "-i", src,
34
+ *(["-t", str(title)] if title is not None else []),
35
+ "-o", str(tmp),
36
+ "--format", "av_mkv",
37
+ "--encoder", cfg.VIDEO_ENCODER, "--encoder-preset", cfg.ENCODER_PRESET, "--quality", quality,
38
+ "-x", f"threads={cfg.ENCODE_THREADS}",
39
+ *cfg.DEINTERLACE_ARGS,
40
+ "--all-audio", "--aencoder", "copy", "--audio-fallback", "ca_aac",
41
+ "--all-subtitles", "--markers",
42
+ ]
43
+ return _encode_to(out, cmd_for, cfg.LOGDIR / f"{out.name}.hb.log")
44
+
45
+
46
+ def audio_ext() -> str:
47
+ """The encoded tracks' extension: AUDIO_FORMAT, or "wav" when no encoder
48
+ for it is installed and the ripped WAV is copied as it is."""
49
+ if (cfg.FLAC and cfg.AUDIO_FORMAT == "flac") or cfg.FFMPEG:
50
+ return cfg.AUDIO_FORMAT
51
+ return "wav"
52
+
53
+
54
+ def encode_audio_one(src: Path, out: Path) -> None:
55
+ def cmd_for(tmp: Path) -> list:
56
+ if cfg.FLAC and cfg.AUDIO_FORMAT == "flac":
57
+ return [cfg.FLAC, "-s", "-f", "-o", str(tmp), str(src)]
58
+ if cfg.FFMPEG:
59
+ return [cfg.FFMPEG, "-y", "-i", str(src), "-c:a", cfg.AUDIO_FORMAT, str(tmp)]
60
+ return ["cp", str(src), str(tmp)]
61
+ _encode_to(out, cmd_for, cfg.LOGDIR / f"{out.name}.enc.log")
62
+
63
+
64
+ def _run_batched(jobs, worker) -> None:
65
+ """Runs `worker(job)` for each job, ENCODE_JOBS at a time in the
66
+ background, waiting for each batch before starting the next - matches
67
+ HandBrake's own single-threaded-per-process model, so ENCODE_JOBS
68
+ processes run in parallel rather than one process trying to use them all.
69
+ """
70
+ import threading
71
+ batch = []
72
+ for job in jobs:
73
+ t = threading.Thread(target=worker, args=(job,))
74
+ t.start()
75
+ batch.append(t)
76
+ if len(batch) >= cfg.ENCODE_JOBS:
77
+ for t in batch:
78
+ t.join()
79
+ batch = []
80
+ for t in batch:
81
+ t.join()
82
+
83
+
84
+ def process_video_job(label: str, dest: Path) -> None:
85
+ counts = {c.name: 0 for c in cfg.DURATION_CLASSES}
86
+ n_already = 0
87
+ seen = {c.name: set() for c in cfg.DURATION_CLASSES if c.dedup}
88
+
89
+ def route(secs, ident):
90
+ """The DurationClass for secs, logging + returning None if the
91
+ title is skipped (no matching class, or a deduped repeat)."""
92
+ cls = classify_class(secs)
93
+ if cls is None:
94
+ log(cfg.ENCLOG, f" skip {ident} ({secs}s - no matching duration class)")
95
+ return None
96
+ if cls.dedup:
97
+ if secs in seen[cls.name]:
98
+ log(cfg.ENCLOG, f" dup {ident} ({secs}s) already taken")
99
+ return None
100
+ seen[cls.name].add(secs)
101
+ return cls
102
+
103
+ video_ts = dest / "VIDEO_TS"
104
+ if cfg.RIP_MODE == "video_ts" and video_ts.is_dir():
105
+ jobs = []
106
+ for idx, secs in hb_titles(str(video_ts)):
107
+ if idx is None:
108
+ continue
109
+ cls = route(secs, f"{label} t{idx}")
110
+ if cls is None:
111
+ continue
112
+ counts[cls.name] += 1
113
+ outdir = dest / cls.folder
114
+ out = outdir / f"title{idx:02d}.mkv"
115
+ if out.exists() and out.stat().st_size > 0:
116
+ n_already += 1
117
+ continue
118
+ log(cfg.ENCLOG, f" -> {label} t{idx} {secs // 60}m {cls.name} -> {outdir.name}/{out.name}")
119
+ jobs.append((str(video_ts), idx, out, cls.quality))
120
+ _run_batched(jobs, lambda j: encode_video_one(*j))
121
+ else:
122
+ src = dest / "source"
123
+ if not src.is_dir():
124
+ log(cfg.ENCLOG, f"no source/ in {dest}")
125
+ return
126
+ jobs = []
127
+ for f in sorted(src.glob("*.mkv")):
128
+ secs = file_duration(str(f))
129
+ cls = route(secs, f.name)
130
+ if cls is None:
131
+ continue
132
+ counts[cls.name] += 1
133
+ outdir = dest / cls.folder
134
+ out = outdir / f.name
135
+ if out.exists() and out.stat().st_size > 0:
136
+ n_already += 1
137
+ continue
138
+ log(cfg.ENCLOG, f" -> {f.name} {secs // 60}m {cls.name} -> {outdir.name}/")
139
+ jobs.append((str(f), None, out, cls.quality))
140
+ _run_batched(jobs, lambda j: encode_video_one(*j))
141
+
142
+ if sum(counts.values()) + n_already == 0:
143
+ log(cfg.ENCLOG, f"WARN {label} - nothing encodable found. Check {cfg.LOGDIR}/")
144
+ notify(f"backcrack - {label} nothing found", "No titles matched a duration class.", "high", "warning")
145
+ else:
146
+ breakdown = ", ".join(f"{n} {name}" for name, n in counts.items() if n) or "nothing new"
147
+ log(cfg.ENCLOG, f"DONE {label} - {breakdown}, {n_already} already present")
148
+ if cfg.NOTIFY_ENCODES:
149
+ notify(f"backcrack - {label} encoded", breakdown, "low", "film_projector")
150
+
151
+
152
+ def process_audio_job(label: str, dest: Path) -> None:
153
+ src = dest / "source"
154
+ if not src.is_dir():
155
+ log(cfg.ENCLOG, f"no source/ in {dest}")
156
+ return
157
+
158
+ n_done = n_skip = 0
159
+ jobs = []
160
+ ext = audio_ext()
161
+ for f in sorted(src.glob("*.wav")):
162
+ # cdparanoia -B names tracks track01.cdda.wav
163
+ out = dest / "encoded" / f"{f.stem.removesuffix('.cdda')}.{ext}"
164
+ if out.exists() and out.stat().st_size > 0:
165
+ n_skip += 1
166
+ continue
167
+ log(cfg.ENCLOG, f" -> {f.name} -> encoded/{out.name}")
168
+ jobs.append((f, out))
169
+ n_done += 1
170
+ _run_batched(jobs, lambda j: encode_audio_one(*j))
171
+
172
+ if n_done + n_skip == 0:
173
+ log(cfg.ENCLOG, f"WARN {label} - no tracks found in source/. Check {cfg.LOGDIR}/")
174
+ notify(f"backcrack - {label} nothing found", "No tracks in source/.", "high", "warning")
175
+ else:
176
+ log(cfg.ENCLOG, f"DONE {label} - {n_done} tracks, {n_skip} already present")
177
+ if cfg.NOTIFY_ENCODES:
178
+ notify(f"backcrack - {label} encoded", f"{n_done} tracks", "low", "film_projector")
179
+
180
+
181
+ def process_job(jobfile: Path) -> None:
182
+ fields = dict(
183
+ line.split("=", 1) for line in jobfile.read_text().splitlines() if "=" in line
184
+ )
185
+ label, dest_s, kind = fields.get("label"), fields.get("dest"), fields.get("kind")
186
+ if not label or not dest_s or not Path(dest_s).is_dir():
187
+ log(cfg.ENCLOG, f"BAD JOB {jobfile}")
188
+ jobfile.unlink(missing_ok=True)
189
+ return
190
+
191
+ dest = Path(dest_s)
192
+ log(cfg.ENCLOG, f"ENCODE {label} ({kind})")
193
+ if kind == "audio":
194
+ process_audio_job(label, dest)
195
+ else:
196
+ process_video_job(label, dest)
197
+ (cfg.ENCDONE / label).touch()
198
+ jobfile.unlink(missing_ok=True)