Allow season zero for auto-downloads
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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)
|
source_root = watchlist_source_library_dir(item, config)
|
||||||
if normalize_media_type(item.get("media_type")) == "movie":
|
if normalize_media_type(item.get("media_type")) == "movie":
|
||||||
return source_root
|
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}"
|
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)
|
season_dir = watchlist_source_season_dir(item, config)
|
||||||
if not season_dir.exists() or not season_dir.is_dir():
|
if not season_dir.exists() or not season_dir.is_dir():
|
||||||
return []
|
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)
|
pattern = re.compile(rf"\bS{int(season):02d}E([0-9][0-9A-Za-z.\-]*)\b", re.IGNORECASE)
|
||||||
values = []
|
values = []
|
||||||
seen = set()
|
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_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_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_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["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_sub_episodes"] = decode_episode_values(item.get("downloaded_sub_episodes_json"))
|
||||||
item["downloaded_dub_episodes"] = decode_episode_values(item.get("downloaded_dub_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_sub_episodes_json": None,
|
||||||
"downloaded_dub_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()
|
requested_mode = str(payload.get("auto_download_mode") or item.get("auto_download_mode") or "").strip().lower()
|
||||||
if requested_mode in MODE_CHOICES:
|
if requested_mode in MODE_CHOICES:
|
||||||
item["auto_download_mode"] = requested_mode
|
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()
|
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:
|
if not normalized_source_name:
|
||||||
normalized_source_name = defaults["auto_download_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 = (
|
normalized_offset = (
|
||||||
normalize_episode_offset(existing.get("auto_download_offset"))
|
normalize_episode_offset(existing.get("auto_download_offset"))
|
||||||
if episode_offset is None
|
if episode_offset is None
|
||||||
@@ -2825,7 +2831,7 @@ def download_watchlist_item(show_id, mode):
|
|||||||
"title": item["title"],
|
"title": item["title"],
|
||||||
"anime_name": item.get("auto_download_name") or item["title"],
|
"anime_name": item.get("auto_download_name") or item["title"],
|
||||||
"media_type": item.get("media_type") or "tv",
|
"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"),
|
"episode_offset": item.get("auto_download_offset"),
|
||||||
"mode": normalized_mode,
|
"mode": normalized_mode,
|
||||||
"quality": item.get("auto_download_quality")
|
"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",
|
item.get("auto_download_name") or item.get("title") or "Anime",
|
||||||
sanitize_path_component(item.get("title") or "Anime", "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"))
|
episode_offset = normalize_episode_offset(item.get("auto_download_offset"))
|
||||||
jobs = []
|
jobs = []
|
||||||
for episode_spec in specs:
|
for episode_spec in specs:
|
||||||
|
|||||||
+8
-5
@@ -961,12 +961,14 @@ def sanitize_path_component(value, fallback="Anime"):
|
|||||||
|
|
||||||
|
|
||||||
def normalize_season(value):
|
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):
|
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)
|
number = int(text, 10)
|
||||||
if number < 1:
|
if number < 0:
|
||||||
raise ValueError("Season must be a positive number")
|
raise ValueError("Season must be a non-negative number")
|
||||||
return str(number)
|
return str(number)
|
||||||
|
|
||||||
|
|
||||||
@@ -1001,7 +1003,8 @@ def job_staging_dir(job):
|
|||||||
|
|
||||||
|
|
||||||
def season_label(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):
|
def episode_label(value):
|
||||||
|
|||||||
+2
-1
@@ -420,7 +420,8 @@ QUEUE_HTML = r"""<!doctype html>
|
|||||||
return `${progress} · ${moved} · ${files} · ${target}`;
|
return `${progress} · ${moved} · ${files} · ${target}`;
|
||||||
}
|
}
|
||||||
const libraryName = job.anime_name || job.title;
|
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 seasonFolder = `Season ${season}`;
|
||||||
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
|
const target = job.target_dir || `${job.download_dir}/${libraryName}/${seasonFolder}`;
|
||||||
return `${libraryName} · S${season} · ${job.mode} · ${job.quality} · episodes ${job.episodes} · ${target}`;
|
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>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>Season
|
<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>
|
||||||
<label>Episodes
|
<label>Episodes
|
||||||
<input id="episodesInput" placeholder="1-12">
|
<input id="episodesInput" placeholder="1-12">
|
||||||
|
|||||||
+97
@@ -906,6 +906,32 @@ class QueueApiTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(Path(moved[0]).exists())
|
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):
|
def test_tv_finalizer_can_move_one_finished_episode_without_removing_staging(self):
|
||||||
with tempfile.TemporaryDirectory() as temp_root:
|
with tempfile.TemporaryDirectory() as temp_root:
|
||||||
job = APP.app_support.build_job(
|
job = APP.app_support.build_job(
|
||||||
@@ -2016,6 +2042,22 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(result["item"]["auto_download_series"], "4")
|
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):
|
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)):
|
with mock.patch.object(APP.WATCHLIST, "schedule_refresh", side_effect=lambda show_id, source="manual": APP.WATCHLIST.get(show_id)):
|
||||||
result = APP.WATCHLIST.add(
|
result = APP.WATCHLIST.add(
|
||||||
@@ -2245,6 +2287,26 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertEqual(result["job"], job)
|
self.assertEqual(result["job"], job)
|
||||||
self.assertIn("Queued Queue Show for dub download.", result["message"])
|
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):
|
def test_remove_from_watchlist_detaches_existing_queue_jobs(self):
|
||||||
runtime = APP.ensure_runtime()
|
runtime = APP.ensure_runtime()
|
||||||
APP.WATCHLIST.add({"show_id": "show-queue-remove", "title": "Queue Remove Show"})
|
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):
|
def test_queue_watchlist_auto_download_can_queue_existing_backlog_after_initial_seed_refresh(self):
|
||||||
item = {
|
item = {
|
||||||
"show_id": "show-99",
|
"show_id": "show-99",
|
||||||
@@ -4270,6 +4365,7 @@ class TemplateHelperTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_search_page_sends_watchlist_auto_download_series(self):
|
def test_search_page_sends_watchlist_auto_download_series(self):
|
||||||
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)
|
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):
|
def test_search_page_closes_selection_modal_after_successful_actions(self):
|
||||||
self.assertIn('setNotice(data.message || "Added to watchlist.");\n closeSelectionModal();', APP.INDEX_HTML)
|
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("closeAutoDownloadOverlay()", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("Save auto-download", APP.WATCHLIST_HTML)
|
self.assertIn("Save auto-download", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("Manual add does not trigger it", 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("Source name", APP.WATCHLIST_HTML)
|
||||||
self.assertIn('id="autoDownloadSourceName"', APP.WATCHLIST_HTML)
|
self.assertIn('id="autoDownloadSourceName"', APP.WATCHLIST_HTML)
|
||||||
self.assertIn("Name source", APP.WATCHLIST_HTML)
|
self.assertIn("Name source", APP.WATCHLIST_HTML)
|
||||||
|
|||||||
+1
-1
@@ -835,7 +835,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Series
|
<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>
|
||||||
<label>Episode offset
|
<label>Episode offset
|
||||||
<input class="auto-download-offset" id="autoDownloadOffset" type="number" min="1" step="1" placeholder="Optional">
|
<input class="auto-download-offset" id="autoDownloadOffset" type="number" min="1" step="1" placeholder="Optional">
|
||||||
|
|||||||
Reference in New Issue
Block a user