chore: snapshot current project updates
This commit is contained in:
+133
-10
@@ -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", {}),
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user