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:
co-authored by
Claude Sonnet 4.5
parent
8832f5da3f
commit
9814b2b917
+25
-13
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,6 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from vlm.cli import main
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user