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/__init__.py +0 -0
- backcrack/__main__.py +3 -0
- backcrack/cli.py +84 -0
- backcrack/common.py +99 -0
- backcrack/config.py +286 -0
- backcrack/deps.py +39 -0
- backcrack/disc.py +178 -0
- backcrack/diskspeed.py +129 -0
- backcrack/encd.py +42 -0
- backcrack/encode.py +198 -0
- backcrack/namer.py +178 -0
- backcrack/pattern.py +135 -0
- backcrack/rip.py +266 -0
- backcrack/ripd.py +83 -0
- backcrack/sort.py +146 -0
- backcrack/sortd.py +39 -0
- backcrack/status.py +59 -0
- backcrack/swapd.py +69 -0
- backcrack/titles.py +58 -0
- backcrack/watch.py +418 -0
- backpack_backcrack-0.2.0.dist-info/METADATA +360 -0
- backpack_backcrack-0.2.0.dist-info/RECORD +26 -0
- backpack_backcrack-0.2.0.dist-info/WHEEL +5 -0
- backpack_backcrack-0.2.0.dist-info/entry_points.txt +2 -0
- backpack_backcrack-0.2.0.dist-info/licenses/LICENSE +21 -0
- backpack_backcrack-0.2.0.dist-info/top_level.txt +1 -0
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)
|