Allow season zero for auto-downloads

This commit is contained in:
Dymas
2026-09-09 22:54:01 +02:00
parent 91228d58e0
commit 079488fa5a
9 changed files with 128 additions and 17 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog
## 0.52.30 - 2026-09-09
- Allowed season/series `0` in search and watchlist auto-download settings so Jellyfin specials finalize under `Season 00` with `S00E..` filenames.
## 0.52.29 - 2026-09-07
- Added retry and exponential backoff for temporary HLS segment network failures, including DNS resolution, connection, and socket timeout errors.
+1 -1
View File
@@ -12,7 +12,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A
- Monitor active Queue jobs with per-episode progress, segment counts, and live downloader output.
- Open result actions in a floating window for watchlist, media type, subbed/dubbed mode, library name, season, episode range, and download folder choices.
- Fall back across the other configured providers when an episode or stream cannot be resolved on the primary provider.
- Save files with Jellyfin-friendly layout: `TV/Series Name/Season 01/Series Name - S01E01.mp4`.
- Save files with Jellyfin-friendly layout: `TV/Series Name/Season 01/Series Name - S01E01.mp4`, including Season 00 for specials.
- Save English external subtitles when the provider exposes usable subtitle tracks.
- Manage focused watchlists for `Watching`, `Planned`, `Finished`, and `Dropped`, with provider source tags and title links back to the original provider page.
- Periodically refresh selected watchlists with a single visible advancing progress bar and auto-download newly available episodes.
+1 -1
View File
@@ -1 +1 @@
0.52.29
0.52.30
+13 -7
View File
@@ -1048,7 +1048,7 @@ def watchlist_source_season_dir(item, config):
source_root = watchlist_source_library_dir(item, config)
if normalize_media_type(item.get("media_type")) == "movie":
return source_root
season = normalize_season((item or {}).get("auto_download_series") or "1")
season = normalize_season((item or {}).get("auto_download_series"))
return source_root / f"Season {int(season):02d}"
@@ -1145,7 +1145,7 @@ def watchlist_downloaded_episode_values_from_filesystem(item, config, mode):
season_dir = watchlist_source_season_dir(item, config)
if not season_dir.exists() or not season_dir.is_dir():
return []
season = normalize_season((item or {}).get("auto_download_series") or "1")
season = normalize_season((item or {}).get("auto_download_series"))
pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE)
values = []
seen = set()
@@ -1703,7 +1703,7 @@ class WatchlistStore:
item["auto_download_quality"] = quality if quality in QUALITY_CHOICES else defaults["auto_download_quality"]
item["auto_download_name"] = sanitize_path_component(item.get("auto_download_name") or defaults["auto_download_name"], defaults["auto_download_name"])
item["auto_download_source_name"] = str(item.get("auto_download_source_name") or defaults["auto_download_source_name"]).strip() or defaults["auto_download_source_name"]
item["auto_download_series"] = normalize_season(item.get("auto_download_series") or defaults["auto_download_series"])
item["auto_download_series"] = normalize_season(item.get("auto_download_series"))
item["auto_download_offset"] = normalize_episode_offset(item.get("auto_download_offset"))
item["downloaded_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json"))
item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_episodes_json"))
@@ -2027,7 +2027,10 @@ class WatchlistStore:
"downloaded_sub_episodes_json": None,
"downloaded_dub_episodes_json": None,
}
item["auto_download_series"] = normalize_season(payload.get("auto_download_series") or item.get("auto_download_series") or "1")
requested_series = payload.get("auto_download_series")
if requested_series is None or str(requested_series).strip() == "":
requested_series = item.get("auto_download_series")
item["auto_download_series"] = normalize_season(requested_series)
requested_mode = str(payload.get("auto_download_mode") or item.get("auto_download_mode") or "").strip().lower()
if requested_mode in MODE_CHOICES:
item["auto_download_mode"] = requested_mode
@@ -2574,7 +2577,10 @@ class WatchlistStore:
normalized_source_name = str(source_name or existing.get("auto_download_source_name") or defaults["auto_download_source_name"]).strip()
if not normalized_source_name:
normalized_source_name = defaults["auto_download_source_name"]
normalized_series = normalize_season(series or existing.get("auto_download_series") or defaults["auto_download_series"])
requested_series = series
if requested_series is None or str(requested_series).strip() == "":
requested_series = existing.get("auto_download_series")
normalized_series = normalize_season(requested_series)
normalized_offset = (
normalize_episode_offset(existing.get("auto_download_offset"))
if episode_offset is None
@@ -2825,7 +2831,7 @@ def download_watchlist_item(show_id, mode):
"title": item["title"],
"anime_name": item.get("auto_download_name") or item["title"],
"media_type": item.get("media_type") or "tv",
"season": item.get("auto_download_series") or "1",
"season": normalize_season(item.get("auto_download_series")),
"episode_offset": item.get("auto_download_offset"),
"mode": normalized_mode,
"quality": item.get("auto_download_quality")
@@ -2878,7 +2884,7 @@ def queue_watchlist_auto_download(item, refresh_source="manual"):
item.get("auto_download_name") or item.get("title") or "Anime",
sanitize_path_component(item.get("title") or "Anime", "Anime"),
)
series = normalize_season(item.get("auto_download_series") or "1")
series = normalize_season(item.get("auto_download_series"))
episode_offset = normalize_episode_offset(item.get("auto_download_offset"))
jobs = []
for episode_spec in specs:
+8 -5
View File
@@ -961,12 +961,14 @@ def sanitize_path_component(value, fallback="Anime"):
def normalize_season(value):
text = str(value or "1").strip()
text = "1" if value is None else str(value).strip()
if not text:
text = "1"
if not re.match(r"^[0-9]+$", text):
raise ValueError("Season must be a positive number")
raise ValueError("Season must be a non-negative number")
number = int(text, 10)
if number < 1:
raise ValueError("Season must be a positive number")
if number < 0:
raise ValueError("Season must be a non-negative number")
return str(number)
@@ -1001,7 +1003,8 @@ def job_staging_dir(job):
def season_label(job):
return f"{int(job.get('season') or 1):02d}"
season = normalize_season(job.get("season"))
return f"{int(season):02d}"
def episode_label(value):
+2 -1
View File
@@ -420,7 +420,8 @@ QUEUE_HTML = r"""<!doctype html>
return `${progress} · ${moved} · ${files} · ${target}`;
}
const libraryName = job.anime_name || job.title;
const season = String(job.season || "1").padStart(2, "0");
const rawSeason = job.season === 0 || job.season ? job.season : "1";
const season = String(rawSeason).padStart(2, "0");
const seasonFolder = `Season ${season}`;
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
return `${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
+1 -1
View File
@@ -520,7 +520,7 @@ INDEX_HTML = r"""<!doctype html>
</div>
<div class="row">
<label>Season
<input id="seasonInput" inputmode="numeric" pattern="[0-9]*" value="1">
<input id="seasonInput" type="number" min="0" step="1" inputmode="numeric" pattern="[0-9]*" value="1">
</label>
<label>Episodes
<input id="episodesInput" placeholder="1-12">
+97
View File
@@ -906,6 +906,32 @@ class QueueApiTests(unittest.TestCase):
)
self.assertTrue(Path(moved[0]).exists())
def test_tv_finalizer_accepts_season_zero_for_specials(self):
with tempfile.TemporaryDirectory() as temp_root:
job = APP.app_support.build_job(
{
"query": "Special Show",
"title": "Special Show",
"anime_name": "Special Show",
"media_type": "tv",
"mode": "sub",
"quality": "best",
"episodes": "1",
"download_dir": temp_root,
"season": "0",
},
{"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 / "Special Show Episode 1.mp4"
source.write_bytes(b"ep1")
moved = APP.app_support.finalize_library_files(job)
self.assertEqual(moved, [f"{temp_root}/tv/Special Show/Season 00/Special Show - S00E01.mp4"])
self.assertTrue(Path(moved[0]).exists())
def test_tv_finalizer_can_move_one_finished_episode_without_removing_staging(self):
with tempfile.TemporaryDirectory() as temp_root:
job = APP.app_support.build_job(
@@ -2016,6 +2042,22 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(result["item"]["auto_download_series"], "4")
def test_add_uses_requested_auto_download_series_zero(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
result = APP.WATCHLIST.add(
{"show_id": "show-23z", "title": "Queued Specials", "category": "watching", "auto_download_series": "0"}
)
self.assertEqual(result["item"]["auto_download_series"], "0")
def test_update_auto_download_settings_uses_series_zero(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
APP.WATCHLIST.add({"show_id": "show-23s", "title": "Queued Specials", "category": "watching"})
item = APP.WATCHLIST.update_auto_download_settings("show-23s", series="0")
self.assertEqual(item["auto_download_series"], "0")
def test_add_uses_requested_auto_download_offset(self):
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
result = APP.WATCHLIST.add(
@@ -2245,6 +2287,26 @@ class WatchlistCompletionTests(unittest.TestCase):
self.assertEqual(result["job"], job)
self.assertIn("Queued Queue Show for dub download.", result["message"])
def test_download_watchlist_item_queues_season_zero(self):
item = {
"show_id": "show-42",
"title": "Queue Specials",
"auto_download_name": "Queue Specials Library",
"auto_download_source_name": "Queue Specials Source",
"auto_download_series": "0",
"auto_download_quality": "720",
"media_type": "tv",
}
job = {"id": "job-1", "show_id": "show-42", "episodes": "1", "mode": "dub"}
with mock.patch.object(APP, "ensure_runtime", return_value={"watchlist": APP.WATCHLIST, "download_queue": mock.Mock(), "config": {"quality": "best", "download_dir": "/tmp/downloads"}}), mock.patch.object(
APP.WATCHLIST, "get", return_value=item
), mock.patch.object(APP, "episode_list", return_value=["1"]):
runtime = APP.ensure_runtime()
runtime["download_queue"].add.return_value = job
APP.download_watchlist_item("show-42", "dub")
self.assertEqual(runtime["download_queue"].add.call_args.args[0]["season"], "0")
def test_remove_from_watchlist_detaches_existing_queue_jobs(self):
runtime = APP.ensure_runtime()
APP.WATCHLIST.add({"show_id": "show-queue-remove", "title": "Queue Remove Show"})
@@ -3205,6 +3267,39 @@ class AutoDownloadQueueTests(unittest.TestCase):
}
)
def test_queue_watchlist_auto_download_queues_season_zero(self):
item = {
"show_id": "show-42",
"title": "Queue Specials",
"category": "watching",
"dub_episode_values": ["1"],
"sub_episode_values": [],
"auto_download_mode": "dub",
"auto_download_quality": "best",
"auto_download_name": "Queue Specials",
"auto_download_source_name": "Queue Specials Source",
"auto_download_series": "0",
"auto_download_offset": None,
"downloaded_dub_episodes": [],
"downloaded_sub_episodes": [],
}
download_queue = mock.Mock()
download_queue.covered_episodes.return_value = set()
runtime = {
"config": {
"auto_download_enabled": True,
"auto_download_mode": "dub",
"auto_download_quality": "best",
"download_dir": "/tmp/downloads",
},
"download_queue": download_queue,
}
with mock.patch.object(APP, "ensure_runtime", return_value=runtime):
result = APP.queue_watchlist_auto_download(item, refresh_source="manual")
self.assertTrue(result["queued"])
self.assertEqual(download_queue.add.call_args.args[0]["season"], "0")
def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self):
item = {
"show_id": "show-99",
@@ -4270,6 +4365,7 @@ class TemplateHelperTests(unittest.TestCase):
def test_search_page_sends_watchlist_auto_download_series(self):
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)
self.assertIn('id="seasonInput" type="number" min="0"', APP.INDEX_HTML)
def test_search_page_closes_selection_modal_after_successful_actions(self):
self.assertIn('setNotice(data.message || "Added to watchlist.");\n closeSelectionModal();', APP.INDEX_HTML)
@@ -4423,6 +4519,7 @@ class TemplateHelperTests(unittest.TestCase):
self.assertIn("closeAutoDownloadOverlay()", APP.WATCHLIST_HTML)
self.assertIn("Save auto-download", APP.WATCHLIST_HTML)
self.assertIn("Manual add does not trigger it", APP.WATCHLIST_HTML)
self.assertIn('id="autoDownloadSeries" type="number" min="0"', APP.WATCHLIST_HTML)
self.assertIn("Source name", APP.WATCHLIST_HTML)
self.assertIn('id="autoDownloadSourceName"', APP.WATCHLIST_HTML)
self.assertIn("Name source", APP.WATCHLIST_HTML)
+1 -1
View File
@@ -835,7 +835,7 @@ WATCHLIST_HTML = r"""<!doctype html>
</select>
</label>
<label>Series
<input class="auto-download-series" id="autoDownloadSeries" type="number" min="1" step="1" value="1">
<input class="auto-download-series" id="autoDownloadSeries" type="number" min="0" step="1" value="1">
</label>
<label>Episode offset
<input class="auto-download-offset" id="autoDownloadOffset" type="number" min="1" step="1" placeholder="Optional">