Enhance project structure and add new files for enrichment and analysis
- Updated AGENTS.md to reflect changes in CLI commands and module organization, including the addition of an enrichment step and new functional modules. - Introduced analysis.json, identities.json, inventory.csv, and plan.json to support enriched metadata and execution planning. - Added CODE_IMPROVEMENTS.md to document identified code issues and proposed solutions for future enhancements. - Updated README.md to include new enrichment features and configuration options. - Removed unused dependency on ffmpeg-python from pyproject.toml. These changes improve the overall functionality and maintainability of the Video Library Manager project.
This commit is contained in:
+14
-40
@@ -68,65 +68,39 @@ def analyze_series_completeness(episodes: list[SeriesIdentity]) -> list[SeasonCo
|
||||
|
||||
|
||||
def detect_duplicates(
|
||||
identities: list[MovieIdentity | SeriesIdentity],
|
||||
files: list[VideoFile]
|
||||
identity_file_pairs: list[tuple[MovieIdentity | SeriesIdentity, VideoFile]],
|
||||
) -> list[DuplicateGroup]:
|
||||
"""Detect duplicate video files and provide quality comparison data.
|
||||
|
||||
|
||||
Groups files by normalized identity (title+year for movies, title+season+episode
|
||||
for series) and identifies groups with multiple files as potential duplicates.
|
||||
|
||||
Uses (identity, file) pairs so that same filename under different paths are
|
||||
not conflated.
|
||||
|
||||
Args:
|
||||
identities: List of parsed identities (movies or series)
|
||||
files: List of video files corresponding to the identities
|
||||
|
||||
identity_file_pairs: List of (identity, video_file) in matching order
|
||||
|
||||
Returns:
|
||||
List of DuplicateGroup objects for files with duplicates
|
||||
"""
|
||||
# Create a mapping from original filename to VideoFile for quick lookup
|
||||
file_map = {file.filename: file for file in files}
|
||||
|
||||
# Group identities by normalized identity
|
||||
groups: dict[tuple, list[tuple[MovieIdentity | SeriesIdentity, VideoFile]]] = {}
|
||||
|
||||
for identity in identities:
|
||||
# Create grouping key based on identity type
|
||||
|
||||
for identity, video_file in identity_file_pairs:
|
||||
if isinstance(identity, MovieIdentity):
|
||||
# For movies: group by (title, year)
|
||||
# Skip if year is None (needs review)
|
||||
if identity.year is None:
|
||||
continue
|
||||
key = ('movie', identity.title, identity.year)
|
||||
key = ("movie", identity.title, identity.year)
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append((identity, video_file))
|
||||
else: # SeriesIdentity
|
||||
# For series: group by (title, season, episode)
|
||||
# Skip if season is None or episodes is empty (needs review)
|
||||
if identity.season is None or not identity.episodes:
|
||||
continue
|
||||
# For multi-episode files, use the first episode for grouping
|
||||
# Each episode in the list should be treated separately
|
||||
for episode in identity.episodes:
|
||||
key = ('series', identity.title, identity.season, episode)
|
||||
|
||||
# Get the corresponding VideoFile
|
||||
video_file = file_map.get(identity.original_filename)
|
||||
if video_file is None:
|
||||
continue
|
||||
|
||||
# Add to group
|
||||
key = ("series", identity.title, identity.season, episode)
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append((identity, video_file))
|
||||
continue
|
||||
|
||||
# Get the corresponding VideoFile for movies
|
||||
video_file = file_map.get(identity.original_filename)
|
||||
if video_file is None:
|
||||
continue
|
||||
|
||||
# Add to group
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
groups[key].append((identity, video_file))
|
||||
|
||||
# Filter groups to only those with multiple files (duplicates)
|
||||
duplicate_groups = []
|
||||
|
||||
+52
-469
@@ -5,6 +5,7 @@ It implements global options (--config, --log-level) and error handling.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -12,7 +13,9 @@ import click
|
||||
import yaml
|
||||
|
||||
from vlm.config import Config, load_config, create_default_config, validate_config
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.logging_config import setup_logging, get_logger
|
||||
from vlm.utils import format_size
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
@@ -20,17 +23,6 @@ def default_config_path() -> Path:
|
||||
return Path.home() / ".vlm" / "config.yaml"
|
||||
|
||||
|
||||
class CLIContext:
|
||||
"""Context object to pass configuration and logger between commands."""
|
||||
|
||||
def __init__(self, config: Config, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
|
||||
|
||||
pass_context = click.make_pass_decorator(CLIContext)
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.option(
|
||||
'--config',
|
||||
@@ -105,6 +97,7 @@ def main(ctx, config: Path, log_level: Optional[str]):
|
||||
ctx.obj = CLIContext(config=cfg, logger=logger)
|
||||
|
||||
except Exception as e:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
click.echo(f"Error initializing VLM: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -154,118 +147,15 @@ def scan(
|
||||
vlm scan --reuse-from old.csv # Reuse prior metadata cache
|
||||
vlm scan --force-refresh-metadata # Re-run ffprobe for all files
|
||||
"""
|
||||
from vlm.scanner import scan_library, save_inventory_csv, load_inventory_csv
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
# Display scan start message
|
||||
click.echo(f"Scanning library at: {config.library_root}")
|
||||
click.echo("This may take a while for large libraries...")
|
||||
click.echo()
|
||||
|
||||
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
|
||||
progress_position = {"current": 0}
|
||||
|
||||
def _scan_progress(processed: int, total: int) -> None:
|
||||
if total <= 0:
|
||||
return
|
||||
if progress_state["bar"] is None:
|
||||
bar = click.progressbar(
|
||||
length=total,
|
||||
label="Scanning files",
|
||||
show_pos=True,
|
||||
)
|
||||
progress_state["bar"] = bar.__enter__()
|
||||
|
||||
step = processed - progress_position["current"]
|
||||
if step > 0 and progress_state["bar"] is not None:
|
||||
progress_state["bar"].update(step)
|
||||
progress_position["current"] = processed
|
||||
|
||||
metadata_cache = None
|
||||
cache_source = None
|
||||
if not force_refresh_metadata:
|
||||
cache_source = reuse_from if reuse_from is not None else (output if output.exists() else None)
|
||||
|
||||
if metadata and force_refresh_metadata:
|
||||
click.echo("Forcing metadata refresh for all files (cache disabled).")
|
||||
click.echo()
|
||||
|
||||
if metadata and cache_source is not None:
|
||||
click.echo(f"Loading metadata cache from: {cache_source}")
|
||||
try:
|
||||
cached_files = load_inventory_csv(cache_source)
|
||||
metadata_cache = {str(vf.path): vf for vf in cached_files}
|
||||
click.echo(f"Loaded metadata cache entries: {len(metadata_cache)}")
|
||||
click.echo()
|
||||
except Exception as e:
|
||||
click.echo(f"Warning: could not load metadata cache: {e}")
|
||||
click.echo("Continuing without cache.")
|
||||
click.echo()
|
||||
|
||||
# Perform the scan
|
||||
try:
|
||||
video_files = scan_library(
|
||||
config.library_root,
|
||||
config,
|
||||
progress_callback=_scan_progress,
|
||||
include_video_metadata=metadata,
|
||||
metadata_cache=metadata_cache
|
||||
)
|
||||
finally:
|
||||
if progress_state["bar"] is not None:
|
||||
progress_state["bar"].__exit__(None, None, None)
|
||||
click.echo()
|
||||
|
||||
# Display summary
|
||||
click.echo(f"Scan complete!")
|
||||
click.echo(f" Total files found: {len(video_files)}")
|
||||
|
||||
# Count by category
|
||||
categories = {}
|
||||
total_size = 0
|
||||
for vf in video_files:
|
||||
categories[vf.category] = categories.get(vf.category, 0) + 1
|
||||
total_size += vf.size_bytes
|
||||
|
||||
click.echo(f" Total size: {_format_size(total_size)}")
|
||||
click.echo()
|
||||
click.echo("Files by category:")
|
||||
for category in sorted(categories.keys()):
|
||||
click.echo(f" {category}: {categories[category]}")
|
||||
|
||||
# Save inventory to CSV
|
||||
click.echo()
|
||||
click.echo(f"Saving inventory to: {output}")
|
||||
save_inventory_csv(video_files, output, config.library_root)
|
||||
click.echo(f"Inventory saved successfully!")
|
||||
|
||||
logger.info(f"Scan completed: {len(video_files)} files found, saved to {output}")
|
||||
|
||||
from vlm.commands.scan import scan_cmd
|
||||
scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata)
|
||||
except Exception as e:
|
||||
click.echo(f"Error during scan: {e}", err=True)
|
||||
logger.error(f"Scan failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"Scan failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _format_size(size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format.
|
||||
|
||||
Args:
|
||||
size_bytes: Size in bytes
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "1.5 GB", "234.2 MB")
|
||||
"""
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} PB"
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
'--input',
|
||||
@@ -337,7 +227,7 @@ def parse(ctx: CLIContext, input: Path, output: Path):
|
||||
category = vf['category']
|
||||
|
||||
if category == 'movie':
|
||||
identity = parse_movie(filename)
|
||||
identity = parse_movie(filename, extensions=config.video_extensions)
|
||||
movie_identities.append({
|
||||
'path': vf['path'],
|
||||
'filename': filename,
|
||||
@@ -349,7 +239,7 @@ def parse(ctx: CLIContext, input: Path, output: Path):
|
||||
})
|
||||
|
||||
elif category == 'series':
|
||||
identity = parse_series(filename)
|
||||
identity = parse_series(filename, extensions=config.video_extensions)
|
||||
series_identities.append({
|
||||
'path': vf['path'],
|
||||
'filename': filename,
|
||||
@@ -543,12 +433,14 @@ def enrich(
|
||||
|
||||
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
|
||||
progress_position = {"current": 0}
|
||||
progress_bucket = {"value": -1}
|
||||
is_tty = bool(getattr(sys.stderr, "isatty", lambda: False)())
|
||||
|
||||
def _enrich_progress(processed: int, total_count: int, _metrics: dict[str, int]) -> None:
|
||||
def _enrich_progress(processed: int, total_count: int, metrics: dict[str, int]) -> None:
|
||||
if total_count <= 0:
|
||||
return
|
||||
|
||||
if progress_state["bar"] is None:
|
||||
if is_tty and progress_state["bar"] is None:
|
||||
bar = click.progressbar(
|
||||
length=total_count,
|
||||
label="Enriching records",
|
||||
@@ -561,6 +453,19 @@ def enrich(
|
||||
progress_state["bar"].update(step)
|
||||
progress_position["current"] = processed
|
||||
|
||||
if not is_tty:
|
||||
percent = int(processed * 100 / total_count)
|
||||
bucket = percent // 5
|
||||
if bucket > progress_bucket["value"] or processed == total_count:
|
||||
progress_bucket["value"] = bucket
|
||||
click.echo(
|
||||
"Progress: "
|
||||
f"{processed}/{total_count} ({percent}%) "
|
||||
f"api_calls={metrics.get('api_calls', 0)} "
|
||||
f"cache_hits={metrics.get('cache_hits', 0)} "
|
||||
f"failed={metrics.get('failed', 0)}"
|
||||
)
|
||||
|
||||
try:
|
||||
if total == 0:
|
||||
click.echo("No movie/series/anime records found to enrich.")
|
||||
@@ -596,6 +501,11 @@ def enrich(
|
||||
click.echo(f" Failed requests: {stats['failed']}")
|
||||
click.echo(f" Skipped: {stats['skipped']}")
|
||||
click.echo(f" Needs review: {stats['needs_review']}")
|
||||
skip_reasons = stats.get("skip_reasons", {})
|
||||
if isinstance(skip_reasons, dict):
|
||||
non_zero = [f"{name}={count}" for name, count in sorted(skip_reasons.items()) if int(count) > 0]
|
||||
if non_zero:
|
||||
click.echo(f" Skip reasons: {' '.join(non_zero)}")
|
||||
failed_items = stats.get('failed_items', [])
|
||||
if isinstance(failed_items, list) and failed_items:
|
||||
click.echo(" Failure sample:")
|
||||
@@ -640,8 +550,14 @@ def enrich(
|
||||
default=Path('analysis.json'),
|
||||
help='Path to save analysis results (default: analysis.json)'
|
||||
)
|
||||
@click.option(
|
||||
'--inventory',
|
||||
type=click.Path(exists=True, path_type=Path),
|
||||
default=None,
|
||||
help='Optional inventory CSV to merge size/resolution/codec for duplicate quality comparison'
|
||||
)
|
||||
@pass_context
|
||||
def analyze(ctx: CLIContext, input: Path, output: Path):
|
||||
def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
|
||||
"""Analyze completeness and duplicates.
|
||||
|
||||
Detects episode gaps in series and identifies potential duplicate files.
|
||||
@@ -651,185 +567,23 @@ def analyze(ctx: CLIContext, input: Path, output: Path):
|
||||
|
||||
vlm analyze # Use default files
|
||||
vlm analyze --input my_identities.json # Custom input
|
||||
vlm analyze --inventory inventory.csv # Merge metadata for quality comparison
|
||||
vlm analyze --output my_analysis.json # Custom output
|
||||
"""
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates
|
||||
from vlm.models import SeriesIdentity, MovieIdentity, VideoFile
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
# Display analyze start message
|
||||
click.echo(f"Analyzing identities from: {input}")
|
||||
click.echo()
|
||||
|
||||
# Load identities from JSON
|
||||
with open(input, 'r', encoding='utf-8') as jsonfile:
|
||||
identities_data = json.load(jsonfile)
|
||||
|
||||
# Extract movies and series
|
||||
movies_data = identities_data.get('movies', [])
|
||||
series_data = identities_data.get('series', [])
|
||||
|
||||
click.echo(f"Loaded {len(movies_data)} movies and {len(series_data)} series")
|
||||
click.echo()
|
||||
|
||||
# Convert to identity objects
|
||||
movie_identities = []
|
||||
for m in movies_data:
|
||||
movie_identities.append(MovieIdentity(
|
||||
title=m['title'],
|
||||
year=m.get('year'),
|
||||
confidence=m['confidence'],
|
||||
needs_review=m['needs_review'],
|
||||
original_filename=m['filename']
|
||||
))
|
||||
|
||||
series_identities = []
|
||||
for s in series_data:
|
||||
series_identities.append(SeriesIdentity(
|
||||
title=s['title'],
|
||||
season=s.get('season'),
|
||||
episodes=s.get('episodes', []),
|
||||
confidence=s['confidence'],
|
||||
needs_review=s['needs_review'],
|
||||
original_filename=s['filename']
|
||||
))
|
||||
|
||||
# Create VideoFile objects for duplicate detection
|
||||
# We need to reconstruct basic VideoFile info from the identities data
|
||||
video_files = []
|
||||
for m in movies_data:
|
||||
video_files.append(VideoFile(
|
||||
path=Path(m['path']),
|
||||
filename=m['filename'],
|
||||
size_bytes=0, # Not available from identities file
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category=m['category'],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
))
|
||||
|
||||
for s in series_data:
|
||||
video_files.append(VideoFile(
|
||||
path=Path(s['path']),
|
||||
filename=s['filename'],
|
||||
size_bytes=0, # Not available from identities file
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category=s['category'],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
))
|
||||
|
||||
# Analyze series completeness
|
||||
click.echo("Analyzing series completeness...")
|
||||
completeness_results = analyze_series_completeness(series_identities)
|
||||
|
||||
# Detect duplicates
|
||||
click.echo("Detecting duplicates...")
|
||||
all_identities = movie_identities + series_identities
|
||||
duplicate_groups = detect_duplicates(all_identities, video_files)
|
||||
|
||||
# Display analysis summary
|
||||
click.echo()
|
||||
click.echo("Analysis complete!")
|
||||
click.echo()
|
||||
click.echo("Results:")
|
||||
click.echo(f" Series with episode gaps: {len(completeness_results)}")
|
||||
|
||||
if completeness_results:
|
||||
total_missing = sum(len(c.episodes_missing) for c in completeness_results)
|
||||
click.echo(f" - Total missing episodes: {total_missing}")
|
||||
|
||||
click.echo(f" Duplicate groups found: {len(duplicate_groups)}")
|
||||
|
||||
if duplicate_groups:
|
||||
total_duplicates = sum(len(g.files) for g in duplicate_groups)
|
||||
click.echo(f" - Total duplicate files: {total_duplicates}")
|
||||
|
||||
# Save analysis results to JSON
|
||||
click.echo()
|
||||
click.echo(f"Saving analysis results to: {output}")
|
||||
|
||||
# Ensure output directory exists
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Build JSON structure
|
||||
generation_timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
# Convert completeness results to dict
|
||||
completeness_list = []
|
||||
for c in completeness_results:
|
||||
completeness_list.append({
|
||||
'series_title': c.series_title,
|
||||
'season': c.season,
|
||||
'episodes_found': c.episodes_found,
|
||||
'episodes_missing': c.episodes_missing
|
||||
})
|
||||
|
||||
# Convert duplicate groups to dict
|
||||
duplicates_list = []
|
||||
for d in duplicate_groups:
|
||||
# Get identity info
|
||||
if isinstance(d.identity, MovieIdentity):
|
||||
identity_info = {
|
||||
'type': 'movie',
|
||||
'title': d.identity.title,
|
||||
'year': d.identity.year
|
||||
}
|
||||
else: # SeriesIdentity
|
||||
identity_info = {
|
||||
'type': 'series',
|
||||
'title': d.identity.title,
|
||||
'season': d.identity.season,
|
||||
'episodes': d.identity.episodes
|
||||
}
|
||||
|
||||
duplicates_list.append({
|
||||
'identity': identity_info,
|
||||
'files': [str(f.path) for f in d.files],
|
||||
'quality_comparison': d.quality_comparison
|
||||
})
|
||||
|
||||
analysis_data = {
|
||||
'metadata': {
|
||||
'generated': generation_timestamp,
|
||||
'source_identities': str(input),
|
||||
'total_movies': len(movies_data),
|
||||
'total_series': len(series_data)
|
||||
},
|
||||
'completeness': completeness_list,
|
||||
'duplicates': duplicates_list
|
||||
}
|
||||
|
||||
# Write JSON file with pretty formatting
|
||||
with open(output, 'w', encoding='utf-8') as jsonfile:
|
||||
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
|
||||
|
||||
click.echo(f"Analysis results saved successfully!")
|
||||
|
||||
logger.info(f"Analysis completed: {len(completeness_results)} incomplete series, {len(duplicate_groups)} duplicate groups, saved to {output}")
|
||||
|
||||
from vlm.commands.analyze import analyze_cmd
|
||||
analyze_cmd(ctx, input, output, inventory)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Error: Input file not found: {input}", err=True)
|
||||
logger.error(f"Input file not found: {input}")
|
||||
ctx.logger.error(f"Input file not found: {input}")
|
||||
sys.exit(1)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
||||
logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error during analysis: {e}", err=True)
|
||||
logger.error(f"Analysis failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"Analysis failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -859,192 +613,20 @@ def plan(ctx: CLIContext, input: Path, output: Path):
|
||||
vlm plan --input my_identities.json # Custom input
|
||||
vlm plan --output my_plan.json # Custom output
|
||||
"""
|
||||
import json
|
||||
from vlm.planner import generate_plan, save_plan
|
||||
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
# Display plan start message
|
||||
click.echo(f"Generating execution plan from: {input}")
|
||||
click.echo()
|
||||
|
||||
# Load identities from JSON
|
||||
with open(input, 'r', encoding='utf-8') as jsonfile:
|
||||
identities_data = json.load(jsonfile)
|
||||
|
||||
# Extract movies and series
|
||||
movies_data = identities_data.get('movies', [])
|
||||
series_data = identities_data.get('series', [])
|
||||
anime_data = identities_data.get('anime', [])
|
||||
other_data = identities_data.get('other', [])
|
||||
|
||||
click.echo(f"Loaded {len(movies_data)} movies, {len(series_data)} series, {len(anime_data)} anime, {len(other_data)} other")
|
||||
click.echo()
|
||||
|
||||
# Build list of (VideoFile, Identity) tuples for plan generator
|
||||
identities_list = []
|
||||
|
||||
# Process movies
|
||||
for m in movies_data:
|
||||
is_approved = m.get('review_status') == 'approved'
|
||||
video_file = VideoFile(
|
||||
path=Path(m['path']),
|
||||
filename=m['filename'],
|
||||
size_bytes=0, # Not available from identities file
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category=m['category'],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
)
|
||||
|
||||
movie_identity = MovieIdentity(
|
||||
title=m.get('display_title', m['title']),
|
||||
year=m.get('year'),
|
||||
confidence=m['confidence'],
|
||||
needs_review=(m['needs_review'] and not is_approved),
|
||||
original_filename=m['filename'],
|
||||
canonical_id=m.get('canonical_id'),
|
||||
title_zh=m.get('title_zh'),
|
||||
title_en=m.get('title_en'),
|
||||
translation_source=m.get('translation_source'),
|
||||
reputation_score=m.get('reputation_score'),
|
||||
reputation_votes=m.get('reputation_votes'),
|
||||
reputation_source=m.get('reputation_source'),
|
||||
review_status=m.get('review_status', 'pending'),
|
||||
enrichment_confidence=m.get('enrichment_confidence'),
|
||||
provider_metadata=m.get('provider_metadata', {})
|
||||
)
|
||||
|
||||
identities_list.append((video_file, movie_identity))
|
||||
|
||||
# Process series
|
||||
for s in series_data:
|
||||
is_approved = s.get('review_status') == 'approved'
|
||||
video_file = VideoFile(
|
||||
path=Path(s['path']),
|
||||
filename=s['filename'],
|
||||
size_bytes=0, # Not available from identities file
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category=s['category'],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
)
|
||||
|
||||
series_identity = SeriesIdentity(
|
||||
title=s.get('display_title', s['title']),
|
||||
season=s.get('season'),
|
||||
episodes=s.get('episodes', []),
|
||||
confidence=s['confidence'],
|
||||
needs_review=(s['needs_review'] and not is_approved),
|
||||
original_filename=s['filename'],
|
||||
canonical_id=s.get('canonical_id'),
|
||||
title_zh=s.get('title_zh'),
|
||||
title_en=s.get('title_en'),
|
||||
translation_source=s.get('translation_source'),
|
||||
reputation_score=s.get('reputation_score'),
|
||||
reputation_votes=s.get('reputation_votes'),
|
||||
reputation_source=s.get('reputation_source'),
|
||||
review_status=s.get('review_status', 'pending'),
|
||||
enrichment_confidence=s.get('enrichment_confidence'),
|
||||
provider_metadata=s.get('provider_metadata', {})
|
||||
)
|
||||
|
||||
identities_list.append((video_file, series_identity))
|
||||
|
||||
# Process anime (no identity in v1)
|
||||
for a in anime_data:
|
||||
video_file = VideoFile(
|
||||
path=Path(a['path']),
|
||||
filename=a['filename'],
|
||||
size_bytes=0,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category=a['category'],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
)
|
||||
|
||||
identities_list.append((video_file, None))
|
||||
|
||||
# Process other (no identity)
|
||||
for o in other_data:
|
||||
video_file = VideoFile(
|
||||
path=Path(o['path']),
|
||||
filename=o['filename'],
|
||||
size_bytes=0,
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category=o['category'],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None
|
||||
)
|
||||
|
||||
identities_list.append((video_file, None))
|
||||
|
||||
# Generate execution plan
|
||||
click.echo("Generating execution plan...")
|
||||
execution_plan = generate_plan(identities_list, config)
|
||||
|
||||
# Display plan summary
|
||||
click.echo()
|
||||
click.echo("Plan generation complete!")
|
||||
click.echo()
|
||||
click.echo("Operation summary:")
|
||||
click.echo(f" Total operations: {execution_plan.summary['total']}")
|
||||
click.echo(f" Move operations: {execution_plan.summary['move']}")
|
||||
click.echo(f" Rename operations: {execution_plan.summary['rename']}")
|
||||
click.echo(f" Quarantine operations: {execution_plan.summary['quarantine']}")
|
||||
click.echo(f" No-op (skipped): {execution_plan.summary['no-op']}")
|
||||
|
||||
# Count conflicts
|
||||
conflicts = sum(1 for op in execution_plan.operations if op.has_conflict)
|
||||
if conflicts > 0:
|
||||
click.echo()
|
||||
click.echo(f" ⚠️ Conflicts detected: {conflicts}")
|
||||
click.echo(" Review the plan file for details on conflicting operations.")
|
||||
|
||||
# Save execution plan to JSON
|
||||
click.echo()
|
||||
click.echo(f"Saving execution plan to: {output}")
|
||||
|
||||
# Ensure output directory exists
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
save_plan(execution_plan, output)
|
||||
|
||||
click.echo(f"Execution plan saved successfully!")
|
||||
click.echo()
|
||||
click.echo("Next steps:")
|
||||
click.echo(f" 1. Review the plan: {output}")
|
||||
click.echo(f" 2. Edit the plan if needed (it's JSON)")
|
||||
click.echo(f" 3. Dry-run: vlm execute --plan {output}")
|
||||
click.echo(f" 4. Execute: vlm execute --plan {output} --confirm")
|
||||
|
||||
logger.info(f"Plan generated: {execution_plan.summary['total']} operations, {conflicts} conflicts, saved to {output}")
|
||||
|
||||
from vlm.commands.plan import plan_cmd
|
||||
plan_cmd(ctx, input, output)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Error: Input file not found: {input}", err=True)
|
||||
logger.error(f"Input file not found: {input}")
|
||||
ctx.logger.error(f"Input file not found: {input}")
|
||||
sys.exit(1)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
click.echo(f"Error: Failed to parse JSON file: {e}", err=True)
|
||||
logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"JSON parsing failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
click.echo(f"Error during plan generation: {e}", err=True)
|
||||
logger.error(f"Plan generation failed: {e}", exc_info=True)
|
||||
ctx.logger.error(f"Plan generation failed: {e}", exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@@ -1266,7 +848,7 @@ def quarantine_list(ctx: CLIContext, category: Optional[str]):
|
||||
click.echo(f" Category: {entry.category}")
|
||||
click.echo(f" Original: {entry.original_path}")
|
||||
click.echo(f" Quarantine: {entry.quarantine_path}")
|
||||
click.echo(f" Size: {_format_size(entry.size_bytes)}")
|
||||
click.echo(f" Size: {format_size(entry.size_bytes)}")
|
||||
click.echo(f" Quarantined: {entry.quarantined_at.strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
if entry.reason:
|
||||
click.echo(f" Reason: {entry.reason}")
|
||||
@@ -2246,7 +1828,8 @@ def config_cmd(ctx: CLIContext):
|
||||
default=default_config_path,
|
||||
help='Path where configuration file should be created'
|
||||
)
|
||||
def config_init(path: Path):
|
||||
@pass_context
|
||||
def config_init(ctx: CLIContext, path: Path):
|
||||
"""Initialize configuration file with defaults."""
|
||||
try:
|
||||
if path.exists():
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""CLI command implementations.
|
||||
|
||||
Each module provides *_cmd(ctx, ...) functions that are invoked by cli.py
|
||||
after Click parses options and passes context.
|
||||
"""
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Analyze command implementation."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates
|
||||
from vlm.context import CLIContext
|
||||
from vlm.io import identities_to_analysis_input, load_identities_json, load_inventory_csv
|
||||
from vlm.models import MovieIdentity, SeriesIdentity
|
||||
from vlm.utils import utc_now
|
||||
|
||||
|
||||
def analyze_cmd(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Path,
|
||||
inventory: Optional[Path],
|
||||
) -> None:
|
||||
"""Run analysis: completeness and duplicate detection."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
click.echo(f"Analyzing identities from: {input}")
|
||||
if inventory:
|
||||
click.echo(f"Merging metadata from inventory: {inventory}")
|
||||
click.echo()
|
||||
|
||||
identities_data = load_identities_json(input)
|
||||
movies_data = identities_data.get("movies", [])
|
||||
series_data = identities_data.get("series", [])
|
||||
|
||||
click.echo(f"Loaded {len(movies_data)} movies and {len(series_data)} series")
|
||||
click.echo()
|
||||
|
||||
inventory_files = load_inventory_csv(inventory) if inventory else None
|
||||
movie_identities, series_identities, video_files = identities_to_analysis_input(
|
||||
identities_data, inventory_files=inventory_files
|
||||
)
|
||||
|
||||
click.echo("Analyzing series completeness...")
|
||||
completeness_results = analyze_series_completeness(series_identities)
|
||||
|
||||
click.echo("Detecting duplicates...")
|
||||
n_movies = len(movie_identities)
|
||||
identity_file_pairs = (
|
||||
list(zip(movie_identities, video_files[:n_movies]))
|
||||
+ list(zip(series_identities, video_files[n_movies:]))
|
||||
)
|
||||
duplicate_groups = detect_duplicates(identity_file_pairs)
|
||||
|
||||
click.echo()
|
||||
click.echo("Analysis complete!")
|
||||
click.echo()
|
||||
click.echo("Results:")
|
||||
click.echo(f" Series with episode gaps: {len(completeness_results)}")
|
||||
if completeness_results:
|
||||
total_missing = sum(len(c.episodes_missing) for c in completeness_results)
|
||||
click.echo(f" - Total missing episodes: {total_missing}")
|
||||
click.echo(f" Duplicate groups found: {len(duplicate_groups)}")
|
||||
if duplicate_groups:
|
||||
total_duplicates = sum(len(g.files) for g in duplicate_groups)
|
||||
click.echo(f" - Total duplicate files: {total_duplicates}")
|
||||
|
||||
click.echo()
|
||||
click.echo(f"Saving analysis results to: {output}")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
generation_timestamp = utc_now().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
completeness_list = [
|
||||
{
|
||||
"series_title": c.series_title,
|
||||
"season": c.season,
|
||||
"episodes_found": c.episodes_found,
|
||||
"episodes_missing": c.episodes_missing,
|
||||
}
|
||||
for c in completeness_results
|
||||
]
|
||||
duplicates_list = []
|
||||
for d in duplicate_groups:
|
||||
if isinstance(d.identity, MovieIdentity):
|
||||
identity_info = {"type": "movie", "title": d.identity.title, "year": d.identity.year}
|
||||
else:
|
||||
identity_info = {
|
||||
"type": "series",
|
||||
"title": d.identity.title,
|
||||
"season": d.identity.season,
|
||||
"episodes": d.identity.episodes,
|
||||
}
|
||||
duplicates_list.append(
|
||||
{
|
||||
"identity": identity_info,
|
||||
"files": [str(f.path) for f in d.files],
|
||||
"quality_comparison": d.quality_comparison,
|
||||
}
|
||||
)
|
||||
analysis_data = {
|
||||
"metadata": {
|
||||
"generated": generation_timestamp,
|
||||
"source_identities": str(input),
|
||||
"total_movies": len(movies_data),
|
||||
"total_series": len(series_data),
|
||||
},
|
||||
"completeness": completeness_list,
|
||||
"duplicates": duplicates_list,
|
||||
}
|
||||
with open(output, "w", encoding="utf-8") as jsonfile:
|
||||
json.dump(analysis_data, jsonfile, indent=2, ensure_ascii=False)
|
||||
|
||||
click.echo("Analysis results saved successfully!")
|
||||
logger.info(
|
||||
f"Analysis completed: {len(completeness_results)} incomplete series, "
|
||||
f"{len(duplicate_groups)} duplicate groups, saved to {output}"
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Plan command implementation."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.io import identities_to_plan_input, load_identities_json
|
||||
from vlm.planner import generate_plan, save_plan
|
||||
|
||||
|
||||
def plan_cmd(ctx: CLIContext, input: Path, output: Path) -> None:
|
||||
"""Generate execution plan from identities."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
click.echo(f"Generating execution plan from: {input}")
|
||||
click.echo()
|
||||
|
||||
identities_data = load_identities_json(input)
|
||||
movies_data = identities_data.get("movies", [])
|
||||
series_data = identities_data.get("series", [])
|
||||
anime_data = identities_data.get("anime", [])
|
||||
other_data = identities_data.get("other", [])
|
||||
|
||||
click.echo(
|
||||
f"Loaded {len(movies_data)} movies, {len(series_data)} series, "
|
||||
f"{len(anime_data)} anime, {len(other_data)} other"
|
||||
)
|
||||
click.echo()
|
||||
|
||||
identities_list = identities_to_plan_input(identities_data)
|
||||
|
||||
click.echo("Generating execution plan...")
|
||||
execution_plan = generate_plan(identities_list, config)
|
||||
|
||||
click.echo()
|
||||
click.echo("Plan generation complete!")
|
||||
click.echo()
|
||||
click.echo("Operation summary:")
|
||||
click.echo(f" Total operations: {execution_plan.summary['total']}")
|
||||
click.echo(f" Move operations: {execution_plan.summary['move']}")
|
||||
click.echo(f" Rename operations: {execution_plan.summary['rename']}")
|
||||
click.echo(f" Quarantine operations: {execution_plan.summary['quarantine']}")
|
||||
click.echo(f" No-op (skipped): {execution_plan.summary['no-op']}")
|
||||
|
||||
conflicts = sum(1 for op in execution_plan.operations if op.has_conflict)
|
||||
if conflicts > 0:
|
||||
click.echo()
|
||||
click.echo(f" ⚠️ Conflicts detected: {conflicts}")
|
||||
click.echo(" Review the plan file for details on conflicting operations.")
|
||||
|
||||
click.echo()
|
||||
click.echo(f"Saving execution plan to: {output}")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
save_plan(execution_plan, output)
|
||||
|
||||
click.echo("Execution plan saved successfully!")
|
||||
click.echo()
|
||||
click.echo("Next steps:")
|
||||
click.echo(f" 1. Review the plan: {output}")
|
||||
click.echo(" 2. Edit the plan if needed (it's JSON)")
|
||||
click.echo(f" 3. Dry-run: vlm execute --plan {output}")
|
||||
click.echo(f" 4. Execute: vlm execute --plan {output} --confirm")
|
||||
|
||||
logger.info(
|
||||
f"Plan generated: {execution_plan.summary['total']} operations, "
|
||||
f"{conflicts} conflicts, saved to {output}"
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Scan command implementation."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.scanner import load_inventory_csv, save_inventory_csv, scan_library
|
||||
from vlm.utils import format_size
|
||||
|
||||
|
||||
def scan_cmd(
|
||||
ctx: CLIContext,
|
||||
output: Path,
|
||||
metadata: bool,
|
||||
reuse_from: Optional[Path],
|
||||
force_refresh_metadata: bool,
|
||||
) -> None:
|
||||
"""Run scan: discover video files and save inventory."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
click.echo(f"Scanning library at: {config.library_root}")
|
||||
click.echo("This may take a while for large libraries...")
|
||||
click.echo()
|
||||
|
||||
progress_state: dict[str, Optional[click.ProgressBar]] = {"bar": None}
|
||||
progress_position = {"current": 0}
|
||||
|
||||
def _scan_progress(processed: int, total: int) -> None:
|
||||
if total <= 0:
|
||||
return
|
||||
if progress_state["bar"] is None:
|
||||
bar = click.progressbar(
|
||||
length=total,
|
||||
label="Scanning files",
|
||||
show_pos=True,
|
||||
)
|
||||
progress_state["bar"] = bar.__enter__()
|
||||
step = processed - progress_position["current"]
|
||||
if step > 0 and progress_state["bar"] is not None:
|
||||
progress_state["bar"].update(step)
|
||||
progress_position["current"] = processed
|
||||
|
||||
metadata_cache = None
|
||||
cache_source = None
|
||||
if not force_refresh_metadata:
|
||||
cache_source = reuse_from if reuse_from is not None else (output if output.exists() else None)
|
||||
|
||||
if metadata and force_refresh_metadata:
|
||||
click.echo("Forcing metadata refresh for all files (cache disabled).")
|
||||
click.echo()
|
||||
|
||||
if metadata and cache_source is not None:
|
||||
click.echo(f"Loading metadata cache from: {cache_source}")
|
||||
try:
|
||||
cached_files = load_inventory_csv(cache_source)
|
||||
metadata_cache = {str(vf.path): vf for vf in cached_files}
|
||||
click.echo(f"Loaded metadata cache entries: {len(metadata_cache)}")
|
||||
click.echo()
|
||||
except Exception as e:
|
||||
click.echo(f"Warning: could not load metadata cache: {e}")
|
||||
click.echo("Continuing without cache.")
|
||||
click.echo()
|
||||
|
||||
try:
|
||||
video_files = scan_library(
|
||||
config.library_root,
|
||||
config,
|
||||
progress_callback=_scan_progress,
|
||||
include_video_metadata=metadata,
|
||||
metadata_cache=metadata_cache,
|
||||
)
|
||||
finally:
|
||||
if progress_state["bar"] is not None:
|
||||
progress_state["bar"].__exit__(None, None, None)
|
||||
click.echo()
|
||||
|
||||
click.echo("Scan complete!")
|
||||
click.echo(f" Total files found: {len(video_files)}")
|
||||
categories = {}
|
||||
total_size = 0
|
||||
for vf in video_files:
|
||||
categories[vf.category] = categories.get(vf.category, 0) + 1
|
||||
total_size += vf.size_bytes
|
||||
click.echo(f" Total size: {format_size(total_size)}")
|
||||
click.echo()
|
||||
click.echo("Files by category:")
|
||||
for category in sorted(categories.keys()):
|
||||
click.echo(f" {category}: {categories[category]}")
|
||||
click.echo()
|
||||
click.echo(f"Saving inventory to: {output}")
|
||||
save_inventory_csv(video_files, output, config.library_root)
|
||||
click.echo("Inventory saved successfully!")
|
||||
logger.info(f"Scan completed: {len(video_files)} files found, saved to {output}")
|
||||
+53
-26
@@ -37,6 +37,10 @@ class Config:
|
||||
translation_mode: str = "bidirectional"
|
||||
translation_fallback_machine: bool = True
|
||||
tmdb_api_key: Optional[str] = None
|
||||
tmdb_bearer_token: Optional[str] = None
|
||||
tmdb_language: str = "zh-CN"
|
||||
tmdb_region: Optional[str] = None
|
||||
tmdb_include_adult: bool = False
|
||||
openai_api_key: Optional[str] = None
|
||||
reputation_min_votes: int = 50
|
||||
reputation_low_score_threshold: float = 6.0
|
||||
@@ -82,11 +86,14 @@ def load_config(path: Path) -> Config:
|
||||
"anime": ["anime"]
|
||||
})
|
||||
|
||||
enrichment = data.get("enrichment", {})
|
||||
enrichment = data.get("enrichment")
|
||||
if enrichment is None:
|
||||
enrichment = data.get("enrich", {})
|
||||
translation = enrichment.get("translation", {})
|
||||
api_keys = enrichment.get("api_keys", {})
|
||||
reputation = enrichment.get("reputation", {})
|
||||
naming = enrichment.get("naming", {})
|
||||
tmdb = enrichment.get("tmdb", {})
|
||||
|
||||
return Config(
|
||||
library_root=library_root,
|
||||
@@ -110,6 +117,10 @@ def load_config(path: Path) -> Config:
|
||||
translation_mode=translation.get("mode", "bidirectional"),
|
||||
translation_fallback_machine=translation.get("fallback_machine", True),
|
||||
tmdb_api_key=api_keys.get("tmdb"),
|
||||
tmdb_bearer_token=api_keys.get("tmdb_bearer"),
|
||||
tmdb_language=tmdb.get("language", "zh-CN"),
|
||||
tmdb_region=tmdb.get("region"),
|
||||
tmdb_include_adult=tmdb.get("include_adult", False),
|
||||
openai_api_key=api_keys.get("openai"),
|
||||
reputation_min_votes=reputation.get("min_votes", 50),
|
||||
reputation_low_score_threshold=reputation.get("low_score_threshold", 6.0),
|
||||
@@ -125,6 +136,38 @@ def create_default_config(path: Path) -> Config:
|
||||
video_extensions=[".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"],
|
||||
)
|
||||
|
||||
enrichment_content = {
|
||||
"enabled": default_config.enrichment_enabled,
|
||||
"incremental": default_config.enrichment_incremental,
|
||||
"refresh_mode": default_config.enrichment_refresh_mode,
|
||||
"providers": default_config.enrichment_providers,
|
||||
"cache_db": str(default_config.enrichment_cache_db),
|
||||
"max_concurrency": default_config.enrichment_max_concurrency,
|
||||
"min_match_score": default_config.enrichment_min_match_score,
|
||||
"translation": {
|
||||
"mode": default_config.translation_mode,
|
||||
"fallback_machine": default_config.translation_fallback_machine,
|
||||
},
|
||||
"api_keys": {
|
||||
"tmdb": default_config.tmdb_api_key,
|
||||
"tmdb_bearer": default_config.tmdb_bearer_token,
|
||||
"openai": default_config.openai_api_key,
|
||||
},
|
||||
"tmdb": {
|
||||
"language": default_config.tmdb_language,
|
||||
"region": default_config.tmdb_region,
|
||||
"include_adult": default_config.tmdb_include_adult,
|
||||
},
|
||||
"reputation": {
|
||||
"min_votes": default_config.reputation_min_votes,
|
||||
"low_score_threshold": default_config.reputation_low_score_threshold,
|
||||
"policy": default_config.reputation_policy,
|
||||
},
|
||||
"naming": {
|
||||
"title_format": default_config.naming_title_format,
|
||||
},
|
||||
}
|
||||
|
||||
yaml_content = {
|
||||
"library_root": str(default_config.library_root),
|
||||
"video_extensions": default_config.video_extensions,
|
||||
@@ -137,31 +180,9 @@ def create_default_config(path: Path) -> Config:
|
||||
"quarantine_dir": default_config.quarantine_dir,
|
||||
"log_level": default_config.log_level,
|
||||
"categories": default_config.categories,
|
||||
"enrichment": {
|
||||
"enabled": default_config.enrichment_enabled,
|
||||
"incremental": default_config.enrichment_incremental,
|
||||
"refresh_mode": default_config.enrichment_refresh_mode,
|
||||
"providers": default_config.enrichment_providers,
|
||||
"cache_db": str(default_config.enrichment_cache_db),
|
||||
"max_concurrency": default_config.enrichment_max_concurrency,
|
||||
"min_match_score": default_config.enrichment_min_match_score,
|
||||
"translation": {
|
||||
"mode": default_config.translation_mode,
|
||||
"fallback_machine": default_config.translation_fallback_machine,
|
||||
},
|
||||
"api_keys": {
|
||||
"tmdb": default_config.tmdb_api_key,
|
||||
"openai": default_config.openai_api_key,
|
||||
},
|
||||
"reputation": {
|
||||
"min_votes": default_config.reputation_min_votes,
|
||||
"low_score_threshold": default_config.reputation_low_score_threshold,
|
||||
"policy": default_config.reputation_policy,
|
||||
},
|
||||
"naming": {
|
||||
"title_format": default_config.naming_title_format,
|
||||
},
|
||||
},
|
||||
"enrichment": enrichment_content,
|
||||
# Backward-compatible alias for users who prefer `enrich`.
|
||||
"enrich": enrichment_content,
|
||||
}
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -287,5 +308,11 @@ def validate_config(config: Config) -> list[str]:
|
||||
errors.append("reputation_min_votes must be >= 0")
|
||||
if not (0.0 <= config.reputation_low_score_threshold <= 10.0):
|
||||
errors.append("reputation_low_score_threshold must be between 0.0 and 10.0")
|
||||
if not isinstance(config.tmdb_language, str) or not config.tmdb_language.strip():
|
||||
errors.append("tmdb_language must be a non-empty string")
|
||||
if config.tmdb_region is not None and not isinstance(config.tmdb_region, str):
|
||||
errors.append("tmdb_region must be a string when set")
|
||||
if not isinstance(config.tmdb_include_adult, bool):
|
||||
errors.append("tmdb_include_adult must be a boolean")
|
||||
|
||||
return errors
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""CLI context shared by cli.py and command modules."""
|
||||
|
||||
import click
|
||||
|
||||
from vlm.config import Config
|
||||
|
||||
|
||||
class CLIContext:
|
||||
"""Context object to pass configuration and logger between commands."""
|
||||
|
||||
def __init__(self, config: Config, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
|
||||
|
||||
pass_context = click.make_pass_decorator(CLIContext)
|
||||
+112
-26
@@ -12,7 +12,7 @@ from urllib.request import urlopen, Request
|
||||
|
||||
from vlm.cache import EnrichmentCache
|
||||
from vlm.config import Config
|
||||
from vlm.providers import ProviderResult, TMDBProvider
|
||||
from vlm.providers import ProviderResult, TMDBAuthError, TMDBProvider, TMDBProviderError
|
||||
from vlm.parser import normalize_title
|
||||
|
||||
RefreshMode = str
|
||||
@@ -59,6 +59,7 @@ def enrich_identities_data(
|
||||
"failed": 0,
|
||||
"api_calls": 0,
|
||||
"failed_items": [],
|
||||
"skip_reasons": {},
|
||||
}
|
||||
|
||||
refresh_all = refresh_mode == "refresh_all"
|
||||
@@ -69,6 +70,7 @@ def enrich_identities_data(
|
||||
title = record.get("title") or _fallback_title_from_filename(record.get("filename"))
|
||||
if not title:
|
||||
stats["skipped"] = int(stats["skipped"]) + 1
|
||||
_increment_skip_reason(stats, "invalid_input")
|
||||
stats["processed"] = int(stats["processed"]) + 1
|
||||
_emit_progress(stats, progress_callback)
|
||||
continue
|
||||
@@ -92,7 +94,7 @@ def enrich_identities_data(
|
||||
_emit_progress(stats, progress_callback)
|
||||
continue
|
||||
|
||||
payload, api_calls, failures = _enrich_record(
|
||||
payload, api_calls, failures, skip_reason = _enrich_record(
|
||||
record,
|
||||
media_type,
|
||||
providers,
|
||||
@@ -100,26 +102,12 @@ def enrich_identities_data(
|
||||
request_timeout=request_timeout,
|
||||
retries=retries,
|
||||
)
|
||||
stats["api_calls"] = int(stats["api_calls"]) + api_calls
|
||||
if failures:
|
||||
failed_items = stats["failed_items"]
|
||||
assert isinstance(failed_items, list)
|
||||
failed_items.extend(failures)
|
||||
stats["failed"] = int(stats["failed"]) + len(failures)
|
||||
|
||||
_apply_payload(record, payload)
|
||||
cache.put_identity(identity_key, fingerprint, payload)
|
||||
|
||||
if payload.get("enriched"):
|
||||
stats["enriched"] = int(stats["enriched"]) + 1
|
||||
else:
|
||||
stats["skipped"] = int(stats["skipped"]) + 1
|
||||
|
||||
if record.get("needs_review"):
|
||||
stats["needs_review"] = int(stats["needs_review"]) + 1
|
||||
|
||||
stats["processed"] = int(stats["processed"]) + 1
|
||||
_emit_progress(stats, progress_callback)
|
||||
_update_stats_after_enrich(
|
||||
stats, payload, failures, api_calls, record, progress_callback, skip_reason
|
||||
)
|
||||
|
||||
metadata = identities_data.setdefault("metadata", {})
|
||||
metadata["enriched"] = True
|
||||
@@ -154,6 +142,33 @@ def _emit_progress(stats: dict[str, int | list[dict[str, str]]], progress_callba
|
||||
)
|
||||
|
||||
|
||||
def _update_stats_after_enrich(
|
||||
stats: dict[str, int | list[dict[str, str]]],
|
||||
payload: dict,
|
||||
failures: list[dict[str, str]],
|
||||
api_calls: int,
|
||||
record: dict,
|
||||
progress_callback: Optional[ProgressCallback],
|
||||
skip_reason: str,
|
||||
) -> None:
|
||||
"""Update stats and emit progress after enriching a single record."""
|
||||
stats["api_calls"] = int(stats["api_calls"]) + api_calls
|
||||
if failures:
|
||||
failed_items = stats["failed_items"]
|
||||
assert isinstance(failed_items, list)
|
||||
failed_items.extend(failures)
|
||||
stats["failed"] = int(stats["failed"]) + len(failures)
|
||||
if payload.get("enriched"):
|
||||
stats["enriched"] = int(stats["enriched"]) + 1
|
||||
else:
|
||||
stats["skipped"] = int(stats["skipped"]) + 1
|
||||
_increment_skip_reason(stats, skip_reason or "no_match")
|
||||
if record.get("needs_review"):
|
||||
stats["needs_review"] = int(stats["needs_review"]) + 1
|
||||
stats["processed"] = int(stats["processed"]) + 1
|
||||
_emit_progress(stats, progress_callback)
|
||||
|
||||
|
||||
def _build_providers(config: Config, *, request_timeout: int, retries: int) -> list:
|
||||
providers = []
|
||||
unsupported: list[str] = []
|
||||
@@ -163,6 +178,10 @@ def _build_providers(config: Config, *, request_timeout: int, retries: int) -> l
|
||||
providers.append(
|
||||
TMDBProvider(
|
||||
config.tmdb_api_key,
|
||||
bearer_token=config.tmdb_bearer_token,
|
||||
language=config.tmdb_language,
|
||||
region=config.tmdb_region,
|
||||
include_adult=config.tmdb_include_adult,
|
||||
timeout_seconds=request_timeout,
|
||||
retries=retries,
|
||||
min_interval_seconds=0.25,
|
||||
@@ -187,37 +206,57 @@ def _enrich_record(
|
||||
*,
|
||||
request_timeout: int,
|
||||
retries: int,
|
||||
) -> tuple[dict, int, list[dict[str, str]]]:
|
||||
) -> tuple[dict, int, list[dict[str, str]], str]:
|
||||
title = record.get("title")
|
||||
year = record.get("year") if media_type == "movie" else None
|
||||
|
||||
provider_results: list[ProviderResult] = []
|
||||
failures: list[dict[str, str]] = []
|
||||
api_calls = 0
|
||||
configured_provider_count = 0
|
||||
|
||||
for provider in providers:
|
||||
api_calls += 1
|
||||
if not _provider_is_configured(provider, config):
|
||||
continue
|
||||
|
||||
configured_provider_count += 1
|
||||
try:
|
||||
result = provider.enrich(title=title, media_type=media_type, year=year)
|
||||
except TMDBAuthError as exc:
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
except TMDBProviderError as exc:
|
||||
failures.append(
|
||||
{
|
||||
"path": str(record.get("path", "")),
|
||||
"title": str(title),
|
||||
"provider": provider.name,
|
||||
"reason": str(exc),
|
||||
}
|
||||
)
|
||||
api_calls += provider.last_request_count
|
||||
continue
|
||||
except Exception as exc:
|
||||
failures.append(
|
||||
{
|
||||
"path": str(record.get("path", "")),
|
||||
"title": str(title),
|
||||
"provider": getattr(provider, "name", "unknown"),
|
||||
"provider": provider.name,
|
||||
"reason": str(exc),
|
||||
}
|
||||
)
|
||||
api_calls += provider.last_request_count
|
||||
continue
|
||||
|
||||
api_calls += provider.last_request_count
|
||||
if result:
|
||||
provider_results.append(result)
|
||||
|
||||
merged = _merge_provider_results(provider_results)
|
||||
|
||||
# Optional AI fallback for missing translated titles.
|
||||
if config.translation_fallback_machine:
|
||||
if config.translation_fallback_machine and config.openai_api_key:
|
||||
if not merged.get("title_zh"):
|
||||
api_calls += 1
|
||||
translated = _translate_with_openai(
|
||||
title,
|
||||
target_language="Chinese (Simplified)",
|
||||
@@ -225,12 +264,12 @@ def _enrich_record(
|
||||
timeout_seconds=request_timeout,
|
||||
retries=retries,
|
||||
)
|
||||
api_calls += 1
|
||||
if translated:
|
||||
merged["title_zh"] = translated
|
||||
merged["translation_source"] = merged.get("translation_source") or "openai"
|
||||
|
||||
if not merged.get("title_en"):
|
||||
api_calls += 1
|
||||
translated = _translate_with_openai(
|
||||
title,
|
||||
target_language="English",
|
||||
@@ -238,7 +277,6 @@ def _enrich_record(
|
||||
timeout_seconds=request_timeout,
|
||||
retries=retries,
|
||||
)
|
||||
api_calls += 1
|
||||
if translated:
|
||||
merged["title_en"] = translated
|
||||
merged["translation_source"] = merged.get("translation_source") or "openai"
|
||||
@@ -266,7 +304,55 @@ def _enrich_record(
|
||||
merged["enriched"] = bool(provider_results or merged.get("translation_source"))
|
||||
merged["display_title"] = _build_display_title(record, merged, config)
|
||||
|
||||
return merged, api_calls, failures
|
||||
skip_reason = _determine_skip_reason(
|
||||
provider_results=provider_results,
|
||||
failures=failures,
|
||||
configured_provider_count=configured_provider_count,
|
||||
api_calls=api_calls,
|
||||
)
|
||||
|
||||
return merged, api_calls, failures, skip_reason
|
||||
|
||||
|
||||
def _determine_skip_reason(
|
||||
*,
|
||||
provider_results: list[ProviderResult],
|
||||
failures: list[dict[str, str]],
|
||||
configured_provider_count: int,
|
||||
api_calls: int,
|
||||
) -> str:
|
||||
if provider_results:
|
||||
return ""
|
||||
if configured_provider_count == 0 and api_calls == 0:
|
||||
return "no_key"
|
||||
if failures:
|
||||
for failure in failures:
|
||||
reason = str(failure.get("reason", "")).lower()
|
||||
if "rate limit" in reason or "(429)" in reason:
|
||||
return "rate_limited"
|
||||
if "authentication failed" in reason or "(401/403)" in reason:
|
||||
return "auth_error"
|
||||
return "provider_error"
|
||||
return "no_match"
|
||||
|
||||
|
||||
def _increment_skip_reason(stats: dict[str, int | list[dict[str, str]]], reason: str) -> None:
|
||||
if not reason:
|
||||
return
|
||||
current = stats.get("skip_reasons")
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
stats["skip_reasons"] = current
|
||||
current[reason] = int(current.get(reason, 0)) + 1
|
||||
|
||||
|
||||
def _provider_is_configured(provider: object, config: Config) -> bool:
|
||||
provider_name = provider.name.lower()
|
||||
|
||||
if provider_name == "tmdb":
|
||||
return bool(config.tmdb_bearer_token or config.tmdb_api_key)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _merge_provider_results(results: list[ProviderResult]) -> dict:
|
||||
|
||||
+8
-7
@@ -16,6 +16,7 @@ from uuid import uuid4
|
||||
|
||||
from .logging_config import get_logger, log_operation
|
||||
from .models import ExecutionPlan, FileOperation, OperationResult, RollbackLog
|
||||
from .utils import ensure_utc, utc_now
|
||||
|
||||
|
||||
class ExecutionEngine:
|
||||
@@ -86,7 +87,7 @@ class ExecutionEngine:
|
||||
rollback_log = RollbackLog(
|
||||
log_id=str(uuid4()),
|
||||
execution_plan_id=plan.plan_id,
|
||||
executed_at=datetime.now(),
|
||||
executed_at=utc_now(),
|
||||
operations=successful_operations
|
||||
)
|
||||
|
||||
@@ -116,8 +117,8 @@ class ExecutionEngine:
|
||||
Returns:
|
||||
OperationResult with success status and any error message
|
||||
"""
|
||||
executed_at = datetime.now()
|
||||
|
||||
executed_at = utc_now()
|
||||
|
||||
# Handle no-op operations
|
||||
if operation.operation_type == "no-op":
|
||||
log_operation(
|
||||
@@ -385,19 +386,19 @@ class ExecutionEngine:
|
||||
conflict_reason=None
|
||||
)
|
||||
|
||||
# Reconstruct OperationResult
|
||||
# Reconstruct OperationResult (normalize naive datetime to UTC)
|
||||
op_result = OperationResult(
|
||||
operation=file_op,
|
||||
success=op_data["success"],
|
||||
error_message=op_data["error_message"],
|
||||
executed_at=datetime.fromisoformat(op_data["executed_at"])
|
||||
executed_at=ensure_utc(datetime.fromisoformat(op_data["executed_at"]))
|
||||
)
|
||||
operations.append(op_result)
|
||||
|
||||
rollback_log = RollbackLog(
|
||||
log_id=log_data["log_id"],
|
||||
execution_plan_id=log_data["execution_plan_id"],
|
||||
executed_at=datetime.fromisoformat(log_data["executed_at"]),
|
||||
executed_at=ensure_utc(datetime.fromisoformat(log_data["executed_at"])),
|
||||
operations=operations
|
||||
)
|
||||
|
||||
@@ -468,7 +469,7 @@ class ExecutionEngine:
|
||||
OperationResult indicating success or failure of the rollback
|
||||
"""
|
||||
operation = original_result.operation
|
||||
executed_at = datetime.now()
|
||||
executed_at = utc_now()
|
||||
|
||||
# Skip no-op operations
|
||||
if operation.operation_type == "no-op":
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
"""Unified I/O layer for inventory and identities data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from vlm.models import MovieIdentity, SeriesIdentity, VideoFile
|
||||
from vlm.utils import utc_now
|
||||
|
||||
# Re-export scanner CSV functions so CLI and others use a single I/O entry point
|
||||
from vlm.scanner import load_inventory_csv, save_inventory_csv
|
||||
|
||||
__all__ = [
|
||||
"load_inventory_csv",
|
||||
"save_inventory_csv",
|
||||
"load_identities_json",
|
||||
"save_identities_json",
|
||||
"identities_to_plan_input",
|
||||
"identities_to_analysis_input",
|
||||
]
|
||||
|
||||
|
||||
def load_identities_json(path: Path) -> dict:
|
||||
"""Load identities from JSON file."""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_identities_json(data: dict, path: Path) -> None:
|
||||
"""Save identities dict to JSON file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def _video_file_from_record(record: dict) -> VideoFile:
|
||||
"""Build a minimal VideoFile from an identities record (no inventory metadata)."""
|
||||
return VideoFile(
|
||||
path=Path(record["path"]),
|
||||
filename=record["filename"],
|
||||
size_bytes=0,
|
||||
modified_timestamp=utc_now(),
|
||||
category=record["category"],
|
||||
resolution=None,
|
||||
codec=None,
|
||||
duration_seconds=None,
|
||||
bitrate_kbps=None,
|
||||
)
|
||||
|
||||
|
||||
def _movie_identity_from_record(m: dict) -> MovieIdentity:
|
||||
"""Build MovieIdentity from identities JSON record."""
|
||||
is_approved = m.get("review_status") == "approved"
|
||||
return MovieIdentity(
|
||||
title=m.get("display_title", m["title"]),
|
||||
year=m.get("year"),
|
||||
confidence=m["confidence"],
|
||||
needs_review=(m["needs_review"] and not is_approved),
|
||||
original_filename=m["filename"],
|
||||
canonical_id=m.get("canonical_id"),
|
||||
title_zh=m.get("title_zh"),
|
||||
title_en=m.get("title_en"),
|
||||
translation_source=m.get("translation_source"),
|
||||
reputation_score=m.get("reputation_score"),
|
||||
reputation_votes=m.get("reputation_votes"),
|
||||
reputation_source=m.get("reputation_source"),
|
||||
review_status=m.get("review_status", "pending"),
|
||||
enrichment_confidence=m.get("enrichment_confidence"),
|
||||
provider_metadata=m.get("provider_metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
def _series_identity_from_record(s: dict) -> SeriesIdentity:
|
||||
"""Build SeriesIdentity from identities JSON record."""
|
||||
is_approved = s.get("review_status") == "approved"
|
||||
return SeriesIdentity(
|
||||
title=s.get("display_title", s["title"]),
|
||||
season=s.get("season"),
|
||||
episodes=s.get("episodes", []),
|
||||
confidence=s["confidence"],
|
||||
needs_review=(s["needs_review"] and not is_approved),
|
||||
original_filename=s["filename"],
|
||||
canonical_id=s.get("canonical_id"),
|
||||
title_zh=s.get("title_zh"),
|
||||
title_en=s.get("title_en"),
|
||||
translation_source=s.get("translation_source"),
|
||||
reputation_score=s.get("reputation_score"),
|
||||
reputation_votes=s.get("reputation_votes"),
|
||||
reputation_source=s.get("reputation_source"),
|
||||
review_status=s.get("review_status", "pending"),
|
||||
enrichment_confidence=s.get("enrichment_confidence"),
|
||||
provider_metadata=s.get("provider_metadata", {}),
|
||||
)
|
||||
|
||||
|
||||
def identities_to_plan_input(
|
||||
data: dict,
|
||||
) -> list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]]:
|
||||
"""Convert identities JSON dict to list of (VideoFile, Identity) for plan generator."""
|
||||
result: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]] = []
|
||||
movies_data = data.get("movies", [])
|
||||
series_data = data.get("series", [])
|
||||
anime_data = data.get("anime", [])
|
||||
other_data = data.get("other", [])
|
||||
|
||||
for m in movies_data:
|
||||
result.append((_video_file_from_record(m), _movie_identity_from_record(m)))
|
||||
for s in series_data:
|
||||
result.append((_video_file_from_record(s), _series_identity_from_record(s)))
|
||||
for a in anime_data:
|
||||
result.append((_video_file_from_record(a), None))
|
||||
for o in other_data:
|
||||
result.append((_video_file_from_record(o), None))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def identities_to_analysis_input(
|
||||
data: dict,
|
||||
inventory_files: list[VideoFile] | None = None,
|
||||
) -> tuple[list[MovieIdentity], list[SeriesIdentity], list[VideoFile]]:
|
||||
"""Convert identities JSON dict to analysis inputs; optionally merge inventory metadata by path."""
|
||||
movies_data = data.get("movies", [])
|
||||
series_data = data.get("series", [])
|
||||
movie_identities = []
|
||||
for m in movies_data:
|
||||
movie_identities.append(
|
||||
MovieIdentity(
|
||||
title=m["title"],
|
||||
year=m.get("year"),
|
||||
confidence=m["confidence"],
|
||||
needs_review=m["needs_review"],
|
||||
original_filename=m["filename"],
|
||||
)
|
||||
)
|
||||
series_identities = []
|
||||
for s in series_data:
|
||||
series_identities.append(
|
||||
SeriesIdentity(
|
||||
title=s["title"],
|
||||
season=s.get("season"),
|
||||
episodes=s.get("episodes", []),
|
||||
confidence=s["confidence"],
|
||||
needs_review=s["needs_review"],
|
||||
original_filename=s["filename"],
|
||||
)
|
||||
)
|
||||
video_files: list[VideoFile] = []
|
||||
path_to_inventory: dict[str, VideoFile] = {}
|
||||
if inventory_files:
|
||||
path_to_inventory = {str(vf.path): vf for vf in inventory_files}
|
||||
for m in movies_data:
|
||||
vf = _video_file_from_record(m)
|
||||
if path_to_inventory:
|
||||
inv = path_to_inventory.get(str(vf.path))
|
||||
if inv:
|
||||
vf = VideoFile(
|
||||
path=inv.path,
|
||||
filename=inv.filename,
|
||||
size_bytes=inv.size_bytes,
|
||||
modified_timestamp=inv.modified_timestamp,
|
||||
category=inv.category,
|
||||
resolution=inv.resolution,
|
||||
codec=inv.codec,
|
||||
duration_seconds=inv.duration_seconds,
|
||||
bitrate_kbps=inv.bitrate_kbps,
|
||||
)
|
||||
video_files.append(vf)
|
||||
for s in series_data:
|
||||
vf = _video_file_from_record(s)
|
||||
if path_to_inventory:
|
||||
inv = path_to_inventory.get(str(vf.path))
|
||||
if inv:
|
||||
vf = VideoFile(
|
||||
path=inv.path,
|
||||
filename=inv.filename,
|
||||
size_bytes=inv.size_bytes,
|
||||
modified_timestamp=inv.modified_timestamp,
|
||||
category=inv.category,
|
||||
resolution=inv.resolution,
|
||||
codec=inv.codec,
|
||||
duration_seconds=inv.duration_seconds,
|
||||
bitrate_kbps=inv.bitrate_kbps,
|
||||
)
|
||||
video_files.append(vf)
|
||||
return movie_identities, series_identities, video_files
|
||||
+11
-2
@@ -41,7 +41,12 @@ class VideoFile:
|
||||
@dataclass
|
||||
class MovieIdentity:
|
||||
"""Represents the parsed identity of a movie file.
|
||||
|
||||
|
||||
review_status (pending/approved/rejected) and needs_review overlap in meaning:
|
||||
needs_review is True when (review_status == 'pending') and parsing or
|
||||
enrichment indicates the record should be reviewed; once approved, needs_review
|
||||
is typically False.
|
||||
|
||||
Attributes:
|
||||
title: Extracted movie title (normalized)
|
||||
year: Extracted release year (None if not found)
|
||||
@@ -69,7 +74,11 @@ class MovieIdentity:
|
||||
@dataclass
|
||||
class SeriesIdentity:
|
||||
"""Represents the parsed identity of a TV series episode file.
|
||||
|
||||
|
||||
review_status (pending/approved/rejected) and needs_review overlap in meaning:
|
||||
needs_review is True when (review_status == 'pending') and parsing or
|
||||
enrichment indicates the record should be reviewed.
|
||||
|
||||
Attributes:
|
||||
title: Extracted series title (normalized)
|
||||
season: Extracted season number (None if not found)
|
||||
|
||||
+23
-6
@@ -9,6 +9,11 @@ from typing import Optional
|
||||
|
||||
from vlm.models import MovieIdentity, SeriesIdentity
|
||||
|
||||
# Default extensions used when extensions param is not provided (matches config default)
|
||||
DEFAULT_VIDEO_EXTENSIONS = [
|
||||
".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v"
|
||||
]
|
||||
|
||||
|
||||
# Quality tags to remove from titles
|
||||
QUALITY_TAGS = [
|
||||
@@ -80,7 +85,10 @@ def normalize_title(title: str) -> str:
|
||||
return title.strip()
|
||||
|
||||
|
||||
def parse_movie(filename: str) -> MovieIdentity:
|
||||
def parse_movie(
|
||||
filename: str,
|
||||
extensions: Optional[list[str]] = None,
|
||||
) -> MovieIdentity:
|
||||
"""Parse a movie filename to extract title and year.
|
||||
|
||||
Supports patterns:
|
||||
@@ -91,14 +99,17 @@ def parse_movie(filename: str) -> MovieIdentity:
|
||||
|
||||
Args:
|
||||
filename: Movie filename to parse
|
||||
extensions: Video extensions to strip (default: DEFAULT_VIDEO_EXTENSIONS)
|
||||
|
||||
Returns:
|
||||
MovieIdentity with extracted information
|
||||
"""
|
||||
if extensions is None:
|
||||
extensions = DEFAULT_VIDEO_EXTENSIONS
|
||||
# Remove file extension
|
||||
name_without_ext = filename
|
||||
for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
for ext in extensions:
|
||||
if name_without_ext.lower().endswith(ext.lower()):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
@@ -148,7 +159,10 @@ def parse_movie(filename: str) -> MovieIdentity:
|
||||
)
|
||||
|
||||
|
||||
def parse_series(filename: str) -> SeriesIdentity:
|
||||
def parse_series(
|
||||
filename: str,
|
||||
extensions: Optional[list[str]] = None,
|
||||
) -> SeriesIdentity:
|
||||
"""Parse a series filename to extract title, season, and episode numbers.
|
||||
|
||||
Supports patterns:
|
||||
@@ -160,14 +174,17 @@ def parse_series(filename: str) -> SeriesIdentity:
|
||||
|
||||
Args:
|
||||
filename: Series filename to parse
|
||||
extensions: Video extensions to strip (default: DEFAULT_VIDEO_EXTENSIONS)
|
||||
|
||||
Returns:
|
||||
SeriesIdentity with extracted information
|
||||
"""
|
||||
if extensions is None:
|
||||
extensions = DEFAULT_VIDEO_EXTENSIONS
|
||||
# Remove file extension
|
||||
name_without_ext = filename
|
||||
for ext in ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm', '.m4v']:
|
||||
if name_without_ext.lower().endswith(ext):
|
||||
for ext in extensions:
|
||||
if name_without_ext.lower().endswith(ext.lower()):
|
||||
name_without_ext = name_without_ext[:-len(ext)]
|
||||
break
|
||||
|
||||
|
||||
+5
-3
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from vlm.config import Config
|
||||
from vlm.utils import ensure_utc, utc_now
|
||||
from vlm.models import (
|
||||
ExecutionPlan,
|
||||
FileOperation,
|
||||
@@ -48,7 +49,7 @@ def generate_plan(
|
||||
|
||||
return ExecutionPlan(
|
||||
plan_id=str(uuid.uuid4()),
|
||||
created_at=datetime.now(),
|
||||
created_at=utc_now(),
|
||||
operations=operations,
|
||||
summary=summary
|
||||
)
|
||||
@@ -391,10 +392,11 @@ def load_plan(input_path: Path) -> ExecutionPlan:
|
||||
for op in plan_dict["operations"]
|
||||
]
|
||||
|
||||
# Reconstruct ExecutionPlan
|
||||
# Reconstruct ExecutionPlan (normalize naive datetime to UTC for backward compatibility)
|
||||
created_at = ensure_utc(datetime.fromisoformat(plan_dict["created_at"]))
|
||||
return ExecutionPlan(
|
||||
plan_id=plan_dict["plan_id"],
|
||||
created_at=datetime.fromisoformat(plan_dict["created_at"]),
|
||||
created_at=created_at,
|
||||
operations=operations,
|
||||
summary=plan_dict["summary"]
|
||||
)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Provider implementations for enrichment."""
|
||||
|
||||
from vlm.providers.base import EnrichmentProvider, ProviderResult
|
||||
from vlm.providers.tmdb import TMDBProvider
|
||||
from vlm.providers.tmdb import TMDBAuthError, TMDBProvider, TMDBProviderError
|
||||
|
||||
__all__ = [
|
||||
"EnrichmentProvider",
|
||||
"ProviderResult",
|
||||
"TMDBProvider",
|
||||
"TMDBAuthError",
|
||||
"TMDBProviderError",
|
||||
]
|
||||
|
||||
@@ -26,6 +26,7 @@ class EnrichmentProvider(Protocol):
|
||||
"""Protocol for title/score providers."""
|
||||
|
||||
name: str
|
||||
last_request_count: int # API request count for the last enrich() call (reset at start of each call)
|
||||
|
||||
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
|
||||
"""Return normalized metadata for a single identity."""
|
||||
|
||||
+170
-18
@@ -3,14 +3,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any, Optional
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from vlm.providers.base import ProviderResult
|
||||
|
||||
|
||||
class TMDBAuthError(RuntimeError):
|
||||
"""Raised when TMDB credentials are invalid."""
|
||||
|
||||
|
||||
class TMDBProviderError(RuntimeError):
|
||||
"""Raised for TMDB errors that should be reported as provider failures."""
|
||||
|
||||
|
||||
class TMDBProvider:
|
||||
"""Fetch translations and reputation data from TMDB."""
|
||||
|
||||
@@ -19,48 +30,66 @@ class TMDBProvider:
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str],
|
||||
*,
|
||||
bearer_token: Optional[str] = None,
|
||||
language: str = "zh-CN",
|
||||
region: Optional[str] = None,
|
||||
include_adult: bool = False,
|
||||
timeout_seconds: int = 6,
|
||||
retries: int = 2,
|
||||
min_interval_seconds: float = 0.25,
|
||||
backoff_base_seconds: float = 0.5,
|
||||
backoff_max_seconds: float = 4.0,
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.bearer_token = bearer_token
|
||||
self.language = language
|
||||
self.region = region
|
||||
self.include_adult = include_adult
|
||||
self.base_url = "https://api.themoviedb.org/3"
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.retries = retries
|
||||
self.min_interval_seconds = min_interval_seconds
|
||||
self.backoff_base_seconds = backoff_base_seconds
|
||||
self.backoff_max_seconds = backoff_max_seconds
|
||||
self._last_request_at = 0.0
|
||||
self.last_request_count = 0
|
||||
|
||||
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
|
||||
if not self.api_key:
|
||||
self.last_request_count = 0
|
||||
if not (self.bearer_token or self.api_key):
|
||||
return None
|
||||
|
||||
search_type = "tv" if media_type in {"series", "anime", "tv"} else "movie"
|
||||
query_params = {
|
||||
"api_key": self.api_key,
|
||||
query_params: dict[str, Any] = {
|
||||
"query": title,
|
||||
"language": self.language,
|
||||
"include_adult": str(self.include_adult).lower(),
|
||||
}
|
||||
if self.region:
|
||||
query_params["region"] = self.region
|
||||
if year and search_type == "movie":
|
||||
query_params["year"] = year
|
||||
|
||||
search_data = self._get_json(f"{self.base_url}/search/{search_type}", query_params)
|
||||
search_data = self._get_json(f"/search/{search_type}", query_params)
|
||||
if not search_data:
|
||||
return None
|
||||
|
||||
results = search_data.get("results", [])
|
||||
if not results:
|
||||
if not isinstance(results, list) or not results:
|
||||
return None
|
||||
|
||||
candidate, match_score = self._pick_best_candidate(results, title, year)
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
candidate = results[0]
|
||||
tmdb_id = candidate.get("id")
|
||||
if tmdb_id is None:
|
||||
return None
|
||||
|
||||
details = self._get_json(
|
||||
f"{self.base_url}/{search_type}/{tmdb_id}",
|
||||
{"api_key": self.api_key, "language": self.language},
|
||||
f"/{search_type}/{tmdb_id}",
|
||||
{"language": self.language},
|
||||
)
|
||||
if not details:
|
||||
details = candidate
|
||||
@@ -79,10 +108,78 @@ class TMDBProvider:
|
||||
reputation_score=float(vote_average) if vote_average is not None else None,
|
||||
reputation_votes=int(vote_count) if vote_count is not None else None,
|
||||
reputation_source=self.name,
|
||||
match_score=float(candidate.get("popularity", 0.0)) if candidate.get("popularity") is not None else None,
|
||||
match_score=round(match_score, 3),
|
||||
raw_metadata={"media_type": search_type, "id": str(tmdb_id)},
|
||||
)
|
||||
|
||||
def _pick_best_candidate(
|
||||
self,
|
||||
results: list[dict[str, Any]],
|
||||
query_title: str,
|
||||
query_year: Optional[int],
|
||||
) -> tuple[Optional[dict[str, Any]], float]:
|
||||
query_norm = self._normalize_title(query_title)
|
||||
best_candidate: Optional[dict[str, Any]] = None
|
||||
best_score = -1.0
|
||||
|
||||
for result in results:
|
||||
candidates = [
|
||||
result.get("title"),
|
||||
result.get("name"),
|
||||
result.get("original_title"),
|
||||
result.get("original_name"),
|
||||
]
|
||||
title_score = 0.0
|
||||
for candidate_title in candidates:
|
||||
if not isinstance(candidate_title, str) or not candidate_title.strip():
|
||||
continue
|
||||
candidate_norm = self._normalize_title(candidate_title)
|
||||
if not candidate_norm:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, query_norm, candidate_norm).ratio()
|
||||
if ratio > title_score:
|
||||
title_score = ratio
|
||||
|
||||
year_bonus = 0.0
|
||||
if query_year is not None:
|
||||
release = result.get("release_date") or result.get("first_air_date")
|
||||
candidate_year = self._extract_year(release)
|
||||
if candidate_year is None:
|
||||
year_bonus = -0.1
|
||||
else:
|
||||
delta = abs(candidate_year - query_year)
|
||||
if delta == 0:
|
||||
year_bonus = 0.2
|
||||
elif delta == 1:
|
||||
year_bonus = 0.1
|
||||
else:
|
||||
year_bonus = -0.2
|
||||
|
||||
popularity = result.get("popularity")
|
||||
popularity_bonus = 0.0
|
||||
if isinstance(popularity, (int, float)):
|
||||
popularity_bonus = min(float(popularity) / 1000.0, 0.1)
|
||||
|
||||
total_score = title_score + year_bonus + popularity_bonus
|
||||
if total_score > best_score:
|
||||
best_score = total_score
|
||||
best_candidate = result
|
||||
|
||||
return best_candidate, max(best_score, 0.0)
|
||||
|
||||
def _normalize_title(self, text: str) -> str:
|
||||
lowered = text.lower().strip()
|
||||
stripped = re.sub(r"[^\w\s]", " ", lowered)
|
||||
return " ".join(stripped.split())
|
||||
|
||||
def _extract_year(self, date_text: Any) -> Optional[int]:
|
||||
if not isinstance(date_text, str) or len(date_text) < 4:
|
||||
return None
|
||||
try:
|
||||
return int(date_text[:4])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _wait_for_rate_limit(self) -> None:
|
||||
if self.min_interval_seconds <= 0:
|
||||
return
|
||||
@@ -91,18 +188,73 @@ class TMDBProvider:
|
||||
if elapsed < self.min_interval_seconds:
|
||||
time.sleep(self.min_interval_seconds - elapsed)
|
||||
|
||||
def _get_json(self, url: str, params: dict) -> Optional[dict]:
|
||||
full_url = f"{url}?{urlencode(params)}"
|
||||
request = Request(full_url, headers={"Accept": "application/json"})
|
||||
def _sleep_backoff(self, attempt: int, retry_after: Optional[float] = None) -> None:
|
||||
if retry_after is not None and retry_after > 0:
|
||||
time.sleep(min(retry_after, self.backoff_max_seconds))
|
||||
return
|
||||
delay = min(self.backoff_base_seconds * (2 ** attempt), self.backoff_max_seconds)
|
||||
time.sleep(delay)
|
||||
|
||||
for _ in range(max(self.retries + 1, 1)):
|
||||
def _get_json(self, path: str, params: dict[str, Any]) -> Optional[dict]:
|
||||
request_params = dict(params)
|
||||
if not self.bearer_token and self.api_key:
|
||||
request_params["api_key"] = self.api_key
|
||||
|
||||
full_url = f"{self.base_url}{path}?{urlencode(request_params)}"
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||
|
||||
for attempt in range(max(self.retries + 1, 1)):
|
||||
self._wait_for_rate_limit()
|
||||
self.last_request_count += 1
|
||||
request = Request(full_url, headers=headers)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout_seconds) as response:
|
||||
payload = response.read().decode("utf-8")
|
||||
self._last_request_at = time.monotonic()
|
||||
return json.loads(payload)
|
||||
except Exception:
|
||||
parsed = json.loads(payload)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
except HTTPError as exc:
|
||||
self._last_request_at = time.monotonic()
|
||||
continue
|
||||
code = exc.code
|
||||
if code in (401, 403):
|
||||
raise TMDBAuthError(
|
||||
"TMDB authentication failed (401/403). "
|
||||
"Configure enrichment.api_keys.tmdb_bearer or enrichment.api_keys.tmdb."
|
||||
) from exc
|
||||
if code == 404:
|
||||
return None
|
||||
if code == 429:
|
||||
if attempt < self.retries:
|
||||
retry_after = None
|
||||
try:
|
||||
retry_after_header = exc.headers.get("Retry-After")
|
||||
retry_after = float(retry_after_header) if retry_after_header else None
|
||||
except Exception:
|
||||
retry_after = None
|
||||
self._sleep_backoff(attempt, retry_after=retry_after)
|
||||
continue
|
||||
raise TMDBProviderError("TMDB rate limit exceeded (429)") from exc
|
||||
if 500 <= code < 600 and attempt < self.retries:
|
||||
self._sleep_backoff(attempt)
|
||||
continue
|
||||
if 500 <= code < 600:
|
||||
raise TMDBProviderError(f"TMDB server error ({code})") from exc
|
||||
raise TMDBProviderError(f"TMDB request failed with HTTP {code}") from exc
|
||||
except URLError as exc:
|
||||
self._last_request_at = time.monotonic()
|
||||
if attempt < self.retries:
|
||||
self._sleep_backoff(attempt)
|
||||
continue
|
||||
raise TMDBProviderError(f"TMDB network error: {exc}") from exc
|
||||
except Exception as exc:
|
||||
self._last_request_at = time.monotonic()
|
||||
if attempt < self.retries:
|
||||
self._sleep_backoff(attempt)
|
||||
continue
|
||||
raise TMDBProviderError(f"TMDB unexpected error: {exc}") from exc
|
||||
|
||||
return None
|
||||
|
||||
@@ -16,6 +16,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .config import Config
|
||||
from .utils import utc_now
|
||||
from .logging_config import get_logger, log_operation
|
||||
from .models import QuarantineEntry, QuarantineManifest, OperationResult, FileOperation
|
||||
|
||||
@@ -58,7 +59,7 @@ class QuarantineManager:
|
||||
Raises:
|
||||
ValueError: If file is in anime or other category (not supported in v1)
|
||||
"""
|
||||
executed_at = datetime.now()
|
||||
executed_at = utc_now()
|
||||
|
||||
# Verify file exists
|
||||
if not file_path.exists():
|
||||
@@ -526,7 +527,7 @@ class QuarantineManager:
|
||||
Returns:
|
||||
OperationResult indicating success or failure
|
||||
"""
|
||||
executed_at = datetime.now()
|
||||
executed_at = utc_now()
|
||||
|
||||
# Verify quarantine file exists
|
||||
if not quarantine_path.exists():
|
||||
|
||||
+7
-6
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from vlm.models import FileState, StateStore
|
||||
from vlm.utils import ensure_utc, utc_now
|
||||
|
||||
|
||||
# Valid status values
|
||||
@@ -32,20 +33,20 @@ def load_state(path: Path) -> StateStore:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Parse states dictionary
|
||||
# Parse states dictionary (normalize naive datetime to UTC)
|
||||
states = {}
|
||||
for file_path_str, state_data in data.get('states', {}).items():
|
||||
states[file_path_str] = FileState(
|
||||
file_path=Path(state_data['file_path']),
|
||||
status=state_data['status'],
|
||||
reason=state_data.get('reason'),
|
||||
updated_at=datetime.fromisoformat(state_data['updated_at'])
|
||||
updated_at=ensure_utc(datetime.fromisoformat(state_data['updated_at']))
|
||||
)
|
||||
|
||||
return StateStore(
|
||||
states=states,
|
||||
version=data.get('version', '1.0'),
|
||||
last_updated=datetime.fromisoformat(data['last_updated'])
|
||||
last_updated=ensure_utc(datetime.fromisoformat(data['last_updated']))
|
||||
)
|
||||
|
||||
|
||||
@@ -101,7 +102,7 @@ class StateManager:
|
||||
self.store = StateStore(
|
||||
states={},
|
||||
version='1.0',
|
||||
last_updated=datetime.now()
|
||||
last_updated=utc_now()
|
||||
)
|
||||
|
||||
def get_file_state(self, file_path: Path) -> Optional[FileState]:
|
||||
@@ -136,7 +137,7 @@ class StateManager:
|
||||
)
|
||||
|
||||
file_path_str = str(file_path)
|
||||
now = datetime.now()
|
||||
now = utc_now()
|
||||
|
||||
self.store.states[file_path_str] = FileState(
|
||||
file_path=file_path,
|
||||
@@ -170,7 +171,7 @@ class StateManager:
|
||||
file_path_str = str(file_path)
|
||||
if file_path_str in self.store.states:
|
||||
del self.store.states[file_path_str]
|
||||
self.store.last_updated = datetime.now()
|
||||
self.store.last_updated = utc_now()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Save the current state store to disk."""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared utilities for Video Library Manager."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return current UTC time (timezone-aware)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_utc(dt: datetime) -> datetime:
|
||||
"""Ensure datetime is timezone-aware UTC (for backward compatibility with naive ISO strings)."""
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size in human-readable format (e.g. 1.5 GB, 234.2 MB)."""
|
||||
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||
if size_bytes < 1024.0:
|
||||
return f"{size_bytes:.1f} {unit}"
|
||||
size_bytes /= 1024.0
|
||||
return f"{size_bytes:.1f} PB"
|
||||
Reference in New Issue
Block a user