-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathextractor.py
More file actions
1918 lines (1616 loc) · 76.1 KB
/
Copy pathextractor.py
File metadata and controls
1918 lines (1616 loc) · 76.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
extractor.py
Core logic for scanning Blu-ray disc folders, finding the best available
audio track (Dolby Atmos when present, otherwise the best lossless
alternative), reading chapter markers, and splitting that audio stream
into individual named song files.
Requires on PATH (or configured via settings.py):
- mkvmerge / mkvextract (MKVToolNix)
- ffmpeg / ffprobe
This module has no GUI dependencies - it can be used standalone or
imported by main.py.
"""
from __future__ import annotations
import dataclasses
import json
import os
import re
import shutil
import subprocess
import tempfile
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Optional
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class Track:
track_id: int
kind: str # "video" | "audio" | "subtitles"
codec: str # human-readable codec string from mkvmerge JSON, e.g. "TrueHD Atmos"
codec_id: str = "" # internal codec id, e.g. "A_TRUEHD", "V_MPEG4/ISO/AVC"
language: str = "" # ISO 639-2 code, e.g. "eng" - empty if not set on the track
channels: Optional[int] = None # audio channel count, None for non-audio tracks
title: str = "" # track name/title embedded in the container, if any
sample_rate: Optional[int] = None # Hz, from mkvmerge's audio_sampling_frequency
bits_per_sample: Optional[int] = None # from mkvmerge's audio_bits_per_sample
bitrate_kbps: Optional[float] = None # best-effort only - see enrich_bitrates_ffprobe();
# None whenever it couldn't be determined, rather
# than guessing, since a wrong bitrate is worse than
# no bitrate shown
@property
def is_atmos(self) -> bool:
return "atmos" in self.codec.lower()
@property
def channel_layout(self) -> str:
"""
Best-effort "2.0" / "5.1" / "7.1" style layout string. mkvmerge
only reports a raw channel count, not the front/LFE split, so
this covers the layouts that actually ship on Blu-ray audio discs
rather than guessing at anything more exotic.
"""
if not self.channels:
return ""
return {1: "1.0", 2: "2.0", 6: "5.1", 8: "7.1"}.get(self.channels, str(self.channels))
@property
def display_label(self) -> str:
"""
Human-readable summary for a track picker, e.g.:
"DTS-HD Master Audio - 5.1, 96kHz, 8407kbps, 24-bit [eng]"
Degrades gracefully - any field mkvmerge/ffprobe couldn't
determine is simply left out rather than shown as a placeholder.
"""
parts = []
if self.channel_layout:
parts.append(self.channel_layout)
if self.sample_rate:
khz = self.sample_rate / 1000
parts.append(f"{khz:g}kHz")
if self.bitrate_kbps:
parts.append(f"{self.bitrate_kbps:.0f}kbps")
if self.bits_per_sample:
parts.append(f"{self.bits_per_sample}-bit")
label = self.codec or "Unknown codec"
if parts:
label += " - " + ", ".join(parts)
if self.language:
label += f" [{self.language}]"
if self.title:
label += f' "{self.title}"'
return label
@dataclass
class Playlist:
path: Path
tracks: list[Track] = field(default_factory=list)
chapter_count: int = 0
duration_seconds: float = 0.0
@property
def atmos_track(self) -> Optional[Track]:
for t in self.tracks:
if t.is_atmos:
return t
return None
@property
def video_track(self) -> Optional[Track]:
for t in self.tracks:
if t.kind == "video":
return t
return None
@property
def has_atmos(self) -> bool:
return self.atmos_track is not None
@property
def audio_tracks(self) -> list[Track]:
"""Every audio track on this playlist, in mkvmerge/disc order."""
return [t for t in self.tracks if t.kind == "audio"]
def best_default_audio_track(self) -> Optional[Track]:
"""
The track to pre-select in a track picker: the Atmos track if
there is one (matches the old auto-pick behaviour exactly), else
the "best" remaining audio track by a simple, defensible
preference order - lossless codec first, then more channels,
then higher bit depth. Never silently outranks an explicit user
choice; this is only ever used as the initial dropdown value.
"""
if self.has_atmos:
return self.atmos_track
tracks = self.audio_tracks
if not tracks:
return None
LOSSLESS_HINTS = ("truehd", "dts-hd master", "flac", "pcm", "lpcm", "alac")
def sort_key(t: Track) -> tuple:
codec_lower = t.codec.lower()
is_lossless = any(h in codec_lower for h in LOSSLESS_HINTS)
return (is_lossless, t.channels or 0, t.bits_per_sample or 0)
return max(tracks, key=sort_key)
@dataclass
class Chapter:
index: int
start_seconds: float
end_seconds: Optional[float] = None # filled in after all chapters read
name: str = "" # final song title used for the output filename
embedded_name: str = "" # ChapterString read from the source, if any
language: str = "" # ChapterLanguage of the embedded name, if any
# ---------------------------------------------------------------------------
# Tool paths - overridden by settings.py if the user configures custom paths
# ---------------------------------------------------------------------------
TOOL_PATHS = {
"mkvmerge": "mkvmerge",
"mkvextract": "mkvextract",
"ffmpeg": "ffmpeg",
"ffprobe": "ffprobe",
}
# Where to point people if a required tool isn't found. mkvmerge/mkvextract
# both ship in the same MKVToolNix install; ffmpeg/ffprobe both ship in the
# same ffmpeg download.
TOOL_DOWNLOAD_URLS = {
"mkvmerge": "https://mkvtoolnix.download/downloads.html",
"mkvextract": "https://mkvtoolnix.download/downloads.html",
"ffmpeg": "https://ffmpeg.org/download.html",
"ffprobe": "https://ffmpeg.org/download.html",
}
def set_tool_path(tool: str, path: str) -> None:
if tool not in TOOL_PATHS:
raise KeyError(f"Unknown tool '{tool}'")
TOOL_PATHS[tool] = path
def check_tools() -> dict[str, bool]:
"""
Check whether each configured tool is actually runnable and actually
the right tool. See verify_tool_at_path() for why - existence alone
doesn't prove much, and neither does exit code alone.
Returns {tool_name: True/False}.
"""
return {name: verify_tool_at_path(path, name)[0] for name, path in TOOL_PATHS.items()}
# ffmpeg/ffprobe use a single-dash "-version" - "--version" (GNU style) is
# NOT a recognised option for them. mkvmerge/mkvextract do use GNU-style
# "--version". Using the wrong flag doesn't necessarily fail cleanly: ffmpeg
# prints its full version banner to stderr unconditionally at startup,
# *before* it even parses arguments, so an unrecognised "--version" still
# produces a perfectly legible banner followed by an "Unrecognized option"
# error and a nonzero exit - which looks exactly like a real failure if
# you're only checking the exit code.
_VERSION_FLAGS = {
"mkvmerge": "--version",
"mkvextract": "--version",
"ffmpeg": "-version",
"ffprobe": "-version",
}
# Text each tool prints about itself, used to confirm a binary's actual
# identity rather than just trusting that "something ran successfully".
_IDENTIFYING_TEXT = {
"mkvmerge": "mkvmerge v",
"mkvextract": "mkvextract v",
"ffmpeg": "ffmpeg version",
"ffprobe": "ffprobe version",
}
def verify_tool_at_path(path: str, tool_name: str, timeout: float = 10.0) -> tuple[bool, str]:
"""
Check whether a specific path is actually a working copy of tool_name -
usable directly against a path the user just picked in a file browser,
before it's saved to settings at all.
This checks the tool's own self-identifying banner text in its output
(e.g. "ffmpeg version") rather than trusting the exit code alone, for
two reasons that both showed up in practice:
- ffmpeg/ffprobe print their version banner to stderr unconditionally
at startup, before parsing arguments - so a genuinely-working binary
given the wrong flag can still exit nonzero after having already
printed a perfectly valid banner. Checking only the exit code would
wrongly reject it and show that banner back as if it were an error.
- A file that runs fine and exits 0 isn't necessarily the *right*
tool - ffmpeg.exe and ffprobe.exe sit right next to each other in
every install, and it's an easy misclick to pick one while browsing
for the other. Checking identity catches that with a clear message
instead of a false pass.
Returns (True, first line of version output) on success, or
(False, a short human-readable reason - naming what it actually looks
like, if it's recognisably one of our *other* tools) on failure.
"""
flag = _VERSION_FLAGS.get(tool_name, "--version")
try:
result = _run([path, flag], timeout=timeout)
except (FileNotFoundError, OSError) as exc:
return False, f"Could not run this file: {exc}"
except RuntimeError as exc:
# _run's own timeout wrapper - already a clear message.
return False, str(exc)
combined = f"{result.stdout or ''}\n{result.stderr or ''}"
combined_lower = combined.lower()
expected = _IDENTIFYING_TEXT.get(tool_name, tool_name)
if expected.lower() in combined_lower:
first_line = next((ln.strip() for ln in combined.splitlines() if ln.strip()), "OK")
return True, first_line
# Not the expected tool - check whether it's recognisably one of our
# *other* tools, so a mixed-up file gets a specific, actionable answer
# ("this looks like ffprobe, not ffmpeg") instead of a raw dump.
for other_name, other_text in _IDENTIFYING_TEXT.items():
if other_name != tool_name and other_text.lower() in combined_lower:
return False, f"This looks like {other_name}, not {tool_name}."
if result.returncode != 0:
detail_lines = [ln.strip() for ln in combined.splitlines() if ln.strip()]
detail = detail_lines[0] if detail_lines else f"exited with code {result.returncode}"
return False, detail
return False, "Ran, but didn't produce recognisable version output."
# Tools that are always installed together in the same folder, so once the
# user locates one we can offer to auto-detect the other instead of making
# them browse twice for what's really one install.
SIBLING_TOOL_NAMES = {
"ffmpeg": "ffprobe",
"ffprobe": "ffmpeg",
"mkvmerge": "mkvextract",
"mkvextract": "mkvmerge",
}
def guess_sibling_tool_path(chosen_path: str, tool_name: str) -> Optional[str]:
"""
Given a path the user just picked for one tool (e.g. .../bin/ffmpeg.exe),
guess the path for its usual companion in the same install (ffprobe
next to ffmpeg, mkvextract next to mkvmerge - both pairs are always
shipped together in the same bin/ folder). Only replaces the filename
itself, not anything matching the tool name elsewhere in the path
(e.g. a parent folder called "ffmpeg-8.1.2-full_build" is left alone).
Returns the guessed path only if a file actually exists there - the
caller still has to run it through verify_tool_at_path() before
trusting it, since a same-named file isn't proof it's a working,
matching-architecture binary.
"""
sibling_name = SIBLING_TOOL_NAMES.get(tool_name)
if sibling_name is None:
return None
chosen = Path(chosen_path)
candidate = chosen.with_name(chosen.name.replace(tool_name, sibling_name))
if candidate != chosen and candidate.is_file():
return str(candidate)
return None
def _tool_versions() -> dict[str, str]:
"""First line of each configured tool's version output, for the job manifest."""
versions: dict[str, str] = {}
for name, configured_path in TOOL_PATHS.items():
ok, message = verify_tool_at_path(configured_path, name)
versions[name] = message if ok else "unknown"
return versions
# ---------------------------------------------------------------------------
# Job manifest - lets an interrupted job be resumed instead of redone from
# scratch, and gives "Open log" / crash diagnosis something concrete to
# point at.
# ---------------------------------------------------------------------------
MANIFEST_FILENAME = "manifest.json"
@dataclass
class JobManifest:
source_playlist: str
audio_track_id: int
audio_track_label: str # human-readable description of the chosen track, for the resume banner
video_track_id: Optional[int]
output_folder: str
container: str
track_names: dict[str, str] # chapter index (as string, for JSON) -> name
tool_versions: dict[str, str]
created_at: str
status: str = "pending" # pending -> extracting -> splitting -> complete | failed | cancelled
audio_extracted: bool = False # True once extract_audio_mkv has succeeded - independent of status,
# since status becomes "failed"/"cancelled" regardless of which stage failed
chapters: list[dict] = field(default_factory=list) # [{index, start_seconds, end_seconds, name}]
completed_outputs: list[str] = field(default_factory=list) # output paths already written, in order
def to_json(self) -> str:
return json.dumps(dataclasses.asdict(self), indent=2)
@classmethod
def from_json(cls, text: str) -> "JobManifest":
data = json.loads(text)
known_fields = {f.name for f in dataclasses.fields(cls)}
return cls(**{k: v for k, v in data.items() if k in known_fields})
def write_manifest(work_folder: Path, manifest: JobManifest) -> None:
"""Write the manifest atomically - a crash mid-write must never leave a corrupt manifest behind."""
work_folder.mkdir(parents=True, exist_ok=True)
path = work_folder / MANIFEST_FILENAME
tmp = path.with_suffix(".json.tmp")
tmp.write_text(manifest.to_json(), encoding="utf-8")
os.replace(tmp, path)
def read_manifest(work_folder: Path) -> Optional[JobManifest]:
"""
Read a previous job's manifest from work_folder, or None if there
isn't one (fresh job) or it's unreadable (treated the same as "no
manifest" - a corrupt manifest shouldn't block starting a new job,
it just means resume isn't available for whatever was there before).
"""
path = work_folder / MANIFEST_FILENAME
if not path.is_file():
return None
try:
return JobManifest.from_json(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, TypeError, KeyError, OSError):
return None
class JobCancelled(Exception):
"""Raised when a running job is stopped via a cancel_event."""
def _run(
args: list[str],
timeout: Optional[float] = None,
cancel_event: Optional[threading.Event] = None,
) -> subprocess.CompletedProcess:
"""
Run a subprocess, hiding the console window on Windows.
stdin is explicitly set to DEVNULL. When this app is running as a
windowed/no-console exe (PyInstaller --windowed), there is no valid
console handle for the process to inherit as stdin. A child process
that inherits a broken/invalid stdin handle can hang indefinitely
waiting on it even after the child itself has exited - this was the
root cause of the app appearing stuck on "Working..." after mkvmerge
had already finished/died. Explicitly redirecting stdin from DEVNULL
avoids that inheritance entirely, regardless of how the app is launched.
A timeout is also supported (default: no timeout) so that a genuinely
hung external tool doesn't wedge the app forever with no feedback.
cancel_event: if given, checked roughly twice a second while the
process runs. If set, the process is terminated (SIGTERM, then
SIGKILL if it hasn't exited within 5s) and JobCancelled is raised
instead of waiting for it to finish naturally. Omitted entirely for
calls that don't need to be cancellable (quick --version checks etc),
which keeps using the simpler non-polling subprocess.run path.
"""
creationflags = 0
if hasattr(subprocess, "CREATE_NO_WINDOW"):
creationflags = subprocess.CREATE_NO_WINDOW # type: ignore[attr-defined]
if cancel_event is None:
try:
return subprocess.run(
args,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
stdin=subprocess.DEVNULL,
creationflags=creationflags,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
tool = Path(args[0]).name if args else "process"
raise RuntimeError(
f"{tool} timed out after {timeout} seconds and was killed. "
f"Command: {' '.join(args)}"
) from exc
# Cancellable path: subprocess.run() blocks until the process exits
# with no way to interrupt it early, so use Popen + a short-timeout
# communicate() loop instead, checking cancel_event between polls.
# Retrying communicate() after a TimeoutExpired is explicitly
# supported and doesn't lose any output (per the subprocess docs).
proc = subprocess.Popen(
args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
stdin=subprocess.DEVNULL,
creationflags=creationflags,
)
started = time.monotonic()
while True:
try:
stdout, stderr = proc.communicate(timeout=0.5)
return subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
except subprocess.TimeoutExpired:
if cancel_event.is_set():
proc.terminate()
try:
proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate()
raise JobCancelled("Cancelled by user.")
if timeout is not None and (time.monotonic() - started) > timeout:
proc.kill()
proc.communicate()
tool = Path(args[0]).name if args else "process"
raise RuntimeError(
f"{tool} timed out after {timeout} seconds and was killed. "
f"Command: {' '.join(args)}"
)
# ---------------------------------------------------------------------------
# Disc / folder scanning
# ---------------------------------------------------------------------------
def find_playlists(disc_folder: Path) -> list[Path]:
"""Find .mpls playlist files under BDMV/PLAYLIST (ignores BACKUP)."""
playlist_dir = disc_folder / "BDMV" / "PLAYLIST"
if not playlist_dir.is_dir():
return []
return sorted(playlist_dir.glob("*.mpls"))
def inspect_playlist(playlist_path: Path) -> Playlist:
"""
Run `mkvmerge -J` on a playlist and parse the resulting JSON for
tracks and chapter count.
JSON (rather than the human-readable `mkvmerge -i` text output) is
used deliberately: the text format isn't a stable interface - its
wording, spacing, and line layout can shift between MKVToolNix
versions or with locale settings, which is exactly the kind of thing
that quietly breaks a regex without any obvious error. The JSON
schema is the format MKVToolNix documents and maintains for tooling.
"""
result = _run([TOOL_PATHS["mkvmerge"], "-J", str(playlist_path)], timeout=60)
# mkvmerge returns 0 for a clean read and 1 for warnings (still usable
# output), but 2 means it couldn't read the file at all - in that case
# stdout won't have useful track/chapter info, so surface the real
# error instead of silently reporting "no Atmos track".
if result.returncode >= 2:
raise RuntimeError(
f"mkvmerge could not read {playlist_path.name}:\n{result.stderr or result.stdout}"
)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(
f"mkvmerge produced output that wasn't valid JSON for "
f"{playlist_path.name}: {exc}\n"
f"First 2000 chars of output:\n{result.stdout[:2000]}"
) from exc
pl = Playlist(path=playlist_path)
for t in data.get("tracks", []):
props = t.get("properties") or {}
track_id = t.get("id")
if track_id is None:
continue # malformed entry - skip rather than crash on a bad track_id
pl.tracks.append(
Track(
track_id=track_id,
kind=t.get("type", ""),
codec=t.get("codec", ""),
codec_id=props.get("codec_id", "") or "",
language=props.get("language", "") or "",
channels=props.get("audio_channels"),
title=props.get("track_name", "") or "",
sample_rate=props.get("audio_sampling_frequency"),
bits_per_sample=props.get("audio_bits_per_sample"),
)
)
chapters = data.get("chapters") or []
if chapters:
# mkvmerge -J groups chapters into one "edition"; a Blu-ray
# playlist normally has exactly one, so take the first.
pl.chapter_count = chapters[0].get("num_entries", 0)
duration_ns = ((data.get("container") or {}).get("properties") or {}).get("duration")
if duration_ns:
pl.duration_seconds = duration_ns / 1_000_000_000
_enrich_bitrates_ffprobe(pl)
return pl
def _enrich_bitrates_ffprobe(playlist: Playlist) -> None:
"""
Best-effort fill-in of Track.bitrate_kbps via ffprobe, since mkvmerge
-J doesn't report bitrate at all. Failure here (missing ffprobe,
unreadable file, unexpected output) is never fatal to a scan - it
just means the picker shows a track without a bitrate figure, which
is far better than blocking or guessing a wrong number.
Matched by position: ffprobe and mkvmerge both enumerate a Blu-ray
playlist's streams in on-disc order, so the Nth audio stream ffprobe
reports is assumed to be the Nth audio track mkvmerge reported. If
the counts don't match (a sign the assumption broke for this file),
nothing is filled in rather than risk mislabelling a track.
"""
audio_tracks = playlist.audio_tracks
if not audio_tracks:
return
try:
result = _run(
[
TOOL_PATHS["ffprobe"], "-v", "quiet", "-print_format", "json",
"-show_streams", "-select_streams", "a", str(playlist.path),
],
timeout=60,
)
if result.returncode != 0:
return
streams = json.loads(result.stdout).get("streams", [])
except (RuntimeError, json.JSONDecodeError, OSError):
return
if len(streams) != len(audio_tracks):
return # ordering assumption unverifiable - don't guess
for track, stream in zip(audio_tracks, streams):
bit_rate = stream.get("bit_rate")
if bit_rate is None:
# Common for lossless DTS-HD MA/TrueHD: ffprobe sometimes
# only tags the embedded DTS/AC-3 core's bitrate via
# BPS-style tags rather than the top-level field.
tags = stream.get("tags") or {}
bit_rate = tags.get("BPS") or tags.get("BPS-eng")
if bit_rate is None and track.sample_rate and track.bits_per_sample and track.channels:
# PCM/LPCM is uncompressed, so its bitrate is exactly
# derivable rather than best-effort - compute it directly
# instead of relying on ffprobe reporting it.
codec_lower = track.codec.lower()
if "pcm" in codec_lower:
bit_rate = track.sample_rate * track.bits_per_sample * track.channels
try:
if bit_rate is not None:
track.bitrate_kbps = float(bit_rate) / 1000
except (TypeError, ValueError):
pass
def scan_disc_folder(disc_folder: Path) -> list[Playlist]:
"""Inspect every playlist in a disc folder, return list of Playlist."""
return [inspect_playlist(p) for p in find_playlists(disc_folder)]
# ---------------------------------------------------------------------------
# Playlist scoring
# ---------------------------------------------------------------------------
@dataclass
class PlaylistScore:
playlist: Playlist
score: float
reasons: list[str] = field(default_factory=list)
duplicate_of: Optional[Path] = None # set if this looks like a duplicate/alternate angle of a higher-scored playlist
def score_playlists(
playlists: list[Playlist],
expected_chapter_count: Optional[int] = None,
expected_duration_seconds: Optional[float] = None,
) -> list[PlaylistScore]:
"""
Score every scanned playlist as a candidate for "the" concert
feature, replacing the old heuristic of silently picking whichever
Atmos playlist happened to have the most chapters. Atmos is treated
as a bonus signal, not a requirement, so non-Atmos discs are scored
fairly too. Returns
PlaylistScore objects sorted highest-score first, each carrying the
reasons behind its score so the UI can show its work and the user can
confirm (or override) the pick, instead of a single silent choice.
expected_chapter_count / expected_duration_seconds: optional values
from an external source (e.g. a user-imported tracklist). When given,
playlists matching them closely get a bonus. Both are unused for now -
they exist so a future tracklist-import feature can feed into playlist
selection without changing this function's shape.
"""
scored: list[PlaylistScore] = []
for pl in playlists:
score = 0.0
reasons: list[str] = []
if pl.has_atmos:
score += 100
atmos = pl.atmos_track
reasons.append(f"Has a Dolby Atmos/TrueHD track (ID {atmos.track_id})")
else:
best = pl.best_default_audio_track()
if best is not None:
codec_lower = best.codec.lower()
is_lossless = any(
h in codec_lower
for h in ("truehd", "dts-hd master", "flac", "pcm", "lpcm", "alac")
)
if is_lossless:
score += 70
reasons.append(f"No Atmos, but best available track is lossless ({best.codec})")
else:
score += 30
reasons.append(f"No Atmos or lossless track - best available is {best.codec}")
else:
reasons.append("No audio track at all - cannot be the right playlist")
if pl.chapter_count > 0:
score += min(pl.chapter_count * 2, 40)
reasons.append(f"{pl.chapter_count} chapters")
else:
reasons.append("No chapters - can't be split into songs even if selected")
if pl.video_track is not None:
score += 10
reasons.append(f"Includes a video track ({pl.video_track.codec})")
else:
reasons.append("No video track - audio-only playlist")
if pl.duration_seconds > 0:
minutes = pl.duration_seconds / 60
# A real concert feature usually runs from roughly 20 minutes
# to a few hours. Duration mainly helps rule out menus,
# trailers, and short bonus clips rather than reward length
# for its own sake, so this is capped and lightly weighted
# rather than dominating the score.
score += min(minutes, 180) * 0.15
reasons.append(f"Runs {minutes:.0f} minutes")
# Playlist number is a weak signal - naming conventions vary by
# studio/authoring tool - so it only nudges close ties, never
# dominates the score on its own.
try:
score -= int(pl.path.stem) * 0.01
except ValueError:
pass
if expected_chapter_count is not None and pl.chapter_count == expected_chapter_count:
score += 15
reasons.append(f"Chapter count matches the expected tracklist ({expected_chapter_count})")
if expected_duration_seconds is not None and pl.duration_seconds > 0:
if abs(pl.duration_seconds - expected_duration_seconds) < 30:
score += 15
reasons.append("Duration matches the expected tracklist closely")
scored.append(PlaylistScore(playlist=pl, score=score, reasons=reasons))
_flag_duplicate_angles(scored)
scored.sort(key=lambda s: s.score, reverse=True)
return scored
def _flag_duplicate_angles(scored: list[PlaylistScore]) -> None:
"""
Blu-rays sometimes expose the same underlying content as several
playlists - alternate angles, region variants, a "clean" vs
"with-recap" cut. These share duration, chapter count, and track
layout almost exactly, so group candidates by that signature and mark
every playlist but the highest-scored one in each group as a likely
duplicate, rather than presenting near-identical entries as separate
top candidates.
"""
def signature(pl: Playlist) -> tuple:
track_sig = tuple(sorted((t.kind, t.codec) for t in pl.tracks))
return (pl.chapter_count, round(pl.duration_seconds), track_sig)
groups: dict[tuple, list[PlaylistScore]] = {}
for s in scored:
groups.setdefault(signature(s.playlist), []).append(s)
for group in groups.values():
if len(group) < 2:
continue
group.sort(key=lambda s: s.score, reverse=True)
primary = group[0]
for dup in group[1:]:
dup.duplicate_of = primary.playlist.path
dup.score -= 50
dup.reasons.append(
f"Same duration/chapters/tracks as {primary.playlist.path.name} "
f"- likely a duplicate or alternate angle"
)
# ---------------------------------------------------------------------------
# Extraction: playlist -> standalone single-audio-track MKV (with chapters)
# ---------------------------------------------------------------------------
def extract_audio_mkv(
playlist: Playlist,
output_path: Path,
audio_track: Track,
progress_cb: Optional[Callable[[str], None]] = None,
cancel_event: Optional[threading.Event] = None,
) -> Path:
"""
Remux the video track + exactly one chosen audio track (+ chapters)
out of a playlist. Keeping video means playback isn't a black/no-
signal screen on a TV. Every other audio track on the playlist -
whichever other LPCM/DTS-HD/TrueHD/Atmos options it has - is dropped,
same as the rest of the disc's alternate audio.
"""
if audio_track.kind != "audio":
raise ValueError(f"Track {audio_track.track_id} is not an audio track")
video_track = playlist.video_track
output_path.parent.mkdir(parents=True, exist_ok=True)
args = [TOOL_PATHS["mkvmerge"], "-o", str(output_path)]
if video_track is not None:
args += ["-d", str(video_track.track_id)]
else:
args += ["--no-video"]
args += ["--no-subtitles", "-a", str(audio_track.track_id), str(playlist.path)]
if progress_cb:
target = f"video track {video_track.track_id} + " if video_track else ""
progress_cb(
f"Extracting {target}audio track {audio_track.track_id} "
f"({audio_track.codec}) -> {output_path.name}"
)
result = _run(args, timeout=7200, cancel_event=cancel_event) # 2 hours - full-disc extraction can be slow
if result.returncode != 0:
raise RuntimeError(f"mkvmerge failed:\n{result.stdout}\n{result.stderr}")
return output_path
# ---------------------------------------------------------------------------
# Chapters
# ---------------------------------------------------------------------------
_TIME_RE = re.compile(r"(\d+):(\d+):(\d+(?:\.\d+)?)")
def _timecode_to_seconds(tc: str) -> float:
m = _TIME_RE.match(tc)
if not m:
raise ValueError(f"Unrecognised timecode: {tc}")
h, mnt, s = m.groups()
return int(h) * 3600 + int(mnt) * 60 + float(s)
def read_chapters(mkv_path: Path, preferred_language: str = "eng") -> list[Chapter]:
"""
Extract chapter markers from a real Matroska (.mkv) file via
`mkvextract chapters -`.
IMPORTANT: mkvextract can only read chapters from an actual Matroska
container. It cannot read a Blu-ray .mpls playlist directly - handing
it one fails with "Not a valid Matroska file (no EBML head found)",
even though mkvmerge reads the same .mpls fine for scanning/
extraction. For a .mpls (or anything else that isn't already a
.mkv), use read_chapters_from_source() instead, which handles that
conversion.
Also reads each chapter's embedded name, if the source has one: a
ChapterAtom can carry multiple <ChapterDisplay> blocks (one per
language) via <ChapterString>/<ChapterLanguage> - when there's more
than one, the one matching preferred_language is used, falling back
to the first display block present.
"""
result = _run([TOOL_PATHS["mkvextract"], str(mkv_path), "chapters", "-"], timeout=60)
if result.returncode != 0:
# mkvextract doesn't consistently write its error to stderr - some
# failure modes (e.g. "not a valid Matroska file") land on stdout
# instead, which used to leave the caller with an empty, useless
# error message ("mkvextract failed:" and nothing after it).
# Including both means the real reason always makes it to the user.
detail = result.stderr.strip() or result.stdout.strip() or "(no output from mkvextract)"
raise RuntimeError(f"mkvextract failed:\n{detail}")
xml_text = result.stdout
# Defensive cleanup: strip a UTF-8 BOM (now correctly decoded thanks to
# explicit encoding="utf-8" in _run) and drop anything before the
# opening "<" in case a tool ever emits stray leading bytes/whitespace.
xml_text = xml_text.lstrip("\ufeff").strip()
lt_index = xml_text.find("<")
if lt_index > 0:
xml_text = xml_text[lt_index:]
root = ET.fromstring(xml_text)
chapters: list[Chapter] = []
for i, atom in enumerate(root.iter("ChapterAtom"), start=1):
start_el = atom.find("ChapterTimeStart")
if start_el is None or start_el.text is None:
continue
embedded_name, language = _pick_chapter_display(atom, preferred_language)
chapters.append(
Chapter(
index=i,
start_seconds=_timecode_to_seconds(start_el.text),
embedded_name=embedded_name,
language=language,
)
)
# Fill in end times from the next chapter's start.
for i, ch in enumerate(chapters):
if i + 1 < len(chapters):
ch.end_seconds = chapters[i + 1].start_seconds
else:
ch.end_seconds = None # last chapter runs to end of file
return chapters
def read_chapters_from_source(
source_path: Path, preferred_language: str = "eng"
) -> list[Chapter]:
"""
Read chapters from any mkvmerge-readable source, including a Blu-ray
.mpls playlist - unlike read_chapters(), which only works on an
actual .mkv file (see its docstring for why).
For a .mpls (or anything else that isn't already a .mkv), this asks
mkvmerge to remux just the chapter data into a small temporary .mkv
in the system temp folder - no video, audio, or subtitle tracks are
copied, so this is fast even though the source is a full disc rip -
then reads chapters from that via read_chapters(). The temp file is
always cleaned up afterwards, whether or not reading succeeds.
"""
if source_path.suffix.lower() == ".mkv":
return read_chapters(source_path, preferred_language=preferred_language)
tmp_path = Path(tempfile.gettempdir()) / f"_chapters_only_{uuid.uuid4().hex}.mkv"
args = [
TOOL_PATHS["mkvmerge"], "-o", str(tmp_path),
"--no-video", "--no-audio", "--no-subtitles",
str(source_path),
]
result = _run(args, timeout=120)
if result.returncode >= 2 or not tmp_path.is_file():
detail = result.stderr.strip() or result.stdout.strip() or "(no output from mkvmerge)"
raise RuntimeError(
f"mkvmerge could not read chapter data from {source_path.name}:\n{detail}"
)
try:
return read_chapters(tmp_path, preferred_language=preferred_language)
finally:
tmp_path.unlink(missing_ok=True)
def _pick_chapter_display(atom: ET.Element, preferred_language: str) -> tuple[str, str]:
"""
A ChapterAtom can have several <ChapterDisplay> blocks (e.g. one per
language track on the disc). Prefer the one whose <ChapterLanguage>
matches preferred_language; otherwise use the first display block
present. Returns (name, language), both "" if there's no display
block or no <ChapterString> text at all.
"""
displays = atom.findall("ChapterDisplay")
if not displays:
return "", ""
def display_name_lang(display: ET.Element) -> tuple[str, str]:
string_el = display.find("ChapterString")
lang_el = display.find("ChapterLanguage")
name = (string_el.text or "").strip() if string_el is not None else ""
language = (lang_el.text or "").strip() if lang_el is not None else ""
return name, language
for display in displays:
name, language = display_name_lang(display)
if language == preferred_language and name:
return name, language
# No match for preferred_language (or none of them had a name) - fall
# back to the first display block that actually has a name.
for display in displays:
name, language = display_name_lang(display)
if name:
return name, language
return "", ""
def probe_duration_seconds(media_path: Path) -> float:
"""Get total duration of a media file via ffprobe (used for the last chapter)."""
args = [
TOOL_PATHS["ffprobe"],
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(media_path),
]
result = _run(args, timeout=60)
try:
return float(result.stdout.strip())
except ValueError:
raise RuntimeError(f"Could not determine duration of {media_path}")
_LEADING_NUMBER_RE = re.compile(r"^\s*\d+\s*[\.\)\-]?\s*")
def strip_leading_number(line: str) -> str:
"""Strip a leading '1.', '01 -', '1)' etc from a pasted or imported tracklist line."""
return _LEADING_NUMBER_RE.sub("", line).strip()
# ---------------------------------------------------------------------------
# Track-name discovery: disc-local sidecar files and user-imported tracklists
# ---------------------------------------------------------------------------
@dataclass
class TracklistEntry:
name: str
index: Optional[int] = None
start_seconds: Optional[float] = None