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,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user