backpack-backcrack 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/PKG-INFO +41 -21
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/README.md +39 -19
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/common.py +6 -2
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/config.py +9 -33
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/deps.py +3 -5
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/disc.py +45 -51
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/encode.py +1 -1
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/pattern.py +7 -3
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/rip.py +6 -1
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/ripd.py +6 -3
- backpack_backcrack-0.3.0/backcrack/settings.py +239 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/sort.py +1 -1
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/status.py +1 -1
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/swapd.py +1 -1
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/titles.py +1 -1
- backpack_backcrack-0.3.0/backcrack/watch.py +360 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backpack_backcrack.egg-info/PKG-INFO +41 -21
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backpack_backcrack.egg-info/SOURCES.txt +4 -1
- backpack_backcrack-0.3.0/backpack_backcrack.egg-info/requires.txt +1 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/pyproject.toml +2 -2
- backpack_backcrack-0.3.0/tests/test_disc_toc.py +71 -0
- backpack_backcrack-0.3.0/tests/test_settings.py +57 -0
- backpack_backcrack-0.3.0/tests/test_watch_layout.py +78 -0
- backpack_backcrack-0.2.0/backcrack/watch.py +0 -418
- backpack_backcrack-0.2.0/backpack_backcrack.egg-info/requires.txt +0 -1
- backpack_backcrack-0.2.0/tests/test_disc_toc.py +0 -51
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/LICENSE +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/__init__.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/__main__.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/cli.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/diskspeed.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/encd.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/namer.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backcrack/sortd.py +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backpack_backcrack.egg-info/dependency_links.txt +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backpack_backcrack.egg-info/entry_points.txt +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/backpack_backcrack.egg-info/top_level.txt +0 -0
- {backpack_backcrack-0.2.0 → backpack_backcrack-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: backpack-backcrack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: CD/DVD/Blu-ray rip pipeline - MakeMKV/HandBrake for video, cdparanoia for audio, dynamic %token% library patterning.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/RedEraRrow/backcrack
|
|
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
14
14
|
Requires-Python: >=3.10
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
-
Requires-Dist: backpack-backbone<0.
|
|
17
|
+
Requires-Dist: backpack-backbone<0.4,>=0.3.2
|
|
18
18
|
Dynamic: license-file
|
|
19
19
|
|
|
20
20
|
# backcrack
|
|
@@ -29,15 +29,27 @@ Python, stdlib only apart from `backbone`, the library the back* tools share
|
|
|
29
29
|
|
|
30
30
|
## Install
|
|
31
31
|
|
|
32
|
-
macOS only (it drives the drives through diskutil and drutil)
|
|
32
|
+
macOS only (it drives the drives through diskutil and drutil). Starting from
|
|
33
|
+
nothing:
|
|
34
|
+
|
|
35
|
+
# Homebrew, if you don't have it yet (it asks for your password)
|
|
36
|
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
37
|
+
# put brew on your PATH, now and in new windows (Apple Silicon; does
|
|
38
|
+
# nothing on an Intel Mac)
|
|
39
|
+
echo 'eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null)"' >> ~/.zprofile
|
|
40
|
+
eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null)"
|
|
33
41
|
|
|
34
42
|
brew install pipx
|
|
43
|
+
pipx ensurepath # then open a new terminal window
|
|
35
44
|
pipx install backpack-backcrack
|
|
36
45
|
backcrack doctor
|
|
37
46
|
|
|
47
|
+
macOS's own `python3` is too old (3.9); pipx from Homebrew brings a current
|
|
48
|
+
one. `pipx ensurepath` puts `backcrack` on your PATH for new terminal windows.
|
|
49
|
+
|
|
38
50
|
`backcrack doctor` lists the rippers and encoders it drives, found or how to
|
|
39
51
|
install each. For everything: `brew install --cask makemkv` and
|
|
40
|
-
`brew install handbrake
|
|
52
|
+
`brew install handbrake libcdio-paranoia flac`. Paths it can't find
|
|
41
53
|
itself go in `settings.env` (`MKVCON`, `HBCLI`, …).
|
|
42
54
|
|
|
43
55
|
Every tool is a subcommand of the one command, `backcrack` (`backcrack
|
|
@@ -87,15 +99,21 @@ Check progress any time:
|
|
|
87
99
|
backcrack watch # live view
|
|
88
100
|
|
|
89
101
|
In `watch`, `q` quits (and offers to stop ripd, encd and sortd with it) and
|
|
90
|
-
`s` opens the settings screen - see [Settings](#settings).
|
|
91
|
-
|
|
102
|
+
`s` opens the settings screen - see [Settings](#settings). `?` shows or hides
|
|
103
|
+
the keys along the bottom. The keys, and those of every list, can be changed
|
|
104
|
+
under Key bindings on that screen.
|
|
105
|
+
|
|
106
|
+
Each part of `watch` is in its own box: the overview, what's ripping, what's
|
|
107
|
+
encoding, and the latest rip events. In a short window the boxes that matter
|
|
108
|
+
least give up their room first (Recent, then the overview, then Encoding);
|
|
109
|
+
too small for any box, it asks for a bigger window.
|
|
92
110
|
|
|
93
111
|
## What it detects
|
|
94
112
|
|
|
95
113
|
- **DVD / Blu-ray** - MakeMKV rips each title to a lossless MKV, HandBrake
|
|
96
114
|
encodes the ones matching a configured duration class - see
|
|
97
115
|
`DURATION_CLASSES` below.
|
|
98
|
-
- **Audio CD** -
|
|
116
|
+
- **Audio CD** - cd-paranoia extracts each track losslessly
|
|
99
117
|
(`track01.cdda.wav`...), then each is compressed to `AUDIO_FORMAT` (flac
|
|
100
118
|
by default) as `track01.flac`...
|
|
101
119
|
|
|
@@ -104,8 +122,8 @@ Both land in the same shape: `source/` (lossless rip) and `encoded/`
|
|
|
104
122
|
than the main window.
|
|
105
123
|
|
|
106
124
|
A video disc is known by its volume label. An audio CD has no useful label,
|
|
107
|
-
so it is named `AudioCD-<n>tracks-<id>`, where the id is
|
|
108
|
-
|
|
125
|
+
so it is named `AudioCD-<n>tracks-<id>`, where the id is the freedb disc id worked
|
|
126
|
+
out from its track table. That name is
|
|
109
127
|
what shows in the log and in `UNSORTED/`, and what a `labels.map` line for
|
|
110
128
|
the CD has to use.
|
|
111
129
|
|
|
@@ -184,7 +202,7 @@ you see combing, set `DEINTERLACE_ARGS` to HandBrake's deinterlace flags,
|
|
|
184
202
|
e.g. `DEINTERLACE_ARGS=--comb-detect --decomb`. Leave it empty for
|
|
185
203
|
progressive sources.
|
|
186
204
|
|
|
187
|
-
For audio, `
|
|
205
|
+
For audio, `cd-paranoia -Q -d <device>` lists a disc's tracks directly if you
|
|
188
206
|
want to sanity-check before ripping.
|
|
189
207
|
|
|
190
208
|
## Encoding throughput
|
|
@@ -251,17 +269,20 @@ Each setting is read, in order, from an environment variable, then
|
|
|
251
269
|
`backcrack/config.py`. So a one-off `LIBRARY=... ripd` overrides
|
|
252
270
|
everything, and `settings.env` holds what you want every time.
|
|
253
271
|
|
|
254
|
-
`s` in `watch` opens
|
|
255
|
-
|
|
272
|
+
`s` in `watch` opens the Settings screen: most of them, in sections, each
|
|
273
|
+
with its value beside it. Enter changes one (space flips an on/off one), and
|
|
274
|
+
it's saved to `settings.env` at once. A number or a duration class that
|
|
275
|
+
can't be read isn't taken. The tool paths below aren't on it; set those in
|
|
256
276
|
`settings.env` or the environment. The daemons read their settings at
|
|
257
|
-
startup, so
|
|
277
|
+
startup, so after a change the screen says which running ones to restart.
|
|
258
278
|
|
|
259
279
|
The ones not covered elsewhere in this README:
|
|
260
280
|
|
|
261
281
|
| Setting | Default | What it does |
|
|
262
282
|
|---|---|---|
|
|
263
|
-
| `MKVCON`, `HBCLI`, `CDPARANOIA`, `
|
|
283
|
+
| `MKVCON`, `HBCLI`, `CDPARANOIA`, `FLAC`, `FFMPEG` | found on PATH (MakeMKV at `/Applications/MakeMKV.app`) | tool paths |
|
|
264
284
|
| `NOTIFY_ENCODES` | `0` | also push when a disc finishes encoding |
|
|
285
|
+
| `DEBUG` | `0` | the diagnostics log, `backcrack.log` in your config directory: what failed quietly (a MusicBrainz lookup, a HandBrake scan, a crashed rip) and why |
|
|
265
286
|
| `ACCENT` | `green` | the accent colour: `green`, `red`, `yellow`, `blue`, `magenta`, `cyan` (your terminal's own), `amber`, `coral`, `rose`, `lavender`, `sky`, `mint`, or `#RRGGBB` |
|
|
266
287
|
| `WATCH_INTERVAL` | `1` | seconds between `watch` refreshes |
|
|
267
288
|
| `SORT_INTERVAL` | `5` | seconds between `sortd` passes |
|
|
@@ -293,7 +314,7 @@ The directory is `$BACKCRACK_CONFIG_DIR` if set, else
|
|
|
293
314
|
first run. Point `BACKCRACK_CONFIG_DIR` somewhere else to keep separate sets
|
|
294
315
|
of overrides for separate shelves.
|
|
295
316
|
|
|
296
|
-
The same screen has
|
|
317
|
+
The same screen has Add a labels.map line…, which is the
|
|
297
318
|
manual escape hatch for a disc whose %tokens% couldn't be resolved - pick it
|
|
298
319
|
out of `UNSORTED/`, fill in the tokens the active pattern needs, and `sortd`
|
|
299
320
|
files it within a few seconds.
|
|
@@ -339,16 +360,15 @@ names). With another pattern or other folder names, don't use it.
|
|
|
339
360
|
|
|
340
361
|
- **MakeMKV** (`makemkvcon`) - for DVD/Blu-ray. `brew install --cask makemkv`.
|
|
341
362
|
- **HandBrakeCLI** - to encode video. `brew install handbrake`.
|
|
342
|
-
- **
|
|
363
|
+
- **cd-paranoia** - for audio CDs: ripping, and the track table that names
|
|
364
|
+
each CD and looks up its artist and album. `brew install libcdio-paranoia`
|
|
365
|
+
(or `cdparanoia` from your Linux package manager).
|
|
343
366
|
- **flac** (or ffmpeg) - to compress ripped audio. `brew install flac`.
|
|
344
367
|
With neither, tracks are copied to `encoded/` as `.wav`.
|
|
345
|
-
- **cd-discid** - optional, for automatic artist/album lookup on audio CDs.
|
|
346
|
-
`brew install cd-discid`. Without it, every CD goes to `UNSORTED/` until
|
|
347
|
-
you add a `labels.map` line.
|
|
348
368
|
|
|
349
369
|
`backcrack doctor` checks all of them. A missing tool also gets a warning at
|
|
350
|
-
startup rather than a silent failure: `ripd` warns about makemkvcon
|
|
351
|
-
|
|
370
|
+
startup rather than a silent failure: `ripd` warns about makemkvcon and
|
|
371
|
+
cd-paranoia, and `encd` about HandBrakeCLI and flac/ffmpeg. The
|
|
352
372
|
daemon still runs; that disc kind just won't rip or encode until the tool is
|
|
353
373
|
installed.
|
|
354
374
|
|
|
@@ -10,15 +10,27 @@ Python, stdlib only apart from `backbone`, the library the back* tools share
|
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
13
|
-
macOS only (it drives the drives through diskutil and drutil)
|
|
13
|
+
macOS only (it drives the drives through diskutil and drutil). Starting from
|
|
14
|
+
nothing:
|
|
15
|
+
|
|
16
|
+
# Homebrew, if you don't have it yet (it asks for your password)
|
|
17
|
+
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
|
18
|
+
# put brew on your PATH, now and in new windows (Apple Silicon; does
|
|
19
|
+
# nothing on an Intel Mac)
|
|
20
|
+
echo 'eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null)"' >> ~/.zprofile
|
|
21
|
+
eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null)"
|
|
14
22
|
|
|
15
23
|
brew install pipx
|
|
24
|
+
pipx ensurepath # then open a new terminal window
|
|
16
25
|
pipx install backpack-backcrack
|
|
17
26
|
backcrack doctor
|
|
18
27
|
|
|
28
|
+
macOS's own `python3` is too old (3.9); pipx from Homebrew brings a current
|
|
29
|
+
one. `pipx ensurepath` puts `backcrack` on your PATH for new terminal windows.
|
|
30
|
+
|
|
19
31
|
`backcrack doctor` lists the rippers and encoders it drives, found or how to
|
|
20
32
|
install each. For everything: `brew install --cask makemkv` and
|
|
21
|
-
`brew install handbrake
|
|
33
|
+
`brew install handbrake libcdio-paranoia flac`. Paths it can't find
|
|
22
34
|
itself go in `settings.env` (`MKVCON`, `HBCLI`, …).
|
|
23
35
|
|
|
24
36
|
Every tool is a subcommand of the one command, `backcrack` (`backcrack
|
|
@@ -68,15 +80,21 @@ Check progress any time:
|
|
|
68
80
|
backcrack watch # live view
|
|
69
81
|
|
|
70
82
|
In `watch`, `q` quits (and offers to stop ripd, encd and sortd with it) and
|
|
71
|
-
`s` opens the settings screen - see [Settings](#settings).
|
|
72
|
-
|
|
83
|
+
`s` opens the settings screen - see [Settings](#settings). `?` shows or hides
|
|
84
|
+
the keys along the bottom. The keys, and those of every list, can be changed
|
|
85
|
+
under Key bindings on that screen.
|
|
86
|
+
|
|
87
|
+
Each part of `watch` is in its own box: the overview, what's ripping, what's
|
|
88
|
+
encoding, and the latest rip events. In a short window the boxes that matter
|
|
89
|
+
least give up their room first (Recent, then the overview, then Encoding);
|
|
90
|
+
too small for any box, it asks for a bigger window.
|
|
73
91
|
|
|
74
92
|
## What it detects
|
|
75
93
|
|
|
76
94
|
- **DVD / Blu-ray** - MakeMKV rips each title to a lossless MKV, HandBrake
|
|
77
95
|
encodes the ones matching a configured duration class - see
|
|
78
96
|
`DURATION_CLASSES` below.
|
|
79
|
-
- **Audio CD** -
|
|
97
|
+
- **Audio CD** - cd-paranoia extracts each track losslessly
|
|
80
98
|
(`track01.cdda.wav`...), then each is compressed to `AUDIO_FORMAT` (flac
|
|
81
99
|
by default) as `track01.flac`...
|
|
82
100
|
|
|
@@ -85,8 +103,8 @@ Both land in the same shape: `source/` (lossless rip) and `encoded/`
|
|
|
85
103
|
than the main window.
|
|
86
104
|
|
|
87
105
|
A video disc is known by its volume label. An audio CD has no useful label,
|
|
88
|
-
so it is named `AudioCD-<n>tracks-<id>`, where the id is
|
|
89
|
-
|
|
106
|
+
so it is named `AudioCD-<n>tracks-<id>`, where the id is the freedb disc id worked
|
|
107
|
+
out from its track table. That name is
|
|
90
108
|
what shows in the log and in `UNSORTED/`, and what a `labels.map` line for
|
|
91
109
|
the CD has to use.
|
|
92
110
|
|
|
@@ -165,7 +183,7 @@ you see combing, set `DEINTERLACE_ARGS` to HandBrake's deinterlace flags,
|
|
|
165
183
|
e.g. `DEINTERLACE_ARGS=--comb-detect --decomb`. Leave it empty for
|
|
166
184
|
progressive sources.
|
|
167
185
|
|
|
168
|
-
For audio, `
|
|
186
|
+
For audio, `cd-paranoia -Q -d <device>` lists a disc's tracks directly if you
|
|
169
187
|
want to sanity-check before ripping.
|
|
170
188
|
|
|
171
189
|
## Encoding throughput
|
|
@@ -232,17 +250,20 @@ Each setting is read, in order, from an environment variable, then
|
|
|
232
250
|
`backcrack/config.py`. So a one-off `LIBRARY=... ripd` overrides
|
|
233
251
|
everything, and `settings.env` holds what you want every time.
|
|
234
252
|
|
|
235
|
-
`s` in `watch` opens
|
|
236
|
-
|
|
253
|
+
`s` in `watch` opens the Settings screen: most of them, in sections, each
|
|
254
|
+
with its value beside it. Enter changes one (space flips an on/off one), and
|
|
255
|
+
it's saved to `settings.env` at once. A number or a duration class that
|
|
256
|
+
can't be read isn't taken. The tool paths below aren't on it; set those in
|
|
237
257
|
`settings.env` or the environment. The daemons read their settings at
|
|
238
|
-
startup, so
|
|
258
|
+
startup, so after a change the screen says which running ones to restart.
|
|
239
259
|
|
|
240
260
|
The ones not covered elsewhere in this README:
|
|
241
261
|
|
|
242
262
|
| Setting | Default | What it does |
|
|
243
263
|
|---|---|---|
|
|
244
|
-
| `MKVCON`, `HBCLI`, `CDPARANOIA`, `
|
|
264
|
+
| `MKVCON`, `HBCLI`, `CDPARANOIA`, `FLAC`, `FFMPEG` | found on PATH (MakeMKV at `/Applications/MakeMKV.app`) | tool paths |
|
|
245
265
|
| `NOTIFY_ENCODES` | `0` | also push when a disc finishes encoding |
|
|
266
|
+
| `DEBUG` | `0` | the diagnostics log, `backcrack.log` in your config directory: what failed quietly (a MusicBrainz lookup, a HandBrake scan, a crashed rip) and why |
|
|
246
267
|
| `ACCENT` | `green` | the accent colour: `green`, `red`, `yellow`, `blue`, `magenta`, `cyan` (your terminal's own), `amber`, `coral`, `rose`, `lavender`, `sky`, `mint`, or `#RRGGBB` |
|
|
247
268
|
| `WATCH_INTERVAL` | `1` | seconds between `watch` refreshes |
|
|
248
269
|
| `SORT_INTERVAL` | `5` | seconds between `sortd` passes |
|
|
@@ -274,7 +295,7 @@ The directory is `$BACKCRACK_CONFIG_DIR` if set, else
|
|
|
274
295
|
first run. Point `BACKCRACK_CONFIG_DIR` somewhere else to keep separate sets
|
|
275
296
|
of overrides for separate shelves.
|
|
276
297
|
|
|
277
|
-
The same screen has
|
|
298
|
+
The same screen has Add a labels.map line…, which is the
|
|
278
299
|
manual escape hatch for a disc whose %tokens% couldn't be resolved - pick it
|
|
279
300
|
out of `UNSORTED/`, fill in the tokens the active pattern needs, and `sortd`
|
|
280
301
|
files it within a few seconds.
|
|
@@ -320,16 +341,15 @@ names). With another pattern or other folder names, don't use it.
|
|
|
320
341
|
|
|
321
342
|
- **MakeMKV** (`makemkvcon`) - for DVD/Blu-ray. `brew install --cask makemkv`.
|
|
322
343
|
- **HandBrakeCLI** - to encode video. `brew install handbrake`.
|
|
323
|
-
- **
|
|
344
|
+
- **cd-paranoia** - for audio CDs: ripping, and the track table that names
|
|
345
|
+
each CD and looks up its artist and album. `brew install libcdio-paranoia`
|
|
346
|
+
(or `cdparanoia` from your Linux package manager).
|
|
324
347
|
- **flac** (or ffmpeg) - to compress ripped audio. `brew install flac`.
|
|
325
348
|
With neither, tracks are copied to `encoded/` as `.wav`.
|
|
326
|
-
- **cd-discid** - optional, for automatic artist/album lookup on audio CDs.
|
|
327
|
-
`brew install cd-discid`. Without it, every CD goes to `UNSORTED/` until
|
|
328
|
-
you add a `labels.map` line.
|
|
329
349
|
|
|
330
350
|
`backcrack doctor` checks all of them. A missing tool also gets a warning at
|
|
331
|
-
startup rather than a silent failure: `ripd` warns about makemkvcon
|
|
332
|
-
|
|
351
|
+
startup rather than a silent failure: `ripd` warns about makemkvcon and
|
|
352
|
+
cd-paranoia, and `encd` about HandBrakeCLI and flac/ffmpeg. The
|
|
333
353
|
daemon still runs; that disc kind just won't rip or encode until the tool is
|
|
334
354
|
installed.
|
|
335
355
|
|
|
@@ -3,6 +3,7 @@ import json
|
|
|
3
3
|
import subprocess
|
|
4
4
|
|
|
5
5
|
from backbone.files import log_line
|
|
6
|
+
from backbone.log import log as diag
|
|
6
7
|
from backbone.notify import ntfy
|
|
7
8
|
from backbone.procs import find_processes, spawn_module, stop_processes
|
|
8
9
|
|
|
@@ -71,15 +72,18 @@ def hb_json(input_path: str) -> dict:
|
|
|
71
72
|
[cfg.HBCLI, "-i", input_path, "--title", "0", "--scan", "--json"],
|
|
72
73
|
capture_output=True, text=True, timeout=120,
|
|
73
74
|
).stdout
|
|
74
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
75
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
76
|
+
diag.warning("HandBrake scan of %s failed: %s", input_path, e)
|
|
75
77
|
return {}
|
|
76
78
|
marker = "JSON Title Set:"
|
|
77
79
|
idx = out.find(marker)
|
|
78
80
|
if idx < 0:
|
|
81
|
+
diag.warning("HandBrake scan of %s gave no title set", input_path)
|
|
79
82
|
return {}
|
|
80
83
|
try:
|
|
81
84
|
return json.loads(out[idx + len(marker):])
|
|
82
|
-
except json.JSONDecodeError:
|
|
85
|
+
except json.JSONDecodeError as e:
|
|
86
|
+
diag.warning("HandBrake scan of %s: unreadable JSON: %s", input_path, e)
|
|
83
87
|
return {}
|
|
84
88
|
|
|
85
89
|
|
|
@@ -26,8 +26,8 @@ def _default_config_dir() -> Path:
|
|
|
26
26
|
CONFIG_DIR = Path(os.getenv("BACKCRACK_CONFIG_DIR") or _default_config_dir())
|
|
27
27
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
28
28
|
|
|
29
|
-
# backbone keeps the key bindings, the hints switch and its log here too.
|
|
30
|
-
from backbone import app as _app # noqa: E402
|
|
29
|
+
# backbone keeps the key bindings, the hints switch and its diagnostics log here too.
|
|
30
|
+
from backbone import app as _app, log as _log # noqa: E402
|
|
31
31
|
_app.configure("backcrack", CONFIG_DIR)
|
|
32
32
|
|
|
33
33
|
# Settings changed from `backcrack watch`'s settings screen land here rather
|
|
@@ -65,7 +65,7 @@ def save_setting(name: str, value: str) -> None:
|
|
|
65
65
|
"""Persist one setting to SETTINGS_FILE, replacing its line if already
|
|
66
66
|
present. Only processes started afterwards see it (ripd/encd need
|
|
67
67
|
restarting); a caller that wants it in its own running `cfg` has to set
|
|
68
|
-
that too (see
|
|
68
|
+
that too (see settings.py).
|
|
69
69
|
"""
|
|
70
70
|
lines = SETTINGS_FILE.read_text().splitlines() if SETTINGS_FILE.exists() else []
|
|
71
71
|
out, found = [], False
|
|
@@ -125,8 +125,7 @@ LIBRARY = Path(_env("LIBRARY", str(Path.home() / "Media" / "rips"))).expanduser(
|
|
|
125
125
|
MKVCON = _env("MKVCON", _env("MKV", shutil.which("makemkvcon")
|
|
126
126
|
or "/Applications/MakeMKV.app/Contents/MacOS/makemkvcon"))
|
|
127
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 "")
|
|
128
|
+
CDPARANOIA = _env("CDPARANOIA", shutil.which("cd-paranoia") or shutil.which("cdparanoia") or "")
|
|
130
129
|
FLAC = _env("FLAC", shutil.which("flac") or "")
|
|
131
130
|
FFMPEG = _env("FFMPEG", shutil.which("ffmpeg") or "")
|
|
132
131
|
|
|
@@ -224,39 +223,16 @@ PATTERN_AUDIO = _env("PATTERN_AUDIO", "%artist%/%album%")
|
|
|
224
223
|
# on. A failed disc is deliberately left in the drive so it retries itself.
|
|
225
224
|
MAX_RETRIES = _env_int("MAX_RETRIES", 3)
|
|
226
225
|
|
|
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
226
|
# The accent colour of every screen (backbone.ui.ACCENT_PRESETS key or
|
|
256
227
|
# "#RRGGBB"), applied here once so each tool gets it by importing config.
|
|
257
228
|
ACCENT = _env("ACCENT", "green")
|
|
258
229
|
_ui.set_accent(ACCENT)
|
|
259
230
|
|
|
231
|
+
# backbone's diagnostics log, CONFIG_DIR/backcrack.log: what failed quietly
|
|
232
|
+
# (a lookup, a scan, a crashed rip thread) and why. Every process writes it.
|
|
233
|
+
DEBUG = _env("DEBUG", "0") == "1"
|
|
234
|
+
_log.configure(DEBUG)
|
|
235
|
+
|
|
260
236
|
# Derived. Don't edit.
|
|
261
237
|
# Your files, in CONFIG_DIR (see the top of this file): manual disc->token
|
|
262
238
|
# overrides, and the per-season episode titles namer.py renames from.
|
|
@@ -21,12 +21,10 @@ def deps() -> list[Dep]:
|
|
|
21
21
|
Dep("HandBrake", _path(cfg.HBCLI), "encoding video", hints={
|
|
22
22
|
"macos": "brew install handbrake", "debian": "sudo apt install handbrake-cli",
|
|
23
23
|
"other": "https://handbrake.fr/downloads2.php"}),
|
|
24
|
-
Dep("
|
|
25
|
-
"macos": "brew install
|
|
24
|
+
Dep("cd-paranoia", _path(cfg.CDPARANOIA), "ripping and looking up audio CDs", hints={
|
|
25
|
+
"macos": "brew install libcdio-paranoia", "debian": "sudo apt install cdparanoia"}),
|
|
26
26
|
Dep("flac", _path(cfg.FLAC), "encoding CD audio", hints={
|
|
27
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
28
|
Dep("ffmpeg", _path(cfg.FFMPEG), "audio formats other than FLAC", hints={
|
|
31
29
|
"macos": "brew install ffmpeg", "debian": "sudo apt install ffmpeg"}),
|
|
32
30
|
]
|
|
@@ -36,4 +34,4 @@ def doctor() -> None:
|
|
|
36
34
|
"""backcrack doctor: every tool it drives, found or how to install it."""
|
|
37
35
|
from backbone.deps import report_lines
|
|
38
36
|
print("\n".join(report_lines(deps())))
|
|
39
|
-
print("Paths can be set in settings.env (MKVCON, HBCLI, CDPARANOIA, FLAC,
|
|
37
|
+
print("Paths can be set in settings.env (MKVCON, HBCLI, CDPARANOIA, FLAC, FFMPEG).")
|
|
@@ -8,11 +8,14 @@ import subprocess
|
|
|
8
8
|
import time
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
|
|
11
|
+
from backbone.log import log as diag
|
|
12
|
+
|
|
11
13
|
from backcrack import config as cfg
|
|
12
14
|
|
|
13
15
|
|
|
14
|
-
# Optical drive speeds are quoted in multiples of
|
|
16
|
+
# Optical drive speeds are quoted in multiples of 1x for that kind of disc.
|
|
15
17
|
DVD_1X_BPS = 1_385_000
|
|
18
|
+
CD_1X_BPS = 176_400
|
|
16
19
|
|
|
17
20
|
|
|
18
21
|
def raw(dev: str) -> str:
|
|
@@ -68,41 +71,35 @@ def pending_discs():
|
|
|
68
71
|
yield dev, "audio", Path(dev).name
|
|
69
72
|
|
|
70
73
|
|
|
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
74
|
def cd_toc(rdev: str):
|
|
83
75
|
"""The CD's table of contents as MusicBrainz wants it: (first track, last
|
|
84
|
-
track, lead-out, [track offsets]) in sectors
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
track, lead-out, [track offsets]) in sectors from the disc's start, or
|
|
77
|
+
None. Read from cd-paranoia's track list, which counts from track 1's
|
|
78
|
+
pregap, hence the 150. Audio tracks only, so on an enhanced CD the
|
|
79
|
+
lead-out is the last audio track's end (MusicBrainz's toc lookup still
|
|
80
|
+
finds the near match)."""
|
|
81
|
+
if not cfg.CDPARANOIA:
|
|
89
82
|
return None
|
|
90
83
|
try:
|
|
91
|
-
out = subprocess.run([cfg.
|
|
92
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
93
|
-
|
|
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():
|
|
84
|
+
out = subprocess.run([cfg.CDPARANOIA, "-Q", "-d", rdev], capture_output=True, text=True, timeout=30).stderr
|
|
85
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
86
|
+
diag.warning("cd-paranoia -Q on %s failed: %s", rdev, e)
|
|
99
87
|
return None
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
88
|
+
# " 1. 16503 [03:40.03] 0 [00:00.00] no no 2": number, length, begin
|
|
89
|
+
tracks = [(int(m[1]), int(m[2]), int(m[3]))
|
|
90
|
+
for m in re.finditer(r"^\s*(\d+)\.\s+(\d+)\s+\[[^]]*\]\s+(\d+)", out, re.M)]
|
|
91
|
+
if not tracks:
|
|
92
|
+
diag.warning("cd-paranoia -Q on %s listed no tracks: %s", rdev, out.strip()[-300:])
|
|
105
93
|
return None
|
|
94
|
+
first, last = tracks[0][0], tracks[-1][0]
|
|
95
|
+
return first, last, tracks[-1][2] + tracks[-1][1] + 150, [begin + 150 for _, _, begin in tracks]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def freedb_id(toc) -> str:
|
|
99
|
+
"""The freedb (CDDB) disc id for a toc, as cd-discid printed it."""
|
|
100
|
+
_, _, leadout, offsets = toc
|
|
101
|
+
digits = sum(sum(map(int, str(o // 75))) for o in offsets)
|
|
102
|
+
return f"{(digits % 255) << 24 | (leadout // 75 - offsets[0] // 75) << 8 | len(offsets):08x}"
|
|
106
103
|
|
|
107
104
|
|
|
108
105
|
def musicbrainz_discid(toc) -> str:
|
|
@@ -116,26 +113,22 @@ def musicbrainz_discid(toc) -> str:
|
|
|
116
113
|
return digest.translate(str.maketrans("+/=", "._-"))
|
|
117
114
|
|
|
118
115
|
|
|
119
|
-
def audio_label(
|
|
120
|
-
"""A name for
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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}"
|
|
116
|
+
def audio_label(toc) -> str:
|
|
117
|
+
"""A name for a CD that stays the same whichever drive it is in."""
|
|
118
|
+
return f"AudioCD-{len(toc[3])}tracks-{freedb_id(toc)}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Holds audio_bytes() for a rip in progress, in its folder, for watch's
|
|
122
|
+
# progress bar: cd-paranoia runs inside the folder rather than naming it, so
|
|
123
|
+
# watch can't match the rip to its drive the way it does for MakeMKV.
|
|
124
|
+
DISC_BYTES_FILE = ".disc-bytes"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def audio_bytes(toc) -> int:
|
|
128
|
+
"""What ripping every track to WAV comes to: 2352 bytes a sector, plus
|
|
129
|
+
each file's 44-byte header."""
|
|
130
|
+
_, _, leadout, offsets = toc
|
|
131
|
+
return (leadout - offsets[0]) * 2352 + 44 * len(offsets)
|
|
139
132
|
|
|
140
133
|
|
|
141
134
|
_DRV_RE = re.compile(r'^DRV:(\d+),2,\d+,\d+,"([^"]*)","([^"]*)","([^"]*)"')
|
|
@@ -147,7 +140,8 @@ def mkv_drives(timeout: int = 60) -> list:
|
|
|
147
140
|
try:
|
|
148
141
|
out = subprocess.run([cfg.MKVCON, "-r", "--cache=1", "info", "disc:9999"],
|
|
149
142
|
capture_output=True, text=True, timeout=timeout).stdout
|
|
150
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
143
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
144
|
+
diag.warning("makemkvcon drive listing failed: %s", e)
|
|
151
145
|
return []
|
|
152
146
|
return [m.groups() for m in map(_DRV_RE.match, out.splitlines()) if m]
|
|
153
147
|
|
|
@@ -159,7 +159,7 @@ def process_audio_job(label: str, dest: Path) -> None:
|
|
|
159
159
|
jobs = []
|
|
160
160
|
ext = audio_ext()
|
|
161
161
|
for f in sorted(src.glob("*.wav")):
|
|
162
|
-
#
|
|
162
|
+
# cd-paranoia -B names tracks track01.cdda.wav
|
|
163
163
|
out = dest / "encoded" / f"{f.stem.removesuffix('.cdda')}.{ext}"
|
|
164
164
|
if out.exists() and out.stat().st_size > 0:
|
|
165
165
|
n_skip += 1
|
|
@@ -7,6 +7,8 @@ import urllib.request
|
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from typing import Optional
|
|
9
9
|
|
|
10
|
+
from backbone.log import log as diag
|
|
11
|
+
|
|
10
12
|
from backcrack import config as cfg
|
|
11
13
|
from backcrack import disc
|
|
12
14
|
|
|
@@ -93,8 +95,8 @@ def auto_audio_tokens(dev: Optional[str]) -> Optional[dict]:
|
|
|
93
95
|
toc = disc.cd_toc(dev) if dev else None
|
|
94
96
|
if not toc:
|
|
95
97
|
return None
|
|
96
|
-
# The MusicBrainz disc id (
|
|
97
|
-
#
|
|
98
|
+
# The MusicBrainz disc id (the label's freedb id is one MusicBrainz doesn't
|
|
99
|
+
# know), plus the toc so a near match is found when there's no exact one.
|
|
98
100
|
toc_param = "+".join(str(n) for n in (toc[0], toc[1], toc[2], *toc[3]))
|
|
99
101
|
try:
|
|
100
102
|
req = urllib.request.Request(
|
|
@@ -104,10 +106,12 @@ def auto_audio_tokens(dev: Optional[str]) -> Optional[dict]:
|
|
|
104
106
|
)
|
|
105
107
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
106
108
|
data = json.loads(resp.read())
|
|
107
|
-
except Exception:
|
|
109
|
+
except Exception as e:
|
|
110
|
+
diag.warning("MusicBrainz lookup for %s failed: %s: %s", dev, type(e).__name__, e)
|
|
108
111
|
return None
|
|
109
112
|
releases = data.get("releases") or []
|
|
110
113
|
if not releases:
|
|
114
|
+
diag.info("MusicBrainz has no release for disc id %s", disc.musicbrainz_discid(toc))
|
|
111
115
|
return None
|
|
112
116
|
release = releases[0]
|
|
113
117
|
artist_credit = release.get("artist-credit") or []
|
|
@@ -139,6 +139,7 @@ def mark_unsorted_kind(dest: Path, kind: str) -> None:
|
|
|
139
139
|
|
|
140
140
|
|
|
141
141
|
def finish_rip(label: str, dest: Path, cdev: str, ok: bool, mins: int, kind: str) -> None:
|
|
142
|
+
(dest / disc.DISC_BYTES_FILE).unlink(missing_ok=True)
|
|
142
143
|
kb = dir_size_kb(dest)
|
|
143
144
|
gb = human_gb(kb)
|
|
144
145
|
rel = str(dest.relative_to(cfg.LIBRARY))
|
|
@@ -243,7 +244,9 @@ def rip_audio_disc(dev: str, label: str) -> None:
|
|
|
243
244
|
# is only the device name there, so name the CD itself now this thread
|
|
244
245
|
# holds the drive.
|
|
245
246
|
cdev, rdev = dev, disc.raw(dev)
|
|
246
|
-
|
|
247
|
+
toc = disc.cd_toc(rdev)
|
|
248
|
+
if toc:
|
|
249
|
+
label = disc.audio_label(toc)
|
|
247
250
|
if already_handled(label, cdev):
|
|
248
251
|
return
|
|
249
252
|
|
|
@@ -256,6 +259,8 @@ def rip_audio_disc(dev: str, label: str) -> None:
|
|
|
256
259
|
mark_unsorted_kind(dest, "audio")
|
|
257
260
|
|
|
258
261
|
nsrc = dest / "source"; nsrc.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
if toc:
|
|
263
|
+
(dest / disc.DISC_BYTES_FILE).write_text(f"{disc.audio_bytes(toc)}\n")
|
|
259
264
|
t0 = time.monotonic()
|
|
260
265
|
rc = subprocess.run(
|
|
261
266
|
[cfg.CDPARANOIA, "-d", rdev, "-B", "1-"], cwd=nsrc, capture_output=True, timeout=None,
|
|
@@ -10,6 +10,8 @@ import os
|
|
|
10
10
|
import time
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
+
from backbone.log import log as diag
|
|
14
|
+
|
|
13
15
|
from backcrack import config as cfg
|
|
14
16
|
from backcrack import disc
|
|
15
17
|
from backcrack.rip import rip_video_disc, rip_audio_disc
|
|
@@ -40,9 +42,7 @@ def main() -> None:
|
|
|
40
42
|
if not Path(cfg.MKVCON).exists():
|
|
41
43
|
print(f"WARNING: makemkvcon not found at {cfg.MKVCON} - video discs will fail. See `backcrack doctor`.")
|
|
42
44
|
if not cfg.CDPARANOIA:
|
|
43
|
-
print("WARNING:
|
|
44
|
-
if not cfg.CD_DISCID:
|
|
45
|
-
print("WARNING: cd-discid not found - audio CDs will go to UNSORTED/. See `backcrack doctor`.")
|
|
45
|
+
print("WARNING: cd-paranoia not found - audio CDs will fail. See `backcrack doctor`.")
|
|
46
46
|
if not cfg.NTFY_TOPIC:
|
|
47
47
|
print("WARNING: NTFY_TOPIC is not set (env var or settings.env) - no pushes will be sent.")
|
|
48
48
|
|
|
@@ -68,6 +68,9 @@ def main() -> None:
|
|
|
68
68
|
def _run(dev=dev, label=label, target=target, lock=lock):
|
|
69
69
|
try:
|
|
70
70
|
target(dev, label)
|
|
71
|
+
except Exception:
|
|
72
|
+
diag.exception("rip of %s in %s crashed", label, dev)
|
|
73
|
+
raise
|
|
71
74
|
finally:
|
|
72
75
|
lock.unlink(missing_ok=True)
|
|
73
76
|
|