diff --git a/.github/skills/add-disc-fixture/SKILL.md b/.github/skills/add-disc-fixture/SKILL.md
index 79258b4..b69ba5a 100644
--- a/.github/skills/add-disc-fixture/SKILL.md
+++ b/.github/skills/add-disc-fixture/SKILL.md
@@ -70,6 +70,11 @@ If counts don't match → proceed to step 4 (debug).
See [debugging guide](./references/debug-analysis.md) for systematic
investigation of episode/special count mismatches.
+**Important**: When fixing mismatches, prefer structural signals over
+numeric thresholds. Study chapter durations, IG menu structure, and
+navigation data across multiple fixtures — don't just look at the one
+that broke. The debugging guide's "How to Fix" section has details.
+
### 5. Extract ICS Menu Data
Find the menu clip (usually a short m2ts with IG streams, often clip 00003
diff --git a/.github/skills/add-disc-fixture/references/debug-analysis.md b/.github/skills/add-disc-fixture/references/debug-analysis.md
index e19652e..815b96c 100644
--- a/.github/skills/add-disc-fixture/references/debug-analysis.md
+++ b/.github/skills/add-disc-fixture/references/debug-analysis.md
@@ -121,3 +121,44 @@ for h in sorted(ig_raw, key=lambda x: (x.page_id, x.button_id)):
- **Multi-feature playlists** with register-based chapter selection
(SET reg2 before JumpTitle) are supported, but only when `imm_op2=True`
(immediate value). Register-indirect chapter indices are not resolved.
+
+## How to Fix Mismatches — Structural Signals, Not Thresholds
+
+When analysis returns wrong counts, resist the urge to add a numeric
+threshold or ratio guard that fixes the immediate disc. Thresholds are
+"just happens to work" — they break on the next disc.
+
+### The right process
+
+1. **Dump data across fixtures** — compare the failing disc against
+ fixtures that work. Key data to examine:
+ - Chapter durations (look for repeating OP/body/ED cycles)
+ - IG menu buttons per page (episode pages ~5 buttons, scene grids ~10)
+ - IG chapter marks (JT + reg2 patterns)
+ - Segment labels, play item structure, title counts
+
+2. **Find a structural signal** — something the disc data says about
+ itself. Ask: "What makes the working discs structurally different
+ from the failing disc?"
+
+3. **Require positive evidence** — the code should ask "does the data
+ say this IS an episode compilation?" not "does the data say this is
+ NOT a movie?". Positive detection produces zero false positives
+ when the signal is absent.
+
+4. **Validate across ALL fixtures** — run the new logic against every
+ fixture, not just the one that broke.
+
+### Anti-patterns to avoid
+
+- `if count <= N: return []` — arbitrary threshold, will break
+- `if ratio > X: return []` — same problem
+- Lowering/raising an existing threshold to accommodate one more disc
+- Any fix that only looks at the failing disc without comparing others
+
+### Example: Chapter-split detection
+
+Bad (threshold): `if chapters_per_episode > 7: don't split`
+Good (structural): detect repeating OP/body/ED chapter cycle via
+`_detect_episode_periodicity()` — only split when positive evidence
+of episode structure exists.
diff --git a/AGENTS.md b/AGENTS.md
index e39bafc..6ce865a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -175,9 +175,34 @@ Output includes: `schema_version`, `disc`, `playlists`, `episodes`, `special_fea
- Special feature detection is in `_detect_special_features()` — uses IG JumpTitle buttons pointing to non-episode playlists
- `JumpTitle(N)` in HDMV commands is **1-based** — convert to 0-based index title with `N - 1`
- Chapter-split features: when a button sets `reg2` before `JumpTitle`, it selects a chapter within the target playlist (multi-feature playlists)
-- Playlist classifications are heuristic-based; new disc patterns may need new rules
- Segment keys use quantization (default ±250ms) to handle tiny timing variances
+### Fixing Analysis Mismatches — Structural Signals over Thresholds
+
+When a new disc produces wrong episode or special counts, **do not** add numeric
+thresholds or ratio guards. Instead:
+
+1. **Study the data** — dump chapter durations, IG menu buttons, segment labels,
+ and MovieObject navigation across the failing disc AND existing fixtures that
+ work correctly. Look for structural patterns that differentiate the two cases.
+2. **Identify a structural signal** — something the disc data tells you about its
+ own content type (e.g. repeating OP/body/ED chapter cycle for episodes,
+ IG button-per-page counts matching chapters-per-episode, title-hint references
+ in navigation commands).
+3. **Require positive evidence** — the code should ask "does the data say this IS
+ X?" rather than "does the data say this is NOT X?". Negative guards based on
+ thresholds (like `max_chapters_per_episode = 7`) are brittle and will break on
+ the next disc that doesn't match the assumed range.
+4. **Combine signals** — when one signal isn't sufficient alone, combine multiple
+ independent signals (e.g. IG marks + chapter periodicity + button-per-page).
+ Each signal lowers the confidence bar, but at least one must be present.
+
+Examples of structural signals already in use:
+- **Chapter periodicity** (`_detect_episode_periodicity`): detects repeating
+ OP (~90 s) / body / ED (~90 s) / preview (~30 s) cycle in chapter durations
+- **IG chapter marks**: JT + reg2 buttons directly encode episode boundaries
+- **Digital archive multi-signal**: item count + title hint + no-audio streams
+
## Copyright & Fixture Guidelines
- **NEVER commit copyrighted media content** (m2ts video/audio streams, full disc images, cover art, subtitle tracks, etc.) to the repository.
- **Test fixtures** in `tests/fixtures/` contain only small structural metadata files (MPLS, CLPI, index.bdmv, MovieObject.bdmv, ICS segments) — these are binary headers/indexes, not audiovisual content.
diff --git a/bdpl/analyze/ordering.py b/bdpl/analyze/ordering.py
index 6f09d52..c0af3d1 100644
--- a/bdpl/analyze/ordering.py
+++ b/bdpl/analyze/ordering.py
@@ -104,6 +104,77 @@ def _episodes_from_play_all(
return episodes
+def _chapter_durations_s(playlist: Playlist) -> list[float]:
+ """Return chapter durations in seconds for a playlist."""
+ ch_times = [ticks_to_ms(ch.timestamp) for ch in playlist.chapters]
+ total_ms = playlist.duration_ms
+ durs: list[float] = []
+ for i in range(len(ch_times)):
+ end = ch_times[i + 1] if i + 1 < len(ch_times) else total_ms
+ durs.append((end - ch_times[i]) / 1000)
+ return durs
+
+
+# Anime episode chapter structure ranges (in seconds)
+_OP_MIN_S, _OP_MAX_S = 45, 160 # opening theme
+_BODY_MIN_S_CH = 180 # body segment (scene)
+_ED_MIN_S, _ED_MAX_S = 45, 160 # ending theme
+
+
+def _detect_episode_periodicity(
+ ch_durs_s: list[float],
+) -> tuple[int, int, float] | None:
+ """Detect repeating episode structure in chapter durations.
+
+ Anime episode compilations embed a fixed structure per episode:
+ OP (~90 s) → Body segments → ED (~90 s) [→ Preview (~30 s)]. This
+ creates a periodic pattern visible in the chapter durations.
+
+ Tries periods 4–7 (chapters per episode). For each candidate period,
+ partitions chapters into groups and checks whether each group matches
+ the expected structure (OP-length first chapter, at least one long body
+ chapter, ED-length chapter near the end).
+
+ Returns ``(period, n_episodes, confidence)`` for the best match, where
+ *confidence* is the fraction of groups that match. Returns ``None``
+ when no period achieves ≥ 75 % match with ≥ 2 groups.
+ """
+ n = len(ch_durs_s)
+ best: tuple[int, int, float] | None = None
+
+ for period in range(4, 8):
+ # Allow total chapters to be within ±1 of period × n_groups
+ for n_groups in range(2, n // period + 2):
+ total_expected = n_groups * period
+ if abs(total_expected - n) > 1:
+ continue
+
+ groups_matched = 0
+ for g in range(n_groups):
+ start = g * period
+ end = min(start + period, n)
+ group = ch_durs_s[start:end]
+ if len(group) < 3:
+ continue
+
+ op_ok = _OP_MIN_S <= group[0] <= _OP_MAX_S
+ body_ok = any(d > _BODY_MIN_S_CH for d in group[1:-1])
+ ed_ok = (_ED_MIN_S <= group[-1] <= _ED_MAX_S) or (
+ len(group) >= 3 and _ED_MIN_S <= group[-2] <= _ED_MAX_S
+ )
+
+ if op_ok and body_ok and ed_ok:
+ groups_matched += 1
+
+ if n_groups >= 2:
+ score = groups_matched / n_groups
+ if score >= 0.75:
+ if best is None or score > best[2] or (score == best[2] and n_groups > best[1]):
+ best = (period, n_groups, score)
+
+ return best
+
+
def _episodes_from_chapters(
playlist: Playlist,
ig_chapter_marks: list[int] | None = None,
@@ -113,44 +184,48 @@ def _episodes_from_chapters(
Used when a playlist contains one (or few) very long play item(s) with
multiple episodes encoded back-to-back, distinguishable only by chapters.
- When *ig_chapter_marks* are provided (from IG menu buttons), they serve as
- structural confirmation that the playlist contains multiple episodes.
- Without such evidence, a minimum of 3 estimated episodes is required —
- an ``est_count`` of 2 is ambiguous (could be a single ~50 min movie).
+ **Decision to split** requires positive structural evidence from at least
+ one of two signals:
+
+ 1. **IG chapter marks** — buttons in the disc menu directly encode episode
+ start chapters (e.g. reg2 = [0, 5, 10, 15]). Definitive.
+ 2. **Chapter periodicity** — chapter durations show a repeating
+ OP / body / ED cycle characteristic of anime episode compilations.
- Heuristic: group consecutive chapters into blocks whose total duration
- falls within episode range (10–45 min). When a running block exceeds the
- expected episode length, start a new episode at the chapter boundary.
+ Without either signal the playlist is assumed to be a single movie or OVA
+ and is *not* split, regardless of total duration.
+
+ Splitting uses a greedy algorithm that groups consecutive chapters into
+ blocks whose total duration approaches the target episode length.
"""
if not playlist.chapters or len(playlist.chapters) < 4:
return []
- # Only consider chapters on the main play item (item_ref=0 typically)
- # Build list of (chapter_index, start_time_ms)
main_item = playlist.play_items[0]
- ticks_to_ms(main_item.in_time)
ch_times: list[float] = []
for ch in playlist.chapters:
- ch_ms = ticks_to_ms(ch.timestamp)
- ch_times.append(ch_ms)
+ ch_times.append(ticks_to_ms(ch.timestamp))
- # Compute total playlist duration
total_dur_ms = playlist.duration_ms
- # Estimate episode count from total duration
- # Typical anime episode: 22–26 min; try to find the best fit
est_ep_dur_ms = 25 * 60 * 1000 # 25 minutes as starting estimate
est_count = max(1, round(total_dur_ms / est_ep_dur_ms))
if est_count <= 1:
return [] # Not worth splitting
- # IG chapter marks provide structural evidence of multiple episodes.
- # Without such evidence, require est_count >= 3 because est_count == 2
- # (~50 min total) is ambiguous — could be a single movie.
+ # --- Require positive structural evidence before splitting ---
has_ig_confirmation = ig_chapter_marks is not None and len(ig_chapter_marks) >= 2
- if est_count <= 2 and not has_ig_confirmation:
- return []
+ if not has_ig_confirmation:
+ ch_durs = _chapter_durations_s(playlist)
+ periodicity = _detect_episode_periodicity(ch_durs)
+ if periodicity is None:
+ return [] # No structural evidence of episodes
+ # Use the detected episode count from periodicity when it differs
+ # from the duration-based estimate.
+ _, periodic_count, _ = periodicity
+ if abs(periodic_count - est_count) <= 1:
+ est_count = periodic_count
# Target duration per episode
target_dur_ms = total_dur_ms / est_count
diff --git a/tests/conftest.py b/tests/conftest.py
index 6a3ef83..ad154dd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -268,6 +268,30 @@ def disc19_analysis(disc19_path):
return _analyze_fixture(disc19_path)
+@pytest.fixture(scope="session")
+def disc20_path() -> Path:
+ """Return path to bundled disc20 fixture."""
+ return _fixture_path("disc20")
+
+
+@pytest.fixture(scope="session")
+def disc20_analysis(disc20_path):
+ """Run and cache full analysis for the bundled disc20 fixture."""
+ return _analyze_fixture(disc20_path)
+
+
+@pytest.fixture(scope="session")
+def disc21_path() -> Path:
+ """Return path to bundled disc21 fixture."""
+ return _fixture_path("disc21")
+
+
+@pytest.fixture(scope="session")
+def disc21_analysis(disc21_path):
+ """Run and cache full analysis for the bundled disc21 fixture."""
+ return _analyze_fixture(disc21_path)
+
+
@pytest.fixture
def cli_runner() -> Callable[..., subprocess.CompletedProcess[str]]:
"""Return helper to invoke `python -m bdpl.cli` consistently in tests."""
diff --git a/tests/fixtures/disc20/CLIPINF/00000.clpi b/tests/fixtures/disc20/CLIPINF/00000.clpi
new file mode 100644
index 0000000..271ec5f
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00000.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00001.clpi b/tests/fixtures/disc20/CLIPINF/00001.clpi
new file mode 100644
index 0000000..45f295d
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00001.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00002.clpi b/tests/fixtures/disc20/CLIPINF/00002.clpi
new file mode 100644
index 0000000..5f4f065
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00002.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00003.clpi b/tests/fixtures/disc20/CLIPINF/00003.clpi
new file mode 100644
index 0000000..4625220
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00003.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00004.clpi b/tests/fixtures/disc20/CLIPINF/00004.clpi
new file mode 100644
index 0000000..b93202a
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00004.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00005.clpi b/tests/fixtures/disc20/CLIPINF/00005.clpi
new file mode 100644
index 0000000..36f86db
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00005.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00006.clpi b/tests/fixtures/disc20/CLIPINF/00006.clpi
new file mode 100644
index 0000000..5030a3c
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00006.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00007.clpi b/tests/fixtures/disc20/CLIPINF/00007.clpi
new file mode 100644
index 0000000..826e897
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00007.clpi differ
diff --git a/tests/fixtures/disc20/CLIPINF/00008.clpi b/tests/fixtures/disc20/CLIPINF/00008.clpi
new file mode 100644
index 0000000..95a08d5
Binary files /dev/null and b/tests/fixtures/disc20/CLIPINF/00008.clpi differ
diff --git a/tests/fixtures/disc20/META/DL/bdmt_eng.xml b/tests/fixtures/disc20/META/DL/bdmt_eng.xml
new file mode 100644
index 0000000..7b76388
--- /dev/null
+++ b/tests/fixtures/disc20/META/DL/bdmt_eng.xml
@@ -0,0 +1,6 @@
+
+
+
+TEST DISC 20
+
+
diff --git a/tests/fixtures/disc20/MovieObject.bdmv b/tests/fixtures/disc20/MovieObject.bdmv
new file mode 100644
index 0000000..9e4becd
Binary files /dev/null and b/tests/fixtures/disc20/MovieObject.bdmv differ
diff --git a/tests/fixtures/disc20/PLAYLIST/00000.mpls b/tests/fixtures/disc20/PLAYLIST/00000.mpls
new file mode 100644
index 0000000..cbf16f4
Binary files /dev/null and b/tests/fixtures/disc20/PLAYLIST/00000.mpls differ
diff --git a/tests/fixtures/disc20/PLAYLIST/00001.mpls b/tests/fixtures/disc20/PLAYLIST/00001.mpls
new file mode 100644
index 0000000..92bc632
Binary files /dev/null and b/tests/fixtures/disc20/PLAYLIST/00001.mpls differ
diff --git a/tests/fixtures/disc20/PLAYLIST/00002.mpls b/tests/fixtures/disc20/PLAYLIST/00002.mpls
new file mode 100644
index 0000000..b7580f9
Binary files /dev/null and b/tests/fixtures/disc20/PLAYLIST/00002.mpls differ
diff --git a/tests/fixtures/disc20/PLAYLIST/00003.mpls b/tests/fixtures/disc20/PLAYLIST/00003.mpls
new file mode 100644
index 0000000..7fd6806
Binary files /dev/null and b/tests/fixtures/disc20/PLAYLIST/00003.mpls differ
diff --git a/tests/fixtures/disc20/ics_menu.bin b/tests/fixtures/disc20/ics_menu.bin
new file mode 100644
index 0000000..f0f9726
Binary files /dev/null and b/tests/fixtures/disc20/ics_menu.bin differ
diff --git a/tests/fixtures/disc20/index.bdmv b/tests/fixtures/disc20/index.bdmv
new file mode 100644
index 0000000..cee9035
Binary files /dev/null and b/tests/fixtures/disc20/index.bdmv differ
diff --git a/tests/fixtures/disc21/CLIPINF/00000.clpi b/tests/fixtures/disc21/CLIPINF/00000.clpi
new file mode 100644
index 0000000..12b474d
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00000.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00001.clpi b/tests/fixtures/disc21/CLIPINF/00001.clpi
new file mode 100644
index 0000000..a90358d
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00001.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00002.clpi b/tests/fixtures/disc21/CLIPINF/00002.clpi
new file mode 100644
index 0000000..fb63aa7
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00002.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00003.clpi b/tests/fixtures/disc21/CLIPINF/00003.clpi
new file mode 100644
index 0000000..4625220
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00003.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00004.clpi b/tests/fixtures/disc21/CLIPINF/00004.clpi
new file mode 100644
index 0000000..1898dda
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00004.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00005.clpi b/tests/fixtures/disc21/CLIPINF/00005.clpi
new file mode 100644
index 0000000..e5e83ed
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00005.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00006.clpi b/tests/fixtures/disc21/CLIPINF/00006.clpi
new file mode 100644
index 0000000..c9e9ddf
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00006.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00007.clpi b/tests/fixtures/disc21/CLIPINF/00007.clpi
new file mode 100644
index 0000000..9857468
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00007.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00008.clpi b/tests/fixtures/disc21/CLIPINF/00008.clpi
new file mode 100644
index 0000000..f14eb00
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00008.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00009.clpi b/tests/fixtures/disc21/CLIPINF/00009.clpi
new file mode 100644
index 0000000..807ab6d
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00009.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00010.clpi b/tests/fixtures/disc21/CLIPINF/00010.clpi
new file mode 100644
index 0000000..42ada5e
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00010.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00011.clpi b/tests/fixtures/disc21/CLIPINF/00011.clpi
new file mode 100644
index 0000000..d3dc0a4
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00011.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00012.clpi b/tests/fixtures/disc21/CLIPINF/00012.clpi
new file mode 100644
index 0000000..5418fa7
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00012.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00013.clpi b/tests/fixtures/disc21/CLIPINF/00013.clpi
new file mode 100644
index 0000000..d130141
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00013.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00014.clpi b/tests/fixtures/disc21/CLIPINF/00014.clpi
new file mode 100644
index 0000000..111ade7
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00014.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00015.clpi b/tests/fixtures/disc21/CLIPINF/00015.clpi
new file mode 100644
index 0000000..97c72c1
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00015.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00016.clpi b/tests/fixtures/disc21/CLIPINF/00016.clpi
new file mode 100644
index 0000000..cda1227
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00016.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00017.clpi b/tests/fixtures/disc21/CLIPINF/00017.clpi
new file mode 100644
index 0000000..d49899f
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00017.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00018.clpi b/tests/fixtures/disc21/CLIPINF/00018.clpi
new file mode 100644
index 0000000..c9e9ddf
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00018.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00019.clpi b/tests/fixtures/disc21/CLIPINF/00019.clpi
new file mode 100644
index 0000000..b1c4415
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00019.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00020.clpi b/tests/fixtures/disc21/CLIPINF/00020.clpi
new file mode 100644
index 0000000..807ab6d
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00020.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00021.clpi b/tests/fixtures/disc21/CLIPINF/00021.clpi
new file mode 100644
index 0000000..c9e9ddf
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00021.clpi differ
diff --git a/tests/fixtures/disc21/CLIPINF/00022.clpi b/tests/fixtures/disc21/CLIPINF/00022.clpi
new file mode 100644
index 0000000..6d63c68
Binary files /dev/null and b/tests/fixtures/disc21/CLIPINF/00022.clpi differ
diff --git a/tests/fixtures/disc21/META/DL/bdmt_eng.xml b/tests/fixtures/disc21/META/DL/bdmt_eng.xml
new file mode 100644
index 0000000..48a1786
--- /dev/null
+++ b/tests/fixtures/disc21/META/DL/bdmt_eng.xml
@@ -0,0 +1,6 @@
+
+
+
+TEST DISC 21
+
+
diff --git a/tests/fixtures/disc21/MovieObject.bdmv b/tests/fixtures/disc21/MovieObject.bdmv
new file mode 100644
index 0000000..46f30df
Binary files /dev/null and b/tests/fixtures/disc21/MovieObject.bdmv differ
diff --git a/tests/fixtures/disc21/PLAYLIST/00000.mpls b/tests/fixtures/disc21/PLAYLIST/00000.mpls
new file mode 100644
index 0000000..9541ca8
Binary files /dev/null and b/tests/fixtures/disc21/PLAYLIST/00000.mpls differ
diff --git a/tests/fixtures/disc21/PLAYLIST/00001.mpls b/tests/fixtures/disc21/PLAYLIST/00001.mpls
new file mode 100644
index 0000000..92bc632
Binary files /dev/null and b/tests/fixtures/disc21/PLAYLIST/00001.mpls differ
diff --git a/tests/fixtures/disc21/PLAYLIST/00002.mpls b/tests/fixtures/disc21/PLAYLIST/00002.mpls
new file mode 100644
index 0000000..aa48c08
Binary files /dev/null and b/tests/fixtures/disc21/PLAYLIST/00002.mpls differ
diff --git a/tests/fixtures/disc21/PLAYLIST/00003.mpls b/tests/fixtures/disc21/PLAYLIST/00003.mpls
new file mode 100644
index 0000000..32f5dd0
Binary files /dev/null and b/tests/fixtures/disc21/PLAYLIST/00003.mpls differ
diff --git a/tests/fixtures/disc21/ics_menu.bin b/tests/fixtures/disc21/ics_menu.bin
new file mode 100644
index 0000000..879f245
Binary files /dev/null and b/tests/fixtures/disc21/ics_menu.bin differ
diff --git a/tests/fixtures/disc21/index.bdmv b/tests/fixtures/disc21/index.bdmv
new file mode 100644
index 0000000..cee9035
Binary files /dev/null and b/tests/fixtures/disc21/index.bdmv differ
diff --git a/tests/test_disc20_scan.py b/tests/test_disc20_scan.py
new file mode 100644
index 0000000..13a43ec
--- /dev/null
+++ b/tests/test_disc20_scan.py
@@ -0,0 +1,44 @@
+"""Tests for disc20 fixture — single compilation movie with scene chapters."""
+
+from __future__ import annotations
+
+import pytest
+
+from bdpl.model import DiscAnalysis
+
+pytestmark = pytest.mark.integration
+
+
+class TestDisc20Episodes:
+ def test_episode_count(self, disc20_analysis: DiscAnalysis) -> None:
+ assert len(disc20_analysis.episodes) == 1
+
+ def test_episode_playlist(self, disc20_analysis: DiscAnalysis) -> None:
+ assert disc20_analysis.episodes[0].playlist == "00002.mpls"
+
+ def test_episode_duration_is_movie_length(self, disc20_analysis: DiscAnalysis) -> None:
+ dur_min = disc20_analysis.episodes[0].duration_ms / 60000
+ assert 100 < dur_min < 140, f"Movie duration {dur_min:.1f}min out of range"
+
+ def test_not_chapter_split(self, disc20_analysis: DiscAnalysis) -> None:
+ """Movie with 41 scene chapters must NOT be split into episodes."""
+ assert len(disc20_analysis.episodes) == 1
+ assert disc20_analysis.episodes[0].confidence == 1.0
+
+
+class TestDisc20Specials:
+ def test_special_feature_count(self, disc20_analysis: DiscAnalysis) -> None:
+ assert len(disc20_analysis.special_features) == 1
+
+ def test_special_category(self, disc20_analysis: DiscAnalysis) -> None:
+ sf = disc20_analysis.special_features[0]
+ assert sf.category == "extra"
+ assert sf.playlist == "00003.mpls"
+
+ def test_special_visible(self, disc20_analysis: DiscAnalysis) -> None:
+ assert disc20_analysis.special_features[0].menu_visible
+
+
+class TestDisc20Metadata:
+ def test_disc_title(self, disc20_analysis: DiscAnalysis) -> None:
+ assert disc20_analysis.disc_title == "TEST DISC 20"
diff --git a/tests/test_disc21_scan.py b/tests/test_disc21_scan.py
new file mode 100644
index 0000000..8ce018a
--- /dev/null
+++ b/tests/test_disc21_scan.py
@@ -0,0 +1,39 @@
+"""Integration tests for the disc21 fixture — special disc with OVA + digital archive."""
+
+from __future__ import annotations
+
+import pytest
+
+from bdpl.model import DiscAnalysis
+
+pytestmark = pytest.mark.integration
+
+
+class TestDisc21Episodes:
+ def test_episode_count(self, disc21_analysis: DiscAnalysis) -> None:
+ assert len(disc21_analysis.episodes) == 1
+
+ def test_episode_playlist(self, disc21_analysis: DiscAnalysis) -> None:
+ assert disc21_analysis.episodes[0].playlist == "00002.mpls"
+
+ def test_episode_duration(self, disc21_analysis: DiscAnalysis) -> None:
+ dur_min = disc21_analysis.episodes[0].duration_ms / 60000
+ assert 44.0 < dur_min < 44.2, f"OVA duration {dur_min:.2f}min, expected ~44:03"
+
+
+class TestDisc21Specials:
+ def test_special_feature_count(self, disc21_analysis: DiscAnalysis) -> None:
+ assert len(disc21_analysis.special_features) == 1
+
+ def test_digital_archive(self, disc21_analysis: DiscAnalysis) -> None:
+ sf = disc21_analysis.special_features[0]
+ assert sf.category == "digital_archive"
+ assert sf.playlist == "00003.mpls"
+
+ def test_digital_archive_visible(self, disc21_analysis: DiscAnalysis) -> None:
+ assert disc21_analysis.special_features[0].menu_visible
+
+
+class TestDisc21Metadata:
+ def test_disc_title(self, disc21_analysis: DiscAnalysis) -> None:
+ assert disc21_analysis.disc_title == "TEST DISC 21"
diff --git a/tests/test_disc_matrix.py b/tests/test_disc_matrix.py
index 8ed2b8a..1a02ffe 100644
--- a/tests/test_disc_matrix.py
+++ b/tests/test_disc_matrix.py
@@ -30,6 +30,8 @@
("disc17_analysis", 1, ["00002.mpls"]),
("disc18_analysis", 1, ["00002.mpls"]),
("disc19_analysis", 1, ["00002.mpls"]),
+ ("disc20_analysis", 1, ["00002.mpls"]),
+ ("disc21_analysis", 1, ["00002.mpls"]),
],
)
def test_disc_episode_expectation_matrix(
@@ -65,6 +67,8 @@ def test_disc_episode_expectation_matrix(
("disc17_analysis", 1, 1), # 1 digital archive
("disc18_analysis", 2, 2), # 1 extra + 1 creditless ED
("disc19_analysis", 1, 1), # 1 digital archive (hint-backed)
+ ("disc20_analysis", 1, 1), # 1 extra (trailer)
+ ("disc21_analysis", 1, 1), # 1 digital archive
],
)
def test_disc_special_visibility_expectation_matrix(
@@ -104,6 +108,8 @@ def test_disc_special_visibility_expectation_matrix(
"disc17_analysis",
"disc18_analysis",
"disc19_analysis",
+ "disc20_analysis",
+ "disc21_analysis",
],
)
def test_disc_episode_segment_boundaries_matrix(
@@ -147,6 +153,8 @@ def test_disc_episode_segment_boundaries_matrix(
"disc17_analysis",
"disc18_analysis",
"disc19_analysis",
+ "disc20_analysis",
+ "disc21_analysis",
],
)
def test_disc_special_boundary_semantics_matrix(
@@ -197,6 +205,8 @@ def test_disc_special_boundary_semantics_matrix(
("disc17_analysis", 0),
("disc18_analysis", 0),
("disc19_analysis", 0),
+ ("disc20_analysis", 0),
+ ("disc21_analysis", 0),
],
)
def test_disc_special_chapter_split_expectation_matrix(
@@ -230,6 +240,8 @@ def test_disc_special_chapter_split_expectation_matrix(
("disc17_analysis", "TEST DISC 17"),
("disc18_analysis", "TEST DISC 18"),
("disc19_analysis", "TEST DISC 19"),
+ ("disc20_analysis", "TEST DISC 20"),
+ ("disc21_analysis", "TEST DISC 21"),
],
)
def test_disc_title_extraction_matrix(