Fix provider HLS downloads
This commit is contained in:
@@ -1,5 +1,11 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.51.5 - 2026-08-09
|
||||||
|
|
||||||
|
- Fixed provider downloader episode matching so episode `10` no longer collides with episode `1` when resolving requested ranges.
|
||||||
|
- Made HLS downloads pick the highest-bandwidth variant from master playlists before invoking `ffmpeg`, avoiding empty MP4 outputs from master-only streams.
|
||||||
|
- Changed episode downloads to write through `.mp4.part` files and replace the final file only after a successful `ffmpeg` run, keeping fallback attempts clean.
|
||||||
|
|
||||||
## 0.51.4 - 2026-08-09
|
## 0.51.4 - 2026-08-09
|
||||||
|
|
||||||
- Moved Search controls from the sidebar to a top-of-page search panel.
|
- Moved Search controls from the sidebar to a top-of-page search panel.
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ The Docker image installs Python, Node.js, npm, and `ffmpeg`, then runs `npm ins
|
|||||||
|
|
||||||
## Download Flow
|
## Download Flow
|
||||||
|
|
||||||
Kaizoku stores provider-backed show IDs as `provider:id`, for example `anikoto:some-show-slug`. Queue jobs resolve the episode source through `providers/bridge.js`, then `provider_downloader.py` downloads the media with `ffmpeg` into a staging directory. If the primary provider cannot list, resolve, or download a requested episode, Kaizoku searches the same title on the remaining providers and tries the matching episode there. Existing finalization code moves staged files into the configured library layout, preserving data already present in production download folders.
|
Kaizoku stores provider-backed show IDs as `provider:id`, for example `anikoto:some-show-slug`. Queue jobs resolve the episode source through `providers/bridge.js`, then `provider_downloader.py` downloads the media with `ffmpeg` into a staging directory. If a provider returns a master HLS playlist, Kaizoku selects the highest-bandwidth variant before starting `ffmpeg`. Each episode is written as a temporary `.mp4.part` file and moved into place only after the download succeeds, so failed fallback attempts do not leave broken final MP4 files behind. If the primary provider cannot list, resolve, or download a requested episode, Kaizoku searches the same title on the remaining providers and tries the matching episode there. Existing finalization code moves staged files into the configured library layout, preserving data already present in production download folders.
|
||||||
|
|
||||||
## Data Safety
|
## Data Safety
|
||||||
|
|
||||||
|
|||||||
+130
-15
@@ -8,6 +8,7 @@ import os
|
|||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from urllib.parse import urljoin
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -37,6 +38,19 @@ def parse_show_id(value, default_provider="anikoto"):
|
|||||||
return default_provider, text
|
return default_provider, text
|
||||||
|
|
||||||
|
|
||||||
|
def episode_key(value):
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
number = float(text)
|
||||||
|
except ValueError:
|
||||||
|
return text
|
||||||
|
if number.is_integer():
|
||||||
|
return str(int(number))
|
||||||
|
return str(number).rstrip("0").rstrip(".")
|
||||||
|
|
||||||
|
|
||||||
def bridge(command, provider, *args):
|
def bridge(command, provider, *args):
|
||||||
cmd = ["node", str(BRIDGE), command, provider, *[str(arg) for arg in args]]
|
cmd = ["node", str(BRIDGE), command, provider, *[str(arg) for arg in args]]
|
||||||
proc = subprocess.run(cmd, cwd=PROJECT_ROOT, text=True, capture_output=True, check=False)
|
proc = subprocess.run(cmd, cwd=PROJECT_ROOT, text=True, capture_output=True, check=False)
|
||||||
@@ -96,7 +110,7 @@ def find_provider_show(provider, title, original_provider, original_show_id):
|
|||||||
|
|
||||||
def episode_values(spec, episodes):
|
def episode_values(spec, episodes):
|
||||||
values = []
|
values = []
|
||||||
by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes}
|
by_number = {episode_key(ep.get("number")): ep for ep in episodes}
|
||||||
if not str(spec or "").strip():
|
if not str(spec or "").strip():
|
||||||
return episodes
|
return episodes
|
||||||
for part in re.split(r"[\s,]+", str(spec).strip()):
|
for part in re.split(r"[\s,]+", str(spec).strip()):
|
||||||
@@ -115,7 +129,7 @@ def episode_values(spec, episodes):
|
|||||||
if item and item not in values:
|
if item and item not in values:
|
||||||
values.append(item)
|
values.append(item)
|
||||||
continue
|
continue
|
||||||
item = by_number.get(str(float(part)).rstrip("0").rstrip(".")) if re.match(r"^\d+(?:\.\d+)?$", part) else None
|
item = by_number.get(episode_key(part)) if re.match(r"^\d+(?:\.\d+)?$", part) else None
|
||||||
if item and item not in values:
|
if item and item not in values:
|
||||||
values.append(item)
|
values.append(item)
|
||||||
return values
|
return values
|
||||||
@@ -145,18 +159,18 @@ def requested_episode_numbers(spec, episodes):
|
|||||||
|
|
||||||
|
|
||||||
def wanted_episodes(spec, episodes):
|
def wanted_episodes(spec, episodes):
|
||||||
by_number = {str(ep.get("number")).rstrip("0").rstrip("."): ep for ep in episodes or []}
|
by_number = {episode_key(ep.get("number")): ep for ep in episodes or []}
|
||||||
wanted = []
|
wanted = []
|
||||||
for number in requested_episode_numbers(spec, episodes):
|
for number in requested_episode_numbers(spec, episodes):
|
||||||
key = str(number).rstrip("0").rstrip(".")
|
key = episode_key(number)
|
||||||
wanted.append(by_number.get(key) or {"number": number, "id": ""})
|
wanted.append(by_number.get(key) or {"number": number, "id": ""})
|
||||||
return wanted
|
return wanted
|
||||||
|
|
||||||
|
|
||||||
def episode_by_number(number, episodes):
|
def episode_by_number(number, episodes):
|
||||||
wanted = str(number).rstrip("0").rstrip(".")
|
wanted = episode_key(number)
|
||||||
for episode in episodes or []:
|
for episode in episodes or []:
|
||||||
current = str(episode.get("number")).rstrip("0").rstrip(".")
|
current = episode_key(episode.get("number"))
|
||||||
if current == wanted:
|
if current == wanted:
|
||||||
return episode
|
return episode
|
||||||
return None
|
return None
|
||||||
@@ -170,6 +184,62 @@ def ffmpeg_headers(headers):
|
|||||||
return "\r\n".join(pairs) + ("\r\n" if pairs else "")
|
return "\r\n".join(pairs) + ("\r\n" if pairs else "")
|
||||||
|
|
||||||
|
|
||||||
|
def best_hls_variant_url(master_url, playlist_text):
|
||||||
|
variants = []
|
||||||
|
pending_bandwidth = 0
|
||||||
|
for raw_line in str(playlist_text or "").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if line.startswith("#EXT-X-STREAM-INF"):
|
||||||
|
match = re.search(r"BANDWIDTH=(\d+)", line)
|
||||||
|
pending_bandwidth = int(match.group(1)) if match else 0
|
||||||
|
continue
|
||||||
|
if pending_bandwidth and not line.startswith("#"):
|
||||||
|
variants.append((pending_bandwidth, urljoin(master_url, line)))
|
||||||
|
pending_bandwidth = 0
|
||||||
|
if not variants:
|
||||||
|
return master_url
|
||||||
|
variants.sort(key=lambda item: item[0], reverse=True)
|
||||||
|
return variants[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hls_input_url(stream):
|
||||||
|
url = stream.get("url")
|
||||||
|
if not stream.get("isM3U8") and ".m3u8" not in str(url):
|
||||||
|
return url
|
||||||
|
headers = {"User-Agent": "Mozilla/5.0", **(stream.get("headers") or {})}
|
||||||
|
request = urllib.request.Request(url, headers=headers)
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
data = response.read(2_000_000).decode("utf-8", errors="replace")
|
||||||
|
if "#EXT-X-STREAM-INF" not in data and "mpegurl" not in content_type.lower():
|
||||||
|
return url
|
||||||
|
return best_hls_variant_url(url, data)
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_base_command(stream, input_url):
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"info",
|
||||||
|
"-y",
|
||||||
|
"-protocol_whitelist",
|
||||||
|
"file,http,https,tcp,tls,crypto",
|
||||||
|
"-allowed_extensions",
|
||||||
|
"ALL",
|
||||||
|
]
|
||||||
|
headers = ffmpeg_headers(stream.get("headers") or {})
|
||||||
|
if headers:
|
||||||
|
cmd.extend(["-headers", headers])
|
||||||
|
referer = (stream.get("headers") or {}).get("Referer")
|
||||||
|
if referer:
|
||||||
|
cmd.extend(["-referer", referer])
|
||||||
|
cmd.extend(["-i", input_url])
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
def download_subtitles(subtitles, output_base):
|
def download_subtitles(subtitles, output_base):
|
||||||
saved = []
|
saved = []
|
||||||
english = [
|
english = [
|
||||||
@@ -188,15 +258,60 @@ def download_subtitles(subtitles, output_base):
|
|||||||
|
|
||||||
|
|
||||||
def download_episode(stream, target):
|
def download_episode(stream, target):
|
||||||
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "info", "-y"]
|
input_url = resolve_hls_input_url(stream)
|
||||||
headers = ffmpeg_headers(stream.get("headers") or {})
|
partial = target.with_suffix(target.suffix + ".part")
|
||||||
if headers:
|
for path in (target, partial):
|
||||||
cmd.extend(["-headers", headers])
|
try:
|
||||||
referer = (stream.get("headers") or {}).get("Referer")
|
path.unlink()
|
||||||
if referer:
|
except FileNotFoundError:
|
||||||
cmd.extend(["-referer", referer])
|
pass
|
||||||
cmd.extend(["-i", stream["url"], "-c", "copy", "-bsf:a", "aac_adtstoasc", str(target)])
|
attempts = [
|
||||||
return subprocess.call(cmd)
|
[
|
||||||
|
*ffmpeg_base_command(stream, input_url),
|
||||||
|
"-map",
|
||||||
|
"0:v:0?",
|
||||||
|
"-map",
|
||||||
|
"0:a:0?",
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-bsf:a",
|
||||||
|
"aac_adtstoasc",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
"-f",
|
||||||
|
"mp4",
|
||||||
|
str(partial),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
*ffmpeg_base_command(stream, input_url),
|
||||||
|
"-map",
|
||||||
|
"0",
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-bsf:a",
|
||||||
|
"aac_adtstoasc",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
"-f",
|
||||||
|
"mp4",
|
||||||
|
str(partial),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
code = 1
|
||||||
|
for cmd in attempts:
|
||||||
|
try:
|
||||||
|
partial.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
code = subprocess.call(cmd)
|
||||||
|
if code == 0 and partial.exists() and partial.stat().st_size > 0:
|
||||||
|
partial.replace(target)
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
partial.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode):
|
def provider_episode_candidates(primary_provider, primary_show_id, title, wanted_episode):
|
||||||
|
|||||||
+37
@@ -22,6 +22,7 @@ if str(ROOT) not in sys.path:
|
|||||||
|
|
||||||
import queue_jobs
|
import queue_jobs
|
||||||
import http_handler
|
import http_handler
|
||||||
|
import provider_downloader
|
||||||
|
|
||||||
os.environ.setdefault("ANI_CLI_WEB_DISABLE_WORKER", "1")
|
os.environ.setdefault("ANI_CLI_WEB_DISABLE_WORKER", "1")
|
||||||
os.environ["ANI_CLI_WEB_STATE_ROOT"] = TEMP_STATE.name
|
os.environ["ANI_CLI_WEB_STATE_ROOT"] = TEMP_STATE.name
|
||||||
@@ -4239,5 +4240,41 @@ class AniDbCacheTests(unittest.TestCase):
|
|||||||
APP.ANIDB_TITLES_CACHE.update(original_cache)
|
APP.ANIDB_TITLES_CACHE.update(original_cache)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderDownloaderTests(unittest.TestCase):
|
||||||
|
def test_episode_key_keeps_episode_ten_distinct_from_one(self):
|
||||||
|
self.assertEqual(provider_downloader.episode_key(1), "1")
|
||||||
|
self.assertEqual(provider_downloader.episode_key(10), "10")
|
||||||
|
self.assertEqual(provider_downloader.episode_key("10.0"), "10")
|
||||||
|
self.assertEqual(provider_downloader.episode_key("10.5"), "10.5")
|
||||||
|
|
||||||
|
def test_wanted_episodes_preserves_range_order_and_existing_ids(self):
|
||||||
|
episodes = [
|
||||||
|
{"number": 10, "id": "ep10"},
|
||||||
|
{"number": 2, "id": "ep2"},
|
||||||
|
{"number": 11, "id": "ep11"},
|
||||||
|
{"number": 12, "id": "ep12"},
|
||||||
|
]
|
||||||
|
|
||||||
|
wanted = provider_downloader.wanted_episodes("10-12", episodes)
|
||||||
|
|
||||||
|
self.assertEqual([episode["number"] for episode in wanted], [10, 11, 12])
|
||||||
|
self.assertEqual([episode["id"] for episode in wanted], ["ep10", "ep11", "ep12"])
|
||||||
|
|
||||||
|
def test_best_hls_variant_url_selects_highest_bandwidth(self):
|
||||||
|
master = """#EXTM3U
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=640x360
|
||||||
|
low/index.m3u8
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=5500000,RESOLUTION=1920x1080
|
||||||
|
high/index.m3u8
|
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720
|
||||||
|
mid/index.m3u8
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
provider_downloader.best_hls_variant_url("https://cdn.example/show/master.m3u8", master),
|
||||||
|
"https://cdn.example/show/high/index.m3u8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user