2026-02-10 18:07:38 +08:00
|
|
|
|
"""Duplicate group resolution: choose which file to keep when consuming analysis."""
|
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
|
from datetime import datetime
|
2026-02-11 08:44:48 +08:00
|
|
|
|
import re
|
2026-02-10 18:07:38 +08:00
|
|
|
|
from pathlib import Path
|
2026-02-11 08:44:48 +08:00
|
|
|
|
from typing import Optional, Union
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
|
|
|
|
|
from vlm.models import MovieIdentity, SeriesIdentity
|
2026-04-07 08:07:18 +08:00
|
|
|
|
from vlm.utils import is_sample_path
|
2026-02-13 13:36:39 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-07 11:00:47 +08:00
|
|
|
|
class DuplicateResolutionError(ValueError):
|
|
|
|
|
|
"""Raised when a duplicate group cannot be resolved deterministically."""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 18:07:38 +08:00
|
|
|
|
def choose_keep_index(
|
|
|
|
|
|
items: list[tuple[Path, Union[MovieIdentity, SeriesIdentity]]],
|
|
|
|
|
|
strategy: str,
|
2026-02-11 08:44:48 +08:00
|
|
|
|
quality_comparison: Optional[list[dict]] = None,
|
2026-02-10 18:07:38 +08:00
|
|
|
|
) -> int | None:
|
|
|
|
|
|
"""Choose the index of the item to keep in a duplicate group.
|
|
|
|
|
|
|
|
|
|
|
|
Strategies:
|
2026-02-11 08:44:48 +08:00
|
|
|
|
- by_quality: Prefer higher quality (resolution > source > codec > size). Requires quality_comparison.
|
2026-02-10 18:07:38 +08:00
|
|
|
|
- by_reputation: Prefer items with reputation_score; then sort by score desc,
|
2026-02-13 13:36:39 +08:00
|
|
|
|
then reputation_votes desc, then quality, then first_seen (input order).
|
|
|
|
|
|
- by_reputation_quality_time: Prefer reputation first, then quality, then newer modified time.
|
2026-02-10 18:07:38 +08:00
|
|
|
|
- first_seen: Keep the first item (index 0).
|
|
|
|
|
|
- manual: Return None; caller should not generate quarantine ops, only record in metadata.
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
items: List of (path, identity) for the duplicate group.
|
2026-02-13 13:36:39 +08:00
|
|
|
|
strategy: One of "by_quality", "by_reputation", "by_reputation_quality_time", "first_seen", "manual".
|
2026-02-11 08:44:48 +08:00
|
|
|
|
quality_comparison: List of quality dicts (filename, path, size_bytes, resolution?, codec?)
|
|
|
|
|
|
aligned with items. Required when strategy is "by_quality".
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Index in [0, len(items)) to keep, or None for manual.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if strategy == "manual":
|
|
|
|
|
|
return None
|
|
|
|
|
|
if strategy == "first_seen":
|
|
|
|
|
|
return 0
|
2026-02-11 08:44:48 +08:00
|
|
|
|
if strategy == "by_quality":
|
2026-04-07 11:00:47 +08:00
|
|
|
|
if quality_comparison is None:
|
|
|
|
|
|
raise DuplicateResolutionError(
|
|
|
|
|
|
"by_quality strategy requires quality comparison data"
|
|
|
|
|
|
)
|
|
|
|
|
|
if len(quality_comparison) != len(items):
|
|
|
|
|
|
raise DuplicateResolutionError(
|
|
|
|
|
|
"by_quality strategy requires quality data aligned with duplicate items"
|
|
|
|
|
|
)
|
2026-02-11 08:44:48 +08:00
|
|
|
|
return _by_quality_index(items, quality_comparison)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
if strategy == "by_reputation":
|
2026-02-13 13:36:39 +08:00
|
|
|
|
return _by_reputation_index(items, quality_comparison=quality_comparison)
|
|
|
|
|
|
if strategy == "by_reputation_quality_time":
|
|
|
|
|
|
return _by_reputation_quality_time_index(items, quality_comparison=quality_comparison)
|
2026-04-07 11:00:47 +08:00
|
|
|
|
raise DuplicateResolutionError(f"Unsupported duplicate strategy: {strategy}")
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-02-11 08:44:48 +08:00
|
|
|
|
def _parse_resolution_tier(resolution: Optional[str], path: Path) -> int:
|
|
|
|
|
|
"""Parse resolution to tier. Higher = better. 2160=4, 1080=3, 720=2, 0=unknown."""
|
|
|
|
|
|
if resolution:
|
|
|
|
|
|
m = re.search(r"(\d{3,4})[x×](\d{3,4})", resolution, re.I)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
h = int(m.group(2))
|
|
|
|
|
|
if h >= 2160:
|
|
|
|
|
|
return 4
|
|
|
|
|
|
if h >= 1080:
|
|
|
|
|
|
return 3
|
|
|
|
|
|
if h >= 720:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
return 1
|
|
|
|
|
|
s = path.name.upper()
|
|
|
|
|
|
if "2160" in s or "4K" in s or "UHD" in s:
|
|
|
|
|
|
return 4
|
|
|
|
|
|
if "1080" in s:
|
|
|
|
|
|
return 3
|
|
|
|
|
|
if "720" in s:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
if "480" in s or "576" in s:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_source_tier(path: Path) -> int:
|
|
|
|
|
|
"""Parse source type from path. BluRay=3, WEB-DL=2, HDTV=1, 0=other."""
|
|
|
|
|
|
s = path.name.upper()
|
|
|
|
|
|
if "BLURAY" in s or "BLU-RAY" in s or "BD" in s:
|
|
|
|
|
|
return 3
|
|
|
|
|
|
if "WEB-DL" in s or "WEBRIP" in s or "WEB" in s:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
if "HDTV" in s or "HDTVRIP" in s:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_codec_tier(codec: Optional[str], path: Path) -> int:
|
|
|
|
|
|
"""Parse codec to tier. hevc/x265=2, h264/x264=1, 0=other."""
|
|
|
|
|
|
if codec:
|
|
|
|
|
|
c = codec.lower()
|
|
|
|
|
|
if "hevc" in c or "h265" in c or "x265" in c:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
if "h264" in c or "x264" in c or "avc" in c:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
s = path.name.upper()
|
|
|
|
|
|
if "X265" in s or "H265" in s or "HEVC" in s:
|
|
|
|
|
|
return 2
|
|
|
|
|
|
if "X264" in s or "H264" in s or "AVC" in s:
|
|
|
|
|
|
return 1
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _by_quality_index(
|
|
|
|
|
|
items: list[tuple[Path, Union[MovieIdentity, SeriesIdentity]]],
|
|
|
|
|
|
quality_comparison: list[dict],
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""Sort by: resolution desc, source desc, codec desc, size desc, index asc. Return best index."""
|
2026-02-13 13:36:39 +08:00
|
|
|
|
def quality_key(path: Path, qc: dict, idx: int) -> tuple:
|
2026-02-11 08:44:48 +08:00
|
|
|
|
resolution = qc.get("resolution")
|
|
|
|
|
|
codec = qc.get("codec")
|
|
|
|
|
|
size = qc.get("size_bytes", 0) or 0
|
|
|
|
|
|
res_tier = _parse_resolution_tier(resolution, path)
|
|
|
|
|
|
src_tier = _parse_source_tier(path)
|
|
|
|
|
|
codec_tier = _parse_codec_tier(codec, path)
|
|
|
|
|
|
return (-res_tier, -src_tier, -codec_tier, -size, idx)
|
|
|
|
|
|
|
2026-02-13 13:36:39 +08:00
|
|
|
|
def key(idx_item: tuple[int, tuple[Path, Union[MovieIdentity, SeriesIdentity]]]) -> tuple:
|
|
|
|
|
|
idx, (path, _) = idx_item
|
|
|
|
|
|
qc = quality_comparison[idx] if idx < len(quality_comparison) else {}
|
2026-04-07 08:07:18 +08:00
|
|
|
|
is_sample = is_sample_path(path)
|
2026-02-13 13:36:39 +08:00
|
|
|
|
return (is_sample, *quality_key(path, qc, idx))
|
|
|
|
|
|
|
2026-02-11 08:44:48 +08:00
|
|
|
|
indexed = list(enumerate(items))
|
|
|
|
|
|
indexed.sort(key=key)
|
|
|
|
|
|
return indexed[0][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 18:07:38 +08:00
|
|
|
|
def _by_reputation_index(
|
|
|
|
|
|
items: list[tuple[Path, Union[MovieIdentity, SeriesIdentity]]],
|
2026-02-13 13:36:39 +08:00
|
|
|
|
quality_comparison: Optional[list[dict]] = None,
|
2026-02-10 18:07:38 +08:00
|
|
|
|
) -> int:
|
2026-02-13 13:36:39 +08:00
|
|
|
|
"""Sort by: sample, reputation, quality, then input order."""
|
|
|
|
|
|
quality_list = quality_comparison or []
|
|
|
|
|
|
|
|
|
|
|
|
def quality_key(path: Path, qc: dict, idx: int) -> tuple:
|
|
|
|
|
|
resolution = qc.get("resolution")
|
|
|
|
|
|
codec = qc.get("codec")
|
|
|
|
|
|
size = qc.get("size_bytes", 0) or 0
|
|
|
|
|
|
res_tier = _parse_resolution_tier(resolution, path)
|
|
|
|
|
|
src_tier = _parse_source_tier(path)
|
|
|
|
|
|
codec_tier = _parse_codec_tier(codec, path)
|
|
|
|
|
|
# Reputation fallback: prioritize source first (e.g. BluRay over WEB-DL).
|
|
|
|
|
|
return (-src_tier, -res_tier, -codec_tier, -size, idx)
|
|
|
|
|
|
|
2026-02-10 18:07:38 +08:00
|
|
|
|
def key(idx_reason: tuple[int, tuple[Path, Union[MovieIdentity, SeriesIdentity]]]) -> tuple:
|
|
|
|
|
|
idx, (path, identity) = idx_reason
|
2026-02-13 13:36:39 +08:00
|
|
|
|
qc = quality_list[idx] if idx < len(quality_list) else {}
|
2026-02-10 18:07:38 +08:00
|
|
|
|
has_rep = identity.reputation_score is not None
|
|
|
|
|
|
score = identity.reputation_score if identity.reputation_score is not None else -1.0
|
|
|
|
|
|
votes = identity.reputation_votes if identity.reputation_votes is not None else -1
|
2026-04-07 08:07:18 +08:00
|
|
|
|
is_sample = is_sample_path(path)
|
2026-02-13 13:36:39 +08:00
|
|
|
|
# Prefer non-sample, then reputation, then better quality, then lower index.
|
|
|
|
|
|
return (is_sample, not has_rep, -score, -votes, *quality_key(path, qc, idx))
|
|
|
|
|
|
|
|
|
|
|
|
indexed = list(enumerate(items))
|
|
|
|
|
|
indexed.sort(key=key)
|
|
|
|
|
|
return indexed[0][0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_modified_timestamp(value: Optional[str]) -> float:
|
|
|
|
|
|
"""Parse ISO modified timestamp to unix epoch seconds; unknown returns 0."""
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
try:
|
|
|
|
|
|
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
|
|
|
|
return datetime.fromisoformat(normalized).timestamp()
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
return 0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _by_reputation_quality_time_index(
|
|
|
|
|
|
items: list[tuple[Path, Union[MovieIdentity, SeriesIdentity]]],
|
|
|
|
|
|
quality_comparison: Optional[list[dict]] = None,
|
|
|
|
|
|
) -> int:
|
|
|
|
|
|
"""Sort by: sample, reputation, quality, modified time (newer first), then order."""
|
|
|
|
|
|
quality_list = quality_comparison or []
|
|
|
|
|
|
|
|
|
|
|
|
def quality_key(path: Path, qc: dict) -> tuple:
|
|
|
|
|
|
resolution = qc.get("resolution")
|
|
|
|
|
|
codec = qc.get("codec")
|
|
|
|
|
|
size = qc.get("size_bytes", 0) or 0
|
|
|
|
|
|
res_tier = _parse_resolution_tier(resolution, path)
|
|
|
|
|
|
src_tier = _parse_source_tier(path)
|
|
|
|
|
|
codec_tier = _parse_codec_tier(codec, path)
|
|
|
|
|
|
return (-res_tier, -src_tier, -codec_tier, -size)
|
|
|
|
|
|
|
|
|
|
|
|
def key(idx_reason: tuple[int, tuple[Path, Union[MovieIdentity, SeriesIdentity]]]) -> tuple:
|
|
|
|
|
|
idx, (path, identity) = idx_reason
|
|
|
|
|
|
qc = quality_list[idx] if idx < len(quality_list) else {}
|
|
|
|
|
|
has_rep = identity.reputation_score is not None
|
|
|
|
|
|
score = identity.reputation_score if identity.reputation_score is not None else -1.0
|
|
|
|
|
|
votes = identity.reputation_votes if identity.reputation_votes is not None else -1
|
|
|
|
|
|
modified_ts = _parse_modified_timestamp(qc.get("modified_timestamp"))
|
2026-04-07 08:07:18 +08:00
|
|
|
|
is_sample = is_sample_path(path)
|
2026-02-13 13:36:39 +08:00
|
|
|
|
return (
|
|
|
|
|
|
is_sample,
|
|
|
|
|
|
not has_rep,
|
|
|
|
|
|
-score,
|
|
|
|
|
|
-votes,
|
|
|
|
|
|
*quality_key(path, qc),
|
|
|
|
|
|
-modified_ts,
|
|
|
|
|
|
idx,
|
|
|
|
|
|
)
|
2026-02-10 18:07:38 +08:00
|
|
|
|
|
|
|
|
|
|
indexed = list(enumerate(items))
|
|
|
|
|
|
indexed.sort(key=key)
|
|
|
|
|
|
return indexed[0][0]
|