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 <noreply@anthropic.com>
This commit is contained in:
windyboy
2026-09-27 11:08:15 +08:00
co-authored by Claude Sonnet 4.5
parent 8832f5da3f
commit 9814b2b917
8 changed files with 248 additions and 19 deletions
+25 -13
View File
@@ -17,7 +17,7 @@ from vlm.models import (
SeriesIdentityRecord, SeriesIdentityRecord,
VideoFile, 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 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] = [] movie_identities: list[MovieIdentityRecord] = []
series_identities: list[SeriesIdentityRecord] = [] series_identities: list[SeriesIdentityRecord] = []
anime_files: list[IdentityRecord] = [] anime_identities: list[SeriesIdentityRecord] = []
other_files: list[IdentityRecord] = [] other_files: list[IdentityRecord] = []
def get_video_metadata(file_path: str) -> dict: 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 record["video_metadata"] = video_metadata
series_identities.append(record) series_identities.append(record)
elif category == "anime": elif category == "anime":
anime_files.append( identity = parse_anime(filename, extensions=config.video_extensions)
{ record = {
"path": vf["path"], "path": file_path,
"filename": filename, "filename": filename,
"category": category, "category": category,
"note": "Anime parsing deferred in v1", "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: else:
other_files.append( other_files.append(
{ {
@@ -140,7 +146,12 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
if series_need_review > 0: if series_need_review > 0:
click.echo(f" - Need review: {series_need_review}") 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(f" Other: {len(other_files)} (not parsed)")
click.echo() click.echo()
@@ -158,7 +169,7 @@ def parse_cmd(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Pa
}, },
"movies": movie_identities, "movies": movie_identities,
"series": series_identities, "series": series_identities,
"anime": anime_files, "anime": anime_identities,
"other": other_files, "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!") click.echo("Parsed identities saved successfully!")
logger.info( 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(movie_identities),
len(series_identities), len(series_identities),
len(anime_identities),
output, output,
) )
-1
View File
@@ -8,7 +8,6 @@ from typing import Optional
import click import click
from vlm.cli_helpers import ( from vlm.cli_helpers import (
command_error,
default_artifact_path, default_artifact_path,
emit_report, emit_report,
optional_plan_summary, optional_plan_summary,
+7 -1
View File
@@ -32,8 +32,10 @@ class Config(BaseModel):
video_extensions: list[str] = list(DEFAULT_VIDEO_EXTENSIONS) video_extensions: list[str] = list(DEFAULT_VIDEO_EXTENSIONS)
movie_template: str = "movie/{title} ({year})/" movie_template: str = "movie/{title} ({year})/"
series_template: str = "series/{title}/Season {season:02d}/" series_template: str = "series/{title}/Season {season:02d}/"
anime_template: str = "anime/{title}/Season {season:02d}/"
movie_filename_template: str = "{title} ({year}){ext}" movie_filename_template: str = "{title} ({year}){ext}"
series_filename_template: str = "S{season:02d}E{episode:02d}{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" log_level: str = "INFO"
quarantine_dir: str = ".quarantine" quarantine_dir: str = ".quarantine"
workspace_dir: Path = Path("artifacts") workspace_dir: Path = Path("artifacts")
@@ -87,7 +89,7 @@ class Config(BaseModel):
raise ValueError(f"video extension must start with '.': {ext}") raise ValueError(f"video extension must start with '.': {ext}")
return v 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 @classmethod
def _nonempty_template(cls, v: str, info: Any) -> str: def _nonempty_template(cls, v: str, info: Any) -> str:
if not v: if not v:
@@ -254,8 +256,10 @@ def _flatten_yaml(data: dict) -> dict[str, Any]:
if templates: if templates:
flat["movie_template"] = templates.get("movie_dir", "movie/{title} ({year})/") flat["movie_template"] = templates.get("movie_dir", "movie/{title} ({year})/")
flat["series_template"] = templates.get("series_dir", "series/{title}/Season {season:02d}/") 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["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["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"): for key in ("quarantine_dir", "log_level", "workspace_dir", "categories"):
if key in data: if key in data:
@@ -371,8 +375,10 @@ def create_default_config(path: Path) -> Config:
"templates": { "templates": {
"movie_dir": default_config.movie_template, "movie_dir": default_config.movie_template,
"series_dir": default_config.series_template, "series_dir": default_config.series_template,
"anime_dir": default_config.anime_template,
"movie_filename": default_config.movie_filename_template, "movie_filename": default_config.movie_filename_template,
"series_filename": default_config.series_filename_template, "series_filename": default_config.series_filename_template,
"anime_filename": default_config.anime_filename_template,
}, },
"quarantine_dir": default_config.quarantine_dir, "quarantine_dir": default_config.quarantine_dir,
"workspace_dir": str(default_config.workspace_dir), "workspace_dir": str(default_config.workspace_dir),
+105 -1
View File
@@ -1,7 +1,7 @@
"""Path rendering for planner operations. """Path rendering for planner operations.
Computes destination paths from config templates and identity data 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 from vlm.config import Config
@@ -229,3 +229,107 @@ def _create_series_operation(
has_conflict=has_conflict, has_conflict=has_conflict,
conflict_reason=conflict_reason 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
)
+5 -2
View File
@@ -24,6 +24,7 @@ from vlm.plan_duplicates import (
_select_duplicate_quarantine_reason, _select_duplicate_quarantine_reason,
) )
from vlm.plan_paths import ( from vlm.plan_paths import (
_create_anime_operation,
_create_movie_operation, _create_movie_operation,
_create_series_operation, _create_series_operation,
) )
@@ -264,13 +265,15 @@ def _create_operation(
conflict_reason=None 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 video_file.category == "anime":
if isinstance(identity, SeriesIdentity):
return _create_anime_operation(video_file, identity, config)
return FileOperation( return FileOperation(
operation_type="no-op", operation_type="no-op",
source_path=video_file.path, source_path=video_file.path,
destination_path=None, destination_path=None,
reason="Anime files not organized in v1", reason="Anime needs manual review (no season/episode found)",
has_conflict=False, has_conflict=False,
conflict_reason=None conflict_reason=None
) )
+76
View File
@@ -168,3 +168,79 @@ class TestParseCommand:
assert movie2["video_metadata"]["size_bytes"] == 1500000000 assert movie2["video_metadata"]["size_bytes"] == 1500000000
assert movie2["video_metadata"]["resolution"] is None assert movie2["video_metadata"]["resolution"] is None
assert movie2["video_metadata"]["codec"] 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
-1
View File
@@ -6,7 +6,6 @@ import re
from pathlib import Path from pathlib import Path
import pytest import pytest
from click.testing import CliRunner
from vlm.cli import main from vlm.cli import main
+30
View File
@@ -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() 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): def test_generate_plan_for_other_category(config):
"""Test plan generation for other category files (no-op in v1).""" """Test plan generation for other category files (no-op in v1)."""
video_file = VideoFile( video_file = VideoFile(