From 9814b2b917bbd877b2f6354ae961e0a940996a90 Mon Sep 17 00:00:00 2001 From: windyboy Date: Sun, 27 Sep 2026 11:08:15 +0800 Subject: [PATCH] feat: enable anime parsing and organization with dedicated templates Anime files with SxxEyy format are now parsed and organized into anime-specific directories using configurable templates. Files with absolute episode numbering (no season info) are marked needs_review for manual handling. Also removes unused imports flagged by ruff. Co-Authored-By: Claude Sonnet 4.5 --- src/vlm/commands/parse.py | 38 ++++++++---- src/vlm/commands/report.py | 1 - src/vlm/config.py | 8 ++- src/vlm/plan_paths.py | 106 ++++++++++++++++++++++++++++++++- src/vlm/planner.py | 7 ++- tests/test_cli_parse.py | 76 +++++++++++++++++++++++ tests/test_docs_consistency.py | 1 - tests/test_planner.py | 30 ++++++++++ 8 files changed, 248 insertions(+), 19 deletions(-) diff --git a/src/vlm/commands/parse.py b/src/vlm/commands/parse.py index 5312932..4d2f27b 100644 --- a/src/vlm/commands/parse.py +++ b/src/vlm/commands/parse.py @@ -17,7 +17,7 @@ from vlm.models import ( SeriesIdentityRecord, VideoFile, ) -from vlm.parser import parse_movie, parse_series +from vlm.parser import parse_anime, parse_movie, parse_series from vlm.utils import utc_now @@ -52,7 +52,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa movie_identities: list[MovieIdentityRecord] = [] series_identities: list[SeriesIdentityRecord] = [] - anime_files: list[IdentityRecord] = [] + anime_identities: list[SeriesIdentityRecord] = [] other_files: list[IdentityRecord] = [] def get_video_metadata(file_path: str) -> dict: @@ -107,14 +107,20 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa record["video_metadata"] = video_metadata series_identities.append(record) elif category == "anime": - anime_files.append( - { - "path": vf["path"], - "filename": filename, - "category": category, - "note": "Anime parsing deferred in v1", - } - ) + identity = parse_anime(filename, extensions=config.video_extensions) + record = { + "path": file_path, + "filename": filename, + "category": category, + "title": identity.title, + "season": identity.season, + "episodes": identity.episodes, + "confidence": identity.confidence, + "needs_review": identity.needs_review, + } + if video_metadata: + record["video_metadata"] = video_metadata + anime_identities.append(record) else: other_files.append( { @@ -140,7 +146,12 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa if series_need_review > 0: click.echo(f" - Need review: {series_need_review}") - click.echo(f" Anime: {len(anime_files)} (not parsed in v1)") + click.echo(f" Anime: {len(anime_identities)}") + + anime_need_review = sum(1 for a in anime_identities if a["needs_review"]) + if anime_need_review > 0: + click.echo(f" - Need review: {anime_need_review}") + click.echo(f" Other: {len(other_files)} (not parsed)") click.echo() @@ -158,7 +169,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa }, "movies": movie_identities, "series": series_identities, - "anime": anime_files, + "anime": anime_identities, "other": other_files, } @@ -166,9 +177,10 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa click.echo("Parsed identities saved successfully!") logger.info( - "Parse completed: %s movies, %s series, saved to %s", + "Parse completed: %s movies, %s series, %s anime, saved to %s", len(movie_identities), len(series_identities), + len(anime_identities), output, ) diff --git a/src/vlm/commands/report.py b/src/vlm/commands/report.py index 1ba7b2a..a1338b0 100644 --- a/src/vlm/commands/report.py +++ b/src/vlm/commands/report.py @@ -8,7 +8,6 @@ from typing import Optional import click from vlm.cli_helpers import ( - command_error, default_artifact_path, emit_report, optional_plan_summary, diff --git a/src/vlm/config.py b/src/vlm/config.py index 766c546..54c6ba9 100644 --- a/src/vlm/config.py +++ b/src/vlm/config.py @@ -32,8 +32,10 @@ class Config(BaseModel): video_extensions: list[str] = list(DEFAULT_VIDEO_EXTENSIONS) movie_template: str = "movie/{title} ({year})/" series_template: str = "series/{title}/Season {season:02d}/" + anime_template: str = "anime/{title}/Season {season:02d}/" movie_filename_template: str = "{title} ({year}){ext}" series_filename_template: str = "S{season:02d}E{episode:02d}{ext}" + anime_filename_template: str = "S{season:02d}E{episode:02d}{ext}" log_level: str = "INFO" quarantine_dir: str = ".quarantine" workspace_dir: Path = Path("artifacts") @@ -87,7 +89,7 @@ class Config(BaseModel): raise ValueError(f"video extension must start with '.': {ext}") return v - @field_validator("movie_template", "series_template", "movie_filename_template", "series_filename_template") + @field_validator("movie_template", "series_template", "anime_template", "movie_filename_template", "series_filename_template", "anime_filename_template") @classmethod def _nonempty_template(cls, v: str, info: Any) -> str: if not v: @@ -254,8 +256,10 @@ def _flatten_yaml(data: dict) -> dict[str, Any]: if templates: flat["movie_template"] = templates.get("movie_dir", "movie/{title} ({year})/") flat["series_template"] = templates.get("series_dir", "series/{title}/Season {season:02d}/") + flat["anime_template"] = templates.get("anime_dir", "anime/{title}/Season {season:02d}/") flat["movie_filename_template"] = templates.get("movie_filename", "{title} ({year}){ext}") flat["series_filename_template"] = templates.get("series_filename", "S{season:02d}E{episode:02d}{ext}") + flat["anime_filename_template"] = templates.get("anime_filename", "S{season:02d}E{episode:02d}{ext}") for key in ("quarantine_dir", "log_level", "workspace_dir", "categories"): if key in data: @@ -371,8 +375,10 @@ def create_default_config(path: Path) -> Config: "templates": { "movie_dir": default_config.movie_template, "series_dir": default_config.series_template, + "anime_dir": default_config.anime_template, "movie_filename": default_config.movie_filename_template, "series_filename": default_config.series_filename_template, + "anime_filename": default_config.anime_filename_template, }, "quarantine_dir": default_config.quarantine_dir, "workspace_dir": str(default_config.workspace_dir), diff --git a/src/vlm/plan_paths.py b/src/vlm/plan_paths.py index 12494c7..9de008d 100644 --- a/src/vlm/plan_paths.py +++ b/src/vlm/plan_paths.py @@ -1,7 +1,7 @@ """Path rendering for planner operations. Computes destination paths from config templates and identity data -for movie and series files. +for movie, series, and anime files. """ from vlm.config import Config @@ -229,3 +229,107 @@ def _create_series_operation( has_conflict=has_conflict, conflict_reason=conflict_reason ) + + +def _create_anime_operation( + video_file: VideoFile, + identity: SeriesIdentity, + config: Config +) -> FileOperation: + """Create operation for an anime file. + + Uses anime-specific templates. Like series, anime with needs_review + or missing season/episode info generates a no-op for manual review. + """ + if identity.review_status == "rejected": + return FileOperation( + operation_type="no-op", + source_path=video_file.path, + destination_path=None, + reason="Anime rejected during manual review", + has_conflict=False, + conflict_reason=None + ) + + if identity.needs_review or identity.season is None or len(identity.episodes) == 0: + return FileOperation( + operation_type="no-op", + source_path=video_file.path, + destination_path=None, + reason="Anime needs manual review (no season/episode found)", + has_conflict=False, + conflict_reason=None + ) + if identity.season > config.plan_max_season: + return FileOperation( + operation_type="no-op", + source_path=video_file.path, + destination_path=None, + reason="Anime needs manual review (season exceeds configured threshold)", + has_conflict=False, + conflict_reason=None + ) + if any(ep > config.plan_max_episode for ep in identity.episodes): + return FileOperation( + operation_type="no-op", + source_path=video_file.path, + destination_path=None, + reason="Anime needs manual review (episode exceeds configured threshold)", + has_conflict=False, + conflict_reason=None + ) + + safe_title = sanitize_path_component(identity.title, fallback="untitled") + + target_dir = config.anime_template.format( + title=safe_title, + season=identity.season + ) + + ext = video_file.path.suffix + + target_filename = config.anime_filename_template.format( + season=identity.season, + episode=identity.episodes[0], + ext=ext + ) + + destination = config.library_root / target_dir / target_filename + if not is_within_root(destination, config.library_root): + return FileOperation( + operation_type="no-op", + source_path=video_file.path, + destination_path=None, + reason=f"Unsafe destination outside library root: {destination}", + has_conflict=False, + conflict_reason=None + ) + + if video_file.path.resolve() == destination.resolve(): + return FileOperation( + operation_type="no-op", + source_path=video_file.path, + destination_path=None, + reason="File already at target location", + has_conflict=False, + conflict_reason=None + ) + + if video_file.path.parent == destination.parent: + operation_type = "rename" + else: + operation_type = "move" + + has_conflict = destination.exists() + conflict_reason = None + if has_conflict: + conflict_reason = f"Destination file already exists: {destination}" + + return FileOperation( + operation_type=operation_type, + source_path=video_file.path, + destination_path=destination, + reason=f"Organize anime: {identity.title} S{identity.season:02d}E{identity.episodes[0]:02d}", + has_conflict=has_conflict, + conflict_reason=conflict_reason + ) diff --git a/src/vlm/planner.py b/src/vlm/planner.py index 997fa7f..0060a67 100644 --- a/src/vlm/planner.py +++ b/src/vlm/planner.py @@ -24,6 +24,7 @@ from vlm.plan_duplicates import ( _select_duplicate_quarantine_reason, ) from vlm.plan_paths import ( + _create_anime_operation, _create_movie_operation, _create_series_operation, ) @@ -264,13 +265,15 @@ def _create_operation( conflict_reason=None ) - # Handle anime category - generate no-op (v1 constraint) + # Handle anime category - route to anime-specific operation if video_file.category == "anime": + if isinstance(identity, SeriesIdentity): + return _create_anime_operation(video_file, identity, config) return FileOperation( operation_type="no-op", source_path=video_file.path, destination_path=None, - reason="Anime files not organized in v1", + reason="Anime needs manual review (no season/episode found)", has_conflict=False, conflict_reason=None ) diff --git a/tests/test_cli_parse.py b/tests/test_cli_parse.py index c7ca37e..a06ec5c 100644 --- a/tests/test_cli_parse.py +++ b/tests/test_cli_parse.py @@ -168,3 +168,79 @@ class TestParseCommand: assert movie2["video_metadata"]["size_bytes"] == 1500000000 assert movie2["video_metadata"]["resolution"] is None assert movie2["video_metadata"]["codec"] is None + + def test_parse_anime_with_season_episode(self, tmp_path): + """Test parse correctly parses anime files with SxxEyy format.""" + inventory_csv = tmp_path / "inventory.csv" + inventory_csv.write_text( + "# vlm inventory\n" + "path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n" + "/library/anime/Naruto Shippuden S01E05.mkv,Naruto Shippuden S01E05.mkv,500000,2024-01-01T00:00:00,anime,,,\n" + ) + + output_json = tmp_path / "identities.json" + + runner = CliRunner() + result = runner.invoke( + main, + [ + "--config", + str(tmp_path / "config.yaml"), + "parse", + "--input", + str(inventory_csv), + "--output", + str(output_json), + ], + ) + + assert result.exit_code == 0 + assert "Anime: 1" in result.output + + with open(output_json) as f: + data = json.load(f) + + assert len(data["anime"]) == 1 + anime = data["anime"][0] + assert anime["title"] == "Naruto Shippuden" + assert anime["season"] == 1 + assert anime["episodes"] == [5] + assert anime["confidence"] == 0.9 + assert anime["needs_review"] is False + + def test_parse_anime_absolute_numbering_needs_review(self, tmp_path): + """Test parse marks anime with absolute episode numbering as needs_review.""" + inventory_csv = tmp_path / "inventory.csv" + inventory_csv.write_text( + "# vlm inventory\n" + "path,filename,size_bytes,modified_timestamp,category,resolution,codec,duration_seconds,bitrate_kbps\n" + "/library/anime/Naruto - 042.mkv,Naruto - 042.mkv,500000,2024-01-01T00:00:00,anime,,,\n" + ) + + output_json = tmp_path / "identities.json" + + runner = CliRunner() + result = runner.invoke( + main, + [ + "--config", + str(tmp_path / "config.yaml"), + "parse", + "--input", + str(inventory_csv), + "--output", + str(output_json), + ], + ) + + assert result.exit_code == 0 + + with open(output_json) as f: + data = json.load(f) + + assert len(data["anime"]) == 1 + anime = data["anime"][0] + assert anime["title"] == "Naruto" + assert anime["season"] is None + assert anime["episodes"] == [42] + assert anime["needs_review"] is True diff --git a/tests/test_docs_consistency.py b/tests/test_docs_consistency.py index 67e7e7d..394529c 100644 --- a/tests/test_docs_consistency.py +++ b/tests/test_docs_consistency.py @@ -6,7 +6,6 @@ import re from pathlib import Path import pytest -from click.testing import CliRunner from vlm.cli import main diff --git a/tests/test_planner.py b/tests/test_planner.py index 6a65221..4577192 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -349,6 +349,36 @@ def test_generate_plan_for_anime_category(config): assert "anime" in operation.reason.lower() or "not organized" in operation.reason.lower() +def test_generate_plan_for_anime_with_identity(config): + """Test plan generation for anime files with parsed identity uses anime templates.""" + video_file = VideoFile( + path=Path("/mnt/nas/videos/anime/Some.Anime.S01E05.mkv"), + filename="Some.Anime.S01E05.mkv", + size_bytes=500000, + modified_timestamp=datetime.now(timezone.utc), + category="anime" + ) + + identity = SeriesIdentity( + title="Some Anime", + season=1, + episodes=[5], + confidence=0.9, + needs_review=False, + original_filename="Some.Anime.S01E05.mkv", + ) + + plan = generate_plan([(video_file, identity)], config) + + assert len(plan.operations) == 1 + operation = plan.operations[0] + assert operation.operation_type in ("move", "rename") + assert operation.destination_path is not None + assert "anime" in str(operation.destination_path).lower() + assert "series" not in str(operation.destination_path).lower() + assert "Some_Anime" in operation.destination_path.name or "Some Anime" in str(operation.destination_path) + + def test_generate_plan_for_other_category(config): """Test plan generation for other category files (no-op in v1).""" video_file = VideoFile(