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/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()