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
+22 -10
View File
@@ -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"],
identity = parse_anime(filename, extensions=config.video_extensions)
record = {
"path": file_path,
"filename": filename,
"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:
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,
)
-1
View File
@@ -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,
+7 -1
View File
@@ -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),
+105 -1
View File
@@ -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
)
+5 -2
View File
@@ -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
)
+76
View File
@@ -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
-1
View File
@@ -6,7 +6,6 @@ import re
from pathlib import Path
import pytest
from click.testing import CliRunner
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()
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(