Fix TV episode finalization numbering
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.52.13 - 2026-08-16
|
||||||
|
|
||||||
|
- Fixed TV library finalization so staged provider files named with `SxxEyy` keep the downloaded episode number instead of falling back to episode 1.
|
||||||
|
- Added regression coverage for single-episode season downloads finalizing to the requested episode number.
|
||||||
|
|
||||||
## 0.52.12 - 2026-08-14
|
## 0.52.12 - 2026-08-14
|
||||||
|
|
||||||
- Added retry/backoff handling for rate-limited HLS segment and key downloads, including provider CDN `HTTP 429` responses.
|
- Added retry/backoff handling for rate-limited HLS segment and key downloads, including provider CDN `HTTP 429` responses.
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ Keep `./.kaizoku` mounted for production instances. That directory contains the
|
|||||||
|
|
||||||
## 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 a provider returns a master HLS playlist, Kaizoku selects the highest-bandwidth variant before starting `ffmpeg`. When a media playlist uses extensionless CDN segments, Kaizoku skips direct ffmpeg and uses a StrawVerse-style segment downloader: it fetches the media playlist, downloads and concatenates segments itself, strips short PNG wrappers when present, retries temporary HTTP failures such as `429 Too Many Requests`, then remuxes the local transport stream to MP4. Direct ffmpeg attempts also have a timeout guard so stalled HLS inputs can fall back cleanly. 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.
|
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`. When a media playlist uses extensionless CDN segments, Kaizoku skips direct ffmpeg and uses a StrawVerse-style segment downloader: it fetches the media playlist, downloads and concatenates segments itself, strips short PNG wrappers when present, retries temporary HTTP failures such as `429 Too Many Requests`, then remuxes the local transport stream to MP4. Direct ffmpeg attempts also have a timeout guard so stalled HLS inputs can fall back cleanly. Each episode is written as a temporary `.mp4.part` file and moved into place only after the download succeeds, and finalization preserves episode numbers from staged `SxxEyy` or `Episode yy` filenames before applying configured season/episode offsets.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1030,6 +1030,9 @@ def apply_episode_offset(value, offset):
|
|||||||
|
|
||||||
|
|
||||||
def extract_episode_from_filename(path):
|
def extract_episode_from_filename(path):
|
||||||
|
match = re.search(r"\bS[0-9]+E([0-9][0-9A-Za-z.\-]*)\b", path.stem, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
|
match = re.search(r"\bEpisode\s+([0-9][0-9A-Za-z.\-]*)\s*$", path.stem)
|
||||||
return match.group(1) if match else None
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|||||||
+29
@@ -851,6 +851,35 @@ class QueueApiTests(unittest.TestCase):
|
|||||||
self.assertTrue(Path(moved[0]).exists())
|
self.assertTrue(Path(moved[0]).exists())
|
||||||
self.assertTrue(Path(moved[1]).exists())
|
self.assertTrue(Path(moved[1]).exists())
|
||||||
|
|
||||||
|
def test_tv_finalizer_preserves_episode_number_from_season_episode_filename(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temp_root:
|
||||||
|
job = APP.app_support.build_job(
|
||||||
|
{
|
||||||
|
"query": "Jobless Reincarnation",
|
||||||
|
"title": "Jobless Reincarnation",
|
||||||
|
"anime_name": "Jobless Reincarnation",
|
||||||
|
"media_type": "tv",
|
||||||
|
"mode": "sub",
|
||||||
|
"quality": "best",
|
||||||
|
"episodes": "6",
|
||||||
|
"download_dir": temp_root,
|
||||||
|
"season": "3",
|
||||||
|
},
|
||||||
|
{"mode": "sub", "quality": "best", "download_dir": temp_root},
|
||||||
|
)
|
||||||
|
staging_dir = APP.app_support.job_staging_dir(job)
|
||||||
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
source = staging_dir / "Jobless Reincarnation - S01E06.mp4"
|
||||||
|
source.write_bytes(b"ep6")
|
||||||
|
|
||||||
|
moved = APP.app_support.finalize_library_files(job)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
moved,
|
||||||
|
[f"{temp_root}/tv/Jobless Reincarnation/Season 03/Jobless Reincarnation - S03E06.mp4"],
|
||||||
|
)
|
||||||
|
self.assertTrue(Path(moved[0]).exists())
|
||||||
|
|
||||||
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
||||||
queue = object.__new__(APP.DownloadQueue)
|
queue = object.__new__(APP.DownloadQueue)
|
||||||
queue.lock = threading.RLock()
|
queue.lock = threading.RLock()
|
||||||
|
|||||||
Reference in New Issue
Block a user