Files
dl-organizer/src/vlm/enrichment.py
T
6f0df5a774 release: v0.2.0 repository hygiene, CI, and docs sync
Stop tracking personal workflow artifacts at repo root, add CI and MIT
license, align README and agent skills with artifacts/ defaults, and
enable Ruff in dev/CI so releases are verifiable without local-only runs.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 15:32:03 +08:00

573 lines
18 KiB
Python

"""Identity enrichment pipeline.
Adds bilingual titles and reputation signals with incremental SQLite caching.
"""
from __future__ import annotations
import hashlib
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Callable, Optional
from urllib.request import Request, urlopen
from vlm.cache import EnrichmentCache
from vlm.config import Config
from vlm.parser import normalize_title
from vlm.providers import ProviderResult, TMDBAuthError, TMDBProvider, TMDBProviderError
from vlm.utils import sanitize_path_component
RefreshMode = str
ProgressCallback = Callable[[int, int, dict[str, int]], None]
def enrich_identities_data(
identities_data: dict,
config: Config,
*,
refresh_mode: RefreshMode = "incremental",
request_timeout: int = 6,
retries: int = 2,
logger=None,
progress_callback: Optional[ProgressCallback] = None,
) -> tuple[dict, dict[str, int | list[dict[str, str]]]]:
"""Enrich parsed identities in memory and return updated data + stats.
refresh_mode:
- incremental: default, uses fingerprint cache checks
- refresh_changed_only: semantic alias of incremental mode
- refresh_all: bypasses cache and re-fetches all records
"""
cache = EnrichmentCache(config.enrichment_cache_db)
total_records = (
len(identities_data.get("movies", []))
+ len(identities_data.get("series", []))
+ len(identities_data.get("anime", []))
)
stats: dict[str, int | list[dict[str, str]]] = {
"total": total_records,
"processed": 0,
"enriched": 0,
"cache_hits": 0,
"skipped": 0,
"needs_review": 0,
"failed": 0,
"api_calls": 0,
"failed_items": [],
"skip_reasons": {},
}
refresh_all = refresh_mode == "refresh_all"
max_workers = max(1, int(config.enrichment_max_concurrency))
for section, media_type in (("movies", "movie"), ("series", "series"), ("anime", "anime")):
records = identities_data.get(section, [])
pending_jobs: list[tuple[dict, str, str, str]] = []
for record in records:
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
if "title" not in record:
record["title"] = title
fingerprint = _fingerprint(record, media_type)
identity_key = _identity_key(record, media_type)
cached = None
if not refresh_all:
cached = cache.get_identity(identity_key, fingerprint)
if cached:
_apply_payload(record, cached)
stats["cache_hits"] = int(stats["cache_hits"]) + 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)
continue
pending_jobs.append((record, identity_key, fingerprint, media_type))
if not pending_jobs:
continue
if max_workers <= 1:
for record, identity_key, fingerprint, pending_media_type in pending_jobs:
payload, api_calls, failures, skip_reason = _enrich_record_with_fresh_providers(
record,
pending_media_type,
config,
request_timeout=request_timeout,
retries=retries,
)
_apply_payload(record, payload)
cache.put_identity(identity_key, fingerprint, payload)
_update_stats_after_enrich(
stats, payload, failures, api_calls, record, progress_callback, skip_reason
)
else:
future_map = {}
with ThreadPoolExecutor(max_workers=max_workers) as pool:
for record, identity_key, fingerprint, pending_media_type in pending_jobs:
future = pool.submit(
_enrich_record_with_fresh_providers,
record,
pending_media_type,
config,
request_timeout,
retries,
)
future_map[future] = (record, identity_key, fingerprint)
for future in as_completed(future_map):
record, identity_key, fingerprint = future_map[future]
payload, api_calls, failures, skip_reason = future.result()
_apply_payload(record, payload)
cache.put_identity(identity_key, fingerprint, payload)
_update_stats_after_enrich(
stats, payload, failures, api_calls, record, progress_callback, skip_reason
)
metadata = identities_data.setdefault("metadata", {})
metadata["enriched"] = True
metadata["enrichment_policy"] = "incremental" if refresh_mode != "refresh_all" else "full"
metadata["enrichment_refresh_mode"] = refresh_mode
if logger:
logger.info(
"Enrichment completed: total=%s enriched=%s cache_hits=%s skipped=%s failed=%s api_calls=%s",
stats["total"],
stats["enriched"],
stats["cache_hits"],
stats["skipped"],
stats["failed"],
stats["api_calls"],
)
return identities_data, stats
def _emit_progress(stats: dict[str, int | list[dict[str, str]]], progress_callback: Optional[ProgressCallback]) -> None:
if not progress_callback:
return
progress_callback(
int(stats["processed"]),
int(stats["total"]),
{
"cache_hits": int(stats["cache_hits"]),
"api_calls": int(stats["api_calls"]),
"failed": int(stats["failed"]),
},
)
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] = []
for name in config.enrichment_providers:
key = name.lower()
if key == "tmdb":
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,
)
)
else:
unsupported.append(name)
if unsupported:
raise ValueError(
f"Unsupported enrichment providers: {unsupported}. Supported providers: ['tmdb']"
)
return providers
def _enrich_record_with_fresh_providers(
record: dict,
media_type: str,
config: Config,
request_timeout: int,
retries: int,
) -> tuple[dict, int, list[dict[str, str]], str]:
providers = _build_providers(
config,
request_timeout=request_timeout,
retries=retries,
)
return _enrich_record(
record,
media_type,
providers,
config,
request_timeout=request_timeout,
retries=retries,
)
def _enrich_record(
record: dict,
media_type: str,
providers: list,
config: Config,
*,
request_timeout: int,
retries: int,
) -> 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:
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": 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 and config.openai_api_key:
if not merged.get("title_zh"):
api_calls += 1
translated = _translate_with_openai(
title,
target_language="Chinese (Simplified)",
api_key=config.openai_api_key,
timeout_seconds=request_timeout,
retries=retries,
)
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",
api_key=config.openai_api_key,
timeout_seconds=request_timeout,
retries=retries,
)
if translated:
merged["title_en"] = translated
merged["translation_source"] = merged.get("translation_source") or "openai"
confidence = _enrichment_confidence(merged)
merged["enrichment_confidence"] = confidence
review_status = record.get("review_status", "pending")
needs_review = bool(record.get("needs_review", False))
if confidence < config.enrichment_min_match_score:
needs_review = True
score = merged.get("reputation_score")
votes = merged.get("reputation_votes") or 0
if (
score is not None
and votes >= config.reputation_min_votes
and score < config.reputation_low_score_threshold
):
needs_review = True
merged["review_status"] = review_status
merged["needs_review"] = needs_review
merged["enriched"] = bool(provider_results or merged.get("translation_source"))
merged["display_title"] = _build_display_title(record, merged, config)
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:
payload: dict = {
"canonical_id": None,
"title_zh": None,
"title_en": None,
"translation_source": None,
"reputation_score": None,
"reputation_votes": None,
"reputation_source": None,
"provider_metadata": {},
}
if not results:
return payload
first = results[0]
payload["canonical_id"] = first.canonical_id
payload["title_zh"] = first.title_zh
payload["title_en"] = first.title_en
payload["translation_source"] = first.translation_source
total_weight = 0
weighted_score = 0.0
source_names = []
for result in results:
source_names.append(result.provider)
payload["provider_metadata"][result.provider] = json.dumps(result.raw_metadata, ensure_ascii=False)
if not payload["title_zh"] and result.title_zh:
payload["title_zh"] = result.title_zh
payload["translation_source"] = result.translation_source or result.provider
if not payload["title_en"] and result.title_en:
payload["title_en"] = result.title_en
if result.reputation_score is None:
continue
votes = result.reputation_votes if result.reputation_votes and result.reputation_votes > 0 else 1
weighted_score += result.reputation_score * votes
total_weight += votes
if total_weight > 0:
payload["reputation_score"] = round(weighted_score / total_weight, 3)
payload["reputation_votes"] = total_weight
payload["reputation_source"] = "+".join(sorted(set(source_names)))
return payload
def _enrichment_confidence(payload: dict) -> float:
score = 0.0
if payload.get("canonical_id"):
score += 0.4
if payload.get("title_zh"):
score += 0.2
if payload.get("title_en"):
score += 0.2
if payload.get("reputation_score") is not None:
score += 0.2
return round(score, 3)
def _translate_with_openai(
text: str,
*,
target_language: str,
api_key: Optional[str],
timeout_seconds: int,
retries: int,
) -> Optional[str]:
if not api_key:
return None
body = {
"model": "gpt-4o-mini",
"input": (
f"Translate the movie or TV title into {target_language}. "
"Return only the translated title without explanations."
f"\nTitle: {text}"
),
}
request = Request(
"https://api.openai.com/v1/responses",
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
for _ in range(max(retries + 1, 1)):
try:
with urlopen(request, timeout=timeout_seconds) as response:
payload = json.loads(response.read().decode("utf-8"))
output_text = payload.get("output_text")
if isinstance(output_text, str) and output_text.strip():
return output_text.strip()
except Exception:
continue
return None
def _apply_payload(record: dict, payload: dict) -> None:
for key in (
"canonical_id",
"title_zh",
"title_en",
"translation_source",
"reputation_score",
"reputation_votes",
"reputation_source",
"review_status",
"enrichment_confidence",
"provider_metadata",
"display_title",
):
if key in payload:
record[key] = payload[key]
if "needs_review" in payload:
record["needs_review"] = payload["needs_review"]
def _identity_key(record: dict, media_type: str) -> str:
return f"{media_type}:{record.get('path', '')}"
def _fingerprint(record: dict, media_type: str) -> str:
fields = [
media_type,
str(record.get("path", "")),
str(record.get("title", "")),
str(record.get("year", "")),
str(record.get("season", "")),
json.dumps(record.get("episodes", [])),
]
digest = hashlib.sha256("|".join(fields).encode("utf-8")).hexdigest()
return digest
def _fallback_title_from_filename(filename: Optional[str]) -> Optional[str]:
if not filename:
return None
base = filename.rsplit(".", 1)[0]
return normalize_title(base)
def _build_display_title(record: dict, payload: dict, config: Config) -> str:
fallback_title = record.get("title")
title_zh = payload.get("title_zh") or fallback_title
title_en = payload.get("title_en") or fallback_title
if title_zh and title_en and title_zh == title_en:
title_en = ""
try:
formatted = config.naming_title_format.format(
title_zh=title_zh or "",
title_en=title_en or "",
).strip()
except Exception:
formatted = f"{title_zh or ''} {title_en or ''}".strip()
return sanitize_path_component(" ".join(formatted.split()), fallback=fallback_title or "untitled")