diff --git a/CHANGELOG.md b/CHANGELOG.md index a7047df..dffeeda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.52.4 - 2026-08-09 + +- Added provider metadata to Watchlist entries and display a `Source: ...` tag on Watchlist cards. +- Derived provider labels from provider-prefixed show IDs when older rows do not have an explicit provider value. + ## 0.52.3 - 2026-08-09 - Closed SQLite connections after each database operation to avoid long-running file descriptor leaks. diff --git a/README.md b/README.md index 1c36836..134786f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Kaizoku is a local web app for searching, tracking, and downloading anime from A - 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 English external subtitles when the provider exposes usable subtitle tracks. -- Manage focused watchlists for `Watching`, `Planned`, `Finished`, and `Dropped`. +- Manage focused watchlists for `Watching`, `Planned`, `Finished`, and `Dropped`, with provider source tags on each entry. - Periodically refresh selected watchlists and auto-download newly available episodes. - Sync watchlist download flags from the filesystem. - Hand completed libraries to Jellyfin TV or movie folders. diff --git a/VERSION b/VERSION index e095205..5e3df49 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.3 +0.52.4 diff --git a/app.py b/app.py index bed030a..dfe2632 100644 --- a/app.py +++ b/app.py @@ -347,6 +347,27 @@ def media_type_label(value): return "Movie" if normalize_media_type(value) == "movie" else "TV" +PROVIDER_LABELS = { + "anikoto": "Anikoto", + "anineko": "AniNeko", + "pahe": "AnimePahe", +} + + +def normalize_provider(value, fallback="anikoto"): + provider = str(value or "").strip().lower() + fallback_provider = str(fallback or "anikoto").strip().lower() + if provider in provider_bridge.PROVIDERS: + return provider + if fallback_provider in provider_bridge.PROVIDERS: + return fallback_provider + return "anikoto" + + +def provider_label(value): + return PROVIDER_LABELS.get(normalize_provider(value), PROVIDER_LABELS["anikoto"]) + + def normalize_animeschedule_entry(node): if not isinstance(node, dict): return None @@ -1534,6 +1555,7 @@ class WatchlistStore: """ CREATE TABLE IF NOT EXISTS watchlist ( show_id TEXT PRIMARY KEY, + provider TEXT, title TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'watching', downloaded INTEGER NOT NULL DEFAULT 0, @@ -1571,6 +1593,8 @@ class WatchlistStore: """ ) columns = {row["name"] for row in conn.execute("PRAGMA table_info(watchlist)").fetchall()} + if "provider" not in columns: + conn.execute("ALTER TABLE watchlist ADD COLUMN provider TEXT") if "category" not in columns: conn.execute("ALTER TABLE watchlist ADD COLUMN category TEXT NOT NULL DEFAULT 'watching'") if "downloaded" not in columns: @@ -1661,6 +1685,9 @@ class WatchlistStore: def _row_to_item(self, row): item = dict(row) defaults = self._auto_download_defaults(item.get("title")) + parsed_provider, _provider_id = provider_bridge.parse_provider_id(item.get("show_id"), item.get("provider") or "anikoto") + item["provider"] = normalize_provider(item.get("provider"), parsed_provider) + item["provider_label"] = provider_label(item["provider"]) 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")) @@ -1710,7 +1737,7 @@ class WatchlistStore: conn.execute( """ INSERT INTO watchlist ( - show_id, title, category, downloaded, status, status_message, created_at, updated_at, + show_id, provider, title, category, downloaded, status, status_message, created_at, updated_at, last_checked, sub_count, dub_count, expected_count, airing_status, sub_latest_episode, dub_latest_episode, animeschedule_route, animeschedule_title, @@ -1719,8 +1746,9 @@ class WatchlistStore: auto_download_mode, auto_download_quality, auto_download_name, auto_download_source_name, auto_download_series, auto_download_offset, downloaded_sub_episodes_json, downloaded_dub_episodes_json, thumbnail_path, thumbnail_checked_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(show_id) DO UPDATE SET + provider = excluded.provider, title = excluded.title, category = excluded.category, downloaded = excluded.downloaded, @@ -1756,6 +1784,7 @@ class WatchlistStore: """, ( item["show_id"], + normalize_provider(item.get("provider"), provider_bridge.parse_provider_id(item.get("show_id"), "anikoto")[0]), item["title"], normalize_watchlist_category(item.get("category")), 1 if item.get("downloaded") else 0, @@ -1971,6 +2000,7 @@ class WatchlistStore: title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime" if not show_id: raise ValueError("Missing show_id.") + provider = normalize_provider(payload.get("provider"), provider_bridge.parse_provider_id(show_id, "anikoto")[0]) with self.lock, self._connect() as conn: row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone() @@ -1982,6 +2012,7 @@ class WatchlistStore: item = { **self._auto_download_defaults(title), "show_id": show_id, + "provider": provider, "title": title, "category": normalize_watchlist_category(payload.get("category")), "downloaded": False, diff --git a/test_app.py b/test_app.py index 57a3261..d06a8d4 100644 --- a/test_app.py +++ b/test_app.py @@ -1620,6 +1620,28 @@ class WatchlistCompletionTests(unittest.TestCase): self.assertIn("Sub ready.", message) self.assertNotIn("Dub ready.", message) + def test_watchlist_item_exposes_provider_label_from_added_provider(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": "providerless-show", + "provider": "anineko", + "title": "Provider Show", + "category": "watching", + } + ) + + self.assertEqual(result["item"]["provider"], "anineko") + self.assertEqual(result["item"]["provider_label"], "AniNeko") + + def test_watchlist_item_derives_provider_label_from_prefixed_show_id(self): + self.seed_watchlist_item(show_id="pahe:provider-show", title="Provider Show") + + item = APP.WATCHLIST.get("pahe:provider-show") + + self.assertEqual(item["provider"], "pahe") + self.assertEqual(item["provider_label"], "AnimePahe") + def test_refresh_persists_expected_episode_total_and_ready_flags(self): self.seed_watchlist_item() @@ -4062,6 +4084,10 @@ class TemplateHelperTests(unittest.TestCase): self.assertIn("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML) self.assertIn("card.className = watchlistCardClassName(item);", APP.WATCHLIST_HTML) + def test_watchlist_page_renders_provider_source_tag(self): + self.assertIn("function providerLabel(provider)", APP.WATCHLIST_HTML) + self.assertIn('appendStatusTag(statusRow, `Source: ${item.provider_label || providerLabel(item.provider)}`);', APP.WATCHLIST_HTML) + def test_watchlist_page_download_all_replaces_open_action(self): self.assertIn("Download all", APP.WATCHLIST_HTML) self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML) diff --git a/watchlist_page.py b/watchlist_page.py index 2c112f4..641d33f 100644 --- a/watchlist_page.py +++ b/watchlist_page.py @@ -910,6 +910,15 @@ WATCHLIST_HTML = r""" container.appendChild(tag); } + function providerLabel(provider) { + const labels = { + anikoto: "Anikoto", + anineko: "AniNeko", + pahe: "AnimePahe" + }; + return labels[String(provider || "").toLowerCase()] || "Provider"; + } + function categoryOptions(selected) { return Object.entries(categoryLabels) .map(([value, label]) => ``) @@ -1364,6 +1373,7 @@ WATCHLIST_HTML = r""" : "Finished, but not marked as downloaded"; } const statusRow = card.querySelector(".status-row"); + appendStatusTag(statusRow, `Source: ${item.provider_label || providerLabel(item.provider)}`); appendStatusTag(statusRow, item.category_label || categoryLabels[item.category] || "Watching"); appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV"); if (item.airing_status) {