Added anime provider tsukihime (#3597)

This commit is contained in:
Anderson Shindy Oki
2026-09-19 23:35:31 +09:00
committed by GitHub
parent bb380cf9a5
commit dcbe170f52
7 changed files with 853 additions and 2 deletions
+1
View File
@@ -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
+1
View File
@@ -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,
+1 -1
View File
@@ -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__)
+1 -1
View File
@@ -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",
+460
View File
@@ -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