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/__init__.py
ADDED
|
File without changes
|
backcrack/__main__.py
ADDED
backcrack/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Bare `backcrack` starts ripd, encd and sortd in the background
|
|
2
|
+
and opens the live TUI. Toggle LAUNCH_RIPD / LAUNCH_ENCD / LAUNCH_SORTD /
|
|
3
|
+
LAUNCH_WATCH in settings.env (or as env vars) to run any of them by hand instead.
|
|
4
|
+
|
|
5
|
+
backcrack ripd + encd + sortd in the background, then the live TUI
|
|
6
|
+
backcrack ripd
|
|
7
|
+
backcrack encd
|
|
8
|
+
backcrack status
|
|
9
|
+
backcrack watch
|
|
10
|
+
backcrack sortd
|
|
11
|
+
backcrack swapd
|
|
12
|
+
backcrack titles "/path/to/disc"
|
|
13
|
+
backcrack diskspeed
|
|
14
|
+
backcrack namer ["Season N"]
|
|
15
|
+
backcrack doctor what it needs installed, found or how to get it
|
|
16
|
+
|
|
17
|
+
Settings come from env vars, then settings.env, then backcrack/config.py's
|
|
18
|
+
defaults, the same as running each tool directly:
|
|
19
|
+
|
|
20
|
+
LIBRARY=~/Media/rips backcrack ripd
|
|
21
|
+
"""
|
|
22
|
+
import argparse
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
from backcrack import diskspeed
|
|
26
|
+
from backcrack import encd
|
|
27
|
+
from backcrack import namer
|
|
28
|
+
from backcrack import ripd
|
|
29
|
+
from backcrack import sortd
|
|
30
|
+
from backcrack import status
|
|
31
|
+
from backcrack import swapd
|
|
32
|
+
from backcrack import titles
|
|
33
|
+
from backcrack import watch
|
|
34
|
+
from backcrack import config as cfg
|
|
35
|
+
from backcrack.common import find_daemons, spawn_daemon
|
|
36
|
+
from backcrack.deps import doctor
|
|
37
|
+
|
|
38
|
+
COMMANDS = {
|
|
39
|
+
"ripd": ripd.main,
|
|
40
|
+
"encd": encd.main,
|
|
41
|
+
"sortd": sortd.main,
|
|
42
|
+
"watch": watch.main,
|
|
43
|
+
"status": status.main,
|
|
44
|
+
"swapd": swapd.main,
|
|
45
|
+
"titles": titles.main,
|
|
46
|
+
"diskspeed": diskspeed.main,
|
|
47
|
+
"namer": namer.main,
|
|
48
|
+
"doctor": doctor,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def launch_all() -> None:
|
|
53
|
+
started = []
|
|
54
|
+
for name, wanted in (("ripd", cfg.LAUNCH_RIPD), ("encd", cfg.LAUNCH_ENCD), ("sortd", cfg.LAUNCH_SORTD)):
|
|
55
|
+
if wanted and not find_daemons(name):
|
|
56
|
+
spawn_daemon(name)
|
|
57
|
+
started.append(name)
|
|
58
|
+
|
|
59
|
+
if cfg.LAUNCH_WATCH:
|
|
60
|
+
watch.main()
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
if started:
|
|
64
|
+
print(f"{' + '.join(started)} launched in the background - logs in {cfg.STATE}/*.out")
|
|
65
|
+
print("`backcrack watch` to view, `backcrack status` to check.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> None:
|
|
69
|
+
parser = argparse.ArgumentParser(prog="backcrack", description=__doc__,
|
|
70
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
71
|
+
parser.add_argument("command", choices=COMMANDS, nargs="?", default=None)
|
|
72
|
+
parser.add_argument("args", nargs=argparse.REMAINDER)
|
|
73
|
+
parsed = parser.parse_args()
|
|
74
|
+
|
|
75
|
+
if parsed.command is None:
|
|
76
|
+
launch_all()
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
sys.argv = [parsed.command] + parsed.args
|
|
80
|
+
COMMANDS[parsed.command]()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
main()
|
backcrack/common.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""backbone's helpers bound to backcrack's settings, and what only a ripper needs."""
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from backbone.files import log_line
|
|
6
|
+
from backbone.notify import ntfy
|
|
7
|
+
from backbone.procs import find_processes, spawn_module, stop_processes
|
|
8
|
+
|
|
9
|
+
from backcrack import config as cfg
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# The general parts live in backbone; these bind backcrack's own settings.
|
|
13
|
+
log = log_line # a daemon's timestamped record, printed and appended
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def notify(title: str, message: str, priority: str = "default", tags: str = "optical_disk") -> None:
|
|
17
|
+
"""Push to your ntfy topic (NTFY_TOPIC); nothing is sent without one."""
|
|
18
|
+
ntfy(cfg.NTFY_SERVER, cfg.NTFY_TOPIC, title, message, priority, tags)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def find_daemons(*names: str) -> list:
|
|
22
|
+
"""(pid, command) for each running backcrack daemon in `names`, however it
|
|
23
|
+
was started, including as `backcrack <name>`."""
|
|
24
|
+
return find_processes(*names, launcher="backcrack")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def stop_daemons(*names: str) -> int:
|
|
28
|
+
"""SIGTERM every running backcrack daemon in `names`; returns how many."""
|
|
29
|
+
return stop_processes(*names, launcher="backcrack")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def spawn_daemon(name: str) -> None:
|
|
33
|
+
"""Start the `name` daemon in the background, output to STATE/<name>.out."""
|
|
34
|
+
cfg.make_library_dirs()
|
|
35
|
+
spawn_module(f"backcrack.{name}", cfg.STATE / f"{name}.out")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def class_folders() -> list:
|
|
39
|
+
"""Each duration class's output folder, once each, in config order."""
|
|
40
|
+
return list(dict.fromkeys(c.folder for c in cfg.DURATION_CLASSES))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def class_file_counts() -> dict:
|
|
44
|
+
"""{folder: files in every <folder>/ under LIBRARY} for class_folders()."""
|
|
45
|
+
return {f: sum(1 for _ in cfg.LIBRARY.glob(f"**/{f}/*")) if cfg.LIBRARY.exists() else 0
|
|
46
|
+
for f in class_folders()}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def classify_class(seconds):
|
|
50
|
+
"""The cfg.DurationClass a video title of `seconds` falls in, or None if
|
|
51
|
+
it fits none of them."""
|
|
52
|
+
try:
|
|
53
|
+
s = int(seconds)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
return None
|
|
56
|
+
for c in cfg.DURATION_CLASSES:
|
|
57
|
+
if c.min_s <= s <= c.max_s:
|
|
58
|
+
return c
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def classify(seconds) -> str:
|
|
63
|
+
cls = classify_class(seconds)
|
|
64
|
+
return cls.name if cls else "skip"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def hb_json(input_path: str) -> dict:
|
|
68
|
+
"""HandBrake's JSON title set for a disc folder or media file."""
|
|
69
|
+
try:
|
|
70
|
+
out = subprocess.run(
|
|
71
|
+
[cfg.HBCLI, "-i", input_path, "--title", "0", "--scan", "--json"],
|
|
72
|
+
capture_output=True, text=True, timeout=120,
|
|
73
|
+
).stdout
|
|
74
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
75
|
+
return {}
|
|
76
|
+
marker = "JSON Title Set:"
|
|
77
|
+
idx = out.find(marker)
|
|
78
|
+
if idx < 0:
|
|
79
|
+
return {}
|
|
80
|
+
try:
|
|
81
|
+
return json.loads(out[idx + len(marker):])
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def hb_titles(input_path: str):
|
|
87
|
+
"""Yields (index, seconds) for every title HandBrake found, unfiltered."""
|
|
88
|
+
for t in hb_json(input_path).get("TitleList", []):
|
|
89
|
+
d = t.get("Duration", {})
|
|
90
|
+
secs = d.get("Hours", 0) * 3600 + d.get("Minutes", 0) * 60 + d.get("Seconds", 0)
|
|
91
|
+
yield t.get("Index"), secs
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def file_duration(path: str) -> int:
|
|
95
|
+
titles = hb_json(path).get("TitleList", [])
|
|
96
|
+
if not titles:
|
|
97
|
+
return 0
|
|
98
|
+
d = titles[0].get("Duration", {})
|
|
99
|
+
return d.get("Hours", 0) * 3600 + d.get("Minutes", 0) * 60 + d.get("Seconds", 0)
|
backcrack/config.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Settings for the backcrack rip pipeline, and their defaults.
|
|
2
|
+
|
|
3
|
+
Each value is read from its environment variable first, then from
|
|
4
|
+
settings.env in CONFIG_DIR, then falls back to the default here. So a
|
|
5
|
+
one-off override needs no edit: `RIP_MODE=video_ts ripd`.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from backbone import ui as _ui
|
|
13
|
+
|
|
14
|
+
# Everything you edit or that edits itself lives OUTSIDE the checkout, so the
|
|
15
|
+
# repo stays code-only and nothing personal needs gitignoring: your saved
|
|
16
|
+
# settings, your labels.map overrides, your episodes.map titles. Same
|
|
17
|
+
# convention as backtrack's CONFIG_DIR - $BACKCRACK_CONFIG_DIR wins, then
|
|
18
|
+
# $XDG_CONFIG_HOME/backcrack, else ~/.config/backcrack. (No Windows branch:
|
|
19
|
+
# this pipeline shells out to /Applications/MakeMKV.app and Homebrew binaries,
|
|
20
|
+
# so it is macOS/Linux by construction.)
|
|
21
|
+
def _default_config_dir() -> Path:
|
|
22
|
+
xdg = os.getenv("XDG_CONFIG_HOME")
|
|
23
|
+
return (Path(xdg) if xdg else Path.home() / ".config") / "backcrack"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
CONFIG_DIR = Path(os.getenv("BACKCRACK_CONFIG_DIR") or _default_config_dir())
|
|
27
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
# backbone keeps the key bindings, the hints switch and its log here too.
|
|
30
|
+
from backbone import app as _app # noqa: E402
|
|
31
|
+
_app.configure("backcrack", CONFIG_DIR)
|
|
32
|
+
|
|
33
|
+
# Settings changed from `backcrack watch`'s settings screen land here rather
|
|
34
|
+
# than in real env vars, so they survive past this process without editing
|
|
35
|
+
# this file. os.environ still wins when set (a one-off `FOO=bar` override
|
|
36
|
+
# stays the escape hatch it always was) - this is just a second, persisted
|
|
37
|
+
# fallback beneath it.
|
|
38
|
+
SETTINGS_FILE = CONFIG_DIR / "settings.env"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_settings_file() -> dict:
|
|
42
|
+
d = {}
|
|
43
|
+
if SETTINGS_FILE.exists():
|
|
44
|
+
for line in SETTINGS_FILE.read_text().splitlines():
|
|
45
|
+
line = line.strip()
|
|
46
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
47
|
+
continue
|
|
48
|
+
k, v = line.split("=", 1)
|
|
49
|
+
d[k.strip()] = v.strip()
|
|
50
|
+
return d
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_FILE_SETTINGS = _load_settings_file()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _env(name: str, default: str) -> str:
|
|
57
|
+
return os.environ.get(name, _FILE_SETTINGS.get(name, default))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _env_int(name: str, default: int) -> int:
|
|
61
|
+
return int(_env(name, str(default)))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_setting(name: str, value: str) -> None:
|
|
65
|
+
"""Persist one setting to SETTINGS_FILE, replacing its line if already
|
|
66
|
+
present. Only processes started afterwards see it (ripd/encd need
|
|
67
|
+
restarting); a caller that wants it in its own running `cfg` has to set
|
|
68
|
+
that too (see watch.py).
|
|
69
|
+
"""
|
|
70
|
+
lines = SETTINGS_FILE.read_text().splitlines() if SETTINGS_FILE.exists() else []
|
|
71
|
+
out, found = [], False
|
|
72
|
+
for line in lines:
|
|
73
|
+
if line.split("=", 1)[0].strip() == name:
|
|
74
|
+
out.append(f"{name}={value}")
|
|
75
|
+
found = True
|
|
76
|
+
else:
|
|
77
|
+
out.append(line)
|
|
78
|
+
if not found:
|
|
79
|
+
out.append(f"{name}={value}")
|
|
80
|
+
SETTINGS_FILE.write_text("\n".join(out) + "\n")
|
|
81
|
+
_FILE_SETTINGS[name] = value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class DurationClass:
|
|
86
|
+
name: str
|
|
87
|
+
min_s: int
|
|
88
|
+
max_s: int
|
|
89
|
+
folder: str # output subfolder under a disc's dest, e.g. "encoded"
|
|
90
|
+
quality: str # HandBrake --quality (RF) for titles in this class
|
|
91
|
+
dedup: bool = False # drop a second title with this exact duration -
|
|
92
|
+
# discs sometimes list the main feature twice; two
|
|
93
|
+
# short extras sharing a duration is coincidence
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_duration_classes(spec: str) -> list:
|
|
97
|
+
"""name:min-max:folder:quality[:dedup], comma-separated. First match
|
|
98
|
+
wins, so keep ranges non-overlapping. A duration matching none of them
|
|
99
|
+
is not encoded at all.
|
|
100
|
+
"""
|
|
101
|
+
classes = []
|
|
102
|
+
for part in spec.split(","):
|
|
103
|
+
part = part.strip()
|
|
104
|
+
if not part:
|
|
105
|
+
continue
|
|
106
|
+
name, rng, folder, quality, *rest = part.split(":")
|
|
107
|
+
lo, hi = rng.split("-")
|
|
108
|
+
classes.append(DurationClass(name, int(lo), int(hi), folder, quality, rest == ["dedup"]))
|
|
109
|
+
return classes
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---- ntfy --------------------------------------------------------------
|
|
113
|
+
# Anyone who knows a topic name can read it on the public ntfy.sh server,
|
|
114
|
+
# so generate your own - don't leave this blank in production use.
|
|
115
|
+
NTFY_TOPIC = _env("NTFY_TOPIC", "")
|
|
116
|
+
NTFY_SERVER = _env("NTFY_SERVER", "https://ntfy.sh")
|
|
117
|
+
|
|
118
|
+
# Where finished discs land. The layout beneath this is controlled by
|
|
119
|
+
# PATTERN_VIDEO / PATTERN_AUDIO below, not fixed here - point LIBRARY at
|
|
120
|
+
# whatever you're ripping this run (one show, one CD shelf, a mixed pile).
|
|
121
|
+
# The default suits one particular show; set LIBRARY rather than rely on it.
|
|
122
|
+
LIBRARY = Path(_env("LIBRARY", str(Path.home() / "Media" / "rips"))).expanduser()
|
|
123
|
+
|
|
124
|
+
# MKV is diskspeed's old name for it, still honoured.
|
|
125
|
+
MKVCON = _env("MKVCON", _env("MKV", shutil.which("makemkvcon")
|
|
126
|
+
or "/Applications/MakeMKV.app/Contents/MacOS/makemkvcon"))
|
|
127
|
+
HBCLI = _env("HBCLI", shutil.which("HandBrakeCLI") or "/opt/homebrew/bin/HandBrakeCLI")
|
|
128
|
+
CDPARANOIA = _env("CDPARANOIA", shutil.which("cdparanoia") or "")
|
|
129
|
+
CD_DISCID = _env("CD_DISCID", shutil.which("cd-discid") or "")
|
|
130
|
+
FLAC = _env("FLAC", shutil.which("flac") or "")
|
|
131
|
+
FFMPEG = _env("FFMPEG", shutil.which("ffmpeg") or "")
|
|
132
|
+
|
|
133
|
+
# ---- rip mode (video discs: DVD, Blu-ray) -------------------------------
|
|
134
|
+
# titles MakeMKV rips each title to its own lossless MKV via dev: - gets
|
|
135
|
+
# DIRECT disc access mode, needs no drive index, and MakeMKV does
|
|
136
|
+
# the title detection. Captures the main feature(s) and extras
|
|
137
|
+
# alike. Works for both DVD and Blu-ray.
|
|
138
|
+
# video_ts Full decrypted disc backup, menus included. DVD only - Blu-ray
|
|
139
|
+
# has no VIDEO_TS folder. Needs a MakeMKV drive index, resolved at
|
|
140
|
+
# rip time, so it runs in the slower OS access mode.
|
|
141
|
+
RIP_MODE = _env("RIP_MODE", "titles")
|
|
142
|
+
|
|
143
|
+
# ---- duration classes (seconds, video titles only) -----------------------
|
|
144
|
+
# Each class routes a title's duration to its own output folder and quality.
|
|
145
|
+
# A title matching none of them isn't encoded at all - a logo/menu loop
|
|
146
|
+
# below the shortest class, or a "play all" runtime above the longest one.
|
|
147
|
+
# Run titles.py on the first disc of each kind you rip and tune these to
|
|
148
|
+
# match it: a 22-minute sitcom and a 150-minute film need very different
|
|
149
|
+
# windows, and a gap between classes silently skips whatever runs in it.
|
|
150
|
+
# Add, remove, resize, or rename classes freely: nothing hardcodes
|
|
151
|
+
# "main"/"extra". The folder names are another matter: audio always goes to
|
|
152
|
+
# "encoded", and namer.py only works with "encoded" and "extras".
|
|
153
|
+
# dedup is off by default and wrong for TV; see the README before using it.
|
|
154
|
+
DURATION_CLASSES = _parse_duration_classes(_env(
|
|
155
|
+
"DURATION_CLASSES", "extra:180-1199:extras:22,main:1200-10800:encoded:20"
|
|
156
|
+
))
|
|
157
|
+
|
|
158
|
+
# Titles shorter than this never get ripped at all (MakeMKV's own
|
|
159
|
+
# --minlength) - defaults to the shortest configured class's floor, so a
|
|
160
|
+
# menu loop below every window doesn't even land in source/. Set explicitly
|
|
161
|
+
# to rip shorter junk than you keep, e.g. for manual review.
|
|
162
|
+
MIN_TITLE_S = _env_int("MIN_TITLE_S", min((c.min_s for c in DURATION_CLASSES), default=180))
|
|
163
|
+
|
|
164
|
+
# ---- encoding: video -----------------------------------------------------
|
|
165
|
+
# The defaults for jobs, preset and threads were benchmarked on an M1 Pro;
|
|
166
|
+
# see the README's "Encoding throughput" before changing them.
|
|
167
|
+
ENCODE_JOBS = _env_int("ENCODE_JOBS", 4)
|
|
168
|
+
VIDEO_ENCODER = _env("VIDEO_ENCODER", "x264")
|
|
169
|
+
ENCODER_PRESET = _env("ENCODER_PRESET", "fast")
|
|
170
|
+
|
|
171
|
+
# Caps x264's threads per job, so a job left running alone can't take every
|
|
172
|
+
# core and starve the rips (README, "Encoding throughput").
|
|
173
|
+
ENCODE_THREADS = _env_int("ENCODE_THREADS", max(1, (os.cpu_count() or 4) // ENCODE_JOBS))
|
|
174
|
+
|
|
175
|
+
# HandBrake's deinterlace flags, e.g. "--comb-detect --decomb". Only helps
|
|
176
|
+
# on interlaced sources (older TV masters, some DVD). Leave empty for
|
|
177
|
+
# progressive film/Blu-ray sources - decombing a progressive source can
|
|
178
|
+
# introduce artefacts that were never there. Check one disc either way.
|
|
179
|
+
DEINTERLACE_ARGS = _env("DEINTERLACE_ARGS", "").split()
|
|
180
|
+
|
|
181
|
+
# ---- encoding: audio (CD) ------------------------------------------------
|
|
182
|
+
# Lossless, so this is a format choice, not a quality dial. "flac" uses the
|
|
183
|
+
# flac(1) binary, or ffmpeg if that's what's installed; any other format
|
|
184
|
+
# needs ffmpeg. With no encoder for it, tracks are copied as .wav instead
|
|
185
|
+
# (encode.audio_ext).
|
|
186
|
+
AUDIO_FORMAT = _env("AUDIO_FORMAT", "flac")
|
|
187
|
+
|
|
188
|
+
NOTIFY_ENCODES = _env("NOTIFY_ENCODES", "0") == "1"
|
|
189
|
+
|
|
190
|
+
# ---- bare `backcrack` launches everything ---------------------------------
|
|
191
|
+
# Toggle any of these off to run that piece by hand instead (its own
|
|
192
|
+
# terminal tab, a separate machine, whatever) - `backcrack ripd`/`encd`/
|
|
193
|
+
# `sortd`/`watch` always still work standalone regardless of these.
|
|
194
|
+
LAUNCH_RIPD = _env("LAUNCH_RIPD", "1") == "1"
|
|
195
|
+
LAUNCH_ENCD = _env("LAUNCH_ENCD", "1") == "1"
|
|
196
|
+
LAUNCH_SORTD = _env("LAUNCH_SORTD", "1") == "1"
|
|
197
|
+
LAUNCH_WATCH = _env("LAUNCH_WATCH", "1") == "1"
|
|
198
|
+
|
|
199
|
+
# ---- watch, sortd, diskspeed ----------------------------------------------
|
|
200
|
+
# WATCH_INTERVAL and SORT_INTERVAL fall back to a plain INTERVAL if one is set.
|
|
201
|
+
WATCH_INTERVAL = float(_env("WATCH_INTERVAL", _env("INTERVAL", "1")))
|
|
202
|
+
SORT_INTERVAL = _env_int("SORT_INTERVAL", int(_env("INTERVAL", "5")))
|
|
203
|
+
TOTAL_DISCS = _env_int("TOTAL_DISCS", 0) # discs in this run; 0 hides the percentage and ETA
|
|
204
|
+
WINDOW = _env_int("WINDOW", 20) # seconds of history behind watch's MB/s figure
|
|
205
|
+
ACTIVE_S = _env_int("ACTIVE_S", 90) # a source/ idle this long drops out of RIPPING
|
|
206
|
+
FALLBACK_KB = _env_int("FALLBACK_KB", 7340032) # disc size assumed until diskutil reports it
|
|
207
|
+
READ_MB = _env_int("READ_MB", 600) # diskspeed: how much to read per drive
|
|
208
|
+
SKIP_MB = _env_int("SKIP_MB", 1000) # diskspeed: where on the disc to start reading
|
|
209
|
+
STALL_S = _env_int("STALL_S", 60) # diskspeed: give up on a drive after this long without progress
|
|
210
|
+
DISC_BYTES = _env_int("DISC_BYTES", 7_000_000_000) # diskspeed: disc size for its minutes-per-disc estimate
|
|
211
|
+
RESULTS = Path(_env("RESULTS", str(Path.home() / "diskspeed.txt")))
|
|
212
|
+
|
|
213
|
+
# ---- dynamic patterning ---------------------------------------------------
|
|
214
|
+
# Destination path under LIBRARY, built by substituting %token% once a
|
|
215
|
+
# disc's tokens are known - from labels.map, or from auto-detection (season/
|
|
216
|
+
# disc parsed from the volume label; artist/album looked up online for audio,
|
|
217
|
+
# when the tools for that are installed). See docs/pattern-tokens.md.
|
|
218
|
+
# An unresolved token falls the disc to UNSORTED/ rather than guess - the
|
|
219
|
+
# same safety net as an unparseable label.
|
|
220
|
+
PATTERN_VIDEO = _env("PATTERN_VIDEO", "Season %season%/Disc %disc%")
|
|
221
|
+
PATTERN_AUDIO = _env("PATTERN_AUDIO", "%artist%/%album%")
|
|
222
|
+
|
|
223
|
+
# Give up on a disc after this many failed attempts, then eject it and move
|
|
224
|
+
# on. A failed disc is deliberately left in the drive so it retries itself.
|
|
225
|
+
MAX_RETRIES = _env_int("MAX_RETRIES", 3)
|
|
226
|
+
|
|
227
|
+
# ---- settings exposed to `backcrack watch`'s settings screen -------------
|
|
228
|
+
# (env var, type, one-line label). Add a tuple here for any setting above
|
|
229
|
+
# that should be editable there - type is "str", "int", "bool", or "path".
|
|
230
|
+
# Nothing else in the pipeline reads this list; it's UI metadata only.
|
|
231
|
+
SETTINGS = [
|
|
232
|
+
("LIBRARY", "path", "Library root"),
|
|
233
|
+
("RIP_MODE", "str", "Rip mode (titles / video_ts)"),
|
|
234
|
+
("DURATION_CLASSES", "str", "Duration classes (name:min-max:folder:quality[:dedup],...)"),
|
|
235
|
+
("ENCODE_JOBS", "int", "Concurrent encode jobs"),
|
|
236
|
+
("VIDEO_ENCODER", "str", "HandBrake video encoder"),
|
|
237
|
+
("ENCODER_PRESET", "str", "HandBrake encoder preset"),
|
|
238
|
+
("ENCODE_THREADS", "int", "Threads per encode job"),
|
|
239
|
+
("DEINTERLACE_ARGS", "str", "Deinterlace args (blank = off)"),
|
|
240
|
+
("AUDIO_FORMAT", "str", "Audio rip format"),
|
|
241
|
+
("PATTERN_VIDEO", "str", "Video destination pattern"),
|
|
242
|
+
("PATTERN_AUDIO", "str", "Audio destination pattern"),
|
|
243
|
+
("MAX_RETRIES", "int", "Max retries before giving up on a disc"),
|
|
244
|
+
("NOTIFY_ENCODES", "bool", "Notify on encode complete"),
|
|
245
|
+
("LAUNCH_RIPD", "bool", "Bare `backcrack` also launches ripd"),
|
|
246
|
+
("LAUNCH_ENCD", "bool", "Bare `backcrack` also launches encd"),
|
|
247
|
+
("LAUNCH_SORTD", "bool", "Bare `backcrack` also launches sortd"),
|
|
248
|
+
("LAUNCH_WATCH", "bool", "Bare `backcrack` also launches watch"),
|
|
249
|
+
("MIN_TITLE_S", "int", "Shortest title MakeMKV rips (seconds)"),
|
|
250
|
+
("NTFY_TOPIC", "str", "ntfy topic (blank = no pushes)"),
|
|
251
|
+
("NTFY_SERVER", "str", "ntfy server"),
|
|
252
|
+
("ACCENT", "accent", "Accent colour (green, red, blue, amber… or #RRGGBB)"),
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
# The accent colour of every screen (backbone.ui.ACCENT_PRESETS key or
|
|
256
|
+
# "#RRGGBB"), applied here once so each tool gets it by importing config.
|
|
257
|
+
ACCENT = _env("ACCENT", "green")
|
|
258
|
+
_ui.set_accent(ACCENT)
|
|
259
|
+
|
|
260
|
+
# Derived. Don't edit.
|
|
261
|
+
# Your files, in CONFIG_DIR (see the top of this file): manual disc->token
|
|
262
|
+
# overrides, and the per-season episode titles namer.py renames from.
|
|
263
|
+
LABELS_MAP = CONFIG_DIR / "labels.map"
|
|
264
|
+
EPISODES_MAP = CONFIG_DIR / "episodes.map"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def use_library(path: Path) -> None:
|
|
268
|
+
"""Point LIBRARY and every state path under it at `path`."""
|
|
269
|
+
global LIBRARY, STATE, LOGDIR, QUEUE, DONEDIR, ENCDONE, RIPLOG, ENCLOG
|
|
270
|
+
LIBRARY = path
|
|
271
|
+
STATE = LIBRARY / ".ripstate"
|
|
272
|
+
LOGDIR = STATE / "logs"
|
|
273
|
+
QUEUE = STATE / "queue"
|
|
274
|
+
DONEDIR = STATE / "done"
|
|
275
|
+
ENCDONE = STATE / "encdone"
|
|
276
|
+
RIPLOG = STATE / "rip.log"
|
|
277
|
+
ENCLOG = STATE / "encode.log"
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def make_library_dirs() -> None:
|
|
281
|
+
"""Create LIBRARY and its state folders; only the daemons that write there call this."""
|
|
282
|
+
for d in (LIBRARY, STATE, LOGDIR, QUEUE, DONEDIR, ENCDONE):
|
|
283
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
use_library(LIBRARY)
|
backcrack/deps.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""What backcrack needs besides Python (see backbone.deps): the rippers and
|
|
2
|
+
encoders it drives, at the paths config resolved. Reported by
|
|
3
|
+
`backcrack doctor`; each daemon warns about the ones its own work needs."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from backbone.deps import Dep
|
|
8
|
+
|
|
9
|
+
from backcrack import config as cfg
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _path(value: str):
|
|
13
|
+
"""A probe for a configured tool path: found when it exists."""
|
|
14
|
+
return lambda: value if value and os.path.exists(value) else None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def deps() -> list[Dep]:
|
|
18
|
+
return [
|
|
19
|
+
Dep("MakeMKV", _path(cfg.MKVCON), "ripping DVDs and Blu-rays", hints={
|
|
20
|
+
"macos": "brew install --cask makemkv", "other": "https://www.makemkv.com/download/"}),
|
|
21
|
+
Dep("HandBrake", _path(cfg.HBCLI), "encoding video", hints={
|
|
22
|
+
"macos": "brew install handbrake", "debian": "sudo apt install handbrake-cli",
|
|
23
|
+
"other": "https://handbrake.fr/downloads2.php"}),
|
|
24
|
+
Dep("cdparanoia", _path(cfg.CDPARANOIA), "ripping audio CDs", hints={
|
|
25
|
+
"macos": "brew install cdparanoia", "debian": "sudo apt install cdparanoia"}),
|
|
26
|
+
Dep("flac", _path(cfg.FLAC), "encoding CD audio", hints={
|
|
27
|
+
"macos": "brew install flac", "debian": "sudo apt install flac"}),
|
|
28
|
+
Dep("cd-discid", _path(cfg.CD_DISCID), "looking up CDs", hints={
|
|
29
|
+
"macos": "brew install cd-discid", "debian": "sudo apt install cd-discid"}),
|
|
30
|
+
Dep("ffmpeg", _path(cfg.FFMPEG), "audio formats other than FLAC", hints={
|
|
31
|
+
"macos": "brew install ffmpeg", "debian": "sudo apt install ffmpeg"}),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def doctor() -> None:
|
|
36
|
+
"""backcrack doctor: every tool it drives, found or how to install it."""
|
|
37
|
+
from backbone.deps import report_lines
|
|
38
|
+
print("\n".join(report_lines(deps())))
|
|
39
|
+
print("Paths can be set in settings.env (MKVCON, HBCLI, CDPARANOIA, FLAC, CD_DISCID, FFMPEG).")
|