refactor: consolidate skill docs, add anti-drift tests, and apply audit fixes
DLO-13: Restructure vlm-library-workflow skill as safety contract layer. - Rewrite SKILL.md (69 lines): safety contract, execution threshold semantics, six-step high-risk loop, decision rules, phase skeleton - Delete redundant references (cli-reference, workflow, command-recipes, dev-guide) - Add triage.md (failure mapping + preflight) and dev-map.md (module→test mapping) - Add tests/test_docs_consistency.py: 78 parametrized tests verifying documented vlm commands exist in CLI registry - Add CSV path mismatch test to test_plan_review.py (4th safety gate path) - Delete vlm-expert.skill (Gemini package, 7 months stale) and README Gemini section DLO-2 audit fixes: rate limiter injection, symmetric quarantine categories, review-plan safety gates, parser improvements, planner validation. CLI modularization: commands/ directory with one module per command group.
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -123,3 +125,63 @@ def initialize_cli_context(config: Path, log_level: Optional[str]) -> CLIContext
|
||||
|
||||
logger = setup_logging(log_level=cfg.log_level)
|
||||
return CLIContext(config=cfg, logger=logger)
|
||||
|
||||
|
||||
def emit_report(content: str, output: Optional[Path]) -> None:
|
||||
"""Write report content to a file or stdout."""
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(content, encoding="utf-8")
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(content)
|
||||
|
||||
|
||||
def optional_plan_summary(plan: Optional[Path]):
|
||||
"""Load optional plan JSON and return a display summary string."""
|
||||
if plan is None:
|
||||
return None
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
raise SystemExit(1)
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
|
||||
return preferred_plan_summary(load_plan(plan))
|
||||
|
||||
|
||||
def run_command(
|
||||
ctx: CLIContext,
|
||||
fn: Callable[[], None],
|
||||
*,
|
||||
stage: str,
|
||||
json_errors: bool = False,
|
||||
) -> None:
|
||||
"""Run a command body with consistent CLI error handling."""
|
||||
try:
|
||||
fn()
|
||||
except FileNotFoundError as e:
|
||||
command_error(ctx, f"Error: Input file not found: {e}", f"{stage} file not found: {e}")
|
||||
except json.JSONDecodeError as e:
|
||||
if json_errors:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"{stage} JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
except ValueError as e:
|
||||
command_error(ctx, f"Error: {e}", f"{stage} validation failed: {e}")
|
||||
except OSError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error during {stage}: {e}",
|
||||
f"{stage} I/O failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(f"Error during {stage}: {e}", err=True)
|
||||
ctx.logger.error(f"{stage} failed: {e}", exc_info=True)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
@@ -6,7 +6,8 @@ from typing import Optional
|
||||
import click
|
||||
|
||||
from vlm.analysis import analyze_series_completeness, detect_duplicates
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import (
|
||||
identities_to_analysis_input,
|
||||
load_identities_json,
|
||||
@@ -120,3 +121,23 @@ def analyze_cmd(
|
||||
f"Analysis completed: {len(completeness_results)} incomplete series, "
|
||||
f"{len(duplicate_groups)} duplicate groups, saved to {output}"
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
|
||||
@click.option("--inventory", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@pass_context
|
||||
def analyze(ctx: CLIContext, input: Path, output: Path, inventory: Optional[Path]):
|
||||
"""Analyze completeness and duplicates."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: analyze_cmd(
|
||||
ctx,
|
||||
resolve_legacy_default_input_path(input, "input", "identities.json", "--input"),
|
||||
output,
|
||||
inventory,
|
||||
),
|
||||
stage="analyze",
|
||||
json_errors=True,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.config import create_default_config, validate_config
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
|
||||
|
||||
def config_init_cmd(ctx: CLIContext, path: Path) -> None:
|
||||
@@ -60,3 +60,31 @@ def config_validate_cmd(ctx: CLIContext) -> None:
|
||||
for error in errors:
|
||||
click.echo(f" - {error}", err=True)
|
||||
command_error(ctx, "Configuration validation failed.", "Configuration validation failed")
|
||||
|
||||
|
||||
@click.group(name="config")
|
||||
@pass_context
|
||||
def config_group(ctx: CLIContext):
|
||||
"""Manage VLM configuration."""
|
||||
|
||||
|
||||
@config_group.command("init")
|
||||
@click.option("--path", type=click.Path(path_type=Path), default=default_config_path)
|
||||
@pass_context
|
||||
def config_init(ctx: CLIContext, path: Path):
|
||||
"""Create a default config file."""
|
||||
config_init_cmd(ctx, path)
|
||||
|
||||
|
||||
@config_group.command("show")
|
||||
@pass_context
|
||||
def config_show(ctx: CLIContext):
|
||||
"""Show current configuration."""
|
||||
config_show_cmd(ctx)
|
||||
|
||||
|
||||
@config_group.command("validate")
|
||||
@pass_context
|
||||
def config_validate(ctx: CLIContext):
|
||||
"""Validate configuration."""
|
||||
config_validate_cmd(ctx)
|
||||
|
||||
@@ -8,7 +8,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.enrichment import enrich_identities_data
|
||||
from vlm.io import load_json_file, save_json_file
|
||||
|
||||
@@ -144,3 +145,37 @@ def enrich_cmd(
|
||||
stats["skipped"],
|
||||
refresh_mode,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("identities.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--refresh-changed-only", is_flag=True, default=False)
|
||||
@click.option("--refresh-all", is_flag=True, default=False)
|
||||
@click.option("--timeout", type=int, default=6, show_default=True)
|
||||
@click.option("--retries", type=int, default=2, show_default=True)
|
||||
@pass_context
|
||||
def enrich(
|
||||
ctx: CLIContext,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
refresh_changed_only: bool,
|
||||
refresh_all: bool,
|
||||
timeout: int,
|
||||
retries: int,
|
||||
):
|
||||
"""Enrich identities with translation and reputation metadata."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: enrich_cmd(
|
||||
ctx,
|
||||
resolve_legacy_default_input_path(input, "input", "identities.json", "--input"),
|
||||
output,
|
||||
refresh_changed_only,
|
||||
refresh_all,
|
||||
timeout,
|
||||
retries,
|
||||
),
|
||||
stage="enrich",
|
||||
json_errors=True,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, resolve_legacy_default_input_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.executor import ExecutionEngine
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
@@ -258,3 +259,50 @@ def rollback_cmd(ctx: CLIContext, log: Optional[Path]) -> None:
|
||||
rollback_summary["successful"],
|
||||
rollback_summary["failed"],
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--plan", type=click.Path(path_type=Path), default=lambda: default_artifact_path("plan.json"))
|
||||
@click.option("--confirm", is_flag=True, default=False)
|
||||
@click.option("--yes", is_flag=True, default=False)
|
||||
@click.option("--verbose-ops", is_flag=True, default=False)
|
||||
@click.option("--preserve-directories", is_flag=True, default=False)
|
||||
@click.option("--safe-mode", is_flag=True, default=False)
|
||||
@click.option("--require-review", is_flag=True, default=False)
|
||||
@click.option("--review-csv", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def execute(
|
||||
ctx: CLIContext,
|
||||
plan: Path,
|
||||
confirm: bool,
|
||||
yes: bool,
|
||||
verbose_ops: bool,
|
||||
preserve_directories: bool,
|
||||
safe_mode: bool,
|
||||
require_review: bool,
|
||||
review_csv: Optional[Path],
|
||||
):
|
||||
"""Execute plan (dry-run by default; use --confirm to apply)."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: execute_cmd(
|
||||
ctx,
|
||||
resolve_legacy_default_input_path(plan, "plan", "plan.json", "--plan"),
|
||||
confirm,
|
||||
yes,
|
||||
verbose_ops,
|
||||
preserve_directories,
|
||||
safe_mode,
|
||||
require_review=require_review,
|
||||
review_csv=review_csv,
|
||||
),
|
||||
stage="execute",
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--log", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@pass_context
|
||||
def rollback(ctx: CLIContext, log: Optional[Path]):
|
||||
"""Rollback previous execution (best-effort)."""
|
||||
run_command(ctx, lambda: rollback_cmd(ctx, log), stage="rollback")
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.quarantine import QuarantineManager
|
||||
from vlm.utils import format_size
|
||||
|
||||
@@ -147,3 +147,34 @@ def quarantine_restore_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
f"Failed to restore file: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@pass_context
|
||||
def quarantine(ctx: CLIContext):
|
||||
"""Manage quarantined files (movie and series in v1)."""
|
||||
|
||||
|
||||
@quarantine.command("list")
|
||||
@click.option("--category", type=click.STRING, default=None, help="Category filter (validated against configured categories)")
|
||||
@pass_context
|
||||
def quarantine_list(ctx: CLIContext, category: Optional[str]):
|
||||
"""List quarantined files."""
|
||||
quarantine_list_cmd(ctx, category)
|
||||
|
||||
|
||||
@quarantine.command("add")
|
||||
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option("--reason", type=str, default=None)
|
||||
@pass_context
|
||||
def quarantine_add(ctx: CLIContext, file: Path, reason: Optional[str]):
|
||||
"""Quarantine a file."""
|
||||
quarantine_add_cmd(ctx, file, reason)
|
||||
|
||||
|
||||
@quarantine.command("restore")
|
||||
@click.argument("file", type=click.Path(exists=True, path_type=Path))
|
||||
@pass_context
|
||||
def quarantine_restore(ctx: CLIContext, file: Path):
|
||||
"""Restore a file from quarantine."""
|
||||
quarantine_restore_cmd(ctx, file)
|
||||
|
||||
+100
-280
@@ -1,22 +1,25 @@
|
||||
"""Report CLI command implementations."""
|
||||
"""Report CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error, resolve_legacy_default_input_path
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import (
|
||||
command_error,
|
||||
default_artifact_path,
|
||||
emit_report,
|
||||
optional_plan_summary,
|
||||
resolve_legacy_default_input_path,
|
||||
run_command,
|
||||
)
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.io import load_analysis_json, load_inventory_csv
|
||||
from vlm.models import DuplicateGroup, MovieIdentity, SeasonCompleteness, SeriesIdentity, VideoFile
|
||||
from vlm.plan_render import preferred_plan_summary
|
||||
from vlm.planner import load_plan
|
||||
from vlm.reports import (
|
||||
completeness_from_analysis,
|
||||
duplicate_groups_from_analysis,
|
||||
generate_completeness_report,
|
||||
generate_duplicate_report,
|
||||
generate_inventory_report,
|
||||
@@ -24,290 +27,107 @@ from vlm.reports import (
|
||||
)
|
||||
|
||||
|
||||
def report_inventory_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
def _run_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]) -> None:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
video_files = load_inventory_csv(input)
|
||||
click.echo(f"Loaded {len(video_files)} files\n")
|
||||
report_format = "csv" if format == "text" else format
|
||||
content = generate_inventory_report(video_files, report_format, ctx.config.library_root)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("inventory report: %s files, format=%s", len(video_files), format)
|
||||
|
||||
|
||||
def _run_completeness(
|
||||
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
|
||||
) -> None:
|
||||
"""Generate inventory report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = load_inventory_csv(input)
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating inventory report in {format} format...")
|
||||
|
||||
report_format = "csv" if format == "text" else format
|
||||
report_content = generate_inventory_report(video_files, report_format, config.library_root)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info("Generated inventory report in %s format with %s files", format, len(video_files))
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating inventory report: {e}",
|
||||
f"Inventory report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def report_completeness_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
plan: Optional[Path],
|
||||
) -> None:
|
||||
"""Generate completeness report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = None
|
||||
if plan:
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
sys.exit(1)
|
||||
execution_plan = load_plan(plan)
|
||||
plan_summary = preferred_plan_summary(execution_plan)
|
||||
|
||||
try:
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
completeness_list = analysis_data.get("completeness", [])
|
||||
|
||||
season_completeness = []
|
||||
for c in completeness_list:
|
||||
season_completeness.append(
|
||||
SeasonCompleteness(
|
||||
series_title=c["series_title"],
|
||||
season=c["season"],
|
||||
episodes_found=c["episodes_found"],
|
||||
episodes_missing=c["episodes_missing"],
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"Loaded {len(season_completeness)} series with gaps")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating completeness report in {format} format...")
|
||||
report_content = generate_completeness_report(
|
||||
season_completeness, format, config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info(
|
||||
"Generated completeness report in %s format with %s series",
|
||||
format,
|
||||
len(season_completeness),
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating completeness report: {e}",
|
||||
f"Completeness report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
plan_summary = optional_plan_summary(plan)
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
analysis_data = load_analysis_json(input)
|
||||
seasons = completeness_from_analysis(analysis_data)
|
||||
click.echo(f"Loaded {len(seasons)} series with gaps\n")
|
||||
content = generate_completeness_report(
|
||||
seasons, format, ctx.config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("completeness report: %s series", len(seasons))
|
||||
|
||||
|
||||
def report_duplicates_cmd(
|
||||
ctx: CLIContext,
|
||||
format: str,
|
||||
input: Path,
|
||||
output: Optional[Path],
|
||||
plan: Optional[Path],
|
||||
def _run_duplicates(
|
||||
ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]
|
||||
) -> None:
|
||||
"""Generate duplicate report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
|
||||
input = resolve_legacy_default_input_path(input, "input", "analysis.json", "--input")
|
||||
plan_summary = None
|
||||
if plan:
|
||||
if not plan.exists():
|
||||
click.echo(f"Error: Plan file not found: {plan}", err=True)
|
||||
sys.exit(1)
|
||||
execution_plan = load_plan(plan)
|
||||
plan_summary = preferred_plan_summary(execution_plan)
|
||||
|
||||
try:
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
|
||||
analysis_data = load_analysis_json(input)
|
||||
|
||||
duplicates_list = analysis_data.get("duplicates", [])
|
||||
|
||||
duplicate_groups = []
|
||||
for d in duplicates_list:
|
||||
identity_data = d["identity"]
|
||||
|
||||
if identity_data["type"] == "movie":
|
||||
identity = MovieIdentity(
|
||||
title=identity_data["title"],
|
||||
year=identity_data.get("year"),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
else:
|
||||
identity = SeriesIdentity(
|
||||
title=identity_data["title"],
|
||||
season=identity_data.get("season"),
|
||||
episodes=identity_data.get("episodes", []),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
|
||||
quality_by_path = {
|
||||
str(item.get("path", "")): item for item in d.get("quality_comparison", [])
|
||||
}
|
||||
|
||||
files = []
|
||||
for file_path in d["files"]:
|
||||
quality = quality_by_path.get(str(file_path), {})
|
||||
files.append(
|
||||
VideoFile(
|
||||
path=Path(file_path),
|
||||
filename=Path(file_path).name,
|
||||
size_bytes=int(quality.get("size_bytes", 0) or 0),
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="",
|
||||
resolution=quality.get("resolution"),
|
||||
codec=quality.get("codec"),
|
||||
duration_seconds=quality.get("duration_seconds"),
|
||||
bitrate_kbps=quality.get("bitrate_kbps"),
|
||||
)
|
||||
)
|
||||
|
||||
duplicate_groups.append(
|
||||
DuplicateGroup(
|
||||
identity=identity,
|
||||
files=files,
|
||||
quality_comparison=d["quality_comparison"],
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(f"Loaded {len(duplicate_groups)} duplicate groups")
|
||||
click.echo()
|
||||
|
||||
click.echo(f"Generating duplicate report in {format} format...")
|
||||
report_content = generate_duplicate_report(
|
||||
duplicate_groups, format, config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info(
|
||||
"Generated duplicate report in %s format with %s groups",
|
||||
format,
|
||||
len(duplicate_groups),
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error: Failed to parse JSON file: {e}",
|
||||
f"JSON parsing failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating duplicate report: {e}",
|
||||
f"Duplicate report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
plan_summary = optional_plan_summary(plan)
|
||||
click.echo(f"Loading analysis from: {input}")
|
||||
analysis_data = load_analysis_json(input)
|
||||
groups = duplicate_groups_from_analysis(analysis_data)
|
||||
click.echo(f"Loaded {len(groups)} duplicate groups\n")
|
||||
content = generate_duplicate_report(
|
||||
groups, format, ctx.config.library_root, plan_summary=plan_summary
|
||||
)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("duplicate report: %s groups", len(groups))
|
||||
|
||||
|
||||
def report_summary_cmd(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
|
||||
"""Generate summary report."""
|
||||
config = ctx.config
|
||||
logger = ctx.logger
|
||||
def _run_summary(ctx: CLIContext, input: Path, output: Optional[Path]) -> None:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
video_files = load_inventory_csv(input)
|
||||
click.echo(f"Loaded {len(video_files)} files\n")
|
||||
content = generate_summary_report(video_files, ctx.config.library_root)
|
||||
emit_report(content, output)
|
||||
ctx.logger.info("summary report: %s files", len(video_files))
|
||||
|
||||
try:
|
||||
input = resolve_legacy_default_input_path(input, "input", "inventory.csv", "--input")
|
||||
click.echo(f"Loading inventory from: {input}")
|
||||
|
||||
video_files = load_inventory_csv(input)
|
||||
@click.group()
|
||||
@pass_context
|
||||
def report(ctx: CLIContext):
|
||||
"""Generate inventory, completeness, duplicate, and summary reports."""
|
||||
|
||||
click.echo(f"Loaded {len(video_files)} files")
|
||||
click.echo()
|
||||
|
||||
click.echo("Generating summary report...")
|
||||
report_content = generate_summary_report(video_files, config.library_root)
|
||||
@report.command("inventory")
|
||||
@click.option("--format", type=click.Choice(["csv", "json", "text"], case_sensitive=False), default="text")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_inventory(ctx: CLIContext, format: str, input: Path, output: Optional[Path]):
|
||||
"""List discovered files with metadata."""
|
||||
run_command(ctx, lambda: _run_inventory(ctx, format, input, output), stage="inventory report")
|
||||
|
||||
if output:
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(report_content)
|
||||
click.echo(f"Report saved to: {output}")
|
||||
else:
|
||||
click.echo()
|
||||
click.echo(report_content)
|
||||
|
||||
logger.info("Generated summary report with %s files", len(video_files))
|
||||
@report.command("completeness")
|
||||
@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--plan", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_completeness(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
|
||||
"""Show series with episode gaps."""
|
||||
run_command(
|
||||
ctx, lambda: _run_completeness(ctx, format, input, output, plan),
|
||||
stage="completeness report", json_errors=True,
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
command_error(ctx, f"Error: Input file not found: {input}", f"Input file not found: {input}")
|
||||
|
||||
except Exception as e:
|
||||
command_error(
|
||||
ctx,
|
||||
f"Error generating summary report: {e}",
|
||||
f"Summary report generation failed: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
@report.command("duplicates")
|
||||
@click.option("--format", type=click.Choice(["text", "json"], case_sensitive=False), default="text")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("analysis.json"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@click.option("--plan", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_duplicates(ctx: CLIContext, format: str, input: Path, output: Optional[Path], plan: Optional[Path]):
|
||||
"""Show duplicate groups with quality comparison."""
|
||||
run_command(
|
||||
ctx, lambda: _run_duplicates(ctx, format, input, output, plan),
|
||||
stage="duplicate report", json_errors=True,
|
||||
)
|
||||
|
||||
|
||||
@report.command("summary")
|
||||
@click.option("--input", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=None)
|
||||
@pass_context
|
||||
def report_summary(ctx: CLIContext, input: Path, output: Optional[Path]):
|
||||
"""Show library statistics."""
|
||||
run_command(ctx, lambda: _run_summary(ctx, input, output), stage="summary report")
|
||||
|
||||
@@ -5,7 +5,8 @@ from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from vlm.context import CLIContext
|
||||
from vlm.cli_helpers import default_artifact_path, run_command
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.scanner import load_inventory_csv, save_inventory_csv, scan_library
|
||||
from vlm.utils import format_size
|
||||
|
||||
@@ -94,3 +95,24 @@ def scan_cmd(
|
||||
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}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--output", type=click.Path(path_type=Path), default=lambda: default_artifact_path("inventory.csv"))
|
||||
@click.option("--metadata/--no-metadata", default=True)
|
||||
@click.option("--reuse-from", type=click.Path(exists=True, path_type=Path), default=None)
|
||||
@click.option("--force-refresh-metadata", is_flag=True, default=False)
|
||||
@pass_context
|
||||
def scan(
|
||||
ctx: CLIContext,
|
||||
output: Path,
|
||||
metadata: bool,
|
||||
reuse_from: Optional[Path],
|
||||
force_refresh_metadata: bool,
|
||||
):
|
||||
"""Scan library and write inventory.csv."""
|
||||
run_command(
|
||||
ctx,
|
||||
lambda: scan_cmd(ctx, output, metadata, reuse_from, force_refresh_metadata),
|
||||
stage="scan",
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import Optional
|
||||
import click
|
||||
|
||||
from vlm.cli_helpers import command_error
|
||||
from vlm.context import CLIContext
|
||||
from vlm.context import CLIContext, pass_context
|
||||
from vlm.state import StateManager
|
||||
|
||||
|
||||
@@ -137,3 +137,43 @@ def state_clear_cmd(ctx: CLIContext, file: Path) -> None:
|
||||
f"Failed to clear file state: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
@pass_context
|
||||
def state(ctx: CLIContext):
|
||||
"""Track per-file workflow status."""
|
||||
|
||||
|
||||
@state.command("show")
|
||||
@click.argument("file", type=click.Path(path_type=Path))
|
||||
@pass_context
|
||||
def state_show(ctx: CLIContext, file: Path):
|
||||
"""Show state for a file."""
|
||||
state_show_cmd(ctx, file)
|
||||
|
||||
|
||||
@state.command("set")
|
||||
@click.argument("file", type=click.Path(path_type=Path))
|
||||
@click.option("--status", type=click.Choice(["reviewed", "ignored", "planned", "executed", "quarantined"], case_sensitive=False), required=True)
|
||||
@click.option("--reason", type=str, default=None)
|
||||
@pass_context
|
||||
def state_set(ctx: CLIContext, file: Path, status: str, reason: Optional[str]):
|
||||
"""Set state for a file."""
|
||||
state_set_cmd(ctx, file, status, reason)
|
||||
|
||||
|
||||
@state.command("query")
|
||||
@click.option("--status", type=click.Choice(["reviewed", "ignored", "planned", "executed", "quarantined"], case_sensitive=False), required=True)
|
||||
@pass_context
|
||||
def state_query(ctx: CLIContext, status: str):
|
||||
"""List files with a given status."""
|
||||
state_query_cmd(ctx, status)
|
||||
|
||||
|
||||
@state.command("clear")
|
||||
@click.argument("file", type=click.Path(path_type=Path))
|
||||
@pass_context
|
||||
def state_clear(ctx: CLIContext, file: Path):
|
||||
"""Clear state for a file."""
|
||||
state_clear_cmd(ctx, file)
|
||||
|
||||
+4
-1
@@ -479,7 +479,10 @@ def identities_to_plan_input(
|
||||
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))
|
||||
if "title" in a and "needs_review" in a:
|
||||
result.append((_video_file_from_record(a), _series_identity_from_record(a)))
|
||||
else:
|
||||
result.append((_video_file_from_record(a), None))
|
||||
for o in other_data:
|
||||
result.append((_video_file_from_record(o), None))
|
||||
|
||||
|
||||
+1
-1
@@ -333,7 +333,7 @@ class ParsedIdentitiesJSON(TypedDict, total=False):
|
||||
metadata: dict[str, object]
|
||||
movies: list[MovieIdentityRecord]
|
||||
series: list[SeriesIdentityRecord]
|
||||
anime: list[IdentityRecord]
|
||||
anime: list[SeriesIdentityRecord]
|
||||
other: list[IdentityRecord]
|
||||
|
||||
|
||||
|
||||
@@ -282,6 +282,80 @@ def parse_series(
|
||||
)
|
||||
|
||||
|
||||
def parse_anime(
|
||||
filename: str,
|
||||
extensions: Optional[list[str]] = None,
|
||||
) -> SeriesIdentity:
|
||||
"""Parse an anime filename into a plan-consumable series identity.
|
||||
|
||||
Conservative: explicit ``SxxEyy`` / ``Season N - EP`` are organized; an
|
||||
absolute episode (``Title - NN``) with no season info sets ``needs_review``
|
||||
so the planner treats it as a manual-review no-op (never silently moved).
|
||||
"""
|
||||
if extensions is None:
|
||||
extensions = DEFAULT_VIDEO_EXTENSIONS
|
||||
name = filename
|
||||
for ext in extensions:
|
||||
if name.lower().endswith(ext.lower()):
|
||||
name = name[: -len(ext)]
|
||||
break
|
||||
|
||||
# Strip CRC32 hash brackets first so they are not mistaken for a release group
|
||||
name = re.sub(r'\[[0-9A-Fa-f]{8}\]', '', name)
|
||||
name = remove_release_groups(name)
|
||||
name = remove_quality_tags(name)
|
||||
|
||||
season = None
|
||||
episodes: list[int] = []
|
||||
confidence = 0.0
|
||||
title_part = name
|
||||
|
||||
m = re.search(r'[Ss](\d{1,2})[Ee](\d{1,2})', name)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
episodes = [int(m.group(2))]
|
||||
confidence = 0.9
|
||||
title_part = name[: m.start()]
|
||||
else:
|
||||
m = re.search(r'[Ss]eason\s*(\d{1,2})\s*[-_]\s*(\d{1,3})(?!\d)', name)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
episodes = [int(m.group(2))]
|
||||
confidence = 0.85
|
||||
title_part = name[: m.start()]
|
||||
else:
|
||||
m = re.search(r'\bS(\d{1,2})\s*[-_]\s*(\d{1,3})(?!\d)', name)
|
||||
if m:
|
||||
season = int(m.group(1))
|
||||
episodes = [int(m.group(2))]
|
||||
confidence = 0.85
|
||||
title_part = name[: m.start()]
|
||||
else:
|
||||
m = re.search(r'\s*[-_]\s*(\d{1,3})(?!\d)\s*$', name)
|
||||
if m:
|
||||
episodes = [int(m.group(1))]
|
||||
confidence = 0.5
|
||||
title_part = name[: m.start()]
|
||||
# season remains None -> needs_review
|
||||
|
||||
if title_part and title_part.strip():
|
||||
title = humanize_parsed_title(remove_quality_tags(remove_release_groups(title_part)))
|
||||
else:
|
||||
title = humanize_parsed_title(name)
|
||||
if not title.strip():
|
||||
title = humanize_parsed_title(filename)
|
||||
|
||||
needs_review = season is None or not episodes
|
||||
|
||||
return SeriesIdentity(
|
||||
title=title,
|
||||
season=season,
|
||||
episodes=episodes,
|
||||
confidence=confidence,
|
||||
needs_review=needs_review,
|
||||
original_filename=filename,
|
||||
)
|
||||
|
||||
def group_episodes(episodes: list[SeriesIdentity]) -> dict[tuple[str, int], list[SeriesIdentity]]:
|
||||
|
||||
"""Group parsed episodes by normalized series title and season number.
|
||||
|
||||
@@ -35,6 +35,7 @@ REVIEW_CSV_ENRICHED_FIELDS = [
|
||||
"rel_dest",
|
||||
"quality_hint",
|
||||
"duplicate_group_id",
|
||||
"sidecars",
|
||||
]
|
||||
|
||||
REVIEW_CSV_ALL_FIELDS = REVIEW_CSV_BASE_FIELDS + REVIEW_CSV_ENRICHED_FIELDS
|
||||
@@ -246,6 +247,11 @@ def enrich_review_row(
|
||||
enriched["episode"] = str(ctx["episode"])
|
||||
if ctx.get("duplicate_group_id"):
|
||||
enriched["duplicate_group_id"] = str(ctx["duplicate_group_id"])
|
||||
sidecars = ctx.get("sidecars")
|
||||
if isinstance(sidecars, list) and sidecars:
|
||||
names = [str(item.get("name", "")) for item in sidecars if isinstance(item, dict)]
|
||||
if names:
|
||||
enriched["sidecars"] = ", ".join(names)
|
||||
|
||||
if identity_lookup and source_path:
|
||||
id_ctx = identity_lookup.get(source_path) or identity_lookup.get(
|
||||
|
||||
@@ -11,7 +11,7 @@ from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from vlm.providers.base import ProviderResult
|
||||
from vlm.providers.base import ProviderResult, RequestRateLimiter
|
||||
|
||||
|
||||
class TMDBAuthError(RuntimeError):
|
||||
@@ -40,6 +40,7 @@ class TMDBProvider:
|
||||
min_interval_seconds: float = 0.25,
|
||||
backoff_base_seconds: float = 0.5,
|
||||
backoff_max_seconds: float = 4.0,
|
||||
rate_limiter: Optional[RequestRateLimiter] = None,
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.bearer_token = bearer_token
|
||||
@@ -52,6 +53,7 @@ class TMDBProvider:
|
||||
self.min_interval_seconds = min_interval_seconds
|
||||
self.backoff_base_seconds = backoff_base_seconds
|
||||
self.backoff_max_seconds = backoff_max_seconds
|
||||
self.rate_limiter = rate_limiter
|
||||
self._last_request_at = 0.0
|
||||
self.last_request_count = 0
|
||||
|
||||
@@ -181,6 +183,9 @@ class TMDBProvider:
|
||||
return None
|
||||
|
||||
def _wait_for_rate_limit(self) -> None:
|
||||
if self.rate_limiter is not None:
|
||||
self.rate_limiter.wait()
|
||||
return
|
||||
if self.min_interval_seconds <= 0:
|
||||
return
|
||||
now = time.monotonic()
|
||||
|
||||
+36
-13
@@ -113,11 +113,12 @@ class QuarantineManager:
|
||||
# Determine category from file path
|
||||
category = self._determine_category(file_path)
|
||||
|
||||
# Reject anime and other categories (v1 constraint)
|
||||
if category not in ("movie", "series"):
|
||||
# Reject categories outside the configured quarantine scope
|
||||
supported_categories = self._supported_categories()
|
||||
if category not in supported_categories:
|
||||
error_msg = (
|
||||
f"Quarantine not supported for category '{category}'. "
|
||||
f"Only 'movie' and 'series' categories are supported in v1."
|
||||
f"Supported categories: {', '.join(sorted(supported_categories))}."
|
||||
)
|
||||
log_operation(
|
||||
self.logger,
|
||||
@@ -359,6 +360,30 @@ class QuarantineManager:
|
||||
executed_at=executed_at
|
||||
)
|
||||
|
||||
def _supported_categories(self) -> set:
|
||||
"""Return configured category keys supported by the quarantine lifecycle."""
|
||||
categories_config = self.config.categories or {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
return set(categories_config.keys())
|
||||
|
||||
def _category_from_directory(self, dir_name: str) -> Optional[str]:
|
||||
"""Map a category directory name to its configured category key."""
|
||||
dir_name = dir_name.lower()
|
||||
if dir_name in self._supported_categories():
|
||||
return dir_name
|
||||
categories_config = self.config.categories or {
|
||||
"movie": ["movie"],
|
||||
"series": ["series"],
|
||||
"anime": ["anime"]
|
||||
}
|
||||
for category, dir_names in categories_config.items():
|
||||
if any(dir_name == name.lower() for name in dir_names):
|
||||
return category
|
||||
return None
|
||||
|
||||
def _determine_category(self, file_path: Path) -> str:
|
||||
"""Determine the category of a file based on its path.
|
||||
|
||||
@@ -678,20 +703,21 @@ class QuarantineManager:
|
||||
entries = []
|
||||
|
||||
# Determine which categories to query
|
||||
supported = self._supported_categories()
|
||||
if category is not None:
|
||||
# Validate category
|
||||
if category not in ("movie", "series"):
|
||||
if category not in supported:
|
||||
log_operation(
|
||||
self.logger,
|
||||
logging.WARNING,
|
||||
f"Invalid category '{category}' for listing. Only 'movie' and 'series' are supported.",
|
||||
f"Invalid category '{category}' for listing. Supported categories: {', '.join(sorted(supported))}.",
|
||||
operation_type="quarantine"
|
||||
)
|
||||
return []
|
||||
categories = [category]
|
||||
else:
|
||||
# List from all supported categories
|
||||
categories = ["movie", "series"]
|
||||
categories = sorted(supported)
|
||||
|
||||
# Load manifests from each category
|
||||
for cat in categories:
|
||||
@@ -720,7 +746,7 @@ class QuarantineManager:
|
||||
Returns:
|
||||
The path where the file was moved in quarantine, or None if not found
|
||||
"""
|
||||
for category in ("movie", "series"):
|
||||
for category in sorted(self._supported_categories()):
|
||||
manifest = self._load_manifest(category)
|
||||
for entry in manifest.entries:
|
||||
if entry.original_path == original_path:
|
||||
@@ -1014,14 +1040,11 @@ class QuarantineManager:
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
|
||||
# First part should be category, second should be .quarantine
|
||||
category = parts[0].lower()
|
||||
# First part should be category directory, second should be .quarantine
|
||||
dir_name = parts[0].lower()
|
||||
quarantine_dir = parts[1]
|
||||
|
||||
if quarantine_dir != self.config.quarantine_dir:
|
||||
return None
|
||||
|
||||
if category in ("movie", "series"):
|
||||
return category
|
||||
|
||||
return None
|
||||
return self._category_from_directory(dir_name)
|
||||
|
||||
@@ -565,3 +565,70 @@ def _format_duration(duration_seconds: float) -> str:
|
||||
parts.append(f"{seconds}s")
|
||||
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def completeness_from_analysis(analysis_data: dict) -> list[SeasonCompleteness]:
|
||||
"""Build SeasonCompleteness records from analysis JSON."""
|
||||
result: list[SeasonCompleteness] = []
|
||||
for row in analysis_data.get("completeness", []) or []:
|
||||
result.append(
|
||||
SeasonCompleteness(
|
||||
series_title=row["series_title"],
|
||||
season=row["season"],
|
||||
episodes_found=row["episodes_found"],
|
||||
episodes_missing=row["episodes_missing"],
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def duplicate_groups_from_analysis(analysis_data: dict) -> list[DuplicateGroup]:
|
||||
"""Build DuplicateGroup records from analysis JSON."""
|
||||
groups: list[DuplicateGroup] = []
|
||||
for dup in analysis_data.get("duplicates", []) or []:
|
||||
identity_data = dup["identity"]
|
||||
if identity_data["type"] == "movie":
|
||||
identity = MovieIdentity(
|
||||
title=identity_data["title"],
|
||||
year=identity_data.get("year"),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
else:
|
||||
identity = SeriesIdentity(
|
||||
title=identity_data["title"],
|
||||
season=identity_data.get("season"),
|
||||
episodes=identity_data.get("episodes", []),
|
||||
confidence=1.0,
|
||||
needs_review=False,
|
||||
original_filename="",
|
||||
)
|
||||
|
||||
quality_by_path = {
|
||||
str(item.get("path", "")): item for item in dup.get("quality_comparison", [])
|
||||
}
|
||||
files: list[VideoFile] = []
|
||||
for file_path in dup.get("files", []) or []:
|
||||
quality = quality_by_path.get(str(file_path), {})
|
||||
files.append(
|
||||
VideoFile(
|
||||
path=Path(file_path),
|
||||
filename=Path(file_path).name,
|
||||
size_bytes=int(quality.get("size_bytes", 0) or 0),
|
||||
modified_timestamp=datetime.now(timezone.utc),
|
||||
category="",
|
||||
resolution=quality.get("resolution"),
|
||||
codec=quality.get("codec"),
|
||||
duration_seconds=quality.get("duration_seconds"),
|
||||
bitrate_kbps=quality.get("bitrate_kbps"),
|
||||
)
|
||||
)
|
||||
groups.append(
|
||||
DuplicateGroup(
|
||||
identity=identity,
|
||||
files=files,
|
||||
quality_comparison=dup.get("quality_comparison", []),
|
||||
)
|
||||
)
|
||||
return groups
|
||||
|
||||
@@ -290,6 +290,56 @@ def _is_video_file(file_path: Path, video_extensions: list[str]) -> bool:
|
||||
return file_extension in [ext.lower() for ext in video_extensions]
|
||||
|
||||
|
||||
DEFAULT_SIDECAR_EXTENSIONS = (".srt", ".ass", ".sub", ".idx", ".sup", ".nfo")
|
||||
|
||||
|
||||
def find_sidecar_companions(
|
||||
video_path: Path,
|
||||
sidecar_extensions: tuple = DEFAULT_SIDECAR_EXTENSIONS,
|
||||
) -> list[Path]:
|
||||
"""Find sidecar files in the same directory that belong to a video file.
|
||||
|
||||
Conservative matching: a companion must share the video's exact stem,
|
||||
optionally followed by dot-separated alphabetic suffix tokens (e.g.
|
||||
language or track tags such as ``zh`` or ``en.forced``). Numeric or
|
||||
otherwise non-alphabetic tokens are rejected so unrelated files are
|
||||
never associated.
|
||||
|
||||
Args:
|
||||
video_path: Path to the video file
|
||||
sidecar_extensions: Sidecar extensions to consider (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Sorted list of companion paths (empty when none are found).
|
||||
"""
|
||||
parent = video_path.parent
|
||||
try:
|
||||
with os.scandir(parent) as it:
|
||||
entries = [e for e in it if e.is_file(follow_symlinks=False)]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
stem = video_path.stem
|
||||
exts = {ext.lower() for ext in sidecar_extensions}
|
||||
companions: list[Path] = []
|
||||
for entry in entries:
|
||||
name = entry.name
|
||||
suffix = Path(name).suffix.lower()
|
||||
if suffix not in exts:
|
||||
continue
|
||||
base = name[: -len(suffix)]
|
||||
if base == stem:
|
||||
companions.append(Path(entry.path))
|
||||
continue
|
||||
if not base.startswith(stem + "."):
|
||||
continue
|
||||
tokens = base[len(stem) + 1:].split(".")
|
||||
if tokens and all(token and token.isalpha() for token in tokens):
|
||||
companions.append(Path(entry.path))
|
||||
|
||||
return sorted(companions, key=lambda p: p.name)
|
||||
|
||||
|
||||
def _create_video_file(
|
||||
file_path: Path,
|
||||
library_root: Path,
|
||||
|
||||
Reference in New Issue
Block a user