Add TV and movie watchlist download tags
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.42.0 - 2026-05-25
|
||||
|
||||
- Added a watchlist `TV` or `Movie` media tag with a migration-safe SQLite update for existing installs, plus Search and Watchlist controls to set or change that tag.
|
||||
- Changed completed library layout so TV downloads now go under `.../tv/<anime>/Season NN/...`, while movie-tagged entries go under `.../movie/<anime>/<anime>.ext` without season-style folders or episode naming.
|
||||
|
||||
## 0.41.0 - 2026-05-25
|
||||
|
||||
- Replaced the shared remote-access token flow with a classic single-user username/password login, backed by a persistent signed session cookie for browser sessions and HTTP Basic auth for API clients.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Small local web UI for a system-wide `ani-cli` install.
|
||||
|
||||
Current version: `0.41.0`
|
||||
Current version: `0.42.0`
|
||||
|
||||
## Project Layout
|
||||
|
||||
@@ -118,7 +118,7 @@ Container notes:
|
||||
## What It Does
|
||||
|
||||
- Search AllAnime in `sub` or `dub`.
|
||||
- Queue downloads with editable folder, quality, season, and episode range.
|
||||
- Queue downloads with editable folder, quality, media type, season, and episode range.
|
||||
- Save defaults on `/config`.
|
||||
- Track shows on `/watchlist`.
|
||||
- Refresh watchlist episode counts in the background.
|
||||
@@ -140,7 +140,7 @@ Use it to:
|
||||
|
||||
1. Search for an anime.
|
||||
2. Load episodes.
|
||||
3. Adjust folder, quality, season, and episode range.
|
||||
3. Adjust folder, quality, media type, season, and episode range.
|
||||
4. Add the show to the queue or watchlist.
|
||||
|
||||
Highlights:
|
||||
@@ -226,13 +226,14 @@ Main behavior:
|
||||
3. `Download all` expands an inline picker for `Sub` or `Dub` and then queues every currently available episode directly.
|
||||
4. Clicking the title opens the source page.
|
||||
5. `Remove` deletes the entry and cached poster.
|
||||
6. Each card has an inline category selector.
|
||||
6. Each card has inline category and media-type selectors.
|
||||
7. Summary cards show total tracked shows plus `sub` and `dub` counts.
|
||||
8. Pagination shows 30 cards per page.
|
||||
9. `Alternative titles` opens a centered overlay with English alternate titles from AniDB when the watchlist refresh can match the show to an AniDB entry.
|
||||
10. `Auto-download` opens a centered overlay with per-series settings for language, quality, series number, and the sanitized library name used for automatically queued episodes.
|
||||
Auto-download layout note: `Name source` and `Name` share a wider half-and-half row inside the modal for easier editing.
|
||||
11. Adding a show from Search reuses the current `Season` field as the initial auto-download series value for that new watchlist entry.
|
||||
12. Each watchlist entry can be tagged as `TV` or `Movie`, which changes where finished downloads are stored.
|
||||
|
||||
Status behavior:
|
||||
|
||||
@@ -316,7 +317,8 @@ Available actions:
|
||||
Downloads are staged first, then moved into a media-friendly layout:
|
||||
|
||||
```text
|
||||
ANI_CLI_DOWNLOAD_DIR/anime_name/Season 01/anime_name - S01E01.mp4
|
||||
ANI_CLI_DOWNLOAD_DIR/tv/anime_name/Season 01/anime_name - S01E01.mp4
|
||||
ANI_CLI_DOWNLOAD_DIR/movie/anime_name/anime_name.mp4
|
||||
```
|
||||
|
||||
## Runtime State
|
||||
|
||||
@@ -54,6 +54,7 @@ from app_support import (
|
||||
migrate_legacy_state_storage,
|
||||
normalize_season,
|
||||
normalize_config,
|
||||
normalize_media_type,
|
||||
now_iso,
|
||||
sanitize_path_component,
|
||||
thumbnail_file_path,
|
||||
@@ -323,6 +324,10 @@ def watchlist_category_label(value):
|
||||
return WATCHLIST_CATEGORY_LABELS[normalize_watchlist_category(value)]
|
||||
|
||||
|
||||
def media_type_label(value):
|
||||
return "Movie" if normalize_media_type(value) == "movie" else "TV"
|
||||
|
||||
|
||||
def normalize_animeschedule_entry(node):
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
@@ -1117,6 +1122,7 @@ class WatchlistStore:
|
||||
"auto_download_quality": str(config.get("auto_download_quality") or "best").strip().lower() or "best",
|
||||
"auto_download_name": sanitize_path_component(title, "Anime"),
|
||||
"auto_download_series": "1",
|
||||
"media_type": "tv",
|
||||
}
|
||||
|
||||
def _connect(self):
|
||||
@@ -1190,6 +1196,7 @@ class WatchlistStore:
|
||||
alternative_titles_json TEXT,
|
||||
anidb_aid TEXT,
|
||||
anidb_title TEXT,
|
||||
media_type TEXT NOT NULL DEFAULT 'tv',
|
||||
auto_download_mode TEXT,
|
||||
auto_download_quality TEXT,
|
||||
auto_download_name TEXT,
|
||||
@@ -1218,6 +1225,8 @@ class WatchlistStore:
|
||||
conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_aid TEXT")
|
||||
if "anidb_title" not in columns:
|
||||
conn.execute("ALTER TABLE watchlist ADD COLUMN anidb_title TEXT")
|
||||
if "media_type" not in columns:
|
||||
conn.execute("ALTER TABLE watchlist ADD COLUMN media_type TEXT NOT NULL DEFAULT 'tv'")
|
||||
if "animeschedule_route" not in columns:
|
||||
conn.execute("ALTER TABLE watchlist ADD COLUMN animeschedule_route TEXT")
|
||||
if "animeschedule_title" not in columns:
|
||||
@@ -1245,6 +1254,9 @@ class WatchlistStore:
|
||||
conn.execute(
|
||||
"UPDATE watchlist SET category = 'watching' WHERE category IS NULL OR TRIM(category) = ''"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE watchlist SET media_type = 'tv' WHERE media_type IS NULL OR TRIM(media_type) = ''"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_title ON watchlist(title)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_category ON watchlist(category)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_watchlist_updated_at ON watchlist(updated_at DESC)")
|
||||
@@ -1254,6 +1266,8 @@ class WatchlistStore:
|
||||
defaults = self._auto_download_defaults(item.get("title"))
|
||||
item["category"] = normalize_watchlist_category(item.get("category"))
|
||||
item["category_label"] = watchlist_category_label(item["category"])
|
||||
item["media_type"] = normalize_media_type(item.get("media_type"))
|
||||
item["media_type_label"] = media_type_label(item["media_type"])
|
||||
item["downloaded"] = bool(int(item.get("downloaded") or 0))
|
||||
item["sub_count"] = int(item.get("sub_count") or 0)
|
||||
item["dub_count"] = int(item.get("dub_count") or 0)
|
||||
@@ -1302,11 +1316,11 @@ class WatchlistStore:
|
||||
sub_latest_episode, dub_latest_episode,
|
||||
animeschedule_route, animeschedule_title,
|
||||
anilist_id, anilist_title, anilist_site_url, alternative_titles_json,
|
||||
anidb_aid, anidb_title,
|
||||
anidb_aid, anidb_title, media_type,
|
||||
auto_download_mode, auto_download_quality, auto_download_name, auto_download_series,
|
||||
downloaded_sub_episodes_json, downloaded_dub_episodes_json,
|
||||
thumbnail_path, thumbnail_checked_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(show_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
category = excluded.category,
|
||||
@@ -1329,6 +1343,7 @@ class WatchlistStore:
|
||||
alternative_titles_json = excluded.alternative_titles_json,
|
||||
anidb_aid = excluded.anidb_aid,
|
||||
anidb_title = excluded.anidb_title,
|
||||
media_type = excluded.media_type,
|
||||
auto_download_mode = excluded.auto_download_mode,
|
||||
auto_download_quality = excluded.auto_download_quality,
|
||||
auto_download_name = excluded.auto_download_name,
|
||||
@@ -1362,6 +1377,7 @@ class WatchlistStore:
|
||||
item.get("alternative_titles_json"),
|
||||
item.get("anidb_aid"),
|
||||
item.get("anidb_title"),
|
||||
normalize_media_type(item.get("media_type")),
|
||||
item.get("auto_download_mode"),
|
||||
item.get("auto_download_quality"),
|
||||
item.get("auto_download_name"),
|
||||
@@ -1417,6 +1433,7 @@ class WatchlistStore:
|
||||
"alternative_titles_json": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"media_type": "tv",
|
||||
"auto_download_mode": None,
|
||||
"auto_download_quality": None,
|
||||
"auto_download_name": None,
|
||||
@@ -1553,6 +1570,7 @@ class WatchlistStore:
|
||||
"alternative_titles_json": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"media_type": normalize_media_type(payload.get("media_type")),
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
"downloaded_sub_episodes_json": None,
|
||||
@@ -1738,6 +1756,16 @@ class WatchlistStore:
|
||||
)
|
||||
return self.get(show_id)
|
||||
|
||||
def update_media_type(self, show_id, media_type):
|
||||
existing = self.get(show_id)
|
||||
normalized_media_type = normalize_media_type(media_type)
|
||||
with self.lock, self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE watchlist SET media_type = ?, updated_at = ? WHERE show_id = ?",
|
||||
(normalized_media_type, now_iso(), existing["show_id"]),
|
||||
)
|
||||
return self.get(show_id)
|
||||
|
||||
def queue_refresh(self, show_id, status_message="Queued watchlist refresh.", source="manual"):
|
||||
existing = self.get(show_id)
|
||||
now = now_iso()
|
||||
@@ -1851,6 +1879,7 @@ class WatchlistStore:
|
||||
"alternative_titles_json": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"media_type": "tv",
|
||||
"auto_download_series": None,
|
||||
"downloaded_sub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "sub" else None,
|
||||
"downloaded_dub_episodes_json": encode_episode_values(downloaded_episode_values) if normalized_mode == "dub" else None,
|
||||
@@ -2104,8 +2133,9 @@ def download_watchlist_item(show_id, mode):
|
||||
"show_id": item["show_id"],
|
||||
"query": match.get("query") or query,
|
||||
"title": match.get("title") or item["title"],
|
||||
"anime_name": item["title"],
|
||||
"season": "1",
|
||||
"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",
|
||||
"index": match.get("index", 1),
|
||||
"mode": normalized_mode,
|
||||
"quality": runtime["config"]["quality"],
|
||||
@@ -2171,6 +2201,7 @@ def queue_watchlist_auto_download(item, refresh_source="manual"):
|
||||
"query": match.get("query") or query,
|
||||
"title": match.get("title") or item["title"],
|
||||
"anime_name": anime_name,
|
||||
"media_type": item.get("media_type") or "tv",
|
||||
"season": series,
|
||||
"index": match.get("index", 1),
|
||||
"mode": mode,
|
||||
@@ -2189,6 +2220,12 @@ def update_watchlist_category(show_id, category):
|
||||
return {"message": f"Moved {item['title']} to {item['category_label']}.", "item": item}
|
||||
|
||||
|
||||
def update_watchlist_media_type(show_id, media_type):
|
||||
ensure_runtime()
|
||||
item = WATCHLIST.update_media_type(show_id, media_type)
|
||||
return {"message": f"Saved {item['title']} as {item['media_type_label']}.", "item": item}
|
||||
|
||||
|
||||
def update_watchlist_auto_download(show_id, mode=None, quality=None, name=None, series=None):
|
||||
ensure_runtime()
|
||||
item = WATCHLIST.update_auto_download_settings(show_id, mode=mode, quality=quality, name=name, series=series)
|
||||
@@ -2440,6 +2477,7 @@ Handler = build_handler_class(
|
||||
update_all_watchlist_statuses=update_all_watchlist_statuses,
|
||||
update_watchlist_auto_download=update_watchlist_auto_download,
|
||||
update_watchlist_category=update_watchlist_category,
|
||||
update_watchlist_media_type=update_watchlist_media_type,
|
||||
update_watchlist_status=update_watchlist_status,
|
||||
upload_watchlist_thumbnail=upload_watchlist_thumbnail,
|
||||
watchlist_html=WATCHLIST_HTML,
|
||||
|
||||
+18
-2
@@ -20,7 +20,7 @@ from uuid import uuid4
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||
APP_NAME = "ani-cli-web"
|
||||
VERSION = "0.41.0"
|
||||
VERSION = "0.42.0"
|
||||
ALLANIME_BASE = "allanime.day"
|
||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||
ALLANIME_REFERER = "https://allmanga.to"
|
||||
@@ -35,6 +35,7 @@ DEBUG_MODE = False
|
||||
|
||||
QUALITY_CHOICES = {"best", "1080", "1080p", "720", "720p", "480", "480p", "360", "360p", "worst"}
|
||||
MODE_CHOICES = {"sub", "dub"}
|
||||
MEDIA_TYPE_CHOICES = {"tv", "movie"}
|
||||
WATCHLIST_CATEGORY_LABELS = {
|
||||
"watching": "Watching",
|
||||
"planned": "Planned",
|
||||
@@ -695,9 +696,18 @@ def normalize_season(value):
|
||||
return str(number)
|
||||
|
||||
|
||||
def normalize_media_type(value, default="tv"):
|
||||
media_type = str(value or default).strip().lower()
|
||||
return media_type if media_type in MEDIA_TYPE_CHOICES else default
|
||||
|
||||
|
||||
def job_output_dir(job):
|
||||
anime_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
||||
return Path(job["download_dir"]).expanduser() / anime_name / f"Season {season_label(job)}"
|
||||
media_type = normalize_media_type(job.get("media_type"))
|
||||
library_root = Path(job["download_dir"]).expanduser() / media_type / anime_name
|
||||
if media_type == "movie":
|
||||
return library_root
|
||||
return library_root / f"Season {season_label(job)}"
|
||||
|
||||
|
||||
def job_staging_dir(job):
|
||||
@@ -738,6 +748,7 @@ def finalize_library_files(job):
|
||||
staging_dir = job_staging_dir(job)
|
||||
target_dir = job_output_dir(job)
|
||||
library_name = sanitize_path_component(job.get("anime_name") or job.get("title"))
|
||||
media_type = normalize_media_type(job.get("media_type"))
|
||||
season = season_label(job)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = sorted(
|
||||
@@ -750,6 +761,9 @@ def finalize_library_files(job):
|
||||
moved = []
|
||||
fallback_episode = 1
|
||||
for source in files:
|
||||
if media_type == "movie":
|
||||
final_name = f"{library_name}{source.suffix}"
|
||||
else:
|
||||
ep = extract_episode_from_filename(source)
|
||||
if ep is None:
|
||||
ep = str(fallback_episode)
|
||||
@@ -784,6 +798,7 @@ def build_job(payload, config):
|
||||
mode = str(payload.get("mode") or config["mode"]).lower()
|
||||
quality = str(payload.get("quality") or config["quality"]).lower()
|
||||
download_dir = str(payload.get("download_dir") or config["download_dir"]).strip()
|
||||
media_type = normalize_media_type(payload.get("media_type"), default="tv")
|
||||
if mode not in MODE_CHOICES:
|
||||
raise ValueError("Mode must be sub or dub")
|
||||
if quality not in QUALITY_CHOICES:
|
||||
@@ -794,6 +809,7 @@ def build_job(payload, config):
|
||||
"show_id": str(payload.get("show_id") or "").strip(),
|
||||
"title": title,
|
||||
"anime_name": anime_name,
|
||||
"media_type": media_type,
|
||||
"season": normalize_season(payload.get("season")),
|
||||
"query": query,
|
||||
"result_index": index,
|
||||
|
||||
@@ -60,6 +60,7 @@ class HandlerContext:
|
||||
update_all_watchlist_statuses: object
|
||||
update_watchlist_auto_download: object
|
||||
update_watchlist_category: object
|
||||
update_watchlist_media_type: object
|
||||
update_watchlist_status: object
|
||||
upload_watchlist_thumbnail: object
|
||||
watchlist_html: str
|
||||
@@ -544,6 +545,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category")))
|
||||
elif parsed.path == "/api/watchlist/update-media-type":
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
self.json(Handler._context(self).update_watchlist_media_type(payload["show_id"], payload.get("media_type")))
|
||||
elif parsed.path == "/api/watchlist/update-auto-download":
|
||||
Handler._runtime(self)
|
||||
payload = Handler.require_fields(self, self.body_json(), "show_id")
|
||||
|
||||
+7
-2
@@ -38,6 +38,7 @@ JOB_STRUCTURED_COLUMNS = (
|
||||
"show_id",
|
||||
"title",
|
||||
"anime_name",
|
||||
"media_type",
|
||||
"season",
|
||||
"query",
|
||||
"result_index",
|
||||
@@ -596,6 +597,7 @@ class DownloadQueue:
|
||||
show_id TEXT,
|
||||
title TEXT,
|
||||
anime_name TEXT,
|
||||
media_type TEXT,
|
||||
season TEXT,
|
||||
query TEXT,
|
||||
result_index INTEGER,
|
||||
@@ -621,6 +623,7 @@ class DownloadQueue:
|
||||
("show_id", "ALTER TABLE jobs ADD COLUMN show_id TEXT"),
|
||||
("title", "ALTER TABLE jobs ADD COLUMN title TEXT"),
|
||||
("anime_name", "ALTER TABLE jobs ADD COLUMN anime_name TEXT"),
|
||||
("media_type", "ALTER TABLE jobs ADD COLUMN media_type TEXT"),
|
||||
("season", "ALTER TABLE jobs ADD COLUMN season TEXT"),
|
||||
("query", "ALTER TABLE jobs ADD COLUMN query TEXT"),
|
||||
("result_index", "ALTER TABLE jobs ADD COLUMN result_index INTEGER"),
|
||||
@@ -722,17 +725,18 @@ class DownloadQueue:
|
||||
"""
|
||||
INSERT INTO jobs (
|
||||
id, status, created_at, updated_at,
|
||||
show_id, title, anime_name, season, query, result_index, mode, quality, episodes, download_dir,
|
||||
show_id, title, anime_name, media_type, season, query, result_index, mode, quality, episodes, download_dir,
|
||||
started_at, finished_at, exit_code, pid, command, staging_dir, target_dir, cancel_requested,
|
||||
log_payload, payload
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at,
|
||||
show_id = excluded.show_id,
|
||||
title = excluded.title,
|
||||
anime_name = excluded.anime_name,
|
||||
media_type = excluded.media_type,
|
||||
season = excluded.season,
|
||||
query = excluded.query,
|
||||
result_index = excluded.result_index,
|
||||
@@ -759,6 +763,7 @@ class DownloadQueue:
|
||||
job.get("show_id"),
|
||||
job.get("title"),
|
||||
job.get("anime_name"),
|
||||
job.get("media_type"),
|
||||
job.get("season"),
|
||||
job.get("query"),
|
||||
job.get("result_index"),
|
||||
|
||||
@@ -365,6 +365,12 @@ INDEX_HTML = r"""<!doctype html>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="min-width: 160px;">Media type
|
||||
<select id="trackMediaType">
|
||||
<option value="tv">TV</option>
|
||||
<option value="movie">Movie</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="ghost" id="trackBtn" type="button" disabled>Add to watchlist</button>
|
||||
<button class="primary" id="addBtn" type="button" disabled>Add to queue</button>
|
||||
</div>
|
||||
@@ -517,6 +523,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
show_id: state.selected.id,
|
||||
title: state.selected.title,
|
||||
category: $("trackCategory").value,
|
||||
media_type: $("trackMediaType").value,
|
||||
auto_download_series: $("seasonInput").value || "1"
|
||||
})
|
||||
});
|
||||
@@ -533,6 +540,7 @@ INDEX_HTML = r"""<!doctype html>
|
||||
query: state.selected.query,
|
||||
title: state.selected.title,
|
||||
anime_name: $("animeNameInput").value || state.selected.title,
|
||||
media_type: $("trackMediaType").value,
|
||||
season: $("seasonInput").value || "1",
|
||||
index: state.selected.index,
|
||||
mode: state.config.mode,
|
||||
|
||||
+59
-3
@@ -308,6 +308,7 @@ class QueueApiTests(unittest.TestCase):
|
||||
"show_id": "show-1",
|
||||
"title": "Structured Show",
|
||||
"anime_name": "Structured Show",
|
||||
"media_type": "movie",
|
||||
"season": "1",
|
||||
"query": "Structured Show",
|
||||
"result_index": 1,
|
||||
@@ -329,11 +330,39 @@ class QueueApiTests(unittest.TestCase):
|
||||
row = conn.execute("SELECT * FROM jobs WHERE id = ?", (job["id"],)).fetchone()
|
||||
|
||||
self.assertEqual(row["title"], "Structured Show")
|
||||
self.assertEqual(row["media_type"], "movie")
|
||||
self.assertEqual(row["log_payload"], '["line 1", "line 2"]')
|
||||
self.assertEqual(APP.json.loads(row["payload"]), {"custom_flag": True})
|
||||
restored = queue._job_from_row(row)
|
||||
self.assertEqual(restored["log"], ["line 1", "line 2"])
|
||||
self.assertTrue(restored["custom_flag"])
|
||||
self.assertEqual(restored["media_type"], "movie")
|
||||
|
||||
def test_movie_finalizer_uses_movie_folder_and_single_file_name(self):
|
||||
job = APP.app_support.build_job(
|
||||
{
|
||||
"query": "Movie Show",
|
||||
"title": "Movie Show",
|
||||
"anime_name": "Movie Show",
|
||||
"media_type": "movie",
|
||||
"mode": "sub",
|
||||
"quality": "best",
|
||||
"episodes": "1",
|
||||
"download_dir": "/tmp/example",
|
||||
"season": "1",
|
||||
},
|
||||
{"mode": "sub", "quality": "best", "download_dir": "/tmp/example"},
|
||||
)
|
||||
staging_dir = APP.app_support.job_staging_dir(job)
|
||||
staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
source = staging_dir / "Movie Show Episode 1.mp4"
|
||||
source.write_bytes(b"movie-bytes")
|
||||
|
||||
moved = APP.app_support.finalize_library_files(job)
|
||||
|
||||
self.assertEqual(moved, ["/tmp/example/movie/Movie Show/Movie Show.mp4"])
|
||||
self.assertFalse(source.exists())
|
||||
self.assertTrue(Path(moved[0]).exists())
|
||||
|
||||
def test_shutdown_wait_cancels_active_process_for_deterministic_teardown(self):
|
||||
queue = object.__new__(APP.DownloadQueue)
|
||||
@@ -764,6 +793,7 @@ class ThumbnailEventRecoveryTests(unittest.TestCase):
|
||||
"animeschedule_title": None,
|
||||
"anidb_aid": None,
|
||||
"anidb_title": None,
|
||||
"media_type": "tv",
|
||||
"thumbnail_path": None,
|
||||
"thumbnail_checked_at": None,
|
||||
}
|
||||
@@ -1038,6 +1068,15 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(result["item"]["auto_download_series"], "4")
|
||||
|
||||
def test_add_uses_requested_media_type(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-24", "title": "Queued Movie", "category": "watching", "media_type": "movie"}
|
||||
)
|
||||
|
||||
self.assertEqual(result["item"]["media_type"], "movie")
|
||||
self.assertEqual(result["item"]["media_type_label"], "Movie")
|
||||
|
||||
def test_refresh_uses_bounded_request_timeout(self):
|
||||
self.seed_watchlist_item(show_id="show-1", title="Example Show")
|
||||
snapshot = {
|
||||
@@ -1093,7 +1132,13 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
self.assertEqual(prepared["title"], "Queue Show Season 2")
|
||||
|
||||
def test_download_watchlist_item_queues_full_episode_range_for_selected_mode(self):
|
||||
item = {"show_id": "show-42", "title": "Queue Show"}
|
||||
item = {
|
||||
"show_id": "show-42",
|
||||
"title": "Queue Show",
|
||||
"auto_download_name": "Queue Show Library",
|
||||
"auto_download_series": "4",
|
||||
"media_type": "movie",
|
||||
}
|
||||
job = {"id": "job-1", "show_id": "show-42", "episodes": "1-12", "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
|
||||
@@ -1109,8 +1154,9 @@ class WatchlistCompletionTests(unittest.TestCase):
|
||||
"show_id": "show-42",
|
||||
"query": "Queue Show",
|
||||
"title": "Queue Show",
|
||||
"anime_name": "Queue Show",
|
||||
"season": "1",
|
||||
"anime_name": "Queue Show Library",
|
||||
"media_type": "movie",
|
||||
"season": "4",
|
||||
"index": 3,
|
||||
"mode": "dub",
|
||||
"quality": "best",
|
||||
@@ -1555,6 +1601,7 @@ class AutoDownloadQueueTests(unittest.TestCase):
|
||||
"query": "Queue Show",
|
||||
"title": "Queue Show",
|
||||
"anime_name": "Queue Show- Alt-Name",
|
||||
"media_type": "tv",
|
||||
"season": "3",
|
||||
"index": 2,
|
||||
"mode": "dub",
|
||||
@@ -1928,6 +1975,15 @@ class HandlerRouteTests(unittest.TestCase):
|
||||
self.assertEqual(handler.json_payload, payload)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
|
||||
def test_watchlist_media_type_post_returns_updated_item(self):
|
||||
body = b'{"show_id":"show-1","media_type":"movie"}'
|
||||
handler = DummyHandler("/api/watchlist/update-media-type", body=body, content_length=len(body))
|
||||
payload = {"message": "Saved Show 1 as Movie.", "item": {"show_id": "show-1", "media_type": "movie"}}
|
||||
handler.handler_context = mock.Mock(update_watchlist_media_type=mock.Mock(return_value=payload))
|
||||
APP.Handler.do_POST(handler)
|
||||
self.assertEqual(handler.json_payload, payload)
|
||||
self.assertEqual(handler.json_status, HTTPStatus.OK)
|
||||
|
||||
def test_generic_handler_exception_skips_traceback_when_debug_disabled(self):
|
||||
handler = DummyHandler("/api/config")
|
||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||
|
||||
+40
-1
@@ -727,6 +727,12 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
<option value="dropped">Dropped</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Media type
|
||||
<select id="watchlist-media-type">
|
||||
<option value="tv">TV</option>
|
||||
<option value="movie">Movie</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="field-hint">Tip: you can also add entries directly from the Search page.</div>
|
||||
<button class="primary" type="submit">Add to watchlist</button>
|
||||
</form>
|
||||
@@ -861,6 +867,10 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
finished: "Finished",
|
||||
dropped: "Dropped"
|
||||
};
|
||||
const mediaTypeLabels = {
|
||||
tv: "TV",
|
||||
movie: "Movie"
|
||||
};
|
||||
const autoDownloadOverlay = document.getElementById("autoDownloadOverlay");
|
||||
const altTitlesOverlay = document.getElementById("altTitlesOverlay");
|
||||
const altTitlesCloseBtn = document.getElementById("altTitlesCloseBtn");
|
||||
@@ -940,6 +950,12 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
.join("");
|
||||
}
|
||||
|
||||
function mediaTypeOptions(selected) {
|
||||
return Object.entries(mediaTypeLabels)
|
||||
.map(([value, label]) => `<option value="${value}"${value === selected ? " selected" : ""}>${label}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderSummary(summary) {
|
||||
const totals = summary || { shows: 0, sub: 0, dub: 0 };
|
||||
const cards = $("watchlist-summary").querySelectorAll(".summary-card");
|
||||
@@ -1343,6 +1359,9 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
<label>Category
|
||||
<select class="category-select">${categoryOptions(item.category)}</select>
|
||||
</label>
|
||||
<label>Media
|
||||
<select class="media-type-select">${mediaTypeOptions(item.media_type || "tv")}</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="muted card-date"></div>
|
||||
<div class="card-actions">
|
||||
@@ -1372,6 +1391,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
}
|
||||
const statusRow = card.querySelector(".status-row");
|
||||
appendStatusTag(statusRow, item.category_label || categoryLabels[item.category] || "Watching");
|
||||
appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV");
|
||||
if (item.airing_status) {
|
||||
appendStatusTag(statusRow, item.airing_status);
|
||||
}
|
||||
@@ -1405,6 +1425,7 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
const dubBtn = actionButtons[5];
|
||||
const downloadPicker = card.querySelector(".download-picker");
|
||||
const categorySelect = card.querySelector(".category-select");
|
||||
const mediaTypeSelect = card.querySelector(".media-type-select");
|
||||
categorySelect.addEventListener("change", async () => {
|
||||
categorySelect.disabled = true;
|
||||
try {
|
||||
@@ -1421,6 +1442,22 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
categorySelect.disabled = false;
|
||||
}
|
||||
});
|
||||
mediaTypeSelect.addEventListener("change", async () => {
|
||||
mediaTypeSelect.disabled = true;
|
||||
try {
|
||||
const data = await api("/api/watchlist/update-media-type", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ show_id: item.show_id, media_type: mediaTypeSelect.value })
|
||||
});
|
||||
setNotice(data.message || "Media type updated.");
|
||||
await fetchWatchlist(state.watchlistPage);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
mediaTypeSelect.value = item.media_type || "tv";
|
||||
} finally {
|
||||
mediaTypeSelect.disabled = false;
|
||||
}
|
||||
});
|
||||
refreshBtn.addEventListener("click", () => refreshWatchlistItem(item.show_id));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const visible = downloadPicker.classList.toggle("visible");
|
||||
@@ -1500,11 +1537,13 @@ WATCHLIST_HTML = r"""<!doctype html>
|
||||
body: JSON.stringify({
|
||||
show_id: showId,
|
||||
title,
|
||||
category: $("watchlist-category").value
|
||||
category: $("watchlist-category").value,
|
||||
media_type: $("watchlist-media-type").value
|
||||
})
|
||||
});
|
||||
$("watchlist-form").reset();
|
||||
$("watchlist-category").value = "watching";
|
||||
$("watchlist-media-type").value = "tv";
|
||||
setNotice(data.message || "Added to watchlist.");
|
||||
await fetchWatchlist();
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user