Allow season zero for auto-downloads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user