add incremental enrich controls with progress and retry limits

This commit is contained in:
windyboy
2026-02-09 23:55:13 +08:00
parent 259e7506d7
commit 59a3b52fee
15 changed files with 1653 additions and 142 deletions
+12
View File
@@ -0,0 +1,12 @@
"""Provider implementations for enrichment."""
from vlm.providers.base import EnrichmentProvider, ProviderResult
from vlm.providers.tmdb import TMDBProvider
from vlm.providers.douban import DoubanProvider
__all__ = [
"EnrichmentProvider",
"ProviderResult",
"TMDBProvider",
"DoubanProvider",
]
+31
View File
@@ -0,0 +1,31 @@
"""Provider interfaces for enrichment sources."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, Optional
@dataclass
class ProviderResult:
"""Normalized provider output used by enrichment pipeline."""
provider: str
canonical_id: Optional[str] = None
title_zh: Optional[str] = None
title_en: Optional[str] = None
translation_source: Optional[str] = None
reputation_score: Optional[float] = None
reputation_votes: Optional[int] = None
reputation_source: Optional[str] = None
match_score: Optional[float] = None
raw_metadata: dict[str, str] = field(default_factory=dict)
class EnrichmentProvider(Protocol):
"""Protocol for title/score providers."""
name: str
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
"""Return normalized metadata for a single identity."""
+97
View File
@@ -0,0 +1,97 @@
"""Douban provider implementation.
This provider is optional. If no endpoint is configured, it silently degrades.
"""
from __future__ import annotations
import json
import time
from typing import Optional
from urllib.parse import urlencode
from urllib.request import urlopen, Request
from vlm.providers.base import ProviderResult
class DoubanProvider:
"""Fetch reputation data from a configurable Douban-compatible API."""
name = "douban"
def __init__(
self,
api_key: Optional[str],
endpoint: Optional[str] = None,
timeout_seconds: int = 6,
retries: int = 2,
min_interval_seconds: float = 0.4,
) -> None:
self.api_key = api_key
self.endpoint = endpoint
self.timeout_seconds = timeout_seconds
self.retries = retries
self.min_interval_seconds = min_interval_seconds
self._last_request_at = 0.0
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
if not self.endpoint:
return None
params = {
"q": title,
"type": media_type,
}
if year is not None:
params["year"] = year
if self.api_key:
params["api_key"] = self.api_key
data = self._get_json(self.endpoint, params)
if not data:
return None
items = data.get("items") or data.get("subjects") or []
if not items:
return None
item = items[0]
score = item.get("rating") or item.get("score")
votes = item.get("vote_count") or item.get("ratings_count")
title_zh = item.get("title")
title_en = item.get("original_title")
return ProviderResult(
provider=self.name,
canonical_id=f"douban:{item.get('id', 'unknown')}",
title_zh=title_zh,
title_en=title_en,
translation_source=self.name if title_zh or title_en else None,
reputation_score=float(score) if score is not None else None,
reputation_votes=int(votes) if votes is not None else None,
reputation_source=self.name,
raw_metadata={"id": str(item.get("id", ""))},
)
def _wait_for_rate_limit(self) -> None:
if self.min_interval_seconds <= 0:
return
now = time.monotonic()
elapsed = now - self._last_request_at
if elapsed < self.min_interval_seconds:
time.sleep(self.min_interval_seconds - elapsed)
def _get_json(self, url: str, params: dict) -> Optional[dict]:
full_url = f"{url}?{urlencode(params)}"
request = Request(full_url, headers={"Accept": "application/json"})
for _ in range(max(self.retries + 1, 1)):
self._wait_for_rate_limit()
try:
with urlopen(request, timeout=self.timeout_seconds) as response:
payload = response.read().decode("utf-8")
self._last_request_at = time.monotonic()
return json.loads(payload)
except Exception:
self._last_request_at = time.monotonic()
continue
return None
+108
View File
@@ -0,0 +1,108 @@
"""TMDB provider implementation."""
from __future__ import annotations
import json
import time
from typing import Optional
from urllib.parse import urlencode
from urllib.request import urlopen, Request
from vlm.providers.base import ProviderResult
class TMDBProvider:
"""Fetch translations and reputation data from TMDB."""
name = "tmdb"
def __init__(
self,
api_key: Optional[str],
language: str = "zh-CN",
timeout_seconds: int = 6,
retries: int = 2,
min_interval_seconds: float = 0.25,
) -> None:
self.api_key = api_key
self.language = language
self.base_url = "https://api.themoviedb.org/3"
self.timeout_seconds = timeout_seconds
self.retries = retries
self.min_interval_seconds = min_interval_seconds
self._last_request_at = 0.0
def enrich(self, *, title: str, media_type: str, year: Optional[int] = None) -> Optional[ProviderResult]:
if not self.api_key:
return None
search_type = "tv" if media_type in {"series", "anime", "tv"} else "movie"
query_params = {
"api_key": self.api_key,
"query": title,
"language": self.language,
}
if year and search_type == "movie":
query_params["year"] = year
search_data = self._get_json(f"{self.base_url}/search/{search_type}", query_params)
if not search_data:
return None
results = search_data.get("results", [])
if not results:
return None
candidate = results[0]
tmdb_id = candidate.get("id")
if tmdb_id is None:
return None
details = self._get_json(
f"{self.base_url}/{search_type}/{tmdb_id}",
{"api_key": self.api_key, "language": self.language},
)
if not details:
details = candidate
original_title = details.get("original_title") or details.get("original_name")
localized_title = details.get("title") or details.get("name")
vote_average = details.get("vote_average")
vote_count = details.get("vote_count")
return ProviderResult(
provider=self.name,
canonical_id=f"tmdb:{search_type}:{tmdb_id}",
title_zh=localized_title,
title_en=original_title,
translation_source=self.name,
reputation_score=float(vote_average) if vote_average is not None else None,
reputation_votes=int(vote_count) if vote_count is not None else None,
reputation_source=self.name,
match_score=float(candidate.get("popularity", 0.0)) if candidate.get("popularity") is not None else None,
raw_metadata={"media_type": search_type, "id": str(tmdb_id)},
)
def _wait_for_rate_limit(self) -> None:
if self.min_interval_seconds <= 0:
return
now = time.monotonic()
elapsed = now - self._last_request_at
if elapsed < self.min_interval_seconds:
time.sleep(self.min_interval_seconds - elapsed)
def _get_json(self, url: str, params: dict) -> Optional[dict]:
full_url = f"{url}?{urlencode(params)}"
request = Request(full_url, headers={"Accept": "application/json"})
for _ in range(max(self.retries + 1, 1)):
self._wait_for_rate_limit()
try:
with urlopen(request, timeout=self.timeout_seconds) as response:
payload = response.read().decode("utf-8")
self._last_request_at = time.monotonic()
return json.loads(payload)
except Exception:
self._last_request_at = time.monotonic()
continue
return None