mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-10-12 02:19:24 +00:00
Added anime provider tsukihime (#3597)
This commit is contained in:
@@ -107,6 +107,7 @@ At the request of some users, here is a way to show appreciation for the efforts
|
||||
- Titlovi
|
||||
- Titrari.ro
|
||||
- Titulky.com
|
||||
- TsukiHime
|
||||
- Turkcealtyazi.org
|
||||
- TuSubtitulo
|
||||
- TVSubtitles
|
||||
|
||||
@@ -344,6 +344,7 @@ def get_providers_auth():
|
||||
'search_threshold': settings.animetosho.search_threshold,
|
||||
},
|
||||
"animetosho_xyz": {},
|
||||
"tsukihime": {},
|
||||
"subdl": {
|
||||
'api_key': settings.subdl.api_key,
|
||||
'ai_translate': settings.subdl.ai_translate,
|
||||
|
||||
@@ -20,7 +20,7 @@ except ImportError:
|
||||
except ImportError:
|
||||
import xml.etree.ElementTree as etree
|
||||
|
||||
refined_providers = {'animetosho', 'animetosho_xyz', 'jimaku'}
|
||||
refined_providers = {'animetosho', 'animetosho_xyz', 'jimaku', 'tsukihime'}
|
||||
providers_requiring_anidb_api = {'animetosho', 'animetosho_xyz'}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.config import settings
|
||||
from subliminal import Episode, region, __short_version__
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
refined_providers = {'jimaku'}
|
||||
refined_providers = {'jimaku', 'tsukihime'}
|
||||
|
||||
|
||||
class AniListClient(object):
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
import lzma
|
||||
import os
|
||||
import re
|
||||
|
||||
from guessit import guessit
|
||||
from requests import Session
|
||||
from requests.exceptions import RequestException
|
||||
from subzero.language import Language
|
||||
|
||||
from subliminal.exceptions import ProviderError
|
||||
from subliminal.video import Episode, Movie
|
||||
from subliminal_patch.providers import Provider
|
||||
from subliminal_patch.subtitle import Subtitle, guess_matches
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_URL = "https://api.tsukihime.org/v1"
|
||||
STORAGE_URL = "https://storage.tsukihime.org"
|
||||
MAX_TORRENTS = 20
|
||||
|
||||
# TsukiHime stores each subtitle track as an XZ archive named after the attachment id under a
|
||||
# path keyed on the hexadecimal rendering of that same id. AnimeTosho-mirrored torrents are
|
||||
# served from a dedicated path, the rest from the native one.
|
||||
NATIVE_STORAGE_PATH = "attach"
|
||||
ANIMETOSHO_STORAGE_PATH = "tosho/attach"
|
||||
|
||||
# TsukiHime reports regional language variants with codes that neither resolve to a valid IETF
|
||||
# language nor carry a country babelfish understands. Map them explicitly so a track is never
|
||||
# demoted to a generic language (e.g. Simplified Chinese) and dropped by the pool.
|
||||
_REGIONAL_LANGUAGE_ALIASES = {
|
||||
"es-419": Language("spa", "MX"),
|
||||
"es-es": Language("spa"),
|
||||
"pt-br": Language("por", "BR"),
|
||||
"pt-pt": Language("por"),
|
||||
"zh-cn": Language("zho", "CN"),
|
||||
"zh-hans": Language("zho", "CN"),
|
||||
"zh-hk": Language("zho", "TW"),
|
||||
"zh-hant": Language("zho", "TW"),
|
||||
"zh-tw": Language("zho", "TW"),
|
||||
}
|
||||
|
||||
_SUPPORTED_ALPHA2_CODES = [
|
||||
"af", "am", "ar", "az", "be", "bg", "bn", "bs", "ca", "cs",
|
||||
"da", "de", "el", "en", "eo", "es", "et", "eu", "fa", "fi",
|
||||
"fr", "gl", "he", "hi", "hr", "hu", "hy", "id", "is", "it",
|
||||
"ja", "ka", "kk", "ko", "lt", "lv", "mk", "ms", "nl", "no",
|
||||
"pl", "pt", "ro", "ru", "sk", "sl", "sq", "sr", "sv", "th",
|
||||
"tr", "uk", "ur", "uz", "vi", "zh",
|
||||
]
|
||||
|
||||
# The provider pool intersects the requested languages with these before calling list_subtitles,
|
||||
# so every regional variant a profile may ask for has to be declared or it never reaches this
|
||||
# provider. The same applies to the forced and hearing-impaired variants.
|
||||
languages = {Language.fromalpha2(code) for code in _SUPPORTED_ALPHA2_CODES}
|
||||
languages.update(_REGIONAL_LANGUAGE_ALIASES.values())
|
||||
languages.update(Language.rebuild(language, forced=True) for language in list(languages))
|
||||
languages.update(Language.rebuild(language, hi=True) for language in list(languages))
|
||||
|
||||
_SUBTITLE_CODECS = ("ass", "srt", "ssa", "sub", "vtt")
|
||||
|
||||
# Matches the hearing-impaired markers fansub groups append to a track name, e.g.
|
||||
# "English (CC)", "[SDH]" or "english.hearing impaired".
|
||||
_HI_MARKER_RE = re.compile(
|
||||
r"(?:^|[\s_.\-\[(])(?:cc|sdh|hearing[\s_.\-]*impaired)(?:$|[\s_.\-)\]])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Tracks holding only signs and songs are frequently muxed without the forced disposition flag, so
|
||||
# the track name is the only hint. Treat them like forced ones instead of offering them as full
|
||||
# subtitles, e.g. "English [Signs]" or "Signs & Songs".
|
||||
_SIGNS_MARKER_RE = re.compile(r"\bsigns?\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def _language_from_code(code):
|
||||
if not code:
|
||||
return None
|
||||
|
||||
normalized = code.strip().lower().replace("_", "-")
|
||||
if normalized in _REGIONAL_LANGUAGE_ALIASES:
|
||||
return _REGIONAL_LANGUAGE_ALIASES[normalized]
|
||||
|
||||
for candidate in (code.strip(), normalized):
|
||||
try:
|
||||
return Language.fromietf(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
return Language.fromalpha3b(normalized)
|
||||
except Exception:
|
||||
logger.debug("Unsupported TsukiHime language code: %s", code)
|
||||
return None
|
||||
|
||||
|
||||
class TsukiHimeSubtitle(Subtitle):
|
||||
"""Subtitle track extracted from a release indexed by TsukiHime."""
|
||||
provider_name = "tsukihime"
|
||||
hash_verifiable = False
|
||||
|
||||
def __init__(self, language, download_url, release_info, release_id, codec, verified_matches):
|
||||
super(TsukiHimeSubtitle, self).__init__(
|
||||
language,
|
||||
page_link=download_url,
|
||||
original_format=True,
|
||||
)
|
||||
self.download_url = download_url
|
||||
self.release_info = release_info
|
||||
self.release_id = release_id
|
||||
self.format = codec.lower()
|
||||
self.verified_matches = verified_matches
|
||||
self.matches = set()
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
# TsukiHime reuses the same attachment (and therefore the same storage URL) across release
|
||||
# associations, so the release has to stay part of the id. Otherwise the pool drops every
|
||||
# association but the first and scoring cannot use their release-specific metadata.
|
||||
return f"{self.release_id}:{self.download_url}"
|
||||
|
||||
def get_matches(self, video):
|
||||
video_type = "episode" if isinstance(video, Episode) else "movie"
|
||||
self.matches |= guess_matches(video, guessit(self.release_info, {"type": video_type}))
|
||||
self.matches.update(self.verified_matches)
|
||||
return self.matches
|
||||
|
||||
|
||||
class TsukiHimeProvider(Provider):
|
||||
"""TsukiHime anime subtitle provider."""
|
||||
provider_name = "tsukihime"
|
||||
subtitle_class = TsukiHimeSubtitle
|
||||
languages = languages
|
||||
video_types = (Episode, Movie)
|
||||
|
||||
def __init__(self):
|
||||
self.session = None
|
||||
|
||||
def initialize(self):
|
||||
self.session = Session()
|
||||
self.session.headers.update({"User-Agent": os.environ.get("SZ_USER_AGENT", "Bazarr")})
|
||||
|
||||
def terminate(self):
|
||||
if self.session is not None:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
|
||||
def list_subtitles(self, video, languages):
|
||||
anime = self._get_anime(video)
|
||||
if not anime:
|
||||
return []
|
||||
|
||||
requested_languages = set(languages)
|
||||
entries = [
|
||||
entry for entry in self._get_entries(video, anime["id"])
|
||||
if entry.get("state") == "completed"
|
||||
and self._entry_has_requested_language(entry.get("sublangs", []), requested_languages)
|
||||
]
|
||||
|
||||
# Prefer the releases that best resemble the original media name so, when the candidate cap
|
||||
# is reached, the associations we keep come from the most relevant torrents first.
|
||||
entries.sort(key=lambda entry: self._entry_score(video, entry), reverse=True)
|
||||
|
||||
subtitles = []
|
||||
seen_ids = set()
|
||||
for entry in entries[:MAX_TORRENTS]:
|
||||
entry_id = entry.get("id")
|
||||
if not entry_id:
|
||||
continue
|
||||
|
||||
detail = self._get_json(f"/torrents/{entry_id}")
|
||||
if not detail:
|
||||
continue
|
||||
|
||||
for file_data in self._matching_files(video, detail.get("files", [])):
|
||||
for attachment in file_data.get("attachments", []):
|
||||
subtitle = self._subtitle_from_attachment(
|
||||
video, anime, entry, file_data, attachment, requested_languages,
|
||||
)
|
||||
if subtitle and subtitle.id not in seen_ids:
|
||||
seen_ids.add(subtitle.id)
|
||||
subtitles.append(subtitle)
|
||||
|
||||
return subtitles
|
||||
|
||||
def download_subtitle(self, subtitle):
|
||||
logger.info("Downloading subtitle %r", subtitle)
|
||||
response = self._download(subtitle.download_url)
|
||||
if not response.content.startswith(b"\xFD\x37\x7A\x58\x5A\x00"):
|
||||
raise ProviderError("TsukiHime returned an unidentified archive type")
|
||||
try:
|
||||
subtitle.content = lzma.decompress(response.content)
|
||||
except lzma.LZMAError as error:
|
||||
raise ProviderError("TsukiHime subtitle decompression failed") from error
|
||||
return subtitle
|
||||
|
||||
def _get_anime(self, video):
|
||||
if isinstance(video, Episode):
|
||||
anidb_id = self._scalar(getattr(video, "series_anidb_id", None))
|
||||
if not anidb_id:
|
||||
logger.debug("Skipping %r because no AniDB series ID was identified", video)
|
||||
return None
|
||||
return self._get_json(f"/animes/anidb/{anidb_id}")
|
||||
|
||||
anilist_id = self._scalar(getattr(video, "anilist_id", None))
|
||||
if not anilist_id:
|
||||
logger.debug("Skipping %r because no AniList ID was identified", video)
|
||||
return None
|
||||
return self._get_json(f"/animes/anilist/{anilist_id}")
|
||||
|
||||
def _get_entries(self, video, anime_id):
|
||||
if isinstance(video, Episode):
|
||||
episode_no = self._scalar(getattr(video, "series_anidb_episode_no", None)) or video.episode
|
||||
data = self._get_json(f"/animes/{anime_id}/episodes/{episode_no}")
|
||||
else:
|
||||
data = self._get_json(f"/animes/{anime_id}")
|
||||
return data.get("results", []) if data else []
|
||||
|
||||
def _subtitle_from_attachment(self, video, anime, entry, file_data, attachment, requested_languages):
|
||||
if attachment.get("type") != 1:
|
||||
return None
|
||||
|
||||
info = attachment.get("info") or {}
|
||||
# Native torrents only expose the subtitle once it has been extracted and cached; a 0 here
|
||||
# means the track is not downloadable yet. AnimeTosho-mirrored ones omit the key entirely.
|
||||
if info.get("cached") == 0:
|
||||
return None
|
||||
|
||||
language = _language_from_code(info.get("lang"))
|
||||
if not language:
|
||||
return None
|
||||
|
||||
name = info.get("name", "")
|
||||
if bool(info.get("forced")) or _SIGNS_MARKER_RE.search(name):
|
||||
language = Language.rebuild(language, forced=True)
|
||||
elif _HI_MARKER_RE.search(name):
|
||||
language = Language.rebuild(language, hi=True)
|
||||
if language not in requested_languages:
|
||||
return None
|
||||
|
||||
codec = info.get("codec", "").lower()
|
||||
attachment_id = attachment.get("id")
|
||||
if codec not in _SUBTITLE_CODECS or not attachment_id:
|
||||
return None
|
||||
|
||||
storage_path = ANIMETOSHO_STORAGE_PATH if entry.get("animetosho") else NATIVE_STORAGE_PATH
|
||||
download_url = (
|
||||
f"{STORAGE_URL}/{storage_path}/{int(attachment_id):08X}/{int(attachment_id)}.xz"
|
||||
)
|
||||
|
||||
verified_matches = {"title"} if isinstance(video, Movie) else {"series", "season", "episode"}
|
||||
if video.year and anime.get("release_year") == video.year:
|
||||
verified_matches.add("year")
|
||||
|
||||
release_info = entry.get("name") or file_data.get("filename", "")
|
||||
difference = self._streaming_service(video, release_info) or self._track_label(info.get("name"))
|
||||
if difference:
|
||||
# Releases that differ only by streaming source or by the track they carry would show
|
||||
# as identical rows in the manual search list, so surface that difference in the info.
|
||||
release_info = f"[{difference}] {release_info}"
|
||||
|
||||
return self.subtitle_class(
|
||||
language,
|
||||
download_url,
|
||||
release_info=release_info,
|
||||
release_id=entry.get("id"),
|
||||
codec=codec,
|
||||
verified_matches=verified_matches,
|
||||
)
|
||||
|
||||
def _get_json(self, path):
|
||||
try:
|
||||
response = self.session.get(f"{API_URL}{path}", timeout=10)
|
||||
except RequestException as error:
|
||||
logger.exception("TsukiHime request failed for %s", path)
|
||||
raise ProviderError("TsukiHime request failed; check the Bazarr log") from error
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(f"TsukiHime returned HTTP {response.status_code} for {path}")
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as error:
|
||||
raise ProviderError(f"TsukiHime returned invalid JSON for {path}") from error
|
||||
|
||||
def _download(self, url):
|
||||
try:
|
||||
response = self.session.get(url, timeout=10)
|
||||
except RequestException as error:
|
||||
logger.exception("TsukiHime subtitle download failed for %s", url)
|
||||
raise ProviderError("TsukiHime subtitle download failed; check the Bazarr log") from error
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(f"TsukiHime returned HTTP {response.status_code} for {url}")
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _scalar(value):
|
||||
if isinstance(value, (list, tuple)):
|
||||
return value[-1] if value else None
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _streaming_service(video, release_info):
|
||||
video_type = "episode" if isinstance(video, Episode) else "movie"
|
||||
service = guessit(release_info, {"type": video_type}).get("streaming_service")
|
||||
if isinstance(service, (list, tuple)):
|
||||
return service[0] if service else None
|
||||
return service
|
||||
|
||||
@staticmethod
|
||||
def _track_label(name):
|
||||
# A release can carry several full tracks for the same language (its own translation plus a
|
||||
# streaming one, for instance) under an identical release name. The track name is the only
|
||||
# thing telling them apart, so append it unless it is just the language name.
|
||||
label = (name or "").strip()
|
||||
if not label:
|
||||
return None
|
||||
try:
|
||||
if Language.fromname(label) is not None:
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return label
|
||||
|
||||
@classmethod
|
||||
def _entry_has_requested_language(cls, sublangs, requested_languages):
|
||||
requested_alpha3 = {language.alpha3 for language in requested_languages}
|
||||
return any(
|
||||
language and language.alpha3 in requested_alpha3
|
||||
for language in (_language_from_code(code) for code in sublangs)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _entry_score(cls, video, entry):
|
||||
original_name = os.path.splitext((getattr(video, "original_name", None) or video.name))[0]
|
||||
name_overlap = cls._name_overlap(entry.get("name", ""), original_name)
|
||||
# Among equally similar releases prefer the most recently indexed one.
|
||||
return name_overlap, int(entry.get("source_date") or 0)
|
||||
|
||||
@classmethod
|
||||
def _name_overlap(cls, candidate, reference):
|
||||
candidate_tokens = set(re.split(r"\W+", candidate))
|
||||
reference_tokens = set(re.split(r"\W+", reference))
|
||||
if not candidate_tokens or not reference_tokens:
|
||||
return 0.0
|
||||
return len(candidate_tokens & reference_tokens) / len(candidate_tokens)
|
||||
|
||||
@classmethod
|
||||
def _matching_files(cls, video, files):
|
||||
if not files:
|
||||
return []
|
||||
|
||||
if len(files) <= 1:
|
||||
return files
|
||||
|
||||
if isinstance(video, Movie):
|
||||
original_name = os.path.splitext((getattr(video, "original_name", None) or video.name).lower())[0]
|
||||
best = max(
|
||||
files,
|
||||
key=lambda file_data: cls._name_overlap(
|
||||
file_data.get("filename", "").lower(),
|
||||
original_name,
|
||||
),
|
||||
)
|
||||
return [best]
|
||||
|
||||
expected = {
|
||||
cls._scalar(getattr(video, "series_anidb_episode_no", None)),
|
||||
video.episode,
|
||||
}
|
||||
expected.discard(None)
|
||||
|
||||
matching = []
|
||||
for file_data in files:
|
||||
guessed_episode = guessit(file_data.get("filename", ""), {"type": "episode"}).get("episode")
|
||||
guessed_episodes = guessed_episode if isinstance(guessed_episode, (list, tuple)) else [guessed_episode]
|
||||
if expected.intersection(guessed_episodes):
|
||||
matching.append(file_data)
|
||||
return matching or files
|
||||
@@ -102,6 +102,13 @@ export const ProviderList: Readonly<ProviderInfo[]> = [
|
||||
"AnimeTosho.xyz is a free, completely automated service which mirrors most torrents posted on TokyoTosho's anime category, Nyaa.si's English translated anime category and AniDex's anime category.",
|
||||
requiredIntegration: "anidb",
|
||||
},
|
||||
{
|
||||
key: "tsukihime",
|
||||
name: "TsukiHime",
|
||||
description:
|
||||
"Anime subtitle tracks extracted from the releases indexed by TsukiHime.",
|
||||
requiredIntegration: "anidb",
|
||||
},
|
||||
{
|
||||
key: "animesubinfo",
|
||||
name: "AnimeSub.info",
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import lzma
|
||||
|
||||
import pytest
|
||||
|
||||
from subliminal.exceptions import ProviderError
|
||||
from subliminal_patch.core import Episode, Movie
|
||||
from subliminal_patch.providers import tsukihime
|
||||
from subliminal_patch.providers.tsukihime import TsukiHimeProvider, TsukiHimeSubtitle
|
||||
from subzero.language import Language
|
||||
|
||||
API = "https://api.tsukihime.org/v1"
|
||||
STORE = "https://storage.tsukihime.org"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def episode():
|
||||
video = Episode(
|
||||
"One.Piece.S01E1171.1080p.NF.WEB-DL.AAC2.0.H.264-VARYG.mkv",
|
||||
"One Piece",
|
||||
1,
|
||||
1171,
|
||||
year=1999,
|
||||
series_anidb_id=69,
|
||||
)
|
||||
video.series_anidb_episode_no = 1171
|
||||
return video
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def movie():
|
||||
video = Movie(
|
||||
"Summer.Pockets.Season.1.Omnibus.1080p.BluRay.mkv",
|
||||
"Summer Pockets Season 1: Omnibus",
|
||||
year=2025,
|
||||
)
|
||||
video.anilist_id = 195230
|
||||
return video
|
||||
|
||||
|
||||
def _attachment(attachment_id, lang, codec, name="English", forced=0, cached=1):
|
||||
return {
|
||||
"id": attachment_id,
|
||||
"type": 1,
|
||||
"info": {
|
||||
"cached": cached,
|
||||
"codec": codec,
|
||||
"lang": lang,
|
||||
"name": name,
|
||||
"forced": forced,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _mock_anime(requests_mock, anime):
|
||||
requests_mock.get(f"{API}/animes/anidb/69", json={"id": 2086, "release_year": 1999})
|
||||
requests_mock.get(f"{API}/animes/anilist/195230", json={"id": 400, "release_year": 2025})
|
||||
return anime
|
||||
|
||||
|
||||
def test_list_episode_subtitles_uses_native_storage(episode, requests_mock):
|
||||
_mock_anime(requests_mock, episode)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/2086/episodes/1171",
|
||||
json={
|
||||
"results": [
|
||||
{
|
||||
"id": 301,
|
||||
"name": "One.Piece.S01E1171.1080p.NF.WEB-DL.AAC2.0.H.264-VARYG",
|
||||
"state": "completed",
|
||||
"sublangs": ["ar"],
|
||||
"source_date": 1234,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/301",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "One.Piece.S01E1171.1080p.NF.WEB-DL.AAC2.0.H.264-VARYG.mkv",
|
||||
"attachments": [
|
||||
_attachment(133226, "ar", "srt", name="Arabic"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
subtitles = provider.list_subtitles(episode, {Language("ara")})
|
||||
|
||||
assert len(subtitles) == 1
|
||||
subtitle = subtitles[0]
|
||||
assert subtitle.language == Language("ara")
|
||||
assert subtitle.format == "srt"
|
||||
assert subtitle.download_url == f"{STORE}/attach/0002086A/133226.xz"
|
||||
assert {"series", "season", "episode", "year"} <= subtitle.get_matches(episode)
|
||||
|
||||
|
||||
def test_list_movie_subtitles_uses_animetosho_storage_and_best_file(movie, requests_mock):
|
||||
_mock_anime(requests_mock, movie)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/400",
|
||||
json={
|
||||
"results": [
|
||||
{
|
||||
"id": 401,
|
||||
"name": "Summer.Pockets.Season.1.Omnibus.1080p.BluRay",
|
||||
"state": "completed",
|
||||
"sublangs": ["en"],
|
||||
"source_date": 4321,
|
||||
"animetosho": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/401",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "Summer.Pockets.Season.1.Omnibus.1080p.BluRay.mkv",
|
||||
"attachments": [_attachment(65537, "en", "ass")],
|
||||
},
|
||||
{
|
||||
"filename": "Random.Extra.Track.1080p.mkv",
|
||||
"attachments": [_attachment(65538, "en", "srt")],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
subtitles = provider.list_subtitles(movie, {Language("eng")})
|
||||
|
||||
assert len(subtitles) == 1
|
||||
subtitle = subtitles[0]
|
||||
assert subtitle.format == "ass"
|
||||
assert subtitle.download_url == f"{STORE}/tosho/attach/00010001/65537.xz"
|
||||
assert {"title", "year"} <= subtitle.get_matches(movie)
|
||||
|
||||
|
||||
def test_reused_attachment_across_releases_keeps_distinct_candidates(movie, requests_mock):
|
||||
# TsukiHime reuses the same attachment across release associations, so the same download URL
|
||||
# must stay as distinct candidates, otherwise the pool drops later ones by id (#3582).
|
||||
_mock_anime(requests_mock, movie)
|
||||
shared_url = f"{STORE}/attach/0000000A/10.xz"
|
||||
requests_mock.get(
|
||||
f"{API}/animes/400",
|
||||
json={
|
||||
"results": [
|
||||
{"id": 1, "name": "Summer.Pockets", "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 1},
|
||||
{"id": 2, "name": "Summer.Pockets", "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 2},
|
||||
],
|
||||
},
|
||||
)
|
||||
for entry_id in (1, 2):
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/{entry_id}",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "Summer.Pockets.mkv",
|
||||
"attachments": [_attachment(10, "en", "srt")],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
subtitles = provider.list_subtitles(movie, {Language("eng")})
|
||||
|
||||
assert len(subtitles) == 2
|
||||
assert {s.download_url for s in subtitles} == {shared_url}
|
||||
assert {s.release_id for s in subtitles} == {1, 2}
|
||||
assert {s.id for s in subtitles} == {f"1:{shared_url}", f"2:{shared_url}"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code,expected",
|
||||
[
|
||||
("en", Language("eng")),
|
||||
# Alphabetically ordered: the simpler ISO-639 codes are returned as-is.
|
||||
("zh-Hans", Language("zho", "CN")),
|
||||
("zh-Hant", Language("zho", "TW")),
|
||||
("pt-BR", Language("por", "BR")),
|
||||
("es-419", Language("spa", "MX")),
|
||||
("por", Language("por")),
|
||||
# Unknown codes fall back to the alpha3 conversion or are dropped.
|
||||
("eng", Language("eng")),
|
||||
("xx-not-real", None),
|
||||
],
|
||||
)
|
||||
def test_language_from_code(code, expected):
|
||||
assert tsukihime._language_from_code(code) == expected
|
||||
|
||||
|
||||
def test_forced_track_is_propagated_to_the_language(episode, requests_mock):
|
||||
_mock_anime(requests_mock, episode)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/2086/episodes/1171",
|
||||
json={
|
||||
"results": [
|
||||
{"id": 301, "name": "One.Piece.S01E1171", "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/301",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "One.Piece.S01E1171.mkv",
|
||||
"attachments": [
|
||||
_attachment(1, "en", "srt", name="Signs", forced=1),
|
||||
_attachment(2, "en", "srt", name="English (CC)"),
|
||||
_attachment(3, "en", "srt", name="English", forced=0),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
normal = provider.list_subtitles(episode, {Language("eng")})
|
||||
forced = provider.list_subtitles(episode, {Language("eng", forced=True)})
|
||||
hi = provider.list_subtitles(episode, {Language("eng", hi=True)})
|
||||
|
||||
# Only the plain track answers a normal request, the signs one answers forced, and the CC one
|
||||
# answers hearing-impaired.
|
||||
assert [s.language for s in normal] == [Language("eng")]
|
||||
assert [s.language for s in forced] == [Language("eng", forced=True)]
|
||||
assert [s.language for s in hi] == [Language("eng", hi=True)]
|
||||
assert normal[0].download_url == f"{STORE}/attach/00000003/3.xz"
|
||||
assert forced[0].download_url == f"{STORE}/attach/00000001/1.xz"
|
||||
assert hi[0].download_url == f"{STORE}/attach/00000002/2.xz"
|
||||
|
||||
|
||||
def test_track_name_drives_forced_and_hi_classification(episode, requests_mock):
|
||||
# Signs tracks are often muxed without the forced disposition flag; the name is the only hint,
|
||||
# so an unflagged "Signs" track must not be offered as a full subtitle (nor should a CC one).
|
||||
_mock_anime(requests_mock, episode)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/2086/episodes/1171",
|
||||
json={
|
||||
"results": [
|
||||
{"id": 301, "name": "One.Piece.S01E1171", "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/301",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "One.Piece.S01E1171.mkv",
|
||||
"attachments": [
|
||||
_attachment(1, "en", "ass", name="English [Signs]"),
|
||||
_attachment(2, "en", "ass", name="English (Signs & Songs)"),
|
||||
_attachment(3, "en", "ass", name="English"),
|
||||
_attachment(4, "en", "ass", name="English [CC]"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
normal = provider.list_subtitles(episode, {Language("eng")})
|
||||
forced = provider.list_subtitles(episode, {Language("eng", forced=True)})
|
||||
hi = provider.list_subtitles(episode, {Language("eng", hi=True)})
|
||||
|
||||
assert [s.download_url for s in normal] == [f"{STORE}/attach/00000003/3.xz"]
|
||||
assert {s.download_url for s in forced} == {
|
||||
f"{STORE}/attach/00000001/1.xz",
|
||||
f"{STORE}/attach/00000002/2.xz",
|
||||
}
|
||||
assert [s.download_url for s in hi] == [f"{STORE}/attach/00000004/4.xz"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"release_name,expected_suffix",
|
||||
[
|
||||
# The streaming source belongs in the release info so alike releases stay distinguishable
|
||||
# without repurposing the Uploader column.
|
||||
("[ToonsHub] Mushoku Tensei Jobless Reincarnation S03E03 1080p CR WEB-DL AAC2.0 H.264", "Crunchy Roll"),
|
||||
("[ToonsHub] Mushoku Tensei Jobless Reincarnation S03E03 1080p NF WEB-DL AAC2.0 H.264", "Netflix"),
|
||||
("[SubsPlease] Mushoku Tensei S3 - 03 (1080p) [8488B15C].mkv", None),
|
||||
],
|
||||
)
|
||||
def test_streaming_service_is_exposed_in_release_info(episode, requests_mock, release_name, expected_suffix):
|
||||
_mock_anime(requests_mock, episode)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/2086/episodes/1171",
|
||||
json={
|
||||
"results": [
|
||||
{"id": 301, "name": release_name, "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/301",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "One.Piece.S01E1171.mkv",
|
||||
"attachments": [_attachment(1, "en", "srt")],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
subtitles = provider.list_subtitles(episode, {Language("eng")})
|
||||
|
||||
assert len(subtitles) == 1
|
||||
subtitle = subtitles[0]
|
||||
assert subtitle.uploader is None
|
||||
if expected_suffix:
|
||||
assert subtitle.release_info == f"[{expected_suffix}] {release_name}"
|
||||
else:
|
||||
assert subtitle.release_info == release_name
|
||||
|
||||
|
||||
def test_same_release_tracks_are_distinguished_in_release_info(episode, requests_mock):
|
||||
# [Sonomama]-style releases bundle several full tracks for the same language under one release
|
||||
# name; the track name is appended to the release info so the rows are not identical.
|
||||
_mock_anime(requests_mock, episode)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/2086/episodes/1171",
|
||||
json={
|
||||
"results": [
|
||||
{"id": 301, "name": "[Sonomama] One Piece - S01E1171", "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/301",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "One.Piece.S01E1171.mkv",
|
||||
"attachments": [
|
||||
_attachment(1, "en", "ass", name="[Sonomama]"),
|
||||
_attachment(2, "en", "ass", name="Crunchyroll"),
|
||||
_attachment(3, "en", "ass", name="English"),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
subtitles = provider.list_subtitles(episode, {Language("eng")})
|
||||
|
||||
assert {s.release_info for s in subtitles} == {
|
||||
"[[Sonomama]] [Sonomama] One Piece - S01E1171",
|
||||
"[Crunchyroll] [Sonomama] One Piece - S01E1171",
|
||||
"[Sonomama] One Piece - S01E1171",
|
||||
}
|
||||
assert all(s.uploader is None for s in subtitles)
|
||||
|
||||
|
||||
def test_uncached_native_subtitle_is_skipped(episode, requests_mock):
|
||||
_mock_anime(requests_mock, episode)
|
||||
requests_mock.get(
|
||||
f"{API}/animes/2086/episodes/1171",
|
||||
json={
|
||||
"results": [
|
||||
{"id": 301, "name": "One.Piece.S01E1171", "state": "completed",
|
||||
"sublangs": ["en"], "source_date": 1},
|
||||
],
|
||||
},
|
||||
)
|
||||
requests_mock.get(
|
||||
f"{API}/torrents/301",
|
||||
json={
|
||||
"files": [
|
||||
{
|
||||
"filename": "One.Piece.S01E1171.mkv",
|
||||
"attachments": [
|
||||
_attachment(1, "en", "srt", cached=0),
|
||||
_attachment(2, "en", "srt", cached=1),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
subtitles = provider.list_subtitles(episode, {Language("eng")})
|
||||
|
||||
assert [s.download_url for s in subtitles] == [f"{STORE}/attach/00000002/2.xz"]
|
||||
|
||||
|
||||
def test_download_subtitle_decompresses_xz(requests_mock):
|
||||
subtitle = TsukiHimeSubtitle(
|
||||
Language("eng"),
|
||||
f"{STORE}/attach/00000001/1.xz",
|
||||
release_info="Example",
|
||||
release_id=1,
|
||||
codec="srt",
|
||||
verified_matches={"title"},
|
||||
)
|
||||
content = b"1\n00:00:01,000 --> 00:00:02,000\nExample\n"
|
||||
requests_mock.get(subtitle.download_url, content=lzma.compress(content))
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
result = provider.download_subtitle(subtitle)
|
||||
|
||||
assert result is subtitle
|
||||
assert subtitle.content == content
|
||||
|
||||
|
||||
def test_download_subtitle_rejects_non_xz_response(requests_mock):
|
||||
subtitle = TsukiHimeSubtitle(
|
||||
Language("eng"),
|
||||
f"{STORE}/attach/00000001/1.xz",
|
||||
release_info="Example",
|
||||
release_id=1,
|
||||
codec="srt",
|
||||
verified_matches={"title"},
|
||||
)
|
||||
requests_mock.get(subtitle.download_url, content=b"<html>not a subtitle</html>")
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
with pytest.raises(ProviderError, match="unidentified archive type"):
|
||||
provider.download_subtitle(subtitle)
|
||||
|
||||
|
||||
def test_list_subtitles_without_anime_id_returns_empty(movie, requests_mock):
|
||||
movie.anilist_id = None
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
assert provider.list_subtitles(movie, {Language("eng")}) == []
|
||||
|
||||
|
||||
def test_list_subtitles_missing_anime_404_returns_empty(movie, requests_mock):
|
||||
requests_mock.get(f"{API}/animes/anilist/195230", status_code=404)
|
||||
|
||||
with TsukiHimeProvider() as provider:
|
||||
assert provider.list_subtitles(movie, {Language("eng")}) == []
|
||||
|
||||
|
||||
def test_forced_and_hi_variants_are_declared():
|
||||
# The pool intersects the requested languages with the declared ones before calling
|
||||
# list_subtitles, so regional, forced and hearing-impaired variants have to be present.
|
||||
assert Language("eng", forced=True) in TsukiHimeProvider.languages
|
||||
assert Language("eng", hi=True) in TsukiHimeProvider.languages
|
||||
assert Language("por", "BR") in TsukiHimeProvider.languages
|
||||
assert Language("zho", "CN") in TsukiHimeProvider.languages
|
||||
assert Language("spa", "MX") in TsukiHimeProvider.languages
|
||||
Reference in New Issue
Block a user