mirror of
https://github.com/Cp0204/quark-auto-save.git
synced 2026-01-12 15:20:44 +08:00
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Trakt 服务模块
|
||
基于 TMDB ID 映射到 Trakt 节目,并使用节目级 aired_time + timezone 获取精确播出时间,
|
||
再转换为本地时区的统一播出时间(格式:HH:MM)。
|
||
"""
|
||
|
||
import logging
|
||
from datetime import datetime, date, time as dtime
|
||
from typing import Optional, Dict, Any
|
||
|
||
import requests
|
||
|
||
try:
|
||
# Python 3.9+ 标准库时区支持
|
||
from zoneinfo import ZoneInfo
|
||
except Exception: # pragma: no cover - 兼容极老环境
|
||
ZoneInfo = None # type: ignore
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class TraktService:
|
||
"""
|
||
Trakt API 轻量封装:
|
||
- 通过 TMDB ID 查找 Trakt 节目
|
||
- 获取节目级播出时间 aired_time + timezone
|
||
- 将播出地时区的 daily airtime 转换为本地时区 HH:MM
|
||
"""
|
||
|
||
def __init__(self, client_id: Optional[str] = None, base_url: str = "https://api.trakt.tv"):
|
||
self.client_id = (client_id or "").strip()
|
||
self.base_url = base_url.rstrip("/")
|
||
self.session = requests.Session()
|
||
# 统一请求头
|
||
self.session.headers.update(
|
||
{
|
||
"Content-Type": "application/json",
|
||
"trakt-api-version": "2",
|
||
"trakt-api-key": self.client_id or "",
|
||
}
|
||
)
|
||
|
||
def is_configured(self) -> bool:
|
||
"""检查 Trakt 是否已配置有效的 Client ID。"""
|
||
return bool(self.client_id)
|
||
|
||
# ----------------- HTTP 基础封装 -----------------
|
||
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||
"""发起 GET 请求,失败时记录日志并返回 None,不抛出到上层。"""
|
||
if not self.is_configured():
|
||
return None
|
||
url = f"{self.base_url}{path}"
|
||
try:
|
||
resp = self.session.get(url, params=params or {}, timeout=10)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except Exception as e:
|
||
logger.warning(f"Trakt GET 请求失败: {url}, err={e}")
|
||
return None
|
||
|
||
# ----------------- 节目级信息 -----------------
|
||
def get_show_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||
"""
|
||
通过 TMDB ID 查找 Trakt 节目。
|
||
|
||
返回结构示例:
|
||
{
|
||
"trakt_id": 195268,
|
||
"slug": "it-welcome-to-derry",
|
||
"title": "IT: Welcome to Derry",
|
||
"year": 2025
|
||
}
|
||
"""
|
||
try:
|
||
if not self.is_configured():
|
||
return None
|
||
if not tmdb_id:
|
||
return None
|
||
path = f"/search/tmdb/{int(tmdb_id)}"
|
||
data = self._get(path, params={"type": "show"})
|
||
if not data:
|
||
return None
|
||
# Trakt 搜索返回列表,取第一个 show 结果
|
||
item = None
|
||
for entry in data:
|
||
if entry.get("type") == "show" and entry.get("show"):
|
||
item = entry.get("show") or {}
|
||
break
|
||
if not item:
|
||
return None
|
||
return {
|
||
"trakt_id": item.get("ids", {}).get("trakt"),
|
||
"slug": item.get("ids", {}).get("slug"),
|
||
"title": item.get("title") or "",
|
||
"year": item.get("year"),
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"通过 TMDB ID 获取 Trakt 节目失败: tmdb_id={tmdb_id}, err={e}")
|
||
return None
|
||
|
||
def get_show_airtime(self, trakt_show_id: Any) -> Optional[Dict[str, str]]:
|
||
"""
|
||
获取节目级播出时间信息:aired_time + timezone。
|
||
|
||
Trakt 节目详情通常包含:
|
||
{
|
||
"airs": {
|
||
"day": "sunday",
|
||
"time": "21:00",
|
||
"timezone": "America/New_York"
|
||
},
|
||
...
|
||
}
|
||
"""
|
||
try:
|
||
if not self.is_configured():
|
||
return None
|
||
if not trakt_show_id:
|
||
return None
|
||
path = f"/shows/{trakt_show_id}"
|
||
data = self._get(path, params={"extended": "full"})
|
||
if not data:
|
||
return None
|
||
airs = data.get("airs") or {}
|
||
aired_time = (airs.get("time") or "").strip()
|
||
timezone = (airs.get("timezone") or "").strip()
|
||
if not aired_time or not timezone:
|
||
return None
|
||
return {"aired_time": aired_time, "timezone": timezone}
|
||
except Exception as e:
|
||
logger.warning(f"获取 Trakt 节目播出时间失败: trakt_show_id={trakt_show_id}, err={e}")
|
||
return None
|
||
|
||
# ----------------- 时区转换 -----------------
|
||
def convert_show_airtime_to_local(
|
||
self, aired_time: str, source_tz: str, local_tz: str
|
||
) -> Optional[str]:
|
||
"""
|
||
将播出地时区的 daily airtime 转换为本地时区的 HH:MM。
|
||
|
||
参数:
|
||
aired_time: 播出地时间(字符串形式,如 '21:00')
|
||
source_tz: 播出地时区(如 'America/New_York')
|
||
local_tz: 本地时区(如 'Asia/Shanghai')
|
||
返回:
|
||
本地时区 HH:MM 字符串;失败时返回 None
|
||
"""
|
||
try:
|
||
aired_time = (aired_time or "").strip()
|
||
source_tz = (source_tz or "").strip()
|
||
local_tz = (local_tz or "").strip()
|
||
if not aired_time or not source_tz or not local_tz:
|
||
return None
|
||
if ZoneInfo is None:
|
||
# 环境不支持 zoneinfo 时,退化为直接返回原始播出时间
|
||
return aired_time
|
||
|
||
# 解析 HH:MM
|
||
try:
|
||
hh, mm = [int(x) for x in aired_time.split(":")]
|
||
except Exception:
|
||
return None
|
||
|
||
# 使用任意日期承载“每日播出时间”含义,这里选择今天
|
||
today = date.today()
|
||
naive_dt = datetime.combine(today, dtime(hour=hh, minute=mm))
|
||
try:
|
||
src_zone = ZoneInfo(source_tz)
|
||
dst_zone = ZoneInfo(local_tz)
|
||
except Exception:
|
||
# 时区字符串非法时,保守返回原 time
|
||
return aired_time
|
||
|
||
src_dt = naive_dt.replace(tzinfo=src_zone)
|
||
local_dt = src_dt.astimezone(dst_zone)
|
||
return local_dt.strftime("%H:%M")
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"转换节目播出时间到本地时区失败: aired_time={aired_time}, source_tz={source_tz}, local_tz={local_tz}, err={e}"
|
||
)
|
||
return None
|
||
|
||
|
||
# 方便其它模块直接导入一个全局实例时再按需注入 client_id
|
||
trakt_service: Optional[TraktService] = None
|
||
|
||
|