import re import httpx from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult from app.features.ytdlp.utils import get_archive_id from app.library.config import Config from app.library.log import get_logger from ._base_handler import BaseHandler LOG = get_logger() class TverHandler(BaseHandler): SERIES_API: str = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}" SESSION_API: str = "https://platform-api.tver.jp/v2/api/platform_users/browser/create" HEADERS: dict[str, str] = { "x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/", } RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?Psr[a-z0-9_]+)$") @staticmethod async def can_handle(task: HandleTask) -> bool: LOG.debug( "Checking if task '%s' uses a parsable Tver series URL.", task.name, extra={"task_name": task.name, "url": task.url}, ) return TverHandler.parse(task.url) is not None @staticmethod async def _get_session_tokens() -> dict[str, str] | None: """ Create a session and retrieve platform_uid and platform_token. Returns: dict[str, str] | None: Dictionary with platform_uid and platform_token, or None on failure. """ try: response = await TverHandler.request( url=TverHandler.SESSION_API, headers=TverHandler.HEADERS, method="POST", data={"device_type": "pc"}, ) response.raise_for_status() data = response.json() platform_uid = data.get("result", {}).get("platform_uid") platform_token = data.get("result", {}).get("platform_token") if not platform_uid or not platform_token: LOG.warning("Failed to extract platform_uid and platform_token from session response.") return None return {"platform_uid": platform_uid, "platform_token": platform_token} except Exception as exc: LOG.warning( "Failed to create Tver session.", extra={"error": str(exc), "exception_type": type(exc).__name__}, exc_info=True, ) return None @staticmethod async def _collect_feed( task: HandleTask, params: dict, series_id: str, ) -> tuple[str, list[dict[str, str]], bool]: """ Fetch episodes from tver series API. Args: task (Task): The task containing the tver series URL. params (dict): The ytdlp options. series_id (str): The tver series ID. Returns: tuple[str, list[dict[str, str]], bool]: The feed URL, list of episodes, and whether items were found. """ tokens = await TverHandler._get_session_tokens() if not tokens: msg = "Could not obtain tver session tokens" raise RuntimeError(msg) feed_url = TverHandler.SERIES_API.format(id=series_id) LOG.debug( "Fetching Tver episodes for task '%s'.", task.name, extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url}, ) response = await TverHandler.request( url=feed_url, headers=TverHandler.HEADERS, ytdlp_opts=params, params={ "platform_uid": tokens["platform_uid"], "platform_token": tokens["platform_token"], }, ) response.raise_for_status() data = response.json() items: list[dict[str, str]] = [] has_items = False # Parse episodes from result.contents[0].contents try: contents = data.get("result", {}).get("contents", []) if not contents: LOG.warning( "No contents found in Tver series response for '%s'.", task.name, extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url}, ) return feed_url, items, has_items season_block = contents[0] if contents else {} episodes = season_block.get("contents", []) for episode_data in episodes: if "episode" != episode_data.get("type"): continue content = episode_data.get("content", {}) episode_id = content.pop("id", None) if not episode_id: LOG.warning( "Episode missing ID in '%s' feed. Skipping.", task.name, extra={"task_name": task.name, "series_id": series_id, "feed_url": feed_url}, ) continue url = f"https://tver.jp/episodes/{episode_id}" title = content.pop("title", "") id_dict = get_archive_id(url) archive_id = id_dict.get("archive_id") if not archive_id: LOG.warning( "Task '%s' could not compute an archive ID for an episode. Skipping item.", task.name, extra={"task_name": task.name, "series_id": series_id, "episode_id": episode_id, "url": url}, ) continue has_items = True items.append( {"id": episode_id, "url": url, "title": title, "archive_id": archive_id, "metadata": content} ) except Exception as exc: LOG.warning( "Failed to parse Tver episodes for task '%s'.", task.name, extra={ "task_id": task.id, "task_name": task.name, "series_id": series_id, "error": str(exc), "exception_type": type(exc).__name__, }, exc_info=True, ) return feed_url, items, has_items @staticmethod async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: _ = config series_id: str | None = TverHandler.parse(task.url) if not series_id: return TaskFailure(message="Unrecognized Tver series URL.") params: dict = task.get_ytdlp_opts().get_all() try: feed_url, items, has_items = await TverHandler._collect_feed(task, params, series_id) except httpx.HTTPError as exc: return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc)) except Exception as exc: LOG.exception( "Failed to fetch Tver feed for task '%s'.", task.name, extra={ "task_id": task.id, "task_name": task.name, "url": task.url, "series_id": series_id, "exception_type": type(exc).__name__, }, ) return TaskFailure(message="Failed to fetch Tver feed.", error=str(exc)) task_items: list[TaskItem] = [] for entry in items: if not (url := entry.get("url")): continue archive_id: str | None = entry.get("archive_id") metadata = entry.get("metadata", {}) if not isinstance(metadata, dict): metadata = {} task_items.append(TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=metadata)) return TaskResult(items=task_items, metadata={"feed_url": feed_url, "has_entries": has_items}) @staticmethod def parse(url: str) -> str | None: """ Parse URL to extract series ID. Args: url (str): The url to check. Returns: str | None: The parsed ID if successful, None otherwise. """ match: re.Match[str] | None = TverHandler.RX.match(url) return match.group("id") if match else None @staticmethod def tests() -> list[tuple[str, bool]]: """ Test cases for the URL parser. Returns: list[tuple[str, bool]]: A list of tuples containing the URL and expected result. """ return [ ("https://tver.jp/series/sr1jg44pbb", True), ("https://www.tver.jp/series/sr1jg44pbb", True), ("https://m.tver.jp/series/sr_test_id", True), ("http://tver.jp/series/sr123abc456", True), ("https://tver.jp/videos/sr123abc456", False), ("https://youtube.com/watch?v=123", False), ]