From 58b0afb833eb5e75e955860abe07d4ae20dc6c8e Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 1 Mar 2026 17:42:30 +1000 Subject: [PATCH 1/2] feat: add batch-add-disc-fixtures skill New skill for processing multiple Blu-ray ISOs in one pass: - Mount and analyze all ISOs in a folder - Generate summary report with IG menu cross-checks - User reviews/confirms counts in bulk - Create all fixtures, tests, and matrix entries Includes reference batch-analysis-report.py script template for structured report generation with IG button-per-page cross-validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../skills/batch-add-disc-fixtures/SKILL.md | 304 ++++++++++++++++++ .../references/batch-analysis-report.py | 228 +++++++++++++ 2 files changed, 532 insertions(+) create mode 100644 .github/skills/batch-add-disc-fixtures/SKILL.md create mode 100644 .github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py diff --git a/.github/skills/batch-add-disc-fixtures/SKILL.md b/.github/skills/batch-add-disc-fixtures/SKILL.md new file mode 100644 index 0000000..bf86d6b --- /dev/null +++ b/.github/skills/batch-add-disc-fixtures/SKILL.md @@ -0,0 +1,304 @@ +--- +name: batch-add-disc-fixtures +description: 'Batch-process a folder of Blu-ray ISOs: mount each, run analysis, generate a summary report for user review, then create fixtures for confirmed discs. Use when given a folder of ISOs or asked to add multiple discs at once.' +--- + +# Batch Add Disc Fixtures + +## Overview + +Orchestration workflow for processing multiple Blu-ray ISOs in one pass. +Instead of the single-disc back-and-forth (mount → analyze → user confirms +→ fixture → repeat), this skill: + +1. Mounts and analyzes every ISO in a folder +2. Generates a summary report with IG menu cross-checks +3. User reviews and confirms/corrects counts in one pass +4. Creates all fixtures, tests, and matrix entries + +Delegates per-disc fixture creation to the +[add-disc-fixture](../add-disc-fixture/SKILL.md) skill. + +## When to Use This Skill + +- User provides a folder path containing multiple ISOs +- User asks to "add all discs from …" or "batch add" +- User wants to process an entire series at once + +## Prerequisites + +- Windows (uses `Mount-DiskImage` / `Dismount-DiskImage`) +- ISOs accessible via local path or UNC share +- Python environment with bdpl installed (`pip install -e ".[dev]"`) +- Existing test infrastructure (conftest.py, test_disc_matrix.py) + +## Step-by-Step Workflow + +### 1. Enumerate ISOs + +```powershell +$folder = "\\server\share\SERIES NAME" +Get-ChildItem $folder -Filter "*.ISO" | Select-Object Name, Length | + Sort-Object Name +``` + +List the ISOs found and confirm with the user before proceeding. + +### 2. Determine Next Disc Number + +Check existing fixtures to find the next available disc number: + +```python +import re +from pathlib import Path + +fixtures = Path("tests/fixtures") +existing = sorted( + int(m.group(1)) + for d in fixtures.iterdir() + if d.is_dir() and (m := re.match(r"disc(\d+)", d.name)) +) +next_disc = existing[-1] + 1 if existing else 1 +print(f"Next disc number: {next_disc}") +``` + +### 3. Mount and Analyze All ISOs + +For each ISO, mount it, find the BDMV directory, and run analysis. +Collect results into a structured report. + +**IMPORTANT**: Always dismount ISOs after analysis, even if errors occur. +Use try/finally to guarantee cleanup. + +```python +import json +from pathlib import Path +from bdpl.analyze import scan_disc +from bdpl.bdmv.mpls import parse_mpls_dir +from bdpl.bdmv.clpi import parse_clpi_dir + +results = [] + +for iso_path in iso_files: + # Mount + # $vol = (Mount-DiskImage -ImagePath $iso -PassThru | Get-Volume) + # $drive = "$($vol.DriveLetter):\" + + try: + bdmv = Path(f"{drive}/BDMV") + if not bdmv.exists(): + # Some discs have BDMV directly at root, others nested + results.append({"iso": iso_path.name, "error": "No BDMV found"}) + continue + + playlists = parse_mpls_dir(bdmv / "PLAYLIST") + clips = parse_clpi_dir(bdmv / "CLIPINF") + result = scan_disc(bdmv, playlists, clips) + + results.append({ + "iso": iso_path.name, + "episodes": len(result.episodes), + "episode_details": [ + { + "num": ep.episode, + "playlist": ep.playlist, + "duration_min": round(ep.duration_ms / 60000, 1), + } + for ep in result.episodes + ], + "specials": len(result.special_features), + "special_details": [ + { + "index": sf.index, + "playlist": sf.playlist, + "category": sf.category, + "duration_min": round(sf.duration_ms / 60000, 1), + } + for sf in result.special_features + ], + "classifications": result.analysis.get("classifications", {}), + }) + finally: + # Always dismount + # Dismount-DiskImage -ImagePath $iso + pass +``` + +### 4. Collect IG Menu Cross-Check Data + +For each disc, extract IG menu button counts per page and compare +against detected episode/special counts. This provides a confidence +signal — if the menu says 4 episode buttons but analysis found 5 +episodes, flag it. + +```python +from bdpl.bdmv.ig_stream import parse_ics + +def ig_cross_check(bdmv_path, result): + """Return per-page button summary for cross-checking.""" + ics_path = bdmv_path.parent / "ics_menu.bin" + # During batch analysis, use the live ICS from the mounted disc + # (the fixture ics_menu.bin doesn't exist yet) + hints = result.analysis.get("disc_hints", {}) + ig_raw = hints.get("ig_hints_raw", []) + + if not ig_raw: + return {"pages": [], "note": "No IG data available"} + + # Group buttons by page + pages = {} + for h in ig_raw: + pid = h.page_id + if pid not in pages: + pages[pid] = {"page_id": pid, "buttons": 0, "targets": set()} + pages[pid]["buttons"] += 1 + if h.jump_title is not None: + pages[pid]["targets"].add(f"JT({h.jump_title})") + elif h.playlist is not None: + pages[pid]["targets"].add(f"PL({h.playlist})") + + # Convert sets to sorted lists for display + for p in pages.values(): + p["targets"] = sorted(p["targets"]) + p["unique_targets"] = len(p["targets"]) + + return {"pages": sorted(pages.values(), key=lambda x: x["page_id"])} +``` + +### 5. Generate Summary Report + +Present results in a table format. Flag any rows where IG cross-check +disagrees with detected counts. + +``` +┌──────────────────────┬──────┬─────────┬────────────┬───────────────────────┐ +│ ISO │ Eps │ Specials│ IG Check │ Notes │ +├──────────────────────┼──────┼─────────┼────────────┼───────────────────────┤ +│ SERIES_D1.ISO │ 4 │ 0 │ ✅ 4 btns │ ch-split, 24min each │ +│ SERIES_D2.ISO │ 4 │ 4 │ ✅ 4+4 │ 24min eps, <3min spc │ +│ SERIES_SD.ISO │ 1 │ 1 │ ⚠️ no IG │ 1 ep + 1 dig.archive │ +└──────────────────────┴──────┴─────────┴────────────┴───────────────────────┘ + +Episode details: + SERIES_D1.ISO: + ep1: 00002.mpls 24.0min | ep2: 00002.mpls 24.0min | ... + ... + +Special details: + SERIES_D2.ISO: + #1: 00005.mpls creditless_op 1.5min | #2: 00006.mpls creditless_ed 1.5min | ... + ... +``` + +**Key indicators:** +- ✅ = IG button count matches detected count +- ⚠️ = No IG data or inconclusive (common for SD/archive discs) +- ❌ = IG button count disagrees — needs user attention + +### 6. User Review + +Present the summary and ask the user to confirm or correct. Use +`ask_user` for each disc that needs attention: + +- For ✅ discs: batch confirm ("These N discs look correct?") +- For ⚠️ discs: ask for expected counts individually +- For ❌ discs: flag disagreement, ask user to provide correct counts + +After review, you'll have a confirmed list: +``` +disc22: SERIES_D1.ISO → 4 episodes, 0 specials +disc23: SERIES_D2.ISO → 4 episodes, 4 specials +disc24: SERIES_SD.ISO → 1 episode, 1 digital_archive +``` + +### 7. Create Fixtures (Batch) + +For each confirmed disc, follow the [add-disc-fixture](../add-disc-fixture/SKILL.md) +workflow steps 5–8: + +1. Re-mount the ISO (if dismounted after step 3) +2. Extract ICS menu data +3. Copy PLAYLIST/, CLIPINF/, index.bdmv, MovieObject.bdmv +4. Create generic bdmt_eng.xml with `TEST DISC {N}` +5. Verify size < 100KB +6. Create integration test file +7. Add conftest.py fixtures +8. Add to test_disc_matrix.py (all 6 parametrizations) +9. Dismount ISO + +**Tip**: Process all fixture file copies first (steps 1-5 for all discs), +then create all test files (steps 6-8 for all discs). This minimizes +mount/dismount cycles. + +### 8. Handle Analysis Mismatches + +If user-confirmed counts don't match analysis results for any disc: + +1. **Don't fix yet** — create fixtures for all matching discs first +2. Run tests for matching discs to establish a green baseline +3. Then debug mismatches one at a time using the + [debug-analysis guide](../add-disc-fixture/references/debug-analysis.md) +4. Follow the structural-signals-over-thresholds approach (see AGENTS.md) +5. After each fix, re-run ALL tests to verify no regressions + +### 9. Validate and PR + +```bash +python -m pytest tests/ -x -q # All tests pass +python -m ruff check . # No lint issues +python -m ruff format --check . # No format issues +``` + +Create a single PR for all new fixtures using the +[make-repo-contribution](../make-repo-contribution/SKILL.md) skill. +The PR should list all discs added and any analysis fixes made. + +### 10. Clean Up + +- Dismount all ISOs +- Delete temporary scripts +- Verify no copyrighted content in fixtures + +## Execution Notes + +### Mount/Dismount Pattern (PowerShell) + +```powershell +# Mount — returns drive letter +$vol = Mount-DiskImage -ImagePath $isoPath -PassThru | Get-Volume +$drive = "$($vol.DriveLetter):\" + +# ... do work ... + +# Dismount — use original ISO path +Dismount-DiskImage -ImagePath $isoPath +``` + +**Gotchas:** +- UNC paths may need to be copied locally first for Mount-DiskImage +- Always use `-PassThru` to get the volume object +- Some ISOs mount slowly — add a small delay or retry if drive not ready +- `Dismount-DiskImage` uses the ISO path, not the drive letter + +### Parallel vs Sequential + +Mount ISOs **one at a time**. Windows can mount multiple, but: +- Fewer drive letters to track +- Cleaner error handling +- Lower risk of forgetting to dismount + +### Fixture Numbering + +Disc numbers are assigned sequentially from the next available number. +The order should match the ISO sort order (typically alphabetical). +Document the ISO→disc mapping in the PR description. + +## Comparison with add-disc-fixture + +| Aspect | add-disc-fixture | batch-add-disc-fixtures | +|--------|-----------------|------------------------| +| Input | Single disc path | Folder of ISOs | +| User interaction | Per-disc confirm | Bulk review table | +| IG cross-check | Not included | Included in report | +| When to use | One-off disc | Multiple discs/series | +| Debugging | Inline | After green baseline | diff --git a/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py b/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py new file mode 100644 index 0000000..810b174 --- /dev/null +++ b/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py @@ -0,0 +1,228 @@ +# Batch Analysis Report — Reference Template +# +# This script template is used by the batch-add-disc-fixtures skill. +# The agent adapts it for each batch run — it is NOT meant to be +# executed standalone. +# +# Usage: The agent will inline this logic in a Python session, +# substituting actual ISO paths and drive letters. + +""" +Batch-analyze mounted Blu-ray ISOs and produce a summary report. + +Expects a list of (iso_name, bdmv_path) tuples for already-mounted discs. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path + +# bdpl must be installed: pip install -e ".[dev]" +from bdpl.analyze import scan_disc +from bdpl.bdmv.clpi import parse_clpi_dir +from bdpl.bdmv.mpls import parse_mpls_dir + + +@dataclass +class DiscReport: + iso_name: str + episodes: int + specials: int + episode_details: list[dict] + special_details: list[dict] + classifications: dict + ig_pages: list[dict] + ig_note: str + ig_match: str # "ok", "warn", "mismatch" + + +def analyze_one(iso_name: str, bdmv_path: Path) -> DiscReport: + """Analyze a single mounted BDMV and return a structured report.""" + playlists = parse_mpls_dir(bdmv_path / "PLAYLIST") + clips = parse_clpi_dir(bdmv_path / "CLIPINF") + result = scan_disc(bdmv_path, playlists, clips) + + # Episode details + ep_details = [ + { + "num": ep.episode, + "playlist": ep.playlist, + "duration_min": round(ep.duration_ms / 60000, 1), + } + for ep in result.episodes + ] + + # Special details + sp_details = [ + { + "index": sf.index, + "playlist": sf.playlist, + "category": sf.category, + "duration_min": round(sf.duration_ms / 60000, 1), + } + for sf in result.special_features + ] + + # IG cross-check + hints = result.analysis.get("disc_hints", {}) + ig_raw = hints.get("ig_hints_raw", []) + ig_pages = _extract_ig_pages(ig_raw) + ig_match, ig_note = _ig_cross_check( + ig_pages, len(result.episodes), len(result.special_features) + ) + + return DiscReport( + iso_name=iso_name, + episodes=len(result.episodes), + specials=len(result.special_features), + episode_details=ep_details, + special_details=sp_details, + classifications=result.analysis.get("classifications", {}), + ig_pages=ig_pages, + ig_note=ig_note, + ig_match=ig_match, + ) + + +def _extract_ig_pages(ig_raw: list) -> list[dict]: + """Group IG hints by page, counting buttons and unique targets.""" + if not ig_raw: + return [] + + pages: dict[int, dict] = {} + for h in ig_raw: + pid = h.page_id + if pid not in pages: + pages[pid] = {"page_id": pid, "buttons": 0, "targets": set()} + pages[pid]["buttons"] += 1 + if h.jump_title is not None: + pages[pid]["targets"].add(f"JT({h.jump_title})") + elif h.playlist is not None: + pages[pid]["targets"].add(f"PL({h.playlist})") + + result = [] + for p in sorted(pages.values(), key=lambda x: x["page_id"]): + result.append( + { + "page_id": p["page_id"], + "buttons": p["buttons"], + "unique_targets": len(p["targets"]), + "targets": sorted(p["targets"]), + } + ) + return result + + +def _ig_cross_check( + ig_pages: list[dict], ep_count: int, sp_count: int +) -> tuple[str, str]: + """ + Compare IG page button counts against detected episode/special counts. + + Returns (match_status, note) where match_status is one of: + - "ok": IG data supports the detected counts + - "warn": No IG data or inconclusive + - "mismatch": IG data disagrees with detected counts + """ + if not ig_pages: + return "warn", "No IG data available" + + # Heuristic: the page with the most buttons targeting unique playlists + # that roughly matches episode count is likely the episode page. + # Pages with fewer buttons targeting unique playlists may be special pages. + # + # This is advisory only — the user makes the final call. + notes = [] + for page in ig_pages: + notes.append( + f"p{page['page_id']}: {page['buttons']} btns, " + f"{page['unique_targets']} unique targets" + ) + + # Simple match check: is there a page with unique_targets == ep_count? + ep_page_match = any(p["unique_targets"] == ep_count for p in ig_pages) + if ep_page_match: + return "ok", "; ".join(notes) + + # Check if any page is close (off by 1, which can happen with + # "play all" button being counted) + close_match = any( + abs(p["unique_targets"] - ep_count) <= 1 for p in ig_pages + ) + if close_match: + return "ok", "; ".join(notes) + " (close match)" + + return "mismatch", "; ".join(notes) + + +def format_report(reports: list[DiscReport], start_disc: int) -> str: + """Format batch analysis results as a readable summary table.""" + lines = [] + lines.append("=" * 72) + lines.append("BATCH ANALYSIS REPORT") + lines.append("=" * 72) + lines.append("") + + # Summary table + match_icons = {"ok": "✅", "warn": "⚠️", "mismatch": "❌"} + lines.append( + f"{'#':<5} {'ISO':<30} {'Eps':>4} {'Spc':>4} {'IG':>3} Notes" + ) + lines.append("-" * 72) + for i, r in enumerate(reports): + disc_n = start_disc + i + icon = match_icons.get(r.ig_match, "?") + lines.append( + f"d{disc_n:<4} {r.iso_name:<30} {r.episodes:>4} " + f"{r.specials:>4} {icon} {r.ig_note}" + ) + + # Episode details + lines.append("") + lines.append("EPISODE DETAILS") + lines.append("-" * 72) + for i, r in enumerate(reports): + disc_n = start_disc + i + if r.episode_details: + eps = " | ".join( + f"ep{e['num']}: {e['playlist']} {e['duration_min']}min" + for e in r.episode_details + ) + lines.append(f" disc{disc_n}: {eps}") + + # Special details + lines.append("") + lines.append("SPECIAL DETAILS") + lines.append("-" * 72) + for i, r in enumerate(reports): + disc_n = start_disc + i + if r.special_details: + sps = " | ".join( + f"#{s['index']}: {s['playlist']} {s['category']} " + f"{s['duration_min']}min" + for s in r.special_details + ) + lines.append(f" disc{disc_n}: {sps}") + + # Classifications + lines.append("") + lines.append("CLASSIFICATIONS") + lines.append("-" * 72) + for i, r in enumerate(reports): + disc_n = start_disc + i + if r.classifications: + cls_str = ", ".join( + f"{k}: {v}" for k, v in sorted(r.classifications.items()) + ) + lines.append(f" disc{disc_n}: {cls_str}") + + lines.append("") + lines.append("=" * 72) + return "\n".join(lines) + + +if __name__ == "__main__": + # Example usage — agent substitutes real values + print("This script is a reference template. See SKILL.md for usage.") From 1fdddb95a63a416c1ff620a7ce2319bfb76c97de Mon Sep 17 00:00:00 2001 From: Benjamin Date: Sun, 1 Mar 2026 19:40:56 +1000 Subject: [PATCH 2/2] fix: remove unused sys import in batch-analysis-report.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/batch-analysis-report.py | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py b/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py index 810b174..67c4eef 100644 --- a/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py +++ b/.github/skills/batch-add-disc-fixtures/references/batch-analysis-report.py @@ -15,7 +15,6 @@ from __future__ import annotations -import sys from dataclasses import dataclass from pathlib import Path @@ -115,9 +114,7 @@ def _extract_ig_pages(ig_raw: list) -> list[dict]: return result -def _ig_cross_check( - ig_pages: list[dict], ep_count: int, sp_count: int -) -> tuple[str, str]: +def _ig_cross_check(ig_pages: list[dict], ep_count: int, sp_count: int) -> tuple[str, str]: """ Compare IG page button counts against detected episode/special counts. @@ -137,8 +134,7 @@ def _ig_cross_check( notes = [] for page in ig_pages: notes.append( - f"p{page['page_id']}: {page['buttons']} btns, " - f"{page['unique_targets']} unique targets" + f"p{page['page_id']}: {page['buttons']} btns, {page['unique_targets']} unique targets" ) # Simple match check: is there a page with unique_targets == ep_count? @@ -148,9 +144,7 @@ def _ig_cross_check( # Check if any page is close (off by 1, which can happen with # "play all" button being counted) - close_match = any( - abs(p["unique_targets"] - ep_count) <= 1 for p in ig_pages - ) + close_match = any(abs(p["unique_targets"] - ep_count) <= 1 for p in ig_pages) if close_match: return "ok", "; ".join(notes) + " (close match)" @@ -167,16 +161,13 @@ def format_report(reports: list[DiscReport], start_disc: int) -> str: # Summary table match_icons = {"ok": "✅", "warn": "⚠️", "mismatch": "❌"} - lines.append( - f"{'#':<5} {'ISO':<30} {'Eps':>4} {'Spc':>4} {'IG':>3} Notes" - ) + lines.append(f"{'#':<5} {'ISO':<30} {'Eps':>4} {'Spc':>4} {'IG':>3} Notes") lines.append("-" * 72) for i, r in enumerate(reports): disc_n = start_disc + i icon = match_icons.get(r.ig_match, "?") lines.append( - f"d{disc_n:<4} {r.iso_name:<30} {r.episodes:>4} " - f"{r.specials:>4} {icon} {r.ig_note}" + f"d{disc_n:<4} {r.iso_name:<30} {r.episodes:>4} {r.specials:>4} {icon} {r.ig_note}" ) # Episode details @@ -187,8 +178,7 @@ def format_report(reports: list[DiscReport], start_disc: int) -> str: disc_n = start_disc + i if r.episode_details: eps = " | ".join( - f"ep{e['num']}: {e['playlist']} {e['duration_min']}min" - for e in r.episode_details + f"ep{e['num']}: {e['playlist']} {e['duration_min']}min" for e in r.episode_details ) lines.append(f" disc{disc_n}: {eps}") @@ -200,8 +190,7 @@ def format_report(reports: list[DiscReport], start_disc: int) -> str: disc_n = start_disc + i if r.special_details: sps = " | ".join( - f"#{s['index']}: {s['playlist']} {s['category']} " - f"{s['duration_min']}min" + f"#{s['index']}: {s['playlist']} {s['category']} {s['duration_min']}min" for s in r.special_details ) lines.append(f" disc{disc_n}: {sps}") @@ -213,9 +202,7 @@ def format_report(reports: list[DiscReport], start_disc: int) -> str: for i, r in enumerate(reports): disc_n = start_disc + i if r.classifications: - cls_str = ", ".join( - f"{k}: {v}" for k, v in sorted(r.classifications.items()) - ) + cls_str = ", ".join(f"{k}: {v}" for k, v in sorted(r.classifications.items())) lines.append(f" disc{disc_n}: {cls_str}") lines.append("")