diff --git a/docs/api-reference.md b/docs/api-reference.md index 5ab29d7..312c38e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -22,7 +22,7 @@ |---|---|---| | `text` | string | одна дорожка (строка вида «Профессиональный (многоголосый закадровый) \| LostFilm») → ответ `{"tracks": [одна дорожка]}` | | `html` | string | HTML (или текст) страницы раздачи → ответ-агрегат релиза | -| `url` | string | ссылка на раздачу rutracker / nnmclub / kinozal; страницу сервис скачивает сам (при 403/503 — через FlareSolverr) → ответ-агрегат + `tracker` | +| `url` | string | ссылка на раздачу rutracker / nnmclub / kinozal; страницу сервис скачивает сам (при 403/503 — через FlareSolverr) → ответ-агрегат + `tracker`. Ссылка на `lostfilm.tv` (и поддомены) страницу не загружает: сразу возвращается одна дорожка «проф. многоголосый, LostFilm, RUS» (`tracker: "lostfilm"`, `lang_source: "url"`, `source: "url"`) | | `title` | string, необязательно | заголовок раздачи для `html` (во фрагменте вкладки его нет); для `url` берётся со страницы | | `cookie` | string, необязательно | Cookie сессии (`uid=...; pass=...`) для `url`. **Для kinozal обычно не нужен**: описание многих раздач открывается и без входа (проверено на `details.php?id=2158906`). Передавайте, только если сервис вернул `400` про форму входа | @@ -56,7 +56,7 @@ | `series` | object \| null | сезон, выпущенные серии, «какая студия какие серии» (для Sonarr); `null` — сведений нет | | `title` | string \| null | заголовок раздачи, если найден | | `segmenter` | `"model"` \| `"rules"` | чем страница разбита на строки дорожек: обученным сегментатором или запасными правилами | -| `tracker` | string | только для `url`: `rutracker`, `nnm` или `kinozal` | +| `tracker` | string | только для `url`: `rutracker`, `nnm`, `kinozal` или `lostfilm` | `series`: `season` — номер сезона строкой или `null`; `episodes` — выпущенные серии («1-9»); `total` — всего серий; `by_studio` — `{студия: серии}`; `full_release` — студии без указанных серий @@ -154,7 +154,7 @@ type Track = { age_confidence: number; // 0..1 lang: string | null; // 3-буквенный код или null lang_confidence: number | null; // не null только при lang_source == "model" - lang_source: "model" | "audio" | "text" | null; // null вместе с lang + lang_source: "model" | "audio" | "text" | "url" | null; // "url" — ссылка lostfilm.tv; null вместе с lang role: "перевод" | "оригинал" | "комментарии"; // никогда null official: boolean; audio: { codec: string | null; channels: number | null; bitrate_kbps: number | null } | null; @@ -179,7 +179,7 @@ type Release = { } | null; // null — ни сезона, ни серий в раздаче нет title: string | null; segmenter: "model" | "rules"; - tracker?: "rutracker" | "nnm" | "kinozal"; // только для url + tracker?: "rutracker" | "nnm" | "kinozal" | "lostfilm"; // только для url }; ``` diff --git a/src/fetch.py b/src/fetch.py index 745e806..dee9d18 100644 --- a/src/fetch.py +++ b/src/fetch.py @@ -42,6 +42,12 @@ MAX_BYTES = 5_000_000 RETRY_CODES = (403, 429, 500, 502, 503, 520, 521, 522, 523, 524) +def is_lostfilm(url: str) -> bool: + """Ссылка на сайт LostFilm (lostfilm.tv и зеркала): озвучка известна без загрузки.""" + host = (urlparse(url).hostname or "").lower() + return "lostfilm" in host.split(".")[-2:-1] or host.startswith("lostfilm.") or ".lostfilm." in host + + def detect_tracker(url: str) -> str: """Определяет трекер по домену; неизвестный хост -> ValueError.""" parsed = urlparse(url) diff --git a/src/release.py b/src/release.py index 48a4ac4..e99029e 100644 --- a/src/release.py +++ b/src/release.py @@ -160,6 +160,20 @@ def original_info(lines: list[dict]) -> dict | None: return None +def lostfilm_release(title: str | None = None) -> dict: + """Ссылка на lostfilm.tv: озвучка известна заранее — MVO LostFilm, страница не нужна.""" + track = {"segment": "Профессиональный (многоголосый закадровый) LostFilm", + "translation_type": "проф. многоголосый", "type_confidence": 1.0, + "studio_span": "LostFilm", "studio": "LostFilm", "studio_confidence": 1.0, + "is_18plus": False, "age_confidence": 1.0, "lang": "RUS", "lang_confidence": None, + "lang_source": "url", "role": "перевод", "official": False, "audio": None, + "source": "url", "episode_ranges": [], "episodes": None} + result = aggregate([track]) + result.update(original=None, series=series_info(title or "", result["tracks"]), + title=title or None, segmenter="rules") + return result + + RE_SEASON = re.compile(r"(?i)сезон[ыа]?\s*(\d+(?:\s*[-–]\s*\d+)?)|\bS(\d{1,2})\b(?!E)") diff --git a/src/service.py b/src/service.py index 75c7018..f484137 100644 --- a/src/service.py +++ b/src/service.py @@ -31,9 +31,9 @@ from fastapi import Depends, FastAPI, Header, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel -from .fetch import fetch_html +from .fetch import fetch_html, is_lostfilm from .predict import DubPredictor -from .release import predict_release +from .release import lostfilm_release, predict_release class Utf8JSONResponse(JSONResponse): @@ -95,6 +95,8 @@ def create_app(predictor: DubPredictor, api_token: str | None = None, try: if req.text: return {"tracks": [predictor.predict_text(req.text)]} + if req.url and is_lostfilm(req.url): + return {**lostfilm_release(req.title), "tracker": "lostfilm"} if req.url: try: html, tracker = fetch_html(req.url, cookie=req.cookie, timeout=fetch_timeout) diff --git a/tests/run_tests.py b/tests/run_tests.py index 0bd5a32..bc335b4 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1105,6 +1105,18 @@ check("токен: Bearer и X-API-Key принимаются", check("без DUB_API_TOKEN /predict открыт, /health показывает segmenter и auth", TestClient(create_app(_Stub())).post("/predict", json={"text": "a"}).status_code == 200 and _c.get("/health").json()["auth"] is True and _c.get("/health").json()["segmenter"] == "rules") +_lf = _c.post("/predict", json={"url": "https://www.lostfilm.tv/series/X/season_1/episode_2/"}, + headers={"X-API-Key": "tok"}) +_lfj = _lf.json() if _lf.status_code == 200 else {} +check("ссылка lostfilm.tv: сразу MVO LostFilm без загрузки страницы", + _lfj.get("tracker") == "lostfilm" and _lfj["studios"][0]["studio"] == "LostFilm" + and _lfj["studios"][0]["translation_type"] == "проф. многоголосый" and _lfj["tracks"][0]["lang"] == "RUS", + str(_lf.status_code)) +from src.fetch import is_lostfilm # noqa: E402 +check("is_lostfilm: lostfilm.tv и поддомены да, rutracker и подделка в пути нет", + is_lostfilm("https://lostfilm.tv/x") and is_lostfilm("https://www.lostfilm.tv/x") + and not is_lostfilm("https://rutracker.org/forum/viewtopic.php?t=1&u=lostfilm.tv") + and not is_lostfilm("https://evil.com/lostfilm.tv")) import src.service as _svc8 # noqa: E402 _fetch_calls: list = [] _orig_fetch = _svc8.fetch_html