Show provider tags on watchlist
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 0.52.3 - 2026-08-09
|
||||||
|
|
||||||
- Closed SQLite connections after each database operation to avoid long-running file descriptor leaks.
|
- Closed SQLite connections after each database operation to avoid long-running file descriptor leaks.
|
||||||
|
|||||||
@@ -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.
|
- 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`.
|
||||||
- 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`.
|
- 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.
|
- Periodically refresh selected watchlists and auto-download newly available episodes.
|
||||||
- Sync watchlist download flags from the filesystem.
|
- Sync watchlist download flags from the filesystem.
|
||||||
- Hand completed libraries to Jellyfin TV or movie folders.
|
- Hand completed libraries to Jellyfin TV or movie folders.
|
||||||
|
|||||||
@@ -347,6 +347,27 @@ def media_type_label(value):
|
|||||||
return "Movie" if normalize_media_type(value) == "movie" else "TV"
|
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):
|
def normalize_animeschedule_entry(node):
|
||||||
if not isinstance(node, dict):
|
if not isinstance(node, dict):
|
||||||
return None
|
return None
|
||||||
@@ -1534,6 +1555,7 @@ class WatchlistStore:
|
|||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS watchlist (
|
CREATE TABLE IF NOT EXISTS watchlist (
|
||||||
show_id TEXT PRIMARY KEY,
|
show_id TEXT PRIMARY KEY,
|
||||||
|
provider TEXT,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
category TEXT NOT NULL DEFAULT 'watching',
|
category TEXT NOT NULL DEFAULT 'watching',
|
||||||
downloaded INTEGER NOT NULL DEFAULT 0,
|
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()}
|
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:
|
if "category" not in columns:
|
||||||
conn.execute("ALTER TABLE watchlist ADD COLUMN category TEXT NOT NULL DEFAULT 'watching'")
|
conn.execute("ALTER TABLE watchlist ADD COLUMN category TEXT NOT NULL DEFAULT 'watching'")
|
||||||
if "downloaded" not in columns:
|
if "downloaded" not in columns:
|
||||||
@@ -1661,6 +1685,9 @@ class WatchlistStore:
|
|||||||
def _row_to_item(self, row):
|
def _row_to_item(self, row):
|
||||||
item = dict(row)
|
item = dict(row)
|
||||||
defaults = self._auto_download_defaults(item.get("title"))
|
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"] = normalize_watchlist_category(item.get("category"))
|
||||||
item["category_label"] = watchlist_category_label(item["category"])
|
item["category_label"] = watchlist_category_label(item["category"])
|
||||||
item["media_type"] = normalize_media_type(item.get("media_type"))
|
item["media_type"] = normalize_media_type(item.get("media_type"))
|
||||||
@@ -1710,7 +1737,7 @@ class WatchlistStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO watchlist (
|
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,
|
last_checked, sub_count, dub_count, expected_count, airing_status,
|
||||||
sub_latest_episode, dub_latest_episode,
|
sub_latest_episode, dub_latest_episode,
|
||||||
animeschedule_route, animeschedule_title,
|
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,
|
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,
|
downloaded_sub_episodes_json, downloaded_dub_episodes_json,
|
||||||
thumbnail_path, thumbnail_checked_at
|
thumbnail_path, thumbnail_checked_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(show_id) DO UPDATE SET
|
ON CONFLICT(show_id) DO UPDATE SET
|
||||||
|
provider = excluded.provider,
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
category = excluded.category,
|
category = excluded.category,
|
||||||
downloaded = excluded.downloaded,
|
downloaded = excluded.downloaded,
|
||||||
@@ -1756,6 +1784,7 @@ class WatchlistStore:
|
|||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
item["show_id"],
|
item["show_id"],
|
||||||
|
normalize_provider(item.get("provider"), provider_bridge.parse_provider_id(item.get("show_id"), "anikoto")[0]),
|
||||||
item["title"],
|
item["title"],
|
||||||
normalize_watchlist_category(item.get("category")),
|
normalize_watchlist_category(item.get("category")),
|
||||||
1 if item.get("downloaded") else 0,
|
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"
|
title = str(payload.get("title") or "Unknown Anime").strip() or "Unknown Anime"
|
||||||
if not show_id:
|
if not show_id:
|
||||||
raise ValueError("Missing 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:
|
with self.lock, self._connect() as conn:
|
||||||
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone()
|
row = conn.execute("SELECT * FROM watchlist WHERE show_id = ?", (show_id,)).fetchone()
|
||||||
@@ -1982,6 +2012,7 @@ class WatchlistStore:
|
|||||||
item = {
|
item = {
|
||||||
**self._auto_download_defaults(title),
|
**self._auto_download_defaults(title),
|
||||||
"show_id": show_id,
|
"show_id": show_id,
|
||||||
|
"provider": provider,
|
||||||
"title": title,
|
"title": title,
|
||||||
"category": normalize_watchlist_category(payload.get("category")),
|
"category": normalize_watchlist_category(payload.get("category")),
|
||||||
"downloaded": False,
|
"downloaded": False,
|
||||||
|
|||||||
+26
@@ -1620,6 +1620,28 @@ class WatchlistCompletionTests(unittest.TestCase):
|
|||||||
self.assertIn("Sub ready.", message)
|
self.assertIn("Sub ready.", message)
|
||||||
self.assertNotIn("Dub 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):
|
def test_refresh_persists_expected_episode_total_and_ready_flags(self):
|
||||||
self.seed_watchlist_item()
|
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("const isFullyReady = item.sub_complete && item.dub_complete;", APP.WATCHLIST_HTML)
|
||||||
self.assertIn("card.className = watchlistCardClassName(item);", 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):
|
def test_watchlist_page_download_all_replaces_open_action(self):
|
||||||
self.assertIn("Download all", APP.WATCHLIST_HTML)
|
self.assertIn("Download all", APP.WATCHLIST_HTML)
|
||||||
self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML)
|
self.assertIn('api("/api/watchlist/download-all"', APP.WATCHLIST_HTML)
|
||||||
|
|||||||
@@ -910,6 +910,15 @@ WATCHLIST_HTML = r"""<!doctype html>
|
|||||||
container.appendChild(tag);
|
container.appendChild(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function providerLabel(provider) {
|
||||||
|
const labels = {
|
||||||
|
anikoto: "Anikoto",
|
||||||
|
anineko: "AniNeko",
|
||||||
|
pahe: "AnimePahe"
|
||||||
|
};
|
||||||
|
return labels[String(provider || "").toLowerCase()] || "Provider";
|
||||||
|
}
|
||||||
|
|
||||||
function categoryOptions(selected) {
|
function categoryOptions(selected) {
|
||||||
return Object.entries(categoryLabels)
|
return Object.entries(categoryLabels)
|
||||||
.map(([value, label]) => `<option value="${value}"${value === selected ? " selected" : ""}>${label}</option>`)
|
.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";
|
: "Finished, but not marked as downloaded";
|
||||||
}
|
}
|
||||||
const statusRow = card.querySelector(".status-row");
|
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.category_label || categoryLabels[item.category] || "Watching");
|
||||||
appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV");
|
appendStatusTag(statusRow, item.media_type_label || mediaTypeLabels[item.media_type] || "TV");
|
||||||
if (item.airing_status) {
|
if (item.airing_status) {
|
||||||
|
|||||||
Reference in New Issue
Block a user