chore: snapshot current project updates

This commit is contained in:
windyboy
2026-02-13 13:36:39 +08:00
parent 5931f16a71
commit 0be802eac5
74 changed files with 594899 additions and 66082 deletions
+133 -10
View File
@@ -5,6 +5,7 @@ should be organized based on their parsed identities and configuration templates
"""
import json
import re
import uuid
from datetime import datetime
from pathlib import Path
@@ -12,7 +13,7 @@ from typing import Optional, Union
from vlm.config import Config
from vlm.duplicate_resolve import choose_keep_index
from vlm.utils import ensure_utc, utc_now
from vlm.utils import ensure_utc, is_within_root, sanitize_path_component, utc_now
from vlm.models import (
ExecutionPlan,
FileOperation,
@@ -22,22 +23,50 @@ from vlm.models import (
)
QUARANTINE_REASON_DUPLICATE = "重复项(已按外部评分保留一条)"
QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY = "重复项(外部评分缺失/并列,按画质回退保留一条)"
QUARANTINE_REASON_DUPLICATE_BY_QUALITY = "重复项(已按画质保留最优)"
QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME = "重复项(按外部评分>画质>时间保留一条)"
NO_OP_REASON_SAMPLE_EXCLUDED = "Sample file excluded by plan include_sample_files=false"
NO_OP_REASON_SEASON_OUT_OF_RANGE = "Series needs manual review (season exceeds configured threshold)"
NO_OP_REASON_EPISODE_OUT_OF_RANGE = "Series needs manual review (episode exceeds configured threshold)"
def _is_sample_path(path: Path) -> bool:
"""Return True if path appears to be a sample clip."""
parts = [part.casefold() for part in path.parts]
if "sample" in parts:
return True
stem = path.stem.casefold()
return bool(re.search(r"(^|[\s._-])sample($|[\s._-])", stem))
def generate_plan(
identities: list[tuple[VideoFile, Union[MovieIdentity, SeriesIdentity, None]]],
config: Config,
analysis_data: Optional[dict] = None,
ignored_paths: Optional[set[str]] = None,
) -> ExecutionPlan:
"""Generate an execution plan from parsed identities; optionally apply analysis duplicates.
When analysis_data is provided and duplicate_keep is not "manual", duplicate groups
are resolved (one kept, rest quarantined) according to config.duplicate_keep.
If ignored_paths is provided, files in that set will generate no-op operations.
"""
operations = []
for video_file, identity in identities:
operation = _create_operation(video_file, identity, config)
# Check if file is ignored in state
if ignored_paths and str(video_file.path) in ignored_paths:
operation = FileOperation(
operation_type="no-op",
source_path=video_file.path,
destination_path=None,
reason="User marked as ignored in state",
has_conflict=False,
conflict_reason=None
)
else:
operation = _create_operation(video_file, identity, config)
operations.append(operation)
metadata: dict = {}
@@ -59,6 +88,9 @@ def generate_plan(
if identity is not None and isinstance(
identity, (MovieIdentity, SeriesIdentity)
):
if not config.plan_include_sample_files and _is_sample_path(identities[i][0].path):
# Keep sample files out of duplicate keep/quarantine competition by default.
continue
items.append((identities[i][0].path, identity))
valid_indices.append(i)
if not items:
@@ -73,10 +105,9 @@ def generate_plan(
continue
keep_identity_index = valid_indices[keep_idx]
quarantine_indices = set(valid_indices) - {keep_identity_index}
reason = (
QUARANTINE_REASON_DUPLICATE_BY_QUALITY
if config.duplicate_keep == "by_quality"
else QUARANTINE_REASON_DUPLICATE
reason = _select_duplicate_quarantine_reason(
config.duplicate_keep,
[identity for _, identity in items],
)
for i in quarantine_indices:
vf = identities[i][0]
@@ -119,6 +150,16 @@ def _create_operation(
Returns:
FileOperation specifying what to do with the file
"""
if not config.plan_include_sample_files and _is_sample_path(video_file.path):
return FileOperation(
operation_type="no-op",
source_path=video_file.path,
destination_path=None,
reason=NO_OP_REASON_SAMPLE_EXCLUDED,
has_conflict=False,
conflict_reason=None
)
# Handle anime category - generate no-op (v1 constraint)
if video_file.category == "anime":
return FileOperation(
@@ -208,9 +249,11 @@ def _create_movie_operation(
conflict_reason=None
)
safe_title = sanitize_path_component(identity.title, fallback="untitled")
# Apply movie directory template
target_dir = config.movie_template.format(
title=identity.title,
title=safe_title,
year=identity.year
)
@@ -219,13 +262,22 @@ def _create_movie_operation(
# Apply movie filename template
target_filename = config.movie_filename_template.format(
title=identity.title,
title=safe_title,
year=identity.year,
ext=ext
)
# Construct full destination path
destination = config.library_root / target_dir / target_filename
if not is_within_root(destination, config.library_root):
return FileOperation(
operation_type="no-op",
source_path=video_file.path,
destination_path=None,
reason=f"Unsafe destination outside library root: {destination}",
has_conflict=False,
conflict_reason=None
)
# Check if source and destination are the same
if video_file.path.resolve() == destination.resolve():
@@ -296,10 +348,30 @@ def _create_series_operation(
has_conflict=False,
conflict_reason=None
)
if identity.season > config.plan_max_season:
return FileOperation(
operation_type="no-op",
source_path=video_file.path,
destination_path=None,
reason=NO_OP_REASON_SEASON_OUT_OF_RANGE,
has_conflict=False,
conflict_reason=None
)
if any(ep > config.plan_max_episode for ep in identity.episodes):
return FileOperation(
operation_type="no-op",
source_path=video_file.path,
destination_path=None,
reason=NO_OP_REASON_EPISODE_OUT_OF_RANGE,
has_conflict=False,
conflict_reason=None
)
safe_title = sanitize_path_component(identity.title, fallback="untitled")
# Apply series directory template
target_dir = config.series_template.format(
title=identity.title,
title=safe_title,
season=identity.season
)
@@ -316,6 +388,15 @@ def _create_series_operation(
# Construct full destination path
destination = config.library_root / target_dir / target_filename
if not is_within_root(destination, config.library_root):
return FileOperation(
operation_type="no-op",
source_path=video_file.path,
destination_path=None,
reason=f"Unsafe destination outside library root: {destination}",
has_conflict=False,
conflict_reason=None
)
# Check if source and destination are the same
if video_file.path.resolve() == destination.resolve():
@@ -412,9 +493,51 @@ def _generate_human_summary(
gaps = metadata.get("completeness_seasons_with_gaps", 0)
if dup or gaps:
parts.append(f"依据 analysis:重复组 {dup} 个;剧集缺口 {gaps} 季。")
quarantine_lines = _build_quarantine_recommendation_lines(operations)
if quarantine_lines:
parts.append("删除建议(仅隔离建议,执行删除前请人工复核):")
parts.extend(quarantine_lines)
return "\n".join(parts)
def _select_duplicate_quarantine_reason(
strategy: str,
identities: list[Union[MovieIdentity, SeriesIdentity]],
) -> str:
"""Choose a user-facing reason string for duplicate quarantine."""
if strategy == "by_quality":
return QUARANTINE_REASON_DUPLICATE_BY_QUALITY
if strategy == "by_reputation_quality_time":
return QUARANTINE_REASON_DUPLICATE_BY_REPUTATION_QUALITY_TIME
if strategy == "by_reputation":
rep_values = [i.reputation_score for i in identities if i.reputation_score is not None]
if len(rep_values) <= 1:
return QUARANTINE_REASON_DUPLICATE_FALLBACK_QUALITY
return QUARANTINE_REASON_DUPLICATE
return QUARANTINE_REASON_DUPLICATE
def _build_quarantine_recommendation_lines(operations: list[FileOperation], limit: int = 20) -> list[str]:
"""Build human-readable quarantine recommendations with reasons."""
quarantines = [op for op in operations if op.operation_type == "quarantine"]
if not quarantines:
return []
lines: list[str] = []
for op in quarantines[:limit]:
risk_tags = []
name_l = op.source_path.name.casefold()
if any(tok in name_l for tok in ("disc1", "disc2", "part.", " part ", "cd1", "cd2")):
risk_tags.append("疑似多碟/分段文件,建议勿直接删除")
if "评分缺失/并列" in op.reason:
risk_tags.append("评分依据不足,已回退画质规则")
risk_text = f"{'; '.join(risk_tags)}" if risk_tags else ""
lines.append(f" - {op.source_path.name} -> {op.reason}{risk_text}")
if len(quarantines) > limit:
lines.append(f" - 其余 {len(quarantines) - limit} 条请查看 plan.json 的 quarantine 操作。")
return lines
def save_plan(plan: ExecutionPlan, output_path: Path) -> None:
"""Save execution plan to JSON file.
@@ -495,4 +618,4 @@ def load_plan(input_path: Path) -> ExecutionPlan:
summary_by_reason=plan_dict.get("summary_by_reason", {}),
human_summary=plan_dict.get("human_summary", ""),
metadata=plan_dict.get("metadata", {}),
)
)