Show provider tags on watchlist

This commit is contained in:
Codex
2026-08-09 14:04:58 +02:00
parent 219ef1e059
commit 4eb5252d3a
6 changed files with 76 additions and 4 deletions
+5
View File
@@ -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.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
0.52.3
0.52.4
+33 -2
View File
@@ -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,
+26
View File
@@ -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)
+10
View File
@@ -910,6 +910,15 @@ WATCHLIST_HTML = r"""<!doctype html>
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]) => `<option value="${value}"${value === selected ? " selected" : ""}>${label}</option>`)
@@ -1364,6 +1373,7 @@ WATCHLIST_HTML = r"""<!doctype html>
: "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) {