249 lines
8.5 KiB
Python
249 lines
8.5 KiB
Python
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.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\/(?P<id>sr[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) -> TaskResult | TaskFailure:
|
|
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 = entry.get("archive_id")
|
|
task_items.append(
|
|
TaskItem(url=url, title=entry.get("title"), archive_id=archive_id, metadata=entry.get("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),
|
|
]
|