riplex 0.1.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.
- riplex/__init__.py +3 -0
- riplex/cache.py +111 -0
- riplex/cli.py +2635 -0
- riplex/config.py +121 -0
- riplex/dedup.py +504 -0
- riplex/detect.py +150 -0
- riplex/disc_analysis.py +331 -0
- riplex/disc_provider.py +211 -0
- riplex/formatter.py +115 -0
- riplex/makemkv.py +573 -0
- riplex/matcher.py +445 -0
- riplex/metadata_provider.py +87 -0
- riplex/metadata_sources/__init__.py +0 -0
- riplex/metadata_sources/tmdb.py +208 -0
- riplex/models.py +137 -0
- riplex/normalize.py +147 -0
- riplex/organizer.py +674 -0
- riplex/planner.py +178 -0
- riplex/scanner.py +189 -0
- riplex/snapshot.py +144 -0
- riplex/splitter.py +126 -0
- riplex/tagger.py +146 -0
- riplex/ui.py +217 -0
- riplex-0.1.0.dist-info/METADATA +106 -0
- riplex-0.1.0.dist-info/RECORD +28 -0
- riplex-0.1.0.dist-info/WHEEL +5 -0
- riplex-0.1.0.dist-info/entry_points.txt +2 -0
- riplex-0.1.0.dist-info/top_level.txt +1 -0
riplex/cli.py
ADDED
|
@@ -0,0 +1,2635 @@
|
|
|
1
|
+
"""CLI entry point for riplex."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from riplex import __version__
|
|
15
|
+
from riplex.config import get_api_key, get_archive_root, get_output_root, get_rip_output
|
|
16
|
+
from riplex.dedup import find_all_redundant, find_duplicates, remove_duplicates
|
|
17
|
+
from riplex.detect import detect_format, detect_incomplete, group_title_folders
|
|
18
|
+
from riplex.disc_provider import _convert_film, lookup_discs
|
|
19
|
+
from riplex.matcher import (
|
|
20
|
+
collect_disc_targets,
|
|
21
|
+
map_folders_to_discs,
|
|
22
|
+
match_discs,
|
|
23
|
+
)
|
|
24
|
+
from riplex.metadata_sources.tmdb import TmdbProvider
|
|
25
|
+
from riplex.models import PlannedMovie, SearchRequest
|
|
26
|
+
from riplex.organizer import build_organize_plan, execute_plan
|
|
27
|
+
from riplex.planner import plan
|
|
28
|
+
from riplex.scanner import scan_folder
|
|
29
|
+
from riplex.snapshot import capture as snapshot_capture, load as snapshot_load, save_from_scanned as snapshot_save_from_scanned
|
|
30
|
+
from riplex.ui import is_interactive, prompt_choice, prompt_confirm, prompt_text, set_auto_mode
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
_LOG_DIR = Path(tempfile.gettempdir()) / "riplex"
|
|
35
|
+
|
|
36
|
+
_BAR_STYLES = [
|
|
37
|
+
{"fill": "=", "head": ">", "empty": " ", "left": "[", "right": "]"},
|
|
38
|
+
{"fill": "\u2588", "head": "\u2589", "empty": "\u2591", "left": "\u2595", "right": "\u258f"},
|
|
39
|
+
{"fill": "#", "head": ">", "empty": "-", "left": "[", "right": "]"},
|
|
40
|
+
{"fill": "\u2593", "head": "\u2592", "empty": "\u2591", "left": "|", "right": "|"},
|
|
41
|
+
{"fill": "*", "head": "o", "empty": ".", "left": "<", "right": ">"},
|
|
42
|
+
{"fill": "\u25a0", "head": "\u25a1", "empty": "\u00b7", "left": "\u2595", "right": "\u258f"},
|
|
43
|
+
{"fill": "/", "head": "|", "empty": " ", "left": "[", "right": "]"},
|
|
44
|
+
{"fill": "\u2501", "head": "\u254b", "empty": "\u2500", "left": "\u2523", "right": "\u252b"},
|
|
45
|
+
{"fill": "~", "head": "\u2248", "empty": " ", "left": "{", "right": "}"},
|
|
46
|
+
{"fill": "\u2580", "head": "\u2584", "empty": "_", "left": "|", "right": "|"},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _random_bar_style() -> dict[str, str]:
|
|
51
|
+
"""Pick a random progress bar style for visual variety."""
|
|
52
|
+
import random
|
|
53
|
+
return random.choice(_BAR_STYLES)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_execute_command() -> str:
|
|
57
|
+
"""Reconstruct the current CLI invocation with ``--execute`` appended.
|
|
58
|
+
|
|
59
|
+
Strips any ``--dry-run`` / ``-n`` flags and quotes arguments that
|
|
60
|
+
contain spaces so the result is safe to copy/paste.
|
|
61
|
+
"""
|
|
62
|
+
raw = sys.argv[:]
|
|
63
|
+
# Remove --dry-run / -n (backwards-compat flag on rip)
|
|
64
|
+
cleaned = [a for a in raw if a not in ("--dry-run", "-n")]
|
|
65
|
+
# Don't double-add --execute
|
|
66
|
+
if "--execute" not in cleaned:
|
|
67
|
+
cleaned.append("--execute")
|
|
68
|
+
# Replace full exe path with just the basename
|
|
69
|
+
if cleaned:
|
|
70
|
+
cleaned[0] = Path(cleaned[0]).stem
|
|
71
|
+
parts = [f'"{a}"' if " " in a else a for a in cleaned]
|
|
72
|
+
return " ".join(parts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _dry_run_banner(verb: str) -> str:
|
|
76
|
+
"""Return the banner printed at the start of a dry-run."""
|
|
77
|
+
return f"--- DRY RUN (pass --execute to {verb}) ---"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _execute_hint(subcommand: str) -> str:
|
|
81
|
+
"""Return the end-of-run hint with a copy-pasteable command."""
|
|
82
|
+
verb = "apply these changes" if subcommand == "organize" else "rip"
|
|
83
|
+
return f"Re-run with --execute to {verb}:\n {_build_execute_command()}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _setup_logging(verbose: bool = False) -> Path:
|
|
87
|
+
"""Configure file-based debug logging for the entire package.
|
|
88
|
+
|
|
89
|
+
Always writes DEBUG-level output to a log file in the temp directory.
|
|
90
|
+
When *verbose* is True, also prints DEBUG messages to stderr.
|
|
91
|
+
|
|
92
|
+
Returns the path to the log file.
|
|
93
|
+
"""
|
|
94
|
+
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
log_file = _LOG_DIR / "riplex.log"
|
|
96
|
+
|
|
97
|
+
root = logging.getLogger("riplex")
|
|
98
|
+
root.setLevel(logging.DEBUG)
|
|
99
|
+
|
|
100
|
+
# File handler: always DEBUG
|
|
101
|
+
fh = logging.FileHandler(log_file, mode="w", encoding="utf-8")
|
|
102
|
+
fh.setLevel(logging.DEBUG)
|
|
103
|
+
fh.setFormatter(logging.Formatter(
|
|
104
|
+
"%(asctime)s %(name)s %(levelname)s %(message)s",
|
|
105
|
+
datefmt="%H:%M:%S",
|
|
106
|
+
))
|
|
107
|
+
root.addHandler(fh)
|
|
108
|
+
|
|
109
|
+
# Console handler: only when verbose
|
|
110
|
+
if verbose:
|
|
111
|
+
ch = logging.StreamHandler(sys.stderr)
|
|
112
|
+
ch.setLevel(logging.DEBUG)
|
|
113
|
+
ch.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
|
|
114
|
+
root.addHandler(ch)
|
|
115
|
+
|
|
116
|
+
return log_file
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_TRAILING_YEAR_RE = re.compile(r"\s*\(\d{4}\)\s*$")
|
|
120
|
+
_TRAILING_DISC_RE = re.compile(
|
|
121
|
+
r"\s*[-_]?\s*(?:D(?:isc)?\s*\d+)\s*$", re.IGNORECASE,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _infer_title_from_scanned(scanned: list) -> str | None:
|
|
126
|
+
"""Derive a clean title from MKV title_tag metadata.
|
|
127
|
+
|
|
128
|
+
Picks the title_tag of the longest file (most likely the main feature).
|
|
129
|
+
Returns ``None`` when no useful title_tag is present.
|
|
130
|
+
"""
|
|
131
|
+
all_files = [f for d in scanned for f in d.files]
|
|
132
|
+
if not all_files:
|
|
133
|
+
return None
|
|
134
|
+
longest = max(all_files, key=lambda f: f.duration_seconds)
|
|
135
|
+
tag = longest.title_tag
|
|
136
|
+
if not tag or not tag.strip():
|
|
137
|
+
return None
|
|
138
|
+
# Strip trailing disc label (e.g. "SEVEN WORLDS ONE PLANET D1")
|
|
139
|
+
clean = _TRAILING_DISC_RE.sub("", tag.strip())
|
|
140
|
+
# Strip trailing year if embedded, e.g. "Waterworld (1995)"
|
|
141
|
+
clean, _ = _strip_year_from_title(clean)
|
|
142
|
+
return clean or None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _strip_year_from_title(name: str) -> tuple[str, int | None]:
|
|
146
|
+
"""Strip a trailing ``(YYYY)`` from a folder name.
|
|
147
|
+
|
|
148
|
+
Returns ``(clean_title, year)`` where *year* is the extracted value
|
|
149
|
+
or ``None`` if no trailing year was found.
|
|
150
|
+
"""
|
|
151
|
+
m = _TRAILING_YEAR_RE.search(name)
|
|
152
|
+
if m:
|
|
153
|
+
year = int(m.group().strip().strip("()"))
|
|
154
|
+
return name[: m.start()].strip(), year
|
|
155
|
+
return name, None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
159
|
+
parser = argparse.ArgumentParser(
|
|
160
|
+
prog="riplex",
|
|
161
|
+
description=(
|
|
162
|
+
"Rip physical discs and organize MKV files into "
|
|
163
|
+
"Plex-compatible folder structures."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--version", action="version", version=f"%(prog)s {__version__}",
|
|
168
|
+
)
|
|
169
|
+
subs = parser.add_subparsers(dest="command")
|
|
170
|
+
|
|
171
|
+
# --- organize ---
|
|
172
|
+
org_parser = subs.add_parser(
|
|
173
|
+
"organize",
|
|
174
|
+
help="Scan a MakeMKV rip folder and organize into Plex structure.",
|
|
175
|
+
)
|
|
176
|
+
org_parser.add_argument("folder", help="Path to a MakeMKV rip folder.")
|
|
177
|
+
org_parser.add_argument("--title", help="Override title (default: folder name).")
|
|
178
|
+
org_parser.add_argument("--year", type=int, help="Release year.")
|
|
179
|
+
org_parser.add_argument(
|
|
180
|
+
"--type",
|
|
181
|
+
dest="media_type",
|
|
182
|
+
choices=["movie", "tv", "auto"],
|
|
183
|
+
default="auto",
|
|
184
|
+
help="Force media type. Default: auto-detect.",
|
|
185
|
+
)
|
|
186
|
+
org_parser.add_argument(
|
|
187
|
+
"--format",
|
|
188
|
+
dest="disc_format",
|
|
189
|
+
default=None,
|
|
190
|
+
help="Disc format filter for dvdcompare (e.g. 'Blu-ray 4K').",
|
|
191
|
+
)
|
|
192
|
+
org_parser.add_argument(
|
|
193
|
+
"--release",
|
|
194
|
+
default=None,
|
|
195
|
+
help="Regional release: 1-based index or name keyword (default: auto-detect).",
|
|
196
|
+
)
|
|
197
|
+
org_parser.add_argument(
|
|
198
|
+
"--output",
|
|
199
|
+
default=None,
|
|
200
|
+
help="Output root directory (or set PLEX_ROOT env var, or output_root in config).",
|
|
201
|
+
)
|
|
202
|
+
org_parser.add_argument(
|
|
203
|
+
"--execute",
|
|
204
|
+
action="store_true",
|
|
205
|
+
default=False,
|
|
206
|
+
help="Actually move files (default: dry-run preview only).",
|
|
207
|
+
)
|
|
208
|
+
org_parser.add_argument("--json", action="store_true", default=False)
|
|
209
|
+
org_parser.add_argument("--api-key", default=None)
|
|
210
|
+
org_parser.add_argument(
|
|
211
|
+
"--unmatched",
|
|
212
|
+
choices=["ignore", "move", "delete", "extras"],
|
|
213
|
+
default="ignore",
|
|
214
|
+
help="Policy for files that can't be matched: ignore (default), move to _Unmatched folder, delete, or extras (route to Other/ folder).",
|
|
215
|
+
)
|
|
216
|
+
org_parser.add_argument(
|
|
217
|
+
"--verbose", "-v",
|
|
218
|
+
action="store_true",
|
|
219
|
+
default=False,
|
|
220
|
+
help="Print debug logging to stderr in addition to the log file.",
|
|
221
|
+
)
|
|
222
|
+
org_parser.add_argument(
|
|
223
|
+
"--no-cache",
|
|
224
|
+
action="store_true",
|
|
225
|
+
default=False,
|
|
226
|
+
help="Bypass the local cache and fetch fresh data from APIs.",
|
|
227
|
+
)
|
|
228
|
+
org_parser.add_argument(
|
|
229
|
+
"--force",
|
|
230
|
+
action="store_true",
|
|
231
|
+
default=False,
|
|
232
|
+
help="Re-organize files even if they are already tagged as organized.",
|
|
233
|
+
)
|
|
234
|
+
org_parser.add_argument(
|
|
235
|
+
"--snapshot",
|
|
236
|
+
default=None,
|
|
237
|
+
help="Load a snapshot JSON file instead of scanning a real folder. Forces dry-run mode.",
|
|
238
|
+
)
|
|
239
|
+
org_parser.add_argument(
|
|
240
|
+
"--auto",
|
|
241
|
+
action="store_true",
|
|
242
|
+
default=False,
|
|
243
|
+
help="Skip interactive prompts, use best-guess defaults.",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
# --- lookup ---
|
|
247
|
+
guide_parser = subs.add_parser(
|
|
248
|
+
"lookup",
|
|
249
|
+
help="Look up disc contents and metadata for a title from TMDb and dvdcompare.",
|
|
250
|
+
)
|
|
251
|
+
guide_parser.add_argument("title", help="Movie or TV show title.")
|
|
252
|
+
guide_parser.add_argument("--year", type=int, help="Release year.")
|
|
253
|
+
guide_parser.add_argument(
|
|
254
|
+
"--type",
|
|
255
|
+
dest="media_type",
|
|
256
|
+
choices=["movie", "tv", "auto"],
|
|
257
|
+
default="auto",
|
|
258
|
+
help="Force media type. Default: auto-detect.",
|
|
259
|
+
)
|
|
260
|
+
guide_parser.add_argument(
|
|
261
|
+
"--format",
|
|
262
|
+
dest="disc_format",
|
|
263
|
+
default=None,
|
|
264
|
+
help="Disc format filter for dvdcompare (e.g. 'Blu-ray 4K').",
|
|
265
|
+
)
|
|
266
|
+
guide_parser.add_argument(
|
|
267
|
+
"--release",
|
|
268
|
+
default="america",
|
|
269
|
+
help="Regional release: 1-based index or name keyword (default: america).",
|
|
270
|
+
)
|
|
271
|
+
guide_parser.add_argument(
|
|
272
|
+
"--output",
|
|
273
|
+
default=None,
|
|
274
|
+
help="Output root for --create-folders (or set PLEX_ROOT env var, or config).",
|
|
275
|
+
)
|
|
276
|
+
guide_parser.add_argument(
|
|
277
|
+
"--create-folders",
|
|
278
|
+
action="store_true",
|
|
279
|
+
default=False,
|
|
280
|
+
help="Pre-create the recommended MakeMKV rip folder structure.",
|
|
281
|
+
)
|
|
282
|
+
guide_parser.add_argument("--json", action="store_true", default=False)
|
|
283
|
+
guide_parser.add_argument("--api-key", default=None)
|
|
284
|
+
guide_parser.add_argument(
|
|
285
|
+
"--drive",
|
|
286
|
+
default=None,
|
|
287
|
+
help="Read live disc info from a drive via makemkvcon. "
|
|
288
|
+
"Pass a drive index (e.g. 0), device name (e.g. D:), or 'auto' "
|
|
289
|
+
"to use the first drive with a disc inserted.",
|
|
290
|
+
)
|
|
291
|
+
guide_parser.add_argument(
|
|
292
|
+
"--verbose", "-v",
|
|
293
|
+
action="store_true",
|
|
294
|
+
default=False,
|
|
295
|
+
help="Print debug logging to stderr in addition to the log file.",
|
|
296
|
+
)
|
|
297
|
+
guide_parser.add_argument(
|
|
298
|
+
"--no-cache",
|
|
299
|
+
action="store_true",
|
|
300
|
+
default=False,
|
|
301
|
+
help="Bypass the local cache and fetch fresh data from APIs.",
|
|
302
|
+
)
|
|
303
|
+
guide_parser.add_argument(
|
|
304
|
+
"--no-specials",
|
|
305
|
+
action="store_true",
|
|
306
|
+
default=False,
|
|
307
|
+
help="Exclude specials (Season 00) for TV shows.",
|
|
308
|
+
)
|
|
309
|
+
guide_parser.add_argument(
|
|
310
|
+
"--no-extras",
|
|
311
|
+
action="store_true",
|
|
312
|
+
default=False,
|
|
313
|
+
help="Omit recommended extras folder skeleton.",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# --- rip ---
|
|
317
|
+
rip_parser = subs.add_parser(
|
|
318
|
+
"rip",
|
|
319
|
+
help="Read a disc, recommend titles, and rip via makemkvcon.",
|
|
320
|
+
)
|
|
321
|
+
rip_parser.add_argument(
|
|
322
|
+
"title", nargs="?", default=None,
|
|
323
|
+
help="Movie or TV show title (auto-detected from volume label if omitted).",
|
|
324
|
+
)
|
|
325
|
+
rip_parser.add_argument(
|
|
326
|
+
"--drive",
|
|
327
|
+
default="auto",
|
|
328
|
+
help="Drive index (e.g. 0), device name (e.g. D:), or 'auto' (default: auto).",
|
|
329
|
+
)
|
|
330
|
+
rip_parser.add_argument("--year", type=int, help="Release year.")
|
|
331
|
+
rip_parser.add_argument(
|
|
332
|
+
"--type", dest="media_type", choices=["movie", "tv", "auto"],
|
|
333
|
+
default="auto",
|
|
334
|
+
)
|
|
335
|
+
rip_parser.add_argument(
|
|
336
|
+
"--format", dest="disc_format", default=None,
|
|
337
|
+
help="Disc format filter for dvdcompare (auto-detected from disc resolution if omitted).",
|
|
338
|
+
)
|
|
339
|
+
rip_parser.add_argument(
|
|
340
|
+
"--release", default=None,
|
|
341
|
+
help="Regional release: 1-based index or name keyword (default: auto-detect).",
|
|
342
|
+
)
|
|
343
|
+
rip_parser.add_argument(
|
|
344
|
+
"--output", default=None,
|
|
345
|
+
help="Output root directory (or set PLEX_ROOT env var, or config).",
|
|
346
|
+
)
|
|
347
|
+
rip_parser.add_argument(
|
|
348
|
+
"--titles", default=None,
|
|
349
|
+
help="Comma-separated title indices to rip (overrides auto-recommendation).",
|
|
350
|
+
)
|
|
351
|
+
rip_parser.add_argument(
|
|
352
|
+
"--all", dest="rip_all", action="store_true", default=False,
|
|
353
|
+
help="Rip all titles (skip recommendation filter).",
|
|
354
|
+
)
|
|
355
|
+
rip_parser.add_argument(
|
|
356
|
+
"--yes", "-y", action="store_true", default=False,
|
|
357
|
+
help="Skip confirmation prompt.",
|
|
358
|
+
)
|
|
359
|
+
rip_parser.add_argument(
|
|
360
|
+
"--execute",
|
|
361
|
+
action="store_true",
|
|
362
|
+
default=False,
|
|
363
|
+
help="Actually rip (default: dry-run preview only).",
|
|
364
|
+
)
|
|
365
|
+
rip_parser.add_argument(
|
|
366
|
+
"--dry-run", "-n", action="store_true", default=False,
|
|
367
|
+
help=argparse.SUPPRESS, # kept for backwards compat; dry-run is now default
|
|
368
|
+
)
|
|
369
|
+
rip_parser.add_argument(
|
|
370
|
+
"--organize", dest="auto_organize", action="store_true", default=False,
|
|
371
|
+
help="Automatically run organize after ripping.",
|
|
372
|
+
)
|
|
373
|
+
rip_parser.add_argument("--json", action="store_true", default=False)
|
|
374
|
+
rip_parser.add_argument("--api-key", default=None)
|
|
375
|
+
rip_parser.add_argument(
|
|
376
|
+
"--verbose", "-v", action="store_true", default=False,
|
|
377
|
+
)
|
|
378
|
+
rip_parser.add_argument(
|
|
379
|
+
"--no-cache", action="store_true", default=False,
|
|
380
|
+
)
|
|
381
|
+
rip_parser.add_argument(
|
|
382
|
+
"--auto",
|
|
383
|
+
action="store_true",
|
|
384
|
+
default=False,
|
|
385
|
+
help="Skip interactive prompts, use best-guess defaults.",
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# --- orchestrate ---
|
|
389
|
+
orch_parser = subs.add_parser(
|
|
390
|
+
"orchestrate",
|
|
391
|
+
help="Multi-disc rip and organize pipeline.",
|
|
392
|
+
)
|
|
393
|
+
orch_parser.add_argument(
|
|
394
|
+
"--title", default=None,
|
|
395
|
+
help="Movie or TV show title (auto-detected from volume label if omitted).",
|
|
396
|
+
)
|
|
397
|
+
orch_parser.add_argument(
|
|
398
|
+
"--drive",
|
|
399
|
+
default="auto",
|
|
400
|
+
help="Drive index (e.g. 0), device name (e.g. D:), or 'auto' (default: auto).",
|
|
401
|
+
)
|
|
402
|
+
orch_parser.add_argument("--year", type=int, help="Release year.")
|
|
403
|
+
orch_parser.add_argument(
|
|
404
|
+
"--type", dest="media_type", choices=["movie", "tv", "auto"],
|
|
405
|
+
default="auto",
|
|
406
|
+
)
|
|
407
|
+
orch_parser.add_argument(
|
|
408
|
+
"--format", dest="disc_format", default=None,
|
|
409
|
+
help="Disc format filter for dvdcompare (auto-detected from disc resolution if omitted).",
|
|
410
|
+
)
|
|
411
|
+
orch_parser.add_argument(
|
|
412
|
+
"--release", default=None,
|
|
413
|
+
help="Regional release: 1-based index or name keyword (default: auto-detect).",
|
|
414
|
+
)
|
|
415
|
+
orch_parser.add_argument(
|
|
416
|
+
"--output", default=None,
|
|
417
|
+
help="Output root directory (or set PLEX_ROOT env var, or config).",
|
|
418
|
+
)
|
|
419
|
+
orch_parser.add_argument(
|
|
420
|
+
"--yes", "-y", action="store_true", default=False,
|
|
421
|
+
help="Skip confirmation prompts.",
|
|
422
|
+
)
|
|
423
|
+
orch_parser.add_argument(
|
|
424
|
+
"--execute",
|
|
425
|
+
action="store_true",
|
|
426
|
+
default=False,
|
|
427
|
+
help="Actually rip and organize (default: dry-run preview only).",
|
|
428
|
+
)
|
|
429
|
+
orch_parser.add_argument(
|
|
430
|
+
"--unmatched",
|
|
431
|
+
choices=["ignore", "move", "delete", "extras"],
|
|
432
|
+
default="extras",
|
|
433
|
+
help="Policy for unmatched files during organize (default: extras).",
|
|
434
|
+
)
|
|
435
|
+
orch_parser.add_argument("--json", action="store_true", default=False)
|
|
436
|
+
orch_parser.add_argument("--api-key", default=None)
|
|
437
|
+
orch_parser.add_argument(
|
|
438
|
+
"--verbose", "-v", action="store_true", default=False,
|
|
439
|
+
)
|
|
440
|
+
orch_parser.add_argument(
|
|
441
|
+
"--no-cache", action="store_true", default=False,
|
|
442
|
+
)
|
|
443
|
+
orch_parser.add_argument(
|
|
444
|
+
"--auto",
|
|
445
|
+
action="store_true",
|
|
446
|
+
default=False,
|
|
447
|
+
help="Skip interactive prompts, use best-guess defaults.",
|
|
448
|
+
)
|
|
449
|
+
orch_parser.add_argument(
|
|
450
|
+
"--discs", default=None,
|
|
451
|
+
help="Comma-separated disc numbers to rip (e.g. '1,3'). Skips others.",
|
|
452
|
+
)
|
|
453
|
+
orch_parser.add_argument(
|
|
454
|
+
"--snapshot",
|
|
455
|
+
action="store_true",
|
|
456
|
+
default=False,
|
|
457
|
+
help="Scan disc and write manifest without ripping. Useful to regenerate manifests for already-ripped files.",
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
return parser
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
async def _run(args: argparse.Namespace) -> int:
|
|
465
|
+
if args.command == "organize":
|
|
466
|
+
return await _run_organize(args)
|
|
467
|
+
if args.command == "lookup":
|
|
468
|
+
return await _run_lookup(args)
|
|
469
|
+
if args.command == "rip":
|
|
470
|
+
return await _run_rip(args)
|
|
471
|
+
if args.command == "orchestrate":
|
|
472
|
+
return await _run_orchestrate(args)
|
|
473
|
+
# Unknown or missing command
|
|
474
|
+
return 1
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def _run_lookup(args: argparse.Namespace) -> int:
|
|
478
|
+
"""Look up disc contents and metadata for a title from TMDb and dvdcompare."""
|
|
479
|
+
log_file = _setup_logging(verbose=getattr(args, "verbose", False))
|
|
480
|
+
log.info("riplex lookup: args=%s", vars(args))
|
|
481
|
+
print(f"Debug log: {log_file}", file=sys.stderr)
|
|
482
|
+
|
|
483
|
+
if getattr(args, "no_cache", False):
|
|
484
|
+
from riplex import cache
|
|
485
|
+
cache.disable()
|
|
486
|
+
|
|
487
|
+
api_key = get_api_key(getattr(args, "api_key", None))
|
|
488
|
+
try:
|
|
489
|
+
provider = TmdbProvider(api_key=api_key)
|
|
490
|
+
except ValueError as exc:
|
|
491
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
492
|
+
return 1
|
|
493
|
+
|
|
494
|
+
try:
|
|
495
|
+
request = SearchRequest(
|
|
496
|
+
title=args.title,
|
|
497
|
+
year=getattr(args, "year", None),
|
|
498
|
+
media_type=getattr(args, "media_type", "auto"),
|
|
499
|
+
include_specials=not getattr(args, "no_specials", False),
|
|
500
|
+
include_extras_skeleton=not getattr(args, "no_extras", False),
|
|
501
|
+
)
|
|
502
|
+
result = await plan(request, provider)
|
|
503
|
+
except LookupError as exc:
|
|
504
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
505
|
+
return 1
|
|
506
|
+
except Exception as exc:
|
|
507
|
+
print(f"Error fetching TMDb metadata: {exc}", file=sys.stderr)
|
|
508
|
+
return 1
|
|
509
|
+
finally:
|
|
510
|
+
await provider.close()
|
|
511
|
+
|
|
512
|
+
canonical = result.canonical_title
|
|
513
|
+
year = result.year
|
|
514
|
+
is_movie = isinstance(result, PlannedMovie)
|
|
515
|
+
movie_runtime = result.runtime_seconds if is_movie else None
|
|
516
|
+
|
|
517
|
+
# Look up dvdcompare disc metadata
|
|
518
|
+
disc_format = getattr(args, "disc_format", None)
|
|
519
|
+
release = getattr(args, "release", "america")
|
|
520
|
+
dvdcompare_title = canonical
|
|
521
|
+
print("Looking up disc metadata on dvdcompare.net ...", file=sys.stderr)
|
|
522
|
+
discs: list = []
|
|
523
|
+
try:
|
|
524
|
+
discs = await lookup_discs(dvdcompare_title, disc_format=disc_format, release=release)
|
|
525
|
+
except LookupError:
|
|
526
|
+
print("Warning: no dvdcompare data found. Guide will be limited to TMDb info.", file=sys.stderr)
|
|
527
|
+
except Exception as exc:
|
|
528
|
+
print(f"Warning: dvdcompare lookup failed ({type(exc).__name__}). Guide will be limited to TMDb info.", file=sys.stderr)
|
|
529
|
+
|
|
530
|
+
# Live disc analysis via makemkvcon (run before output so JSON can include it)
|
|
531
|
+
drive_arg = getattr(args, "drive", None)
|
|
532
|
+
disc_info = None
|
|
533
|
+
if drive_arg is not None:
|
|
534
|
+
from riplex.makemkv import (
|
|
535
|
+
DiscInfo,
|
|
536
|
+
find_makemkvcon,
|
|
537
|
+
run_disc_info,
|
|
538
|
+
run_drive_list,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
exe = find_makemkvcon()
|
|
542
|
+
if not exe:
|
|
543
|
+
print("\nError: makemkvcon not found. Install MakeMKV or ensure makemkvcon is on PATH.", file=sys.stderr)
|
|
544
|
+
return 1
|
|
545
|
+
|
|
546
|
+
if drive_arg == "auto":
|
|
547
|
+
print("Scanning drives ...", file=sys.stderr)
|
|
548
|
+
drives = run_drive_list(exe)
|
|
549
|
+
active = [d for d in drives if d.has_disc]
|
|
550
|
+
if not active:
|
|
551
|
+
print("Error: no disc found in any drive.", file=sys.stderr)
|
|
552
|
+
return 1
|
|
553
|
+
print(f"Found disc in drive {active[0].index}: {active[0].disc_label} ({active[0].device})", file=sys.stderr)
|
|
554
|
+
drive_idx = active[0].index
|
|
555
|
+
else:
|
|
556
|
+
try:
|
|
557
|
+
drive_idx = int(drive_arg)
|
|
558
|
+
except ValueError:
|
|
559
|
+
drive_idx = drive_arg # device name like "D:"
|
|
560
|
+
|
|
561
|
+
print(f"Reading disc info from drive {drive_idx} ...", file=sys.stderr)
|
|
562
|
+
try:
|
|
563
|
+
disc_info = run_disc_info(drive_idx, exe)
|
|
564
|
+
except (RuntimeError, FileNotFoundError) as exc:
|
|
565
|
+
print(f"Error reading disc: {exc}", file=sys.stderr)
|
|
566
|
+
return 1
|
|
567
|
+
|
|
568
|
+
if getattr(args, "json", False):
|
|
569
|
+
print(_rip_guide_json(canonical, year, is_movie, movie_runtime, discs, disc_info))
|
|
570
|
+
return 0
|
|
571
|
+
|
|
572
|
+
_print_rip_guide(canonical, year, is_movie, movie_runtime, discs)
|
|
573
|
+
|
|
574
|
+
if disc_info is not None:
|
|
575
|
+
_print_disc_analysis(disc_info, discs, is_movie, movie_runtime)
|
|
576
|
+
|
|
577
|
+
# Optionally create folders
|
|
578
|
+
if getattr(args, "create_folders", False) and discs:
|
|
579
|
+
output_val = get_output_root(getattr(args, "output", None))
|
|
580
|
+
if not output_val:
|
|
581
|
+
print("Error: --output or output_root config required for --create-folders.", file=sys.stderr)
|
|
582
|
+
return 1
|
|
583
|
+
rip_output = get_rip_output()
|
|
584
|
+
makemkv_root = Path(rip_output) / f"{canonical} ({year})" if rip_output else Path(output_val) / "Rips" / f"{canonical} ({year})"
|
|
585
|
+
created = _create_rip_folders(makemkv_root, discs)
|
|
586
|
+
if created:
|
|
587
|
+
print(f"\nCreated {len(created)} folder(s) under {makemkv_root}")
|
|
588
|
+
for p in created:
|
|
589
|
+
print(f" {p}")
|
|
590
|
+
|
|
591
|
+
return 0
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _disc_role(disc, is_movie: bool) -> str:
|
|
596
|
+
"""Return a short role label for a disc in the folder listing."""
|
|
597
|
+
if disc.is_film:
|
|
598
|
+
return " (main film)"
|
|
599
|
+
if is_movie:
|
|
600
|
+
# For movies, episodes on non-film discs are play-all bonus groups
|
|
601
|
+
if disc.extras or disc.episodes:
|
|
602
|
+
return " (extras)"
|
|
603
|
+
return ""
|
|
604
|
+
# TV show
|
|
605
|
+
if disc.episodes and not disc.extras:
|
|
606
|
+
return " (episodes)"
|
|
607
|
+
if disc.extras and not disc.episodes:
|
|
608
|
+
return " (extras)"
|
|
609
|
+
if disc.episodes and disc.extras:
|
|
610
|
+
return " (episodes + extras)"
|
|
611
|
+
return ""
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _print_rip_guide(
|
|
615
|
+
canonical: str,
|
|
616
|
+
year: int,
|
|
617
|
+
is_movie: bool,
|
|
618
|
+
movie_runtime: int | None,
|
|
619
|
+
discs: list,
|
|
620
|
+
) -> None:
|
|
621
|
+
"""Print a human-readable rip guide to stdout."""
|
|
622
|
+
media_label = "Movie" if is_movie else "TV Show"
|
|
623
|
+
print(f"\n{canonical} ({year}) [{media_label}]")
|
|
624
|
+
print("=" * 60)
|
|
625
|
+
|
|
626
|
+
# Recommended folder structure
|
|
627
|
+
print(f"\nRecommended rip folder structure:")
|
|
628
|
+
folder_base = f"{canonical} ({year})"
|
|
629
|
+
if discs:
|
|
630
|
+
for disc in discs:
|
|
631
|
+
label = f"Disc {disc.number}"
|
|
632
|
+
fmt_str = f" [{disc.disc_format}]" if disc.disc_format else ""
|
|
633
|
+
role = _disc_role(disc, is_movie)
|
|
634
|
+
print(f" Rips/{folder_base}/{label}/{fmt_str}{role}")
|
|
635
|
+
else:
|
|
636
|
+
print(f" Rips/{folder_base}/")
|
|
637
|
+
|
|
638
|
+
if not discs:
|
|
639
|
+
print("\nNo dvdcompare disc data available.")
|
|
640
|
+
if movie_runtime:
|
|
641
|
+
print(f"Main feature runtime: {_format_seconds(movie_runtime)}")
|
|
642
|
+
print("Tip: rip all titles and use 'riplex organize' to sort them.")
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
# Per-disc breakdown
|
|
646
|
+
print(f"\nDisc contents ({len(discs)} disc(s)):")
|
|
647
|
+
print("-" * 60)
|
|
648
|
+
|
|
649
|
+
play_all_tips: list[str] = []
|
|
650
|
+
|
|
651
|
+
for disc in discs:
|
|
652
|
+
fmt_str = f" [{disc.disc_format}]" if disc.disc_format else ""
|
|
653
|
+
role_tag = " ** MAIN FILM **" if disc.is_film else ""
|
|
654
|
+
print(f"\n Disc {disc.number}{fmt_str}{role_tag}")
|
|
655
|
+
|
|
656
|
+
if disc.is_film and movie_runtime:
|
|
657
|
+
print(f" The Film: {_format_seconds(movie_runtime)}")
|
|
658
|
+
|
|
659
|
+
items = disc.episodes + disc.extras
|
|
660
|
+
|
|
661
|
+
if not items and disc.is_film:
|
|
662
|
+
continue
|
|
663
|
+
|
|
664
|
+
has_episodes = bool(disc.episodes)
|
|
665
|
+
has_extras = bool(disc.extras)
|
|
666
|
+
|
|
667
|
+
# For movies, "episodes" on a non-film disc are really play-all
|
|
668
|
+
# bonus feature groups (dvdcompare models them as children).
|
|
669
|
+
episodes_are_extras = is_movie and has_episodes and not disc.is_film
|
|
670
|
+
|
|
671
|
+
if has_episodes and not episodes_are_extras:
|
|
672
|
+
total_ep_runtime = sum(e.runtime_seconds for e in disc.episodes)
|
|
673
|
+
print(f" Episodes ({len(disc.episodes)}, total {_format_seconds(total_ep_runtime)}):")
|
|
674
|
+
for ep in disc.episodes:
|
|
675
|
+
rt = _format_seconds(ep.runtime_seconds) if ep.runtime_seconds else "?"
|
|
676
|
+
print(f" {ep.title} ({rt})")
|
|
677
|
+
play_all_tips.append(
|
|
678
|
+
f"Disc {disc.number}: has {len(disc.episodes)} episodes "
|
|
679
|
+
f"(total {_format_seconds(total_ep_runtime)}). "
|
|
680
|
+
f"If MakeMKV shows a single title with {len(disc.episodes)} "
|
|
681
|
+
f"or more chapters totaling ~{_format_seconds(total_ep_runtime)}, "
|
|
682
|
+
f"that is the play-all. You can rip just that one title; "
|
|
683
|
+
f"riplex will split it by chapters."
|
|
684
|
+
)
|
|
685
|
+
elif episodes_are_extras:
|
|
686
|
+
total_ep_runtime = sum(e.runtime_seconds for e in disc.episodes)
|
|
687
|
+
print(f" Extras - play-all group ({len(disc.episodes)} items, total {_format_seconds(total_ep_runtime)}):")
|
|
688
|
+
for ep in disc.episodes:
|
|
689
|
+
rt = _format_seconds(ep.runtime_seconds) if ep.runtime_seconds else "?"
|
|
690
|
+
print(f" {ep.title} ({rt})")
|
|
691
|
+
|
|
692
|
+
if has_extras:
|
|
693
|
+
total_extra_runtime = sum(e.runtime_seconds for e in disc.extras)
|
|
694
|
+
print(f" Extras ({len(disc.extras)}, total {_format_seconds(total_extra_runtime)}):")
|
|
695
|
+
for extra in disc.extras:
|
|
696
|
+
rt = _format_seconds(extra.runtime_seconds) if extra.runtime_seconds else "?"
|
|
697
|
+
ftype = f" [{extra.feature_type}]" if extra.feature_type else ""
|
|
698
|
+
print(f" {extra.title} ({rt}){ftype}")
|
|
699
|
+
|
|
700
|
+
# Summary and tips
|
|
701
|
+
total_features = sum(len(d.episodes) + len(d.extras) for d in discs)
|
|
702
|
+
film_discs = [d for d in discs if d.is_film]
|
|
703
|
+
extras_discs = [d for d in discs if not d.is_film and (d.extras or d.episodes)]
|
|
704
|
+
|
|
705
|
+
print(f"\n{'=' * 60}")
|
|
706
|
+
print("Rip tips:")
|
|
707
|
+
|
|
708
|
+
if film_discs:
|
|
709
|
+
film_nums = ", ".join(str(d.number) for d in film_discs)
|
|
710
|
+
print(f" - Main film is on disc {film_nums}.")
|
|
711
|
+
|
|
712
|
+
if play_all_tips:
|
|
713
|
+
for tip in play_all_tips:
|
|
714
|
+
print(f" - {tip}")
|
|
715
|
+
|
|
716
|
+
if extras_discs:
|
|
717
|
+
nums = ", ".join(str(d.number) for d in extras_discs)
|
|
718
|
+
print(f" - Extras are on disc {nums}. Rip all titles from extras discs;")
|
|
719
|
+
print(f" riplex will match each by runtime to its dvdcompare entry.")
|
|
720
|
+
|
|
721
|
+
if total_features > 0:
|
|
722
|
+
print(f" - {total_features} total feature(s) across {len(discs)} disc(s).")
|
|
723
|
+
|
|
724
|
+
print(f" - After ripping, run: riplex organize \"{folder_base}\"")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _rip_guide_json(
|
|
728
|
+
canonical: str,
|
|
729
|
+
year: int,
|
|
730
|
+
is_movie: bool,
|
|
731
|
+
movie_runtime: int | None,
|
|
732
|
+
discs: list,
|
|
733
|
+
disc_info=None,
|
|
734
|
+
) -> str:
|
|
735
|
+
"""Return JSON representation of the rip guide."""
|
|
736
|
+
import json
|
|
737
|
+
import dataclasses
|
|
738
|
+
|
|
739
|
+
data: dict = {
|
|
740
|
+
"title": canonical,
|
|
741
|
+
"year": year,
|
|
742
|
+
"media_type": "movie" if is_movie else "tv",
|
|
743
|
+
"movie_runtime_seconds": movie_runtime,
|
|
744
|
+
"recommended_folder": f"{canonical} ({year})",
|
|
745
|
+
"discs": [dataclasses.asdict(d) for d in discs],
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if disc_info is not None:
|
|
749
|
+
dvd_entries, total_episode_runtime, episode_count = build_dvd_entries(discs)
|
|
750
|
+
|
|
751
|
+
titles_json = []
|
|
752
|
+
for t in disc_info.titles:
|
|
753
|
+
recommendation = classify_title(
|
|
754
|
+
t, disc_info.titles, dvd_entries,
|
|
755
|
+
is_movie, movie_runtime,
|
|
756
|
+
total_episode_runtime, episode_count,
|
|
757
|
+
)
|
|
758
|
+
skip = is_skip_title(
|
|
759
|
+
t, disc_info.titles, is_movie, movie_runtime,
|
|
760
|
+
total_episode_runtime, episode_count,
|
|
761
|
+
)
|
|
762
|
+
titles_json.append({
|
|
763
|
+
"index": t.index,
|
|
764
|
+
"name": t.name,
|
|
765
|
+
"duration_seconds": t.duration_seconds,
|
|
766
|
+
"chapters": t.chapters,
|
|
767
|
+
"size_bytes": t.size_bytes,
|
|
768
|
+
"filename": t.filename,
|
|
769
|
+
"playlist": t.playlist,
|
|
770
|
+
"resolution": t.resolution,
|
|
771
|
+
"video_codec": t.video_codec,
|
|
772
|
+
"audio_tracks": t.audio_tracks,
|
|
773
|
+
"segment_count": t.segment_count,
|
|
774
|
+
"segment_map": t.segment_map,
|
|
775
|
+
"recommendation": recommendation,
|
|
776
|
+
"skip": skip,
|
|
777
|
+
})
|
|
778
|
+
data["disc_analysis"] = {
|
|
779
|
+
"disc_name": disc_info.disc_name,
|
|
780
|
+
"disc_type": disc_info.disc_type,
|
|
781
|
+
"titles": titles_json,
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return json.dumps(data, indent=2)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _create_rip_folders(makemkv_root: Path, discs: list) -> list[Path]:
|
|
788
|
+
"""Create the recommended disc subfolder structure.
|
|
789
|
+
|
|
790
|
+
Returns list of created directories.
|
|
791
|
+
"""
|
|
792
|
+
created: list[Path] = []
|
|
793
|
+
for disc in discs:
|
|
794
|
+
folder = makemkv_root / f"Disc {disc.number}"
|
|
795
|
+
if not folder.exists():
|
|
796
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
797
|
+
created.append(folder)
|
|
798
|
+
return created
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
# Disc analysis functions are in disc_analysis.py; import for use here.
|
|
802
|
+
from riplex.disc_analysis import ( # noqa: E402
|
|
803
|
+
build_dvd_entries,
|
|
804
|
+
classify_title,
|
|
805
|
+
find_duration_match,
|
|
806
|
+
format_seconds as _format_seconds,
|
|
807
|
+
is_skip_title,
|
|
808
|
+
print_disc_analysis as _print_disc_analysis,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _parse_volume_label(label: str) -> str | None:
|
|
813
|
+
"""Extract a human-readable title from a disc volume label.
|
|
814
|
+
|
|
815
|
+
Examples:
|
|
816
|
+
"FROZEN_PLANET_II_D2" -> "Frozen Planet II"
|
|
817
|
+
"PLANET_EARTH_III-Disc3" -> "Planet Earth III"
|
|
818
|
+
"BLADE_RUNNER_2049" -> "Blade Runner 2049"
|
|
819
|
+
"TGUN2" -> None (too short/ambiguous)
|
|
820
|
+
"""
|
|
821
|
+
if not label or len(label) < 2:
|
|
822
|
+
return None
|
|
823
|
+
|
|
824
|
+
# Strip disc number suffix including its leading separator.
|
|
825
|
+
# Matches: "_D2", "-Disc3", " - Disc 1", "_Disc_1"
|
|
826
|
+
# Won't match titles with dashes like "Spider-Man" or "X-Men".
|
|
827
|
+
cleaned = re.sub(r"[\s_-]+D(?:isc[\s_]*)?\d+\s*$", "", label, flags=re.IGNORECASE)
|
|
828
|
+
|
|
829
|
+
# Replace underscores with spaces
|
|
830
|
+
cleaned = cleaned.replace("_", " ").strip()
|
|
831
|
+
|
|
832
|
+
if len(cleaned) < 2:
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
# Title-case, preserving roman numerals
|
|
836
|
+
words = cleaned.split()
|
|
837
|
+
result = []
|
|
838
|
+
for w in words:
|
|
839
|
+
if re.fullmatch(r"[IVXLCDM]+", w, re.IGNORECASE):
|
|
840
|
+
result.append(w.upper())
|
|
841
|
+
else:
|
|
842
|
+
result.append(w.capitalize())
|
|
843
|
+
return " ".join(result)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _detect_disc_format(disc_info) -> str | None:
|
|
847
|
+
"""Auto-detect dvdcompare format string from disc title resolutions.
|
|
848
|
+
|
|
849
|
+
Returns "Blu-ray 4K" if any title is 3840-wide, else "Blu-ray".
|
|
850
|
+
"""
|
|
851
|
+
if not disc_info.titles:
|
|
852
|
+
return None
|
|
853
|
+
for t in disc_info.titles:
|
|
854
|
+
if t.resolution and "3840" in t.resolution:
|
|
855
|
+
return "Blu-ray 4K"
|
|
856
|
+
return "Blu-ray"
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _infer_media_type(disc_info) -> str:
|
|
860
|
+
"""Infer 'movie' or 'tv' from disc title structure.
|
|
861
|
+
|
|
862
|
+
Heuristic: if a disc has 2+ non-play-all titles with durations
|
|
863
|
+
between 15 and 75 minutes, it's likely a TV disc. A single long
|
|
864
|
+
title (75+ minutes) suggests a movie disc.
|
|
865
|
+
|
|
866
|
+
Returns "movie", "tv", or "auto" if ambiguous.
|
|
867
|
+
"""
|
|
868
|
+
if not disc_info.titles:
|
|
869
|
+
return "auto"
|
|
870
|
+
|
|
871
|
+
# Identify candidate episode titles: substantial duration, low segment count
|
|
872
|
+
candidates = [
|
|
873
|
+
t for t in disc_info.titles
|
|
874
|
+
if t.duration_seconds >= 900 # 15+ minutes
|
|
875
|
+
and t.segment_count <= 1 # not a play-all
|
|
876
|
+
]
|
|
877
|
+
|
|
878
|
+
if not candidates:
|
|
879
|
+
return "auto"
|
|
880
|
+
|
|
881
|
+
episode_length = [t for t in candidates if t.duration_seconds < 4500] # < 75 min
|
|
882
|
+
movie_length = [t for t in candidates if t.duration_seconds >= 4500] # >= 75 min
|
|
883
|
+
|
|
884
|
+
if len(episode_length) >= 2 and len(movie_length) == 0:
|
|
885
|
+
return "tv"
|
|
886
|
+
if len(movie_length) == 1 and len(episode_length) == 0:
|
|
887
|
+
return "movie"
|
|
888
|
+
|
|
889
|
+
return "auto"
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _select_dvdcompare_release(
|
|
893
|
+
film,
|
|
894
|
+
disc_info=None,
|
|
895
|
+
preferred: str | None = None,
|
|
896
|
+
) -> tuple[list, str]:
|
|
897
|
+
"""Select the best dvdcompare release for a disc.
|
|
898
|
+
|
|
899
|
+
Selection strategy:
|
|
900
|
+
1. If *preferred* keyword given, keyword-match against release names.
|
|
901
|
+
Error + exit if not found.
|
|
902
|
+
2. If no *preferred*, try duration matching against *disc_info* (rip only).
|
|
903
|
+
3. Fall back to first release.
|
|
904
|
+
4. Reorder releases so the recommended one is first.
|
|
905
|
+
5. If interactive and >1 release, let the user pick from the list.
|
|
906
|
+
|
|
907
|
+
Returns (PlannedDisc list, release_name) or ([], "").
|
|
908
|
+
"""
|
|
909
|
+
from dvdcompare.cli import select_releases
|
|
910
|
+
|
|
911
|
+
if not film.releases:
|
|
912
|
+
return [], ""
|
|
913
|
+
|
|
914
|
+
# --- determine recommended release index ---
|
|
915
|
+
rec_idx = 0 # 0-based index into film.releases
|
|
916
|
+
|
|
917
|
+
if preferred:
|
|
918
|
+
# Keyword match against release names
|
|
919
|
+
try:
|
|
920
|
+
selected = select_releases(film.releases, preferred)
|
|
921
|
+
rec_idx = next(
|
|
922
|
+
i for i, r in enumerate(film.releases) if r is selected[0]
|
|
923
|
+
)
|
|
924
|
+
except (LookupError, StopIteration):
|
|
925
|
+
print(f"Error: no release matching '{preferred}'.", file=sys.stderr)
|
|
926
|
+
print("Available releases:", file=sys.stderr)
|
|
927
|
+
for i, r in enumerate(film.releases, 1):
|
|
928
|
+
print(f" {i}. {r.name}", file=sys.stderr)
|
|
929
|
+
sys.exit(1)
|
|
930
|
+
elif disc_info and disc_info.titles:
|
|
931
|
+
# Duration matching (rip only)
|
|
932
|
+
live_durations = sorted(
|
|
933
|
+
[t.duration_seconds for t in disc_info.titles if t.duration_seconds > 120],
|
|
934
|
+
reverse=True,
|
|
935
|
+
)
|
|
936
|
+
if live_durations:
|
|
937
|
+
best_idx = None
|
|
938
|
+
best_score = -1
|
|
939
|
+
|
|
940
|
+
for rel_idx, rel in enumerate(film.releases):
|
|
941
|
+
ep_durations = sorted(
|
|
942
|
+
[f.runtime_seconds for d in rel.discs for f in d.features
|
|
943
|
+
if f.runtime_seconds and f.runtime_seconds > 120],
|
|
944
|
+
reverse=True,
|
|
945
|
+
)
|
|
946
|
+
if not ep_durations:
|
|
947
|
+
continue
|
|
948
|
+
|
|
949
|
+
matched = 0
|
|
950
|
+
used = set()
|
|
951
|
+
for live_dur in live_durations:
|
|
952
|
+
for i, ep_dur in enumerate(ep_durations):
|
|
953
|
+
if i not in used and abs(live_dur - ep_dur) < 60:
|
|
954
|
+
matched += 1
|
|
955
|
+
used.add(i)
|
|
956
|
+
break
|
|
957
|
+
|
|
958
|
+
score = matched / len(ep_durations)
|
|
959
|
+
if score > best_score:
|
|
960
|
+
best_score = score
|
|
961
|
+
best_idx = rel_idx
|
|
962
|
+
|
|
963
|
+
if best_idx is not None and best_score >= 0.3:
|
|
964
|
+
rec_idx = best_idx
|
|
965
|
+
|
|
966
|
+
# --- reorder releases so recommended is first ---
|
|
967
|
+
releases = [film.releases[rec_idx]] + [
|
|
968
|
+
r for i, r in enumerate(film.releases) if i != rec_idx
|
|
969
|
+
]
|
|
970
|
+
|
|
971
|
+
# --- interactive selection (skip if preferred already resolved) ---
|
|
972
|
+
if is_interactive() and len(releases) > 1 and not preferred:
|
|
973
|
+
options = []
|
|
974
|
+
for rel in releases:
|
|
975
|
+
disc_count = len(rel.discs) if rel.discs else 0
|
|
976
|
+
disc_word = "disc" if disc_count == 1 else "discs"
|
|
977
|
+
options.append(f"{rel.name} [{disc_count} {disc_word}]")
|
|
978
|
+
chosen_idx = prompt_choice(
|
|
979
|
+
"Select a dvdcompare release:", options, default=0,
|
|
980
|
+
)
|
|
981
|
+
else:
|
|
982
|
+
chosen_idx = 0
|
|
983
|
+
|
|
984
|
+
# --- convert chosen release ---
|
|
985
|
+
chosen_release = releases[chosen_idx]
|
|
986
|
+
# Find the 1-based index in the original film.releases for _convert_film
|
|
987
|
+
orig_idx = next(i for i, r in enumerate(film.releases) if r is chosen_release)
|
|
988
|
+
discs = _convert_film(film, str(orig_idx + 1))
|
|
989
|
+
return discs, chosen_release.name
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _detect_disc_number(
|
|
993
|
+
disc_info,
|
|
994
|
+
dvdcompare_discs: list,
|
|
995
|
+
) -> int | None:
|
|
996
|
+
"""Auto-detect which dvdcompare disc number the physical disc corresponds to.
|
|
997
|
+
|
|
998
|
+
Tries two strategies:
|
|
999
|
+
1. Parse the volume label for a disc number (e.g. "FROZEN_PLANET_II_D2" -> 2)
|
|
1000
|
+
2. Match live title durations against each dvdcompare disc's episodes.
|
|
1001
|
+
|
|
1002
|
+
Returns the disc number (1-based) or None if detection fails.
|
|
1003
|
+
"""
|
|
1004
|
+
# Strategy 1: volume label
|
|
1005
|
+
label = disc_info.disc_name or ""
|
|
1006
|
+
match = re.search(r"[_\s-]D(?:isc\s*)?(\d+)\b", label, re.IGNORECASE)
|
|
1007
|
+
if match:
|
|
1008
|
+
return int(match.group(1))
|
|
1009
|
+
|
|
1010
|
+
# Strategy 2: duration matching against dvdcompare discs
|
|
1011
|
+
if not dvdcompare_discs or not disc_info.titles:
|
|
1012
|
+
return None
|
|
1013
|
+
|
|
1014
|
+
# Collect substantial title durations from the live disc
|
|
1015
|
+
live_durations = sorted(
|
|
1016
|
+
[t.duration_seconds for t in disc_info.titles if t.duration_seconds > 120],
|
|
1017
|
+
reverse=True,
|
|
1018
|
+
)
|
|
1019
|
+
if not live_durations:
|
|
1020
|
+
return None
|
|
1021
|
+
|
|
1022
|
+
best_disc = None
|
|
1023
|
+
best_score = -1
|
|
1024
|
+
|
|
1025
|
+
for disc in dvdcompare_discs:
|
|
1026
|
+
ep_durations = sorted(
|
|
1027
|
+
[ep.runtime_seconds for ep in disc.episodes if ep.runtime_seconds > 0],
|
|
1028
|
+
reverse=True,
|
|
1029
|
+
)
|
|
1030
|
+
if not ep_durations:
|
|
1031
|
+
continue
|
|
1032
|
+
|
|
1033
|
+
# Count how many live titles match an episode within 60 seconds
|
|
1034
|
+
matched = 0
|
|
1035
|
+
used = set()
|
|
1036
|
+
for live_dur in live_durations:
|
|
1037
|
+
for i, ep_dur in enumerate(ep_durations):
|
|
1038
|
+
if i not in used and abs(live_dur - ep_dur) < 60:
|
|
1039
|
+
matched += 1
|
|
1040
|
+
used.add(i)
|
|
1041
|
+
break
|
|
1042
|
+
|
|
1043
|
+
# Score: fraction of episodes matched
|
|
1044
|
+
score = matched / len(ep_durations) if ep_durations else 0
|
|
1045
|
+
if score > best_score:
|
|
1046
|
+
best_score = score
|
|
1047
|
+
best_disc = disc.number
|
|
1048
|
+
|
|
1049
|
+
# Require at least 50% of episodes to match
|
|
1050
|
+
if best_score >= 0.5:
|
|
1051
|
+
return best_disc
|
|
1052
|
+
|
|
1053
|
+
# Strategy 3: for movies, match by disc format/resolution
|
|
1054
|
+
# If the live disc has 4K content and only one dvdcompare disc is 4K, that's our match
|
|
1055
|
+
live_resolutions = {t.resolution for t in disc_info.titles if t.resolution}
|
|
1056
|
+
has_4k = any("2160" in r for r in live_resolutions)
|
|
1057
|
+
has_1080 = any("1080" in r for r in live_resolutions)
|
|
1058
|
+
|
|
1059
|
+
format_candidates = []
|
|
1060
|
+
for disc in dvdcompare_discs:
|
|
1061
|
+
fmt = (getattr(disc, "disc_format", "") or "").lower()
|
|
1062
|
+
if has_4k and ("4k" in fmt or "uhd" in fmt):
|
|
1063
|
+
format_candidates.append(disc.number)
|
|
1064
|
+
elif has_1080 and not has_4k and "4k" not in fmt and "uhd" not in fmt and "blu" in fmt:
|
|
1065
|
+
format_candidates.append(disc.number)
|
|
1066
|
+
|
|
1067
|
+
if len(format_candidates) == 1:
|
|
1068
|
+
return format_candidates[0]
|
|
1069
|
+
|
|
1070
|
+
return None
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
async def _run_rip(args: argparse.Namespace) -> int:
|
|
1074
|
+
"""Read a disc, show analysis, and rip recommended titles."""
|
|
1075
|
+
import json as json_mod
|
|
1076
|
+
import time
|
|
1077
|
+
|
|
1078
|
+
from riplex.makemkv import (
|
|
1079
|
+
find_makemkvcon,
|
|
1080
|
+
run_disc_info,
|
|
1081
|
+
run_drive_list,
|
|
1082
|
+
run_rip,
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
log_file = _setup_logging(verbose=getattr(args, "verbose", False))
|
|
1086
|
+
log.info("riplex rip: args=%s", vars(args))
|
|
1087
|
+
print(f"Debug log: {log_file}", file=sys.stderr)
|
|
1088
|
+
|
|
1089
|
+
dry_run = not getattr(args, "execute", False)
|
|
1090
|
+
if dry_run:
|
|
1091
|
+
print(f"\n{_dry_run_banner('rip')}\n")
|
|
1092
|
+
else:
|
|
1093
|
+
print("\n--- EXECUTING ---\n")
|
|
1094
|
+
|
|
1095
|
+
if getattr(args, "no_cache", False):
|
|
1096
|
+
from riplex import cache
|
|
1097
|
+
cache.disable()
|
|
1098
|
+
|
|
1099
|
+
# Find makemkvcon
|
|
1100
|
+
exe = find_makemkvcon()
|
|
1101
|
+
if not exe:
|
|
1102
|
+
print("Error: makemkvcon not found. Install MakeMKV or ensure makemkvcon is on PATH.", file=sys.stderr)
|
|
1103
|
+
return 1
|
|
1104
|
+
|
|
1105
|
+
# Resolve drive
|
|
1106
|
+
drive_arg = getattr(args, "drive", "auto") or "auto"
|
|
1107
|
+
if drive_arg == "auto":
|
|
1108
|
+
print("Scanning drives ...", file=sys.stderr)
|
|
1109
|
+
drives = run_drive_list(exe)
|
|
1110
|
+
active = [d for d in drives if d.has_disc]
|
|
1111
|
+
if not active:
|
|
1112
|
+
print("Error: no disc found in any drive.", file=sys.stderr)
|
|
1113
|
+
return 1
|
|
1114
|
+
print(f"Found disc in drive {active[0].index}: {active[0].disc_label} ({active[0].device})", file=sys.stderr)
|
|
1115
|
+
drive_idx = active[0].index
|
|
1116
|
+
volume_label = active[0].disc_label
|
|
1117
|
+
else:
|
|
1118
|
+
try:
|
|
1119
|
+
drive_idx = int(drive_arg)
|
|
1120
|
+
except ValueError:
|
|
1121
|
+
drive_idx = drive_arg
|
|
1122
|
+
volume_label = None
|
|
1123
|
+
|
|
1124
|
+
# Read disc info
|
|
1125
|
+
print("Reading disc info ...", file=sys.stderr)
|
|
1126
|
+
try:
|
|
1127
|
+
disc_info = run_disc_info(drive_idx, exe)
|
|
1128
|
+
except (RuntimeError, FileNotFoundError) as exc:
|
|
1129
|
+
print(f"Error reading disc: {exc}", file=sys.stderr)
|
|
1130
|
+
return 1
|
|
1131
|
+
|
|
1132
|
+
if not disc_info.titles:
|
|
1133
|
+
print("Error: no titles found on disc.", file=sys.stderr)
|
|
1134
|
+
return 1
|
|
1135
|
+
|
|
1136
|
+
# If drive wasn't auto, get label from disc_info
|
|
1137
|
+
if volume_label is None:
|
|
1138
|
+
volume_label = disc_info.disc_name or ""
|
|
1139
|
+
|
|
1140
|
+
# Auto-detect title from volume label if not provided
|
|
1141
|
+
title_arg = getattr(args, "title", None)
|
|
1142
|
+
if not title_arg:
|
|
1143
|
+
title_arg = _parse_volume_label(volume_label)
|
|
1144
|
+
if title_arg:
|
|
1145
|
+
print(f"Auto-detected title from volume label: {title_arg}", file=sys.stderr)
|
|
1146
|
+
title_arg = prompt_text("Title", default=title_arg)
|
|
1147
|
+
else:
|
|
1148
|
+
print("Error: could not detect title from volume label. Provide a title argument.", file=sys.stderr)
|
|
1149
|
+
return 1
|
|
1150
|
+
|
|
1151
|
+
# Auto-detect disc format from resolution if not provided
|
|
1152
|
+
disc_format = getattr(args, "disc_format", None)
|
|
1153
|
+
if not disc_format:
|
|
1154
|
+
disc_format = _detect_disc_format(disc_info)
|
|
1155
|
+
if disc_format:
|
|
1156
|
+
log.info("Auto-detected disc format: %s", disc_format)
|
|
1157
|
+
|
|
1158
|
+
# Infer media type from disc structure if not specified
|
|
1159
|
+
media_type_arg = getattr(args, "media_type", "auto")
|
|
1160
|
+
if media_type_arg == "auto":
|
|
1161
|
+
media_type_arg = _infer_media_type(disc_info)
|
|
1162
|
+
if media_type_arg != "auto":
|
|
1163
|
+
log.info("Inferred media type from disc structure: %s", media_type_arg)
|
|
1164
|
+
|
|
1165
|
+
# TMDb lookup
|
|
1166
|
+
api_key = get_api_key(getattr(args, "api_key", None))
|
|
1167
|
+
try:
|
|
1168
|
+
provider = TmdbProvider(api_key=api_key)
|
|
1169
|
+
except ValueError as exc:
|
|
1170
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1171
|
+
return 1
|
|
1172
|
+
|
|
1173
|
+
try:
|
|
1174
|
+
request = SearchRequest(
|
|
1175
|
+
title=title_arg,
|
|
1176
|
+
year=getattr(args, "year", None),
|
|
1177
|
+
media_type=media_type_arg,
|
|
1178
|
+
)
|
|
1179
|
+
result = await plan(request, provider)
|
|
1180
|
+
except LookupError as exc:
|
|
1181
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1182
|
+
return 1
|
|
1183
|
+
except Exception as exc:
|
|
1184
|
+
print(f"Error fetching TMDb metadata: {exc}", file=sys.stderr)
|
|
1185
|
+
return 1
|
|
1186
|
+
finally:
|
|
1187
|
+
await provider.close()
|
|
1188
|
+
|
|
1189
|
+
canonical = result.canonical_title
|
|
1190
|
+
year = result.year
|
|
1191
|
+
is_movie = isinstance(result, PlannedMovie)
|
|
1192
|
+
movie_runtime = result.runtime_seconds if is_movie else None
|
|
1193
|
+
|
|
1194
|
+
# dvdcompare lookup
|
|
1195
|
+
release = getattr(args, "release", None)
|
|
1196
|
+
discs: list = []
|
|
1197
|
+
release_name = ""
|
|
1198
|
+
print("Looking up disc metadata on dvdcompare.net ...", file=sys.stderr)
|
|
1199
|
+
try:
|
|
1200
|
+
from dvdcompare.scraper import find_film
|
|
1201
|
+
film = await find_film(canonical, disc_format)
|
|
1202
|
+
discs, release_name = _select_dvdcompare_release(
|
|
1203
|
+
film, disc_info=disc_info, preferred=release,
|
|
1204
|
+
)
|
|
1205
|
+
if release_name:
|
|
1206
|
+
print(f" Selected release: {release_name}", file=sys.stderr)
|
|
1207
|
+
except SystemExit:
|
|
1208
|
+
raise
|
|
1209
|
+
except LookupError as exc:
|
|
1210
|
+
print(f"Warning: {exc}", file=sys.stderr)
|
|
1211
|
+
except Exception as exc:
|
|
1212
|
+
print(f"Warning: dvdcompare lookup failed ({type(exc).__name__}).", file=sys.stderr)
|
|
1213
|
+
|
|
1214
|
+
# Show disc analysis
|
|
1215
|
+
_print_disc_analysis(disc_info, discs, is_movie, movie_runtime)
|
|
1216
|
+
|
|
1217
|
+
# Determine which titles to rip
|
|
1218
|
+
dvd_entries, total_episode_runtime, episode_count = build_dvd_entries(discs)
|
|
1219
|
+
|
|
1220
|
+
if getattr(args, "titles", None):
|
|
1221
|
+
# User override
|
|
1222
|
+
try:
|
|
1223
|
+
rip_indices = [int(x.strip()) for x in args.titles.split(",")]
|
|
1224
|
+
except ValueError:
|
|
1225
|
+
print("Error: --titles must be comma-separated integers.", file=sys.stderr)
|
|
1226
|
+
return 1
|
|
1227
|
+
rip_titles = [t for t in disc_info.titles if t.index in rip_indices]
|
|
1228
|
+
if not rip_titles:
|
|
1229
|
+
print("Error: none of the specified title indices exist on disc.", file=sys.stderr)
|
|
1230
|
+
return 1
|
|
1231
|
+
elif getattr(args, "rip_all", False):
|
|
1232
|
+
rip_titles = list(disc_info.titles)
|
|
1233
|
+
else:
|
|
1234
|
+
rip_titles = [
|
|
1235
|
+
t for t in disc_info.titles
|
|
1236
|
+
if not is_skip_title(
|
|
1237
|
+
t, disc_info.titles, is_movie, movie_runtime,
|
|
1238
|
+
total_episode_runtime, episode_count,
|
|
1239
|
+
)
|
|
1240
|
+
]
|
|
1241
|
+
|
|
1242
|
+
if not rip_titles:
|
|
1243
|
+
print("\nNo titles to rip.", file=sys.stderr)
|
|
1244
|
+
return 0
|
|
1245
|
+
|
|
1246
|
+
# Output directory
|
|
1247
|
+
output_val = get_output_root(getattr(args, "output", None))
|
|
1248
|
+
if not output_val:
|
|
1249
|
+
print("Error: --output or output_root config required.", file=sys.stderr)
|
|
1250
|
+
return 1
|
|
1251
|
+
|
|
1252
|
+
disc_number = _detect_disc_number(disc_info, discs)
|
|
1253
|
+
folder_base = f"{canonical} ({year})"
|
|
1254
|
+
if disc_number:
|
|
1255
|
+
disc_folder = f"Disc {disc_number}"
|
|
1256
|
+
else:
|
|
1257
|
+
disc_folder = "Disc 1"
|
|
1258
|
+
if discs and len(discs) > 1:
|
|
1259
|
+
print(f"\nWarning: could not auto-detect disc number. Defaulting to '{disc_folder}'.", file=sys.stderr)
|
|
1260
|
+
print(" Use --titles and manually organize if this is wrong.", file=sys.stderr)
|
|
1261
|
+
|
|
1262
|
+
rip_output = get_rip_output()
|
|
1263
|
+
rip_base = Path(rip_output) / folder_base if rip_output else Path(output_val) / "Rips" / folder_base
|
|
1264
|
+
output_dir = rip_base / disc_folder
|
|
1265
|
+
|
|
1266
|
+
# Confirmation
|
|
1267
|
+
total_size = sum(t.size_bytes for t in rip_titles) / (1024 ** 3)
|
|
1268
|
+
rip_indices_str = ", ".join(str(t.index) for t in rip_titles)
|
|
1269
|
+
print(f"\nWill rip {len(rip_titles)} title(s) [{rip_indices_str}] ({total_size:.1f} GB)")
|
|
1270
|
+
print(f"Output: {output_dir}")
|
|
1271
|
+
|
|
1272
|
+
dry_run = not getattr(args, "execute", False)
|
|
1273
|
+
if dry_run:
|
|
1274
|
+
print(f"\n{_execute_hint('rip')}")
|
|
1275
|
+
return 0
|
|
1276
|
+
|
|
1277
|
+
if not getattr(args, "yes", False):
|
|
1278
|
+
if not prompt_confirm("Proceed?"):
|
|
1279
|
+
print("Aborted.", file=sys.stderr)
|
|
1280
|
+
return 0
|
|
1281
|
+
|
|
1282
|
+
# Rip each title
|
|
1283
|
+
rip_start = time.monotonic()
|
|
1284
|
+
results = []
|
|
1285
|
+
for i, t in enumerate(rip_titles, 1):
|
|
1286
|
+
print(f"\nRipping title {t.index} ({i}/{len(rip_titles)}): "
|
|
1287
|
+
f"{_format_seconds(t.duration_seconds)}, "
|
|
1288
|
+
f"{t.size_bytes / (1024**3):.1f} GB ...")
|
|
1289
|
+
|
|
1290
|
+
title_start = time.monotonic()
|
|
1291
|
+
last_pct = [-1]
|
|
1292
|
+
title_bytes = t.size_bytes
|
|
1293
|
+
bar_style = _random_bar_style()
|
|
1294
|
+
|
|
1295
|
+
def _progress_cb(progress, _last=last_pct, _style=bar_style,
|
|
1296
|
+
_start=title_start, _total=title_bytes):
|
|
1297
|
+
if progress.max_val > 0:
|
|
1298
|
+
pct = progress.current * 100 // progress.max_val
|
|
1299
|
+
if pct != _last[0]:
|
|
1300
|
+
_last[0] = pct
|
|
1301
|
+
bar_width = 30
|
|
1302
|
+
filled = bar_width * pct // 100
|
|
1303
|
+
bar = _style["fill"] * filled + _style["head"] * (1 if filled < bar_width else 0) + _style["empty"] * (bar_width - filled - (1 if filled < bar_width else 0))
|
|
1304
|
+
elapsed = time.monotonic() - _start
|
|
1305
|
+
done_bytes = _total * pct // 100
|
|
1306
|
+
done_gb = done_bytes / (1024 ** 3)
|
|
1307
|
+
total_gb = _total / (1024 ** 3)
|
|
1308
|
+
speed_mbs = (done_bytes / (1024 ** 2)) / elapsed if elapsed > 1 else 0
|
|
1309
|
+
if pct > 0 and speed_mbs > 0:
|
|
1310
|
+
remaining_bytes = _total - done_bytes
|
|
1311
|
+
eta_secs = int(remaining_bytes / (speed_mbs * 1024 * 1024))
|
|
1312
|
+
eta_str = _format_seconds(eta_secs)
|
|
1313
|
+
else:
|
|
1314
|
+
eta_str = "..."
|
|
1315
|
+
print(
|
|
1316
|
+
f"\r {_style['left']}{bar}{_style['right']} {pct:3d}% "
|
|
1317
|
+
f"{done_gb:.1f}/{total_gb:.1f} GB "
|
|
1318
|
+
f"{speed_mbs:.0f} MB/s ETA {eta_str} ",
|
|
1319
|
+
end="", flush=True,
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
rip_result = run_rip(
|
|
1323
|
+
drive_idx, t.index, output_dir,
|
|
1324
|
+
makemkvcon=exe,
|
|
1325
|
+
progress_callback=_progress_cb,
|
|
1326
|
+
)
|
|
1327
|
+
|
|
1328
|
+
elapsed = time.monotonic() - title_start
|
|
1329
|
+
print() # newline after progress
|
|
1330
|
+
|
|
1331
|
+
results.append(rip_result)
|
|
1332
|
+
if rip_result.success:
|
|
1333
|
+
print(f" Done: {rip_result.output_file} ({_format_seconds(int(elapsed))})")
|
|
1334
|
+
else:
|
|
1335
|
+
print(f" FAILED: {rip_result.error_message}", file=sys.stderr)
|
|
1336
|
+
|
|
1337
|
+
# Summary
|
|
1338
|
+
total_elapsed = time.monotonic() - rip_start
|
|
1339
|
+
succeeded = [r for r in results if r.success]
|
|
1340
|
+
failed = [r for r in results if not r.success]
|
|
1341
|
+
|
|
1342
|
+
print(f"\n{'=' * 60}")
|
|
1343
|
+
print(f"Rip complete: {len(succeeded)} succeeded, {len(failed)} failed"
|
|
1344
|
+
f" ({_format_seconds(int(total_elapsed))})")
|
|
1345
|
+
if succeeded:
|
|
1346
|
+
print(f"Output: {output_dir}")
|
|
1347
|
+
if failed:
|
|
1348
|
+
for r in failed:
|
|
1349
|
+
print(f" FAILED title {r.title_index}: {r.error_message}", file=sys.stderr)
|
|
1350
|
+
|
|
1351
|
+
# Write rip manifest
|
|
1352
|
+
if succeeded:
|
|
1353
|
+
manifest = {
|
|
1354
|
+
"title": canonical,
|
|
1355
|
+
"year": year,
|
|
1356
|
+
"type": "movie" if is_movie else "tv",
|
|
1357
|
+
"disc_number": disc_number,
|
|
1358
|
+
"disc_label": volume_label,
|
|
1359
|
+
"format": disc_format,
|
|
1360
|
+
"release": release_name,
|
|
1361
|
+
"files": [],
|
|
1362
|
+
}
|
|
1363
|
+
for r in results:
|
|
1364
|
+
if not r.success:
|
|
1365
|
+
continue
|
|
1366
|
+
t = next((t for t in disc_info.titles if t.index == r.title_index), None)
|
|
1367
|
+
classification = ""
|
|
1368
|
+
if t:
|
|
1369
|
+
classification = classify_title(
|
|
1370
|
+
t, disc_info.titles, dvd_entries,
|
|
1371
|
+
is_movie, movie_runtime,
|
|
1372
|
+
total_episode_runtime, episode_count,
|
|
1373
|
+
)
|
|
1374
|
+
# Strip " - rip this" / " - skip ..." suffix for the manifest
|
|
1375
|
+
if " - " in classification:
|
|
1376
|
+
classification = classification[:classification.rindex(" - ")]
|
|
1377
|
+
manifest["files"].append({
|
|
1378
|
+
"filename": Path(r.output_file).name if r.output_file else "",
|
|
1379
|
+
"title_index": r.title_index,
|
|
1380
|
+
"duration": t.duration_seconds if t else 0,
|
|
1381
|
+
"resolution": t.resolution if t else "",
|
|
1382
|
+
"size_bytes": t.size_bytes if t else 0,
|
|
1383
|
+
"classification": classification,
|
|
1384
|
+
"stream_count": t.stream_count if t else 0,
|
|
1385
|
+
"stream_fingerprint": build_stream_fingerprint(t) if t else "",
|
|
1386
|
+
"chapter_count": t.chapters if t else 0,
|
|
1387
|
+
"chapter_durations": (
|
|
1388
|
+
probe_chapter_durations(r.output_file)
|
|
1389
|
+
if r.output_file else []
|
|
1390
|
+
),
|
|
1391
|
+
})
|
|
1392
|
+
|
|
1393
|
+
manifest_path = output_dir / "_rip_manifest.json"
|
|
1394
|
+
manifest_path.write_text(json_mod.dumps(manifest, indent=2), encoding="utf-8")
|
|
1395
|
+
log.info("Wrote rip manifest: %s", manifest_path)
|
|
1396
|
+
|
|
1397
|
+
# Write debug snapshot (disc_info + metadata for troubleshooting)
|
|
1398
|
+
try:
|
|
1399
|
+
snapshot = {
|
|
1400
|
+
"disc_name": disc_info.disc_name,
|
|
1401
|
+
"drive": str(drive_idx),
|
|
1402
|
+
"title_count": len(disc_info.titles),
|
|
1403
|
+
"titles": [
|
|
1404
|
+
{
|
|
1405
|
+
"index": t.index,
|
|
1406
|
+
"duration_seconds": t.duration_seconds,
|
|
1407
|
+
"resolution": t.resolution,
|
|
1408
|
+
"size_bytes": t.size_bytes,
|
|
1409
|
+
"chapters": getattr(t, "chapter_count", None),
|
|
1410
|
+
}
|
|
1411
|
+
for t in disc_info.titles
|
|
1412
|
+
],
|
|
1413
|
+
"tmdb": {
|
|
1414
|
+
"canonical_title": canonical,
|
|
1415
|
+
"year": year,
|
|
1416
|
+
"type": "movie" if is_movie else "tv",
|
|
1417
|
+
"movie_runtime": movie_runtime,
|
|
1418
|
+
},
|
|
1419
|
+
"dvdcompare": {
|
|
1420
|
+
"release": release_name,
|
|
1421
|
+
"disc_count": len(discs),
|
|
1422
|
+
"discs": [
|
|
1423
|
+
{
|
|
1424
|
+
"number": d.number,
|
|
1425
|
+
"episode_count": len(d.episodes),
|
|
1426
|
+
"extra_count": len(d.extras),
|
|
1427
|
+
}
|
|
1428
|
+
for d in discs
|
|
1429
|
+
],
|
|
1430
|
+
},
|
|
1431
|
+
"ripped_titles": [t.index for t in rip_titles],
|
|
1432
|
+
}
|
|
1433
|
+
snapshot_path = output_dir / "_rip_snapshot.json"
|
|
1434
|
+
snapshot_path.write_text(json_mod.dumps(snapshot, indent=2), encoding="utf-8")
|
|
1435
|
+
log.info("Wrote rip snapshot: %s", snapshot_path)
|
|
1436
|
+
except Exception as exc:
|
|
1437
|
+
log.warning("Failed to write rip snapshot: %s", exc)
|
|
1438
|
+
|
|
1439
|
+
# Auto-organize
|
|
1440
|
+
if getattr(args, "auto_organize", False) and succeeded and not failed:
|
|
1441
|
+
print(f"\nRunning organize on {output_dir.parent} ...")
|
|
1442
|
+
organize_args = argparse.Namespace(
|
|
1443
|
+
folder=str(output_dir.parent),
|
|
1444
|
+
title=canonical,
|
|
1445
|
+
year=year,
|
|
1446
|
+
media_type="movie" if is_movie else "tv",
|
|
1447
|
+
disc_format=disc_format,
|
|
1448
|
+
release=release_name or "1",
|
|
1449
|
+
output=output_val,
|
|
1450
|
+
execute=True,
|
|
1451
|
+
json=False,
|
|
1452
|
+
api_key=getattr(args, "api_key", None),
|
|
1453
|
+
unmatched="extras",
|
|
1454
|
+
verbose=getattr(args, "verbose", False),
|
|
1455
|
+
no_cache=getattr(args, "no_cache", False),
|
|
1456
|
+
force=False,
|
|
1457
|
+
snapshot=None,
|
|
1458
|
+
)
|
|
1459
|
+
return await _run_organize(organize_args)
|
|
1460
|
+
|
|
1461
|
+
return 1 if failed else 0
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
# ---- helpers for disc content summaries ----
|
|
1465
|
+
|
|
1466
|
+
def _find_ripped_discs(output_dir: Path) -> set[int]:
|
|
1467
|
+
"""Scan output_dir for Disc N subdirectories with a _rip_manifest.json."""
|
|
1468
|
+
ripped: set[int] = set()
|
|
1469
|
+
if not output_dir.exists():
|
|
1470
|
+
return ripped
|
|
1471
|
+
for child in output_dir.iterdir():
|
|
1472
|
+
if child.is_dir() and (child / "_rip_manifest.json").exists():
|
|
1473
|
+
m = re.match(r"Disc\s+(\d+)", child.name, re.IGNORECASE)
|
|
1474
|
+
if m:
|
|
1475
|
+
ripped.add(int(m.group(1)))
|
|
1476
|
+
return ripped
|
|
1477
|
+
|
|
1478
|
+
|
|
1479
|
+
def _disc_content_summary(disc) -> str:
|
|
1480
|
+
"""Return a short comma-separated summary of a dvdcompare disc's content."""
|
|
1481
|
+
titles = []
|
|
1482
|
+
for ep in disc.episodes:
|
|
1483
|
+
titles.append(ep.title)
|
|
1484
|
+
for ex in disc.extras:
|
|
1485
|
+
titles.append(ex.title)
|
|
1486
|
+
if not titles:
|
|
1487
|
+
return "(no content listed)"
|
|
1488
|
+
# Truncate if too many items
|
|
1489
|
+
if len(titles) > 4:
|
|
1490
|
+
return ", ".join(titles[:4]) + f", ... ({len(titles)} items)"
|
|
1491
|
+
return ", ".join(titles)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _print_disc_overview(
|
|
1495
|
+
dvdcompare_discs: list,
|
|
1496
|
+
release_name: str,
|
|
1497
|
+
ripped_discs: set[int],
|
|
1498
|
+
inserted_disc: int | None,
|
|
1499
|
+
) -> None:
|
|
1500
|
+
"""Print a formatted overview of all discs in the release."""
|
|
1501
|
+
print(f"\n{release_name} [{len(dvdcompare_discs)} discs]")
|
|
1502
|
+
for disc in dvdcompare_discs:
|
|
1503
|
+
fmt = disc.disc_format if hasattr(disc, "disc_format") and disc.disc_format else ""
|
|
1504
|
+
summary = _disc_content_summary(disc)
|
|
1505
|
+
status = ""
|
|
1506
|
+
if disc.number in ripped_discs:
|
|
1507
|
+
status = " [RIPPED]"
|
|
1508
|
+
elif disc.number == inserted_disc:
|
|
1509
|
+
status = " [INSERTED]"
|
|
1510
|
+
fmt_str = f" ({fmt})" if fmt else ""
|
|
1511
|
+
print(f" Disc {disc.number}{fmt_str}: {summary}{status}")
|
|
1512
|
+
print()
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _build_scanned_from_manifests(rip_root: Path) -> list:
|
|
1516
|
+
"""Build ScannedDisc objects from rip manifest files (skip ffprobe).
|
|
1517
|
+
|
|
1518
|
+
Reads _rip_manifest.json from each Disc N subfolder and constructs
|
|
1519
|
+
ScannedFile objects using metadata captured at rip time.
|
|
1520
|
+
"""
|
|
1521
|
+
import json as json_mod
|
|
1522
|
+
|
|
1523
|
+
from riplex.models import ScannedDisc, ScannedFile
|
|
1524
|
+
|
|
1525
|
+
discs: list[ScannedDisc] = []
|
|
1526
|
+
for child in sorted(rip_root.iterdir()):
|
|
1527
|
+
manifest_path = child / "_rip_manifest.json"
|
|
1528
|
+
if not child.is_dir() or not manifest_path.exists():
|
|
1529
|
+
continue
|
|
1530
|
+
try:
|
|
1531
|
+
manifest = json_mod.loads(manifest_path.read_text(encoding="utf-8"))
|
|
1532
|
+
except (OSError, json_mod.JSONDecodeError) as exc:
|
|
1533
|
+
log.warning("Failed to read manifest %s: %s", manifest_path, exc)
|
|
1534
|
+
continue
|
|
1535
|
+
|
|
1536
|
+
files: list[ScannedFile] = []
|
|
1537
|
+
for entry in manifest.get("files", []):
|
|
1538
|
+
filename = entry.get("filename", "")
|
|
1539
|
+
if not filename:
|
|
1540
|
+
continue
|
|
1541
|
+
file_path = child / filename
|
|
1542
|
+
# Parse resolution into width/height
|
|
1543
|
+
res = entry.get("resolution", "")
|
|
1544
|
+
width, height = 0, 0
|
|
1545
|
+
if "x" in res:
|
|
1546
|
+
parts = res.split("x")
|
|
1547
|
+
try:
|
|
1548
|
+
width, height = int(parts[0]), int(parts[1])
|
|
1549
|
+
except ValueError:
|
|
1550
|
+
pass
|
|
1551
|
+
|
|
1552
|
+
sf = ScannedFile(
|
|
1553
|
+
name=filename,
|
|
1554
|
+
path=str(file_path),
|
|
1555
|
+
duration_seconds=entry.get("duration", 0),
|
|
1556
|
+
size_bytes=entry.get("size_bytes", 0),
|
|
1557
|
+
stream_count=entry.get("stream_count", 0),
|
|
1558
|
+
stream_fingerprint=entry.get("stream_fingerprint", ""),
|
|
1559
|
+
chapter_count=entry.get("chapter_count", 0),
|
|
1560
|
+
chapter_durations=entry.get("chapter_durations", []),
|
|
1561
|
+
max_width=width,
|
|
1562
|
+
max_height=height,
|
|
1563
|
+
)
|
|
1564
|
+
files.append(sf)
|
|
1565
|
+
|
|
1566
|
+
if files:
|
|
1567
|
+
discs.append(ScannedDisc(folder_name=child.name, files=files))
|
|
1568
|
+
|
|
1569
|
+
return discs
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
async def _run_orchestrate(args: argparse.Namespace) -> int:
|
|
1573
|
+
"""Multi-disc rip and organize pipeline."""
|
|
1574
|
+
import json as json_mod
|
|
1575
|
+
import time
|
|
1576
|
+
|
|
1577
|
+
from riplex.makemkv import (
|
|
1578
|
+
build_stream_fingerprint,
|
|
1579
|
+
eject_disc,
|
|
1580
|
+
find_makemkvcon,
|
|
1581
|
+
probe_chapter_durations,
|
|
1582
|
+
run_disc_info,
|
|
1583
|
+
run_drive_list,
|
|
1584
|
+
run_rip,
|
|
1585
|
+
wait_for_disc,
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
log_file = _setup_logging(verbose=getattr(args, "verbose", False))
|
|
1589
|
+
log.info("riplex orchestrate: args=%s", vars(args))
|
|
1590
|
+
print(f"Debug log: {log_file}", file=sys.stderr)
|
|
1591
|
+
|
|
1592
|
+
snapshot_mode = getattr(args, "snapshot", False)
|
|
1593
|
+
dry_run = not getattr(args, "execute", False) and not snapshot_mode
|
|
1594
|
+
if snapshot_mode:
|
|
1595
|
+
print("\n--- SNAPSHOT MODE (scan + write manifest, no rip) ---\n")
|
|
1596
|
+
elif dry_run:
|
|
1597
|
+
print(f"\n{_dry_run_banner('rip and organize')}\n")
|
|
1598
|
+
else:
|
|
1599
|
+
print("\n--- EXECUTING ---\n")
|
|
1600
|
+
|
|
1601
|
+
if getattr(args, "no_cache", False):
|
|
1602
|
+
from riplex import cache
|
|
1603
|
+
cache.disable()
|
|
1604
|
+
|
|
1605
|
+
# Find makemkvcon
|
|
1606
|
+
exe = find_makemkvcon()
|
|
1607
|
+
if not exe:
|
|
1608
|
+
print("Error: makemkvcon not found. Install MakeMKV or ensure makemkvcon is on PATH.", file=sys.stderr)
|
|
1609
|
+
return 1
|
|
1610
|
+
|
|
1611
|
+
# Resolve drive
|
|
1612
|
+
drive_arg = getattr(args, "drive", "auto") or "auto"
|
|
1613
|
+
print("Scanning drives ...", file=sys.stderr)
|
|
1614
|
+
drives = run_drive_list(exe)
|
|
1615
|
+
|
|
1616
|
+
if drive_arg == "auto":
|
|
1617
|
+
active = [d for d in drives if d.has_disc]
|
|
1618
|
+
if not active:
|
|
1619
|
+
if not is_interactive():
|
|
1620
|
+
print("Error: no disc found in any drive.", file=sys.stderr)
|
|
1621
|
+
return 1
|
|
1622
|
+
# Interactive: prompt to insert a disc
|
|
1623
|
+
if not drives:
|
|
1624
|
+
print("Error: no optical drives found.", file=sys.stderr)
|
|
1625
|
+
return 1
|
|
1626
|
+
drive_idx = drives[0].index
|
|
1627
|
+
drive_device = drives[0].device
|
|
1628
|
+
print("No disc inserted.", file=sys.stderr)
|
|
1629
|
+
if not prompt_confirm("Insert a disc and continue?"):
|
|
1630
|
+
return 0
|
|
1631
|
+
print("Waiting for disc ...", file=sys.stderr)
|
|
1632
|
+
new_drive = wait_for_disc(
|
|
1633
|
+
drive_idx, makemkvcon=exe, previous_label="",
|
|
1634
|
+
)
|
|
1635
|
+
if not new_drive:
|
|
1636
|
+
print("Timed out waiting for disc.", file=sys.stderr)
|
|
1637
|
+
return 1
|
|
1638
|
+
print(f"Detected: {new_drive.disc_label} ({new_drive.device})", file=sys.stderr)
|
|
1639
|
+
drive_device = new_drive.device
|
|
1640
|
+
volume_label = new_drive.disc_label
|
|
1641
|
+
disc_info = None # read below
|
|
1642
|
+
elif len(active) > 1 and is_interactive():
|
|
1643
|
+
# Multiple drives have discs, let user choose
|
|
1644
|
+
print(f"Found {len(active)} drives with discs:", file=sys.stderr)
|
|
1645
|
+
chosen = prompt_choice(
|
|
1646
|
+
"Which drive to use?",
|
|
1647
|
+
[f"Drive {d.index}: {d.disc_label} ({d.device})" for d in active],
|
|
1648
|
+
default=0,
|
|
1649
|
+
)
|
|
1650
|
+
selected = active[chosen]
|
|
1651
|
+
print(f"Found disc in drive {selected.index}: {selected.disc_label} ({selected.device})", file=sys.stderr)
|
|
1652
|
+
drive_idx = selected.index
|
|
1653
|
+
drive_device = selected.device
|
|
1654
|
+
volume_label = selected.disc_label
|
|
1655
|
+
disc_info = None # read below
|
|
1656
|
+
else:
|
|
1657
|
+
selected = active[0]
|
|
1658
|
+
print(f"Found disc in drive {selected.index}: {selected.disc_label} ({selected.device})", file=sys.stderr)
|
|
1659
|
+
drive_idx = selected.index
|
|
1660
|
+
drive_device = selected.device
|
|
1661
|
+
volume_label = selected.disc_label
|
|
1662
|
+
disc_info = None # read below
|
|
1663
|
+
else:
|
|
1664
|
+
try:
|
|
1665
|
+
drive_idx = int(drive_arg)
|
|
1666
|
+
except ValueError:
|
|
1667
|
+
drive_idx = drive_arg
|
|
1668
|
+
volume_label = None
|
|
1669
|
+
disc_info = None
|
|
1670
|
+
# Resolve device letter from drive list regardless of how drive was specified
|
|
1671
|
+
drive_device = ""
|
|
1672
|
+
for d in drives:
|
|
1673
|
+
if d.index == drive_idx or d.device == drive_arg:
|
|
1674
|
+
drive_device = d.device
|
|
1675
|
+
break
|
|
1676
|
+
|
|
1677
|
+
# Read initial disc info (only if a disc is present or drive explicitly given)
|
|
1678
|
+
if disc_info is None:
|
|
1679
|
+
print("Reading disc info ...", file=sys.stderr)
|
|
1680
|
+
try:
|
|
1681
|
+
disc_info = run_disc_info(drive_idx, exe)
|
|
1682
|
+
except (RuntimeError, FileNotFoundError) as exc:
|
|
1683
|
+
print(f"Error reading disc: {exc}", file=sys.stderr)
|
|
1684
|
+
return 1
|
|
1685
|
+
|
|
1686
|
+
if not disc_info.titles:
|
|
1687
|
+
print("Error: no titles found on disc.", file=sys.stderr)
|
|
1688
|
+
return 1
|
|
1689
|
+
|
|
1690
|
+
if volume_label is None:
|
|
1691
|
+
volume_label = disc_info.disc_name or ""
|
|
1692
|
+
|
|
1693
|
+
# Auto-detect title
|
|
1694
|
+
title_arg = getattr(args, "title", None)
|
|
1695
|
+
if not title_arg:
|
|
1696
|
+
title_arg = _parse_volume_label(volume_label)
|
|
1697
|
+
if title_arg:
|
|
1698
|
+
print(f"Auto-detected title from volume label: {title_arg}", file=sys.stderr)
|
|
1699
|
+
title_arg = prompt_text("Title", default=title_arg)
|
|
1700
|
+
else:
|
|
1701
|
+
print("Error: could not detect title from volume label. Provide --title.", file=sys.stderr)
|
|
1702
|
+
return 1
|
|
1703
|
+
|
|
1704
|
+
# Auto-detect disc format
|
|
1705
|
+
disc_format = getattr(args, "disc_format", None)
|
|
1706
|
+
if not disc_format:
|
|
1707
|
+
disc_format = _detect_disc_format(disc_info)
|
|
1708
|
+
if disc_format:
|
|
1709
|
+
log.info("Auto-detected disc format: %s", disc_format)
|
|
1710
|
+
|
|
1711
|
+
# Infer media type
|
|
1712
|
+
media_type_arg = getattr(args, "media_type", "auto")
|
|
1713
|
+
if media_type_arg == "auto":
|
|
1714
|
+
media_type_arg = _infer_media_type(disc_info)
|
|
1715
|
+
if media_type_arg != "auto":
|
|
1716
|
+
log.info("Inferred media type from disc structure: %s", media_type_arg)
|
|
1717
|
+
|
|
1718
|
+
# TMDb lookup
|
|
1719
|
+
api_key = get_api_key(getattr(args, "api_key", None))
|
|
1720
|
+
try:
|
|
1721
|
+
provider = TmdbProvider(api_key=api_key)
|
|
1722
|
+
except ValueError as exc:
|
|
1723
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1724
|
+
return 1
|
|
1725
|
+
|
|
1726
|
+
try:
|
|
1727
|
+
request = SearchRequest(
|
|
1728
|
+
title=title_arg,
|
|
1729
|
+
year=getattr(args, "year", None),
|
|
1730
|
+
media_type=media_type_arg,
|
|
1731
|
+
)
|
|
1732
|
+
result = await plan(request, provider)
|
|
1733
|
+
except LookupError as exc:
|
|
1734
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
1735
|
+
return 1
|
|
1736
|
+
except Exception as exc:
|
|
1737
|
+
print(f"Error fetching TMDb metadata: {exc}", file=sys.stderr)
|
|
1738
|
+
return 1
|
|
1739
|
+
finally:
|
|
1740
|
+
await provider.close()
|
|
1741
|
+
|
|
1742
|
+
canonical = result.canonical_title
|
|
1743
|
+
year = result.year
|
|
1744
|
+
is_movie = isinstance(result, PlannedMovie)
|
|
1745
|
+
movie_runtime = result.runtime_seconds if is_movie else None
|
|
1746
|
+
|
|
1747
|
+
print(f"TMDb: {canonical} ({year})", file=sys.stderr)
|
|
1748
|
+
|
|
1749
|
+
# dvdcompare lookup
|
|
1750
|
+
release = getattr(args, "release", None)
|
|
1751
|
+
discs: list = []
|
|
1752
|
+
release_name = ""
|
|
1753
|
+
print("Looking up disc metadata on dvdcompare.net ...", file=sys.stderr)
|
|
1754
|
+
try:
|
|
1755
|
+
from dvdcompare.scraper import find_film
|
|
1756
|
+
film = await find_film(canonical, disc_format)
|
|
1757
|
+
discs, release_name = _select_dvdcompare_release(
|
|
1758
|
+
film, disc_info=disc_info, preferred=release,
|
|
1759
|
+
)
|
|
1760
|
+
except SystemExit:
|
|
1761
|
+
raise
|
|
1762
|
+
except LookupError as exc:
|
|
1763
|
+
print(f"Error: dvdcompare lookup failed: {exc}", file=sys.stderr)
|
|
1764
|
+
return 1
|
|
1765
|
+
except Exception as exc:
|
|
1766
|
+
print(f"Error: dvdcompare lookup failed ({type(exc).__name__}): {exc}", file=sys.stderr)
|
|
1767
|
+
return 1
|
|
1768
|
+
|
|
1769
|
+
if not discs:
|
|
1770
|
+
print("Error: no disc metadata found on dvdcompare.", file=sys.stderr)
|
|
1771
|
+
return 1
|
|
1772
|
+
|
|
1773
|
+
# Output directory
|
|
1774
|
+
output_val = get_output_root(getattr(args, "output", None))
|
|
1775
|
+
if not output_val:
|
|
1776
|
+
print("Error: --output or output_root config required.", file=sys.stderr)
|
|
1777
|
+
return 1
|
|
1778
|
+
|
|
1779
|
+
folder_base = f"{canonical} ({year})"
|
|
1780
|
+
rip_output = get_rip_output()
|
|
1781
|
+
rip_root = Path(rip_output) / folder_base if rip_output else Path(output_val) / "Rips" / folder_base
|
|
1782
|
+
|
|
1783
|
+
# Detect which disc is currently inserted
|
|
1784
|
+
current_disc_num = _detect_disc_number(disc_info, discs)
|
|
1785
|
+
|
|
1786
|
+
# Resume: detect already-ripped discs from manifest files
|
|
1787
|
+
ripped_discs = _find_ripped_discs(rip_root)
|
|
1788
|
+
|
|
1789
|
+
# Show disc overview
|
|
1790
|
+
_print_disc_overview(discs, release_name, ripped_discs, current_disc_num)
|
|
1791
|
+
|
|
1792
|
+
if ripped_discs:
|
|
1793
|
+
ripped_list = ", ".join(str(n) for n in sorted(ripped_discs))
|
|
1794
|
+
print(f"Previously ripped: Disc {ripped_list}", file=sys.stderr)
|
|
1795
|
+
|
|
1796
|
+
# ---- Per-disc rip loop ----
|
|
1797
|
+
any_ripped = len(ripped_discs) > 0
|
|
1798
|
+
any_failed = False
|
|
1799
|
+
disc_order = sorted(discs, key=lambda d: d.number)
|
|
1800
|
+
|
|
1801
|
+
# Filter discs based on --discs flag or interactive prompt
|
|
1802
|
+
discs_arg = getattr(args, "discs", None)
|
|
1803
|
+
if discs_arg:
|
|
1804
|
+
# Parse comma-separated disc numbers
|
|
1805
|
+
try:
|
|
1806
|
+
selected_nums = {int(x.strip()) for x in discs_arg.split(",")}
|
|
1807
|
+
except ValueError:
|
|
1808
|
+
print("Error: --discs must be comma-separated numbers (e.g. '1,3').", file=sys.stderr)
|
|
1809
|
+
return 1
|
|
1810
|
+
disc_order = [d for d in disc_order if d.number in selected_nums]
|
|
1811
|
+
if not disc_order:
|
|
1812
|
+
print("Error: none of the specified disc numbers match this release.", file=sys.stderr)
|
|
1813
|
+
return 1
|
|
1814
|
+
elif is_interactive() and not snapshot_mode and len(disc_order) > 1:
|
|
1815
|
+
# Interactive disc selection when multiple discs exist
|
|
1816
|
+
unripped = [d for d in disc_order if d.number not in ripped_discs]
|
|
1817
|
+
if len(unripped) > 1:
|
|
1818
|
+
from riplex.ui import prompt_multi_select
|
|
1819
|
+
options = []
|
|
1820
|
+
for d in unripped:
|
|
1821
|
+
summary = _disc_content_summary(d)
|
|
1822
|
+
fmt = d.disc_format if hasattr(d, "disc_format") and d.disc_format else ""
|
|
1823
|
+
fmt_str = f" ({fmt})" if fmt else ""
|
|
1824
|
+
options.append(f"Disc {d.number}{fmt_str}: {summary}")
|
|
1825
|
+
selected_indices = prompt_multi_select(
|
|
1826
|
+
"Which discs do you want to rip?",
|
|
1827
|
+
options,
|
|
1828
|
+
defaults=list(range(len(options))), # all selected by default
|
|
1829
|
+
)
|
|
1830
|
+
if selected_indices is not None:
|
|
1831
|
+
selected_discs = [unripped[i] for i in selected_indices]
|
|
1832
|
+
# Keep already-ripped discs in order (they'll be skipped anyway)
|
|
1833
|
+
# and replace unripped portion with selection
|
|
1834
|
+
disc_order = [d for d in disc_order if d.number in ripped_discs] + selected_discs
|
|
1835
|
+
|
|
1836
|
+
# Start from the inserted disc if possible, otherwise from first unripped
|
|
1837
|
+
if current_disc_num:
|
|
1838
|
+
# Reorder: inserted disc first, then remaining in order
|
|
1839
|
+
start_disc = next((d for d in disc_order if d.number == current_disc_num), None)
|
|
1840
|
+
remaining = [d for d in disc_order if d.number != current_disc_num]
|
|
1841
|
+
disc_order = ([start_disc] if start_disc else []) + remaining
|
|
1842
|
+
|
|
1843
|
+
for disc_idx, disc in enumerate(disc_order):
|
|
1844
|
+
if disc.number in ripped_discs:
|
|
1845
|
+
continue
|
|
1846
|
+
|
|
1847
|
+
# Check if we need the user to insert this disc
|
|
1848
|
+
need_insert = (disc_idx > 0) or (current_disc_num != disc.number)
|
|
1849
|
+
|
|
1850
|
+
if need_insert and not dry_run:
|
|
1851
|
+
summary = _disc_content_summary(disc)
|
|
1852
|
+
fmt = disc.disc_format if hasattr(disc, "disc_format") and disc.disc_format else ""
|
|
1853
|
+
fmt_str = f" ({fmt})" if fmt else ""
|
|
1854
|
+
print(f"\n{'=' * 60}")
|
|
1855
|
+
print(f"Insert Disc {disc.number}{fmt_str}: {summary}")
|
|
1856
|
+
print(f"{'=' * 60}")
|
|
1857
|
+
|
|
1858
|
+
if is_interactive():
|
|
1859
|
+
# Interactive: wait for user to confirm
|
|
1860
|
+
if not prompt_confirm("Disc inserted and ready?"):
|
|
1861
|
+
action = prompt_choice(
|
|
1862
|
+
"What would you like to do?",
|
|
1863
|
+
["Skip this disc", "Finish and organize"],
|
|
1864
|
+
default=0,
|
|
1865
|
+
)
|
|
1866
|
+
if action == 1:
|
|
1867
|
+
break
|
|
1868
|
+
continue
|
|
1869
|
+
else:
|
|
1870
|
+
# Non-interactive (--auto): wait for a new disc to appear
|
|
1871
|
+
print("Waiting for new disc ...", file=sys.stderr)
|
|
1872
|
+
new_drive = wait_for_disc(
|
|
1873
|
+
drive_idx, makemkvcon=exe,
|
|
1874
|
+
previous_label=volume_label or "",
|
|
1875
|
+
)
|
|
1876
|
+
if not new_drive:
|
|
1877
|
+
print("Timed out waiting for disc. Stopping.", file=sys.stderr)
|
|
1878
|
+
break
|
|
1879
|
+
print(f"Detected: {new_drive.disc_label} ({new_drive.device})", file=sys.stderr)
|
|
1880
|
+
volume_label = new_drive.disc_label
|
|
1881
|
+
|
|
1882
|
+
# Re-read disc info after insertion
|
|
1883
|
+
print("Reading disc info ...", file=sys.stderr)
|
|
1884
|
+
try:
|
|
1885
|
+
disc_info = run_disc_info(drive_idx, exe)
|
|
1886
|
+
except (RuntimeError, FileNotFoundError) as exc:
|
|
1887
|
+
print(f"Error reading disc: {exc}", file=sys.stderr)
|
|
1888
|
+
any_failed = True
|
|
1889
|
+
continue
|
|
1890
|
+
|
|
1891
|
+
# Verify disc number
|
|
1892
|
+
detected = _detect_disc_number(disc_info, discs)
|
|
1893
|
+
if detected and detected != disc.number:
|
|
1894
|
+
print(f"Warning: expected Disc {disc.number} but detected Disc {detected}.", file=sys.stderr)
|
|
1895
|
+
if not prompt_confirm("Continue anyway?"):
|
|
1896
|
+
continue
|
|
1897
|
+
|
|
1898
|
+
# Prompt: rip, skip, or finish
|
|
1899
|
+
if not dry_run and not snapshot_mode:
|
|
1900
|
+
summary = _disc_content_summary(disc)
|
|
1901
|
+
fmt = disc.disc_format if hasattr(disc, "disc_format") and disc.disc_format else ""
|
|
1902
|
+
fmt_str = f" ({fmt})" if fmt else ""
|
|
1903
|
+
action = prompt_choice(
|
|
1904
|
+
f"Disc {disc.number}{fmt_str}: {summary}",
|
|
1905
|
+
[
|
|
1906
|
+
"Rip this disc",
|
|
1907
|
+
"Skip this disc",
|
|
1908
|
+
"Finish and organize",
|
|
1909
|
+
],
|
|
1910
|
+
default=0,
|
|
1911
|
+
)
|
|
1912
|
+
if action == 1:
|
|
1913
|
+
continue
|
|
1914
|
+
if action == 2:
|
|
1915
|
+
break
|
|
1916
|
+
|
|
1917
|
+
# In dry-run, we can only analyze the currently-inserted disc.
|
|
1918
|
+
# For other discs, show a placeholder based on dvdcompare metadata.
|
|
1919
|
+
is_current_disc = (disc.number == current_disc_num)
|
|
1920
|
+
output_dir = rip_root / f"Disc {disc.number}"
|
|
1921
|
+
|
|
1922
|
+
if dry_run and not is_current_disc:
|
|
1923
|
+
summary = _disc_content_summary(disc)
|
|
1924
|
+
fmt = disc.disc_format if hasattr(disc, "disc_format") and disc.disc_format else ""
|
|
1925
|
+
fmt_str = f" ({fmt})" if fmt else ""
|
|
1926
|
+
print(f"\nDisc {disc.number}{fmt_str}: {summary}")
|
|
1927
|
+
print(f" Would prompt for insertion and rip to: {output_dir}")
|
|
1928
|
+
ripped_discs.add(disc.number)
|
|
1929
|
+
continue
|
|
1930
|
+
|
|
1931
|
+
# Show disc analysis for the currently-inserted disc
|
|
1932
|
+
# Only use current disc's entries for classification to avoid
|
|
1933
|
+
# matching extras from other discs to titles on this one
|
|
1934
|
+
current_disc_entries = [d for d in discs if d.number == disc.number]
|
|
1935
|
+
dvd_entries, total_episode_runtime, episode_count = build_dvd_entries(current_disc_entries)
|
|
1936
|
+
_print_disc_analysis(disc_info, current_disc_entries, is_movie, movie_runtime)
|
|
1937
|
+
|
|
1938
|
+
# Filter titles to rip (smart filtering)
|
|
1939
|
+
rip_titles = [
|
|
1940
|
+
t for t in disc_info.titles
|
|
1941
|
+
if not is_skip_title(
|
|
1942
|
+
t, disc_info.titles, is_movie, movie_runtime,
|
|
1943
|
+
total_episode_runtime, episode_count,
|
|
1944
|
+
)
|
|
1945
|
+
]
|
|
1946
|
+
|
|
1947
|
+
if not rip_titles:
|
|
1948
|
+
print(f"\nNo titles to rip on Disc {disc.number}.", file=sys.stderr)
|
|
1949
|
+
continue
|
|
1950
|
+
|
|
1951
|
+
total_size = sum(t.size_bytes for t in rip_titles) / (1024 ** 3)
|
|
1952
|
+
rip_indices_str = ", ".join(str(t.index) for t in rip_titles)
|
|
1953
|
+
print(f"\nWill rip {len(rip_titles)} title(s) [{rip_indices_str}] ({total_size:.1f} GB)")
|
|
1954
|
+
print(f"Output: {output_dir}")
|
|
1955
|
+
|
|
1956
|
+
# --snapshot: write manifest from disc info without ripping
|
|
1957
|
+
if snapshot_mode:
|
|
1958
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1959
|
+
manifest = {
|
|
1960
|
+
"title": canonical,
|
|
1961
|
+
"year": year,
|
|
1962
|
+
"type": "movie" if is_movie else "tv",
|
|
1963
|
+
"disc_number": disc.number,
|
|
1964
|
+
"disc_label": volume_label,
|
|
1965
|
+
"format": disc_format,
|
|
1966
|
+
"release": release_name,
|
|
1967
|
+
"files": [],
|
|
1968
|
+
}
|
|
1969
|
+
for t in rip_titles:
|
|
1970
|
+
classification = classify_title(
|
|
1971
|
+
t, disc_info.titles, dvd_entries,
|
|
1972
|
+
is_movie, movie_runtime,
|
|
1973
|
+
total_episode_runtime, episode_count,
|
|
1974
|
+
)
|
|
1975
|
+
if " - " in classification:
|
|
1976
|
+
classification = classification[:classification.rindex(" - ")]
|
|
1977
|
+
manifest["files"].append({
|
|
1978
|
+
"filename": f"{canonical.replace(' ', '_')}_t{t.index:02d}.mkv",
|
|
1979
|
+
"title_index": t.index,
|
|
1980
|
+
"duration": t.duration_seconds,
|
|
1981
|
+
"resolution": t.resolution,
|
|
1982
|
+
"size_bytes": t.size_bytes,
|
|
1983
|
+
"classification": classification,
|
|
1984
|
+
"stream_count": t.stream_count,
|
|
1985
|
+
"stream_fingerprint": build_stream_fingerprint(t),
|
|
1986
|
+
"chapter_count": t.chapters,
|
|
1987
|
+
"chapter_durations": [],
|
|
1988
|
+
})
|
|
1989
|
+
manifest_path = output_dir / "_rip_manifest.json"
|
|
1990
|
+
manifest_path.write_text(json_mod.dumps(manifest, indent=2), encoding="utf-8")
|
|
1991
|
+
print(f"Snapshot manifest written: {manifest_path}", file=sys.stderr)
|
|
1992
|
+
ripped_discs.add(disc.number)
|
|
1993
|
+
continue
|
|
1994
|
+
|
|
1995
|
+
if dry_run:
|
|
1996
|
+
ripped_discs.add(disc.number)
|
|
1997
|
+
continue
|
|
1998
|
+
|
|
1999
|
+
if not getattr(args, "yes", False):
|
|
2000
|
+
if not prompt_confirm("Proceed?"):
|
|
2001
|
+
continue
|
|
2002
|
+
|
|
2003
|
+
# Rip each title on this disc
|
|
2004
|
+
rip_start = time.monotonic()
|
|
2005
|
+
results = []
|
|
2006
|
+
for i, t in enumerate(rip_titles, 1):
|
|
2007
|
+
print(f"\nRipping title {t.index} ({i}/{len(rip_titles)}): "
|
|
2008
|
+
f"{_format_seconds(t.duration_seconds)}, "
|
|
2009
|
+
f"{t.size_bytes / (1024**3):.1f} GB ...")
|
|
2010
|
+
|
|
2011
|
+
title_start = time.monotonic()
|
|
2012
|
+
title_bytes = t.size_bytes
|
|
2013
|
+
last_pct = [-1]
|
|
2014
|
+
bar_style = _random_bar_style()
|
|
2015
|
+
|
|
2016
|
+
def _progress_cb(progress, _last=last_pct, _style=bar_style,
|
|
2017
|
+
_start=title_start, _total=title_bytes):
|
|
2018
|
+
if progress.max_val > 0:
|
|
2019
|
+
pct = progress.current * 100 // progress.max_val
|
|
2020
|
+
if pct != _last[0]:
|
|
2021
|
+
_last[0] = pct
|
|
2022
|
+
bar_width = 30
|
|
2023
|
+
filled = bar_width * pct // 100
|
|
2024
|
+
bar = _style["fill"] * filled + _style["head"] * (1 if filled < bar_width else 0) + _style["empty"] * (bar_width - filled - (1 if filled < bar_width else 0))
|
|
2025
|
+
elapsed = time.monotonic() - _start
|
|
2026
|
+
# Size progress
|
|
2027
|
+
done_bytes = _total * pct // 100
|
|
2028
|
+
done_gb = done_bytes / (1024 ** 3)
|
|
2029
|
+
total_gb = _total / (1024 ** 3)
|
|
2030
|
+
# Speed
|
|
2031
|
+
speed_mbs = (done_bytes / (1024 ** 2)) / elapsed if elapsed > 1 else 0
|
|
2032
|
+
# ETA
|
|
2033
|
+
if pct > 0 and speed_mbs > 0:
|
|
2034
|
+
remaining_bytes = _total - done_bytes
|
|
2035
|
+
eta_secs = int(remaining_bytes / (speed_mbs * 1024 * 1024))
|
|
2036
|
+
eta_str = _format_seconds(eta_secs)
|
|
2037
|
+
else:
|
|
2038
|
+
eta_str = "..."
|
|
2039
|
+
print(
|
|
2040
|
+
f"\r {_style['left']}{bar}{_style['right']} {pct:3d}% "
|
|
2041
|
+
f"{done_gb:.1f}/{total_gb:.1f} GB "
|
|
2042
|
+
f"{speed_mbs:.0f} MB/s ETA {eta_str} ",
|
|
2043
|
+
end="", flush=True,
|
|
2044
|
+
)
|
|
2045
|
+
|
|
2046
|
+
rip_result = run_rip(
|
|
2047
|
+
drive_idx, t.index, output_dir,
|
|
2048
|
+
makemkvcon=exe,
|
|
2049
|
+
progress_callback=_progress_cb,
|
|
2050
|
+
)
|
|
2051
|
+
|
|
2052
|
+
elapsed = time.monotonic() - title_start
|
|
2053
|
+
print() # newline after progress
|
|
2054
|
+
|
|
2055
|
+
results.append(rip_result)
|
|
2056
|
+
if rip_result.success:
|
|
2057
|
+
print(f" Done: {rip_result.output_file} ({_format_seconds(int(elapsed))})")
|
|
2058
|
+
else:
|
|
2059
|
+
print(f" FAILED: {rip_result.error_message}", file=sys.stderr)
|
|
2060
|
+
any_failed = True
|
|
2061
|
+
|
|
2062
|
+
# Disc rip summary
|
|
2063
|
+
total_elapsed = time.monotonic() - rip_start
|
|
2064
|
+
succeeded = [r for r in results if r.success]
|
|
2065
|
+
failed = [r for r in results if not r.success]
|
|
2066
|
+
|
|
2067
|
+
print(f"\nDisc {disc.number}: {len(succeeded)} succeeded, {len(failed)} failed"
|
|
2068
|
+
f" ({_format_seconds(int(total_elapsed))})")
|
|
2069
|
+
|
|
2070
|
+
# Write rip manifest
|
|
2071
|
+
if succeeded:
|
|
2072
|
+
manifest = {
|
|
2073
|
+
"title": canonical,
|
|
2074
|
+
"year": year,
|
|
2075
|
+
"type": "movie" if is_movie else "tv",
|
|
2076
|
+
"disc_number": disc.number,
|
|
2077
|
+
"disc_label": volume_label,
|
|
2078
|
+
"format": disc_format,
|
|
2079
|
+
"release": release_name,
|
|
2080
|
+
"files": [],
|
|
2081
|
+
}
|
|
2082
|
+
for r in results:
|
|
2083
|
+
if not r.success:
|
|
2084
|
+
continue
|
|
2085
|
+
t = next((t for t in disc_info.titles if t.index == r.title_index), None)
|
|
2086
|
+
classification = ""
|
|
2087
|
+
if t:
|
|
2088
|
+
classification = classify_title(
|
|
2089
|
+
t, disc_info.titles, dvd_entries,
|
|
2090
|
+
is_movie, movie_runtime,
|
|
2091
|
+
total_episode_runtime, episode_count,
|
|
2092
|
+
)
|
|
2093
|
+
if " - " in classification:
|
|
2094
|
+
classification = classification[:classification.rindex(" - ")]
|
|
2095
|
+
manifest["files"].append({
|
|
2096
|
+
"filename": Path(r.output_file).name if r.output_file else "",
|
|
2097
|
+
"title_index": r.title_index,
|
|
2098
|
+
"duration": t.duration_seconds if t else 0,
|
|
2099
|
+
"resolution": t.resolution if t else "",
|
|
2100
|
+
"size_bytes": t.size_bytes if t else 0,
|
|
2101
|
+
"classification": classification,
|
|
2102
|
+
"stream_count": t.stream_count if t else 0,
|
|
2103
|
+
"stream_fingerprint": build_stream_fingerprint(t) if t else "",
|
|
2104
|
+
"chapter_count": t.chapters if t else 0,
|
|
2105
|
+
"chapter_durations": (
|
|
2106
|
+
probe_chapter_durations(r.output_file)
|
|
2107
|
+
if r.output_file else []
|
|
2108
|
+
),
|
|
2109
|
+
})
|
|
2110
|
+
|
|
2111
|
+
manifest_path = output_dir / "_rip_manifest.json"
|
|
2112
|
+
manifest_path.write_text(json_mod.dumps(manifest, indent=2), encoding="utf-8")
|
|
2113
|
+
log.info("Wrote rip manifest: %s", manifest_path)
|
|
2114
|
+
ripped_discs.add(disc.number)
|
|
2115
|
+
any_ripped = True
|
|
2116
|
+
|
|
2117
|
+
# Eject disc after successful rip
|
|
2118
|
+
if drive_device:
|
|
2119
|
+
print(f"\nEjecting disc ...", file=sys.stderr)
|
|
2120
|
+
eject_disc(drive_device)
|
|
2121
|
+
|
|
2122
|
+
# ---- Summary ----
|
|
2123
|
+
if ripped_discs:
|
|
2124
|
+
ripped_list = ", ".join(str(n) for n in sorted(ripped_discs))
|
|
2125
|
+
print(f"\n{'=' * 60}")
|
|
2126
|
+
print(f"Rip phase complete. Discs ripped: {ripped_list}")
|
|
2127
|
+
print(f"Output: {rip_root}")
|
|
2128
|
+
|
|
2129
|
+
# ---- Organize phase ----
|
|
2130
|
+
if snapshot_mode:
|
|
2131
|
+
print(f"\nSnapshot complete. Manifests written to: {rip_root}", file=sys.stderr)
|
|
2132
|
+
return 0
|
|
2133
|
+
|
|
2134
|
+
if not any_ripped and not ripped_discs:
|
|
2135
|
+
print("\nNo discs were ripped. Nothing to organize.", file=sys.stderr)
|
|
2136
|
+
return 0
|
|
2137
|
+
|
|
2138
|
+
# In dry-run, skip organize if the rip folder doesn't actually exist
|
|
2139
|
+
if dry_run and not rip_root.exists():
|
|
2140
|
+
print(f"\n{'=' * 60}")
|
|
2141
|
+
print("Organize phase (skipped in dry-run, no ripped files yet)")
|
|
2142
|
+
print(f"{'=' * 60}")
|
|
2143
|
+
print(f"\nRe-run with --execute to rip and organize:\n {_build_execute_command()}")
|
|
2144
|
+
return 0
|
|
2145
|
+
|
|
2146
|
+
print(f"\n{'=' * 60}")
|
|
2147
|
+
print("Organize phase")
|
|
2148
|
+
print(f"{'=' * 60}")
|
|
2149
|
+
|
|
2150
|
+
organize_args = argparse.Namespace(
|
|
2151
|
+
folder=str(rip_root),
|
|
2152
|
+
title=canonical,
|
|
2153
|
+
year=year,
|
|
2154
|
+
media_type="movie" if is_movie else "tv",
|
|
2155
|
+
disc_format=disc_format,
|
|
2156
|
+
release=release_name or "1",
|
|
2157
|
+
output=output_val,
|
|
2158
|
+
execute=not dry_run,
|
|
2159
|
+
json=False,
|
|
2160
|
+
api_key=getattr(args, "api_key", None),
|
|
2161
|
+
unmatched=getattr(args, "unmatched", "extras"),
|
|
2162
|
+
verbose=getattr(args, "verbose", False),
|
|
2163
|
+
no_cache=getattr(args, "no_cache", False),
|
|
2164
|
+
force=False,
|
|
2165
|
+
snapshot=None,
|
|
2166
|
+
auto=True, # skip interactive prompts in organize since we resolved metadata above
|
|
2167
|
+
)
|
|
2168
|
+
|
|
2169
|
+
# Optimization: build ScannedDisc objects from manifest data (avoids ffprobe)
|
|
2170
|
+
scanned_from_manifest = _build_scanned_from_manifests(rip_root)
|
|
2171
|
+
if scanned_from_manifest:
|
|
2172
|
+
log.info(
|
|
2173
|
+
"Using manifest data for organize (%d discs, skip ffprobe scan)",
|
|
2174
|
+
len(scanned_from_manifest),
|
|
2175
|
+
)
|
|
2176
|
+
print("Using rip manifest data (skipping ffprobe scan).", file=sys.stderr)
|
|
2177
|
+
api_key = get_api_key(getattr(args, "api_key", None))
|
|
2178
|
+
provider = TmdbProvider(api_key=api_key)
|
|
2179
|
+
try:
|
|
2180
|
+
org_result = await _organize_with_scanned(
|
|
2181
|
+
scanned_from_manifest, canonical, organize_args,
|
|
2182
|
+
Path(output_val), provider,
|
|
2183
|
+
)
|
|
2184
|
+
finally:
|
|
2185
|
+
await provider.close()
|
|
2186
|
+
else:
|
|
2187
|
+
org_result = await _run_organize(organize_args)
|
|
2188
|
+
|
|
2189
|
+
if dry_run:
|
|
2190
|
+
# Replace the organize hint with an orchestrate hint
|
|
2191
|
+
print(f"\nRe-run with --execute to rip and organize:\n {_build_execute_command()}")
|
|
2192
|
+
|
|
2193
|
+
# ---- Archive phase ----
|
|
2194
|
+
if not dry_run and org_result == 0:
|
|
2195
|
+
archive_root = get_archive_root()
|
|
2196
|
+
if archive_root and rip_root.exists():
|
|
2197
|
+
archive_dest = Path(archive_root) / folder_base
|
|
2198
|
+
if is_interactive():
|
|
2199
|
+
print(f"\nArchive rip folder to: {archive_dest}", file=sys.stderr)
|
|
2200
|
+
if prompt_confirm("Move rip folder to archive?"):
|
|
2201
|
+
archive_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
2202
|
+
shutil.move(str(rip_root), archive_dest)
|
|
2203
|
+
print(f"Archived: {rip_root} -> {archive_dest}", file=sys.stderr)
|
|
2204
|
+
else:
|
|
2205
|
+
# Auto mode: archive automatically
|
|
2206
|
+
archive_dest.parent.mkdir(parents=True, exist_ok=True)
|
|
2207
|
+
shutil.move(str(rip_root), archive_dest)
|
|
2208
|
+
print(f"Archived: {rip_root} -> {archive_dest}", file=sys.stderr)
|
|
2209
|
+
|
|
2210
|
+
return org_result if not any_failed else 1
|
|
2211
|
+
|
|
2212
|
+
|
|
2213
|
+
def main() -> None:
|
|
2214
|
+
parser = _build_parser()
|
|
2215
|
+
args = parser.parse_args()
|
|
2216
|
+
|
|
2217
|
+
if args.command is None:
|
|
2218
|
+
parser.print_help()
|
|
2219
|
+
sys.exit(1)
|
|
2220
|
+
|
|
2221
|
+
set_auto_mode(getattr(args, "auto", False))
|
|
2222
|
+
sys.exit(asyncio.run(_run(args)))
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
async def _run_organize(args: argparse.Namespace) -> int:
|
|
2226
|
+
"""Run the organize workflow: scan, look up metadata, match, organize."""
|
|
2227
|
+
log_file = _setup_logging(verbose=getattr(args, "verbose", False))
|
|
2228
|
+
log.info("riplex organize: args=%s", vars(args))
|
|
2229
|
+
print(f"Debug log: {log_file}", file=sys.stderr)
|
|
2230
|
+
|
|
2231
|
+
dry_run = not getattr(args, "execute", False)
|
|
2232
|
+
if dry_run:
|
|
2233
|
+
print(f"\n{_dry_run_banner('move files')}\n")
|
|
2234
|
+
else:
|
|
2235
|
+
print("\n--- EXECUTING ---\n")
|
|
2236
|
+
|
|
2237
|
+
if getattr(args, "no_cache", False):
|
|
2238
|
+
from riplex import cache
|
|
2239
|
+
cache.disable()
|
|
2240
|
+
|
|
2241
|
+
# Snapshot mode: load metadata from JSON, force dry-run
|
|
2242
|
+
snapshot_path = getattr(args, "snapshot", None)
|
|
2243
|
+
if snapshot_path:
|
|
2244
|
+
snapshot_file = Path(snapshot_path)
|
|
2245
|
+
if not snapshot_file.is_file():
|
|
2246
|
+
print(f"Error: snapshot file not found: {snapshot_file}", file=sys.stderr)
|
|
2247
|
+
return 1
|
|
2248
|
+
if getattr(args, "execute", False):
|
|
2249
|
+
print("Error: --execute is not allowed with --snapshot (always dry-run).", file=sys.stderr)
|
|
2250
|
+
return 1
|
|
2251
|
+
args.execute = False
|
|
2252
|
+
|
|
2253
|
+
scanned = snapshot_load(snapshot_file)
|
|
2254
|
+
print(f"Loaded snapshot from {snapshot_file}", file=sys.stderr)
|
|
2255
|
+
|
|
2256
|
+
folder = Path(args.folder)
|
|
2257
|
+
output_val = get_output_root(args.output)
|
|
2258
|
+
output_root = Path(output_val) if output_val else folder.parent
|
|
2259
|
+
|
|
2260
|
+
if args.title:
|
|
2261
|
+
title = args.title
|
|
2262
|
+
else:
|
|
2263
|
+
title, inferred_year = _strip_year_from_title(folder.name)
|
|
2264
|
+
if inferred_year and not getattr(args, "year", None):
|
|
2265
|
+
args.year = inferred_year
|
|
2266
|
+
|
|
2267
|
+
api_key = get_api_key(getattr(args, "api_key", None))
|
|
2268
|
+
try:
|
|
2269
|
+
provider = TmdbProvider(api_key=api_key)
|
|
2270
|
+
except ValueError as exc:
|
|
2271
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2272
|
+
return 1
|
|
2273
|
+
try:
|
|
2274
|
+
return await _organize_with_scanned(
|
|
2275
|
+
scanned, title, args, output_root, provider,
|
|
2276
|
+
)
|
|
2277
|
+
finally:
|
|
2278
|
+
await provider.close()
|
|
2279
|
+
|
|
2280
|
+
folder = Path(args.folder)
|
|
2281
|
+
if not folder.is_dir():
|
|
2282
|
+
print(f"Error: not a directory: {folder}", file=sys.stderr)
|
|
2283
|
+
return 1
|
|
2284
|
+
|
|
2285
|
+
output_val = get_output_root(args.output)
|
|
2286
|
+
output_root = Path(output_val) if output_val else folder.parent
|
|
2287
|
+
|
|
2288
|
+
api_key = get_api_key(getattr(args, "api_key", None))
|
|
2289
|
+
try:
|
|
2290
|
+
provider = TmdbProvider(api_key=api_key)
|
|
2291
|
+
except ValueError as exc:
|
|
2292
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2293
|
+
return 1
|
|
2294
|
+
|
|
2295
|
+
try:
|
|
2296
|
+
# Detect batch vs single mode
|
|
2297
|
+
has_root_mkvs = any(folder.glob("*.mkv"))
|
|
2298
|
+
has_sub_mkvs = any(folder.glob("*/*.mkv"))
|
|
2299
|
+
|
|
2300
|
+
if has_root_mkvs or (has_sub_mkvs and not any(folder.glob("*/*/*.mkv"))):
|
|
2301
|
+
# Single folder mode: MKVs at root or one level of subfolders
|
|
2302
|
+
if args.title:
|
|
2303
|
+
title = args.title
|
|
2304
|
+
else:
|
|
2305
|
+
title, inferred_year = _strip_year_from_title(folder.name)
|
|
2306
|
+
if inferred_year and not getattr(args, "year", None):
|
|
2307
|
+
args.year = inferred_year
|
|
2308
|
+
return await _organize_single(
|
|
2309
|
+
folder, title, args, output_root, provider,
|
|
2310
|
+
)
|
|
2311
|
+
elif has_sub_mkvs or any(folder.glob("*/*/*.mkv")):
|
|
2312
|
+
# Batch mode: subfolders contain rip folders
|
|
2313
|
+
return await _organize_batch(
|
|
2314
|
+
folder, args, output_root, provider,
|
|
2315
|
+
)
|
|
2316
|
+
else:
|
|
2317
|
+
print("No MKV files found.", file=sys.stderr)
|
|
2318
|
+
return 1
|
|
2319
|
+
finally:
|
|
2320
|
+
await provider.close()
|
|
2321
|
+
|
|
2322
|
+
|
|
2323
|
+
async def _organize_batch(
|
|
2324
|
+
root: Path,
|
|
2325
|
+
args: argparse.Namespace,
|
|
2326
|
+
output_root: Path,
|
|
2327
|
+
provider: TmdbProvider,
|
|
2328
|
+
) -> int:
|
|
2329
|
+
"""Batch organize: auto-detect title groups and process each."""
|
|
2330
|
+
groups = group_title_folders(root)
|
|
2331
|
+
if not groups:
|
|
2332
|
+
print("No title groups found.", file=sys.stderr)
|
|
2333
|
+
return 1
|
|
2334
|
+
|
|
2335
|
+
print(f"Batch mode: found {len(groups)} title group(s).", file=sys.stderr)
|
|
2336
|
+
for g in groups:
|
|
2337
|
+
folders = ", ".join(f.name for f in g.folders)
|
|
2338
|
+
print(f" {g.title} ({len(g.folders)} folder(s): {folders})", file=sys.stderr)
|
|
2339
|
+
|
|
2340
|
+
overall_rc = 0
|
|
2341
|
+
for i, group in enumerate(groups):
|
|
2342
|
+
print(f"\n{'='*60}", file=sys.stderr)
|
|
2343
|
+
print(f"[{i+1}/{len(groups)}] {group.title}", file=sys.stderr)
|
|
2344
|
+
print(f"{'='*60}", file=sys.stderr)
|
|
2345
|
+
|
|
2346
|
+
# Use the first folder if single, otherwise the group has
|
|
2347
|
+
# subfolders that scanner will pick up. For multi-folder groups
|
|
2348
|
+
# (like "Planet Earth III - Disc 1/2/3"), we need to find a
|
|
2349
|
+
# common parent or use the first folder's parent.
|
|
2350
|
+
if len(group.folders) == 1:
|
|
2351
|
+
target_folder = group.folders[0]
|
|
2352
|
+
else:
|
|
2353
|
+
# Multiple folders share the same parent (root)
|
|
2354
|
+
# Create a virtual scan by passing the root and filtering
|
|
2355
|
+
target_folder = group.folders[0]
|
|
2356
|
+
# If folders share a parent, scanner should handle subfolders
|
|
2357
|
+
# Check if all folders are immediate children of root
|
|
2358
|
+
if all(f.parent == root for f in group.folders):
|
|
2359
|
+
# We need to scan each folder and combine results
|
|
2360
|
+
pass # handled below
|
|
2361
|
+
|
|
2362
|
+
# Override title from group detection
|
|
2363
|
+
if args.title:
|
|
2364
|
+
title = args.title
|
|
2365
|
+
else:
|
|
2366
|
+
title, inferred_year = _strip_year_from_title(group.title)
|
|
2367
|
+
if inferred_year and not getattr(args, "year", None):
|
|
2368
|
+
args.year = inferred_year
|
|
2369
|
+
|
|
2370
|
+
if len(group.folders) == 1:
|
|
2371
|
+
rc = await _organize_single(
|
|
2372
|
+
target_folder, title, args, output_root, provider,
|
|
2373
|
+
)
|
|
2374
|
+
else:
|
|
2375
|
+
# Multi-folder group: scan all folders and combine
|
|
2376
|
+
rc = await _organize_multi_folder(
|
|
2377
|
+
group.folders, title, args, output_root, provider,
|
|
2378
|
+
)
|
|
2379
|
+
|
|
2380
|
+
if rc != 0 and rc != 1:
|
|
2381
|
+
overall_rc = rc
|
|
2382
|
+
elif rc == 1 and overall_rc == 0:
|
|
2383
|
+
overall_rc = 1
|
|
2384
|
+
|
|
2385
|
+
return overall_rc
|
|
2386
|
+
|
|
2387
|
+
|
|
2388
|
+
async def _organize_multi_folder(
|
|
2389
|
+
folders: list[Path],
|
|
2390
|
+
title: str,
|
|
2391
|
+
args: argparse.Namespace,
|
|
2392
|
+
output_root: Path,
|
|
2393
|
+
provider: TmdbProvider,
|
|
2394
|
+
) -> int:
|
|
2395
|
+
"""Organize a title group spanning multiple folders."""
|
|
2396
|
+
from riplex.models import ScannedDisc
|
|
2397
|
+
|
|
2398
|
+
all_scanned: list[ScannedDisc] = []
|
|
2399
|
+
for folder in folders:
|
|
2400
|
+
print(f"Scanning {folder} ...", file=sys.stderr)
|
|
2401
|
+
try:
|
|
2402
|
+
scanned = scan_folder(folder)
|
|
2403
|
+
# Auto-generate snapshot per disc folder if missing
|
|
2404
|
+
snapshot_out = folder / f"{folder.name}.snapshot.json"
|
|
2405
|
+
if not snapshot_out.exists():
|
|
2406
|
+
snapshot_save_from_scanned(folder, scanned, snapshot_out)
|
|
2407
|
+
print(f"Snapshot saved to {snapshot_out}", file=sys.stderr)
|
|
2408
|
+
all_scanned.extend(scanned)
|
|
2409
|
+
except RuntimeError as exc:
|
|
2410
|
+
print(f"Error scanning {folder}: {exc}", file=sys.stderr)
|
|
2411
|
+
|
|
2412
|
+
if not all_scanned:
|
|
2413
|
+
print(f"No MKV files found for {title}.", file=sys.stderr)
|
|
2414
|
+
return 1
|
|
2415
|
+
|
|
2416
|
+
return await _organize_with_scanned(
|
|
2417
|
+
all_scanned, title, args, output_root, provider,
|
|
2418
|
+
)
|
|
2419
|
+
|
|
2420
|
+
|
|
2421
|
+
async def _organize_single(
|
|
2422
|
+
folder: Path,
|
|
2423
|
+
title: str,
|
|
2424
|
+
args: argparse.Namespace,
|
|
2425
|
+
output_root: Path,
|
|
2426
|
+
provider: TmdbProvider,
|
|
2427
|
+
) -> int:
|
|
2428
|
+
"""Organize a single rip folder."""
|
|
2429
|
+
# Step 1: Scan MKV files
|
|
2430
|
+
print(f"Scanning {folder} ...", file=sys.stderr)
|
|
2431
|
+
try:
|
|
2432
|
+
scanned = scan_folder(folder)
|
|
2433
|
+
except RuntimeError as exc:
|
|
2434
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2435
|
+
return 1
|
|
2436
|
+
|
|
2437
|
+
# Auto-generate snapshot if one doesn't exist yet
|
|
2438
|
+
snapshot_out = folder / f"{folder.name}.snapshot.json"
|
|
2439
|
+
if not snapshot_out.exists():
|
|
2440
|
+
snapshot_save_from_scanned(folder, scanned, snapshot_out)
|
|
2441
|
+
print(f"Snapshot saved to {snapshot_out}", file=sys.stderr)
|
|
2442
|
+
|
|
2443
|
+
return await _organize_with_scanned(
|
|
2444
|
+
scanned, title, args, output_root, provider,
|
|
2445
|
+
)
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
async def _organize_with_scanned(
|
|
2449
|
+
scanned: list,
|
|
2450
|
+
title: str,
|
|
2451
|
+
args: argparse.Namespace,
|
|
2452
|
+
output_root: Path,
|
|
2453
|
+
provider: TmdbProvider,
|
|
2454
|
+
) -> int:
|
|
2455
|
+
"""Core organize pipeline operating on already-scanned disc groups."""
|
|
2456
|
+
total_files = sum(len(d.files) for d in scanned)
|
|
2457
|
+
print(f"Found {total_files} MKV files in {len(scanned)} disc group(s).", file=sys.stderr)
|
|
2458
|
+
if total_files == 0:
|
|
2459
|
+
print("No MKV files found.", file=sys.stderr)
|
|
2460
|
+
return 1
|
|
2461
|
+
|
|
2462
|
+
# Skip already-organized files (unless --force)
|
|
2463
|
+
if not getattr(args, "force", False):
|
|
2464
|
+
skipped = 0
|
|
2465
|
+
for disc in scanned:
|
|
2466
|
+
before = len(disc.files)
|
|
2467
|
+
disc.files = [f for f in disc.files if not f.organized_tag]
|
|
2468
|
+
skipped += before - len(disc.files)
|
|
2469
|
+
if skipped:
|
|
2470
|
+
total_files = sum(len(d.files) for d in scanned)
|
|
2471
|
+
print(f"Skipping {skipped} already-organized file(s).", file=sys.stderr)
|
|
2472
|
+
if total_files == 0:
|
|
2473
|
+
print("All files already organized. Use --force to re-organize.", file=sys.stderr)
|
|
2474
|
+
return 0
|
|
2475
|
+
|
|
2476
|
+
# Detect unusable files (incomplete, no audio)
|
|
2477
|
+
incomplete = detect_incomplete(scanned)
|
|
2478
|
+
if incomplete:
|
|
2479
|
+
print(f"Removing {len(incomplete)} unusable file(s):", file=sys.stderr)
|
|
2480
|
+
for f in incomplete:
|
|
2481
|
+
if f.duration_seconds == 0 or f.stream_count == 0:
|
|
2482
|
+
print(f" {f.name} (incomplete: 0 duration / no streams)", file=sys.stderr)
|
|
2483
|
+
else:
|
|
2484
|
+
print(f" {f.name} ({f.duration_seconds}s, no audio)", file=sys.stderr)
|
|
2485
|
+
incomplete_paths = {f.path for f in incomplete}
|
|
2486
|
+
for disc in scanned:
|
|
2487
|
+
disc.files = [f for f in disc.files if f.path not in incomplete_paths]
|
|
2488
|
+
total_files = sum(len(d.files) for d in scanned)
|
|
2489
|
+
if total_files == 0:
|
|
2490
|
+
print("No usable MKV files found.", file=sys.stderr)
|
|
2491
|
+
return 1
|
|
2492
|
+
|
|
2493
|
+
# Auto-detect format if not specified
|
|
2494
|
+
disc_format = getattr(args, "disc_format", None)
|
|
2495
|
+
if not disc_format:
|
|
2496
|
+
disc_format = detect_format(scanned)
|
|
2497
|
+
if disc_format:
|
|
2498
|
+
log.debug("Auto-detected format: %s", disc_format)
|
|
2499
|
+
|
|
2500
|
+
# Detect and remove duplicates + compilations
|
|
2501
|
+
dup_groups, comp_groups = find_all_redundant(scanned)
|
|
2502
|
+
if dup_groups:
|
|
2503
|
+
dup_count = sum(len(g.duplicates) for g in dup_groups)
|
|
2504
|
+
print(f"Detected {dup_count} duplicate(s) in {len(dup_groups)} group(s):", file=sys.stderr)
|
|
2505
|
+
for g in dup_groups:
|
|
2506
|
+
for d in g.duplicates:
|
|
2507
|
+
print(f" DUPLICATE: {d.name} (keeping {g.keep.name})", file=sys.stderr)
|
|
2508
|
+
if comp_groups:
|
|
2509
|
+
print(f"Detected {len(comp_groups)} compilation(s):", file=sys.stderr)
|
|
2510
|
+
for c in comp_groups:
|
|
2511
|
+
parts = ", ".join(p.name for p in c.parts)
|
|
2512
|
+
print(f" COMPILATION: {c.compilation.name} (combined from {parts})", file=sys.stderr)
|
|
2513
|
+
if dup_groups or comp_groups:
|
|
2514
|
+
scanned = remove_duplicates(scanned, dup_groups, comp_groups)
|
|
2515
|
+
total_files = sum(len(d.files) for d in scanned)
|
|
2516
|
+
print(f"Proceeding with {total_files} files after dedup.", file=sys.stderr)
|
|
2517
|
+
|
|
2518
|
+
# Infer title from MKV title_tag when no --title override was given
|
|
2519
|
+
if not getattr(args, "title", None):
|
|
2520
|
+
inferred = _infer_title_from_scanned(scanned)
|
|
2521
|
+
if inferred and inferred.lower() != title.lower():
|
|
2522
|
+
log.debug("Title inferred from MKV title_tag: %r (was %r)", inferred, title)
|
|
2523
|
+
title = inferred
|
|
2524
|
+
title = prompt_text("Title", default=title)
|
|
2525
|
+
|
|
2526
|
+
# Auto-detect media type from file durations when mode is "auto".
|
|
2527
|
+
# Two heuristics:
|
|
2528
|
+
# TV: 2+ files in the 15-75 min range, none above 75 min
|
|
2529
|
+
# Movie: a single feature-length file (> 90 min)
|
|
2530
|
+
media_type = getattr(args, "media_type", "auto")
|
|
2531
|
+
if media_type == "auto":
|
|
2532
|
+
all_files = [f for d in scanned for f in d.files]
|
|
2533
|
+
if all_files:
|
|
2534
|
+
episode_range = [f for f in all_files if 900 <= f.duration_seconds <= 4500]
|
|
2535
|
+
above_episode = [f for f in all_files if f.duration_seconds > 4500]
|
|
2536
|
+
if len(episode_range) >= 2 and not above_episode:
|
|
2537
|
+
media_type = "tv"
|
|
2538
|
+
log.debug("Auto-detected media_type='tv' (%d episode-length files)", len(episode_range))
|
|
2539
|
+
elif all_files:
|
|
2540
|
+
longest_dur = max(f.duration_seconds for f in all_files)
|
|
2541
|
+
if longest_dur > 5400: # > 90 minutes
|
|
2542
|
+
media_type = "movie"
|
|
2543
|
+
log.debug("Auto-detected media_type='movie' (longest file %ds)", longest_dur)
|
|
2544
|
+
|
|
2545
|
+
# Look up TMDb metadata
|
|
2546
|
+
try:
|
|
2547
|
+
request = SearchRequest(
|
|
2548
|
+
title=title,
|
|
2549
|
+
year=getattr(args, "year", None),
|
|
2550
|
+
media_type=media_type,
|
|
2551
|
+
)
|
|
2552
|
+
result = await plan(request, provider)
|
|
2553
|
+
except LookupError as exc:
|
|
2554
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
2555
|
+
return 1
|
|
2556
|
+
except Exception as exc:
|
|
2557
|
+
print(f"Error fetching TMDb metadata: {exc}", file=sys.stderr)
|
|
2558
|
+
return 1
|
|
2559
|
+
|
|
2560
|
+
print(f"TMDb: {result.canonical_title} ({result.year})", file=sys.stderr)
|
|
2561
|
+
|
|
2562
|
+
# Look up dvdcompare disc metadata using TMDb canonical title
|
|
2563
|
+
dvdcompare_title = result.canonical_title
|
|
2564
|
+
print("Looking up disc metadata on dvdcompare.net ...", file=sys.stderr)
|
|
2565
|
+
release = getattr(args, "release", None)
|
|
2566
|
+
try:
|
|
2567
|
+
from dvdcompare.scraper import find_film
|
|
2568
|
+
film = await find_film(dvdcompare_title, disc_format)
|
|
2569
|
+
discs, release_name = _select_dvdcompare_release(
|
|
2570
|
+
film, preferred=release,
|
|
2571
|
+
)
|
|
2572
|
+
print(f"Found {len(discs)} disc(s) on dvdcompare.", file=sys.stderr)
|
|
2573
|
+
except SystemExit:
|
|
2574
|
+
raise
|
|
2575
|
+
except LookupError as exc:
|
|
2576
|
+
print(f"Error: dvdcompare lookup failed: {exc}", file=sys.stderr)
|
|
2577
|
+
sys.exit(1)
|
|
2578
|
+
|
|
2579
|
+
# Map folders to discs and match
|
|
2580
|
+
if discs:
|
|
2581
|
+
folder_map = map_folders_to_discs(scanned, discs, result)
|
|
2582
|
+
for folder_name, disc_num in folder_map.items():
|
|
2583
|
+
if disc_num is not None:
|
|
2584
|
+
print(f" {folder_name} -> Disc {disc_num}", file=sys.stderr)
|
|
2585
|
+
else:
|
|
2586
|
+
print(f" {folder_name} -> (unmapped, global fallback)", file=sys.stderr)
|
|
2587
|
+
|
|
2588
|
+
result_obj = match_discs(scanned, discs, result)
|
|
2589
|
+
print(
|
|
2590
|
+
f"Matched {len(result_obj.matched)} files, "
|
|
2591
|
+
f"{len(result_obj.unmatched)} unmatched, "
|
|
2592
|
+
f"{len(result_obj.missing)} missing.",
|
|
2593
|
+
file=sys.stderr,
|
|
2594
|
+
)
|
|
2595
|
+
|
|
2596
|
+
# Build organize plan
|
|
2597
|
+
file_map = {f.name: f.path for d in scanned for f in d.files}
|
|
2598
|
+
scanned_map = {f.name: f for d in scanned for f in d.files}
|
|
2599
|
+
targets = collect_disc_targets(discs, result) if discs else None
|
|
2600
|
+
unmatched_policy = getattr(args, "unmatched", "ignore")
|
|
2601
|
+
org_plan = build_organize_plan(
|
|
2602
|
+
result_obj, result, output_root, file_map,
|
|
2603
|
+
scanned_files=scanned_map, disc_targets=targets,
|
|
2604
|
+
unmatched_policy=unmatched_policy,
|
|
2605
|
+
)
|
|
2606
|
+
|
|
2607
|
+
# Output
|
|
2608
|
+
dry_run = not getattr(args, "execute", False)
|
|
2609
|
+
unmatched_dir = output_root / "_Unmatched" / title if unmatched_policy == "move" else None
|
|
2610
|
+
actions = execute_plan(org_plan, dry_run=dry_run, unmatched_policy=unmatched_policy, unmatched_dir=unmatched_dir)
|
|
2611
|
+
for line in actions:
|
|
2612
|
+
print(line)
|
|
2613
|
+
|
|
2614
|
+
if dry_run:
|
|
2615
|
+
print(f"\n{_execute_hint('organize')}")
|
|
2616
|
+
|
|
2617
|
+
# Tag organized files after successful execute
|
|
2618
|
+
if not dry_run:
|
|
2619
|
+
from riplex.tagger import tag_organized
|
|
2620
|
+
tagged = 0
|
|
2621
|
+
for move in org_plan.moves:
|
|
2622
|
+
if tag_organized(move.destination, move.label):
|
|
2623
|
+
tagged += 1
|
|
2624
|
+
for split in org_plan.splits:
|
|
2625
|
+
for dest, label in zip(split.chapter_destinations, split.chapter_labels):
|
|
2626
|
+
if tag_organized(dest, label):
|
|
2627
|
+
tagged += 1
|
|
2628
|
+
if tagged:
|
|
2629
|
+
log.info("Tagged %d file(s) as organized", tagged)
|
|
2630
|
+
|
|
2631
|
+
return 0
|
|
2632
|
+
|
|
2633
|
+
|
|
2634
|
+
if __name__ == "__main__":
|
|
2635
|
+
main()
|