add incremental enrich controls with progress and retry limits
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user