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 ADDED
File without changes
backcrack/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from backcrack.cli import main
2
+
3
+ raise SystemExit(main())
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).")