kryten-webqueue 0.14.1__tar.gz → 0.15.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/CHANGELOG.md +18 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/api_gate/client.py +15 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/db.py +43 -3
- kryten_webqueue-0.15.0/kryten_webqueue/playlists/ordering.py +77 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_playlists.py +58 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/queue.py +8 -4
- kryten_webqueue-0.15.0/kryten_webqueue/routes/user.py +89 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/static/css/main.css +167 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/browse.html +80 -0
- kryten_webqueue-0.15.0/kryten_webqueue/templates/user/dashboard.html +449 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/pyproject.toml +1 -1
- kryten_webqueue-0.15.0/tests/test_save_results_to_playlist.py +141 -0
- kryten_webqueue-0.14.1/kryten_webqueue/routes/user.py +0 -35
- kryten_webqueue-0.14.1/kryten_webqueue/templates/user/dashboard.html +0 -126
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/.gitignore +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/README.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/config.example.json +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/tests/test_queue_announce.py +0 -0
|
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.0] — 2026-06-14
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Account progression panel on the Z-Coin dashboard.** The left column now surfaces the user's economy account in full: current rank/level, a progress bar toward the next milestone (remaining Z + percent), active perks (including spend discount), and their purchased vanity items. Powered by the new api-gate `GET /economy/account/{username}` endpoint (economy `account.summary`), proxied via `GET /user/account`.
|
|
14
|
+
- **Vanity item editing dialogs.** Inline **Edit** buttons open dialogs to purchase/update the custom greeting (textarea, 200-char limit) and custom chat color (native color picker synced with a 6-digit hex field). Backed by `POST /user/vanity/greeting` and `POST /user/vanity/color`, which proxy the economy `vanity.set_greeting` / `vanity.set_color` commands. The username is taken from the authenticated session, never the request body.
|
|
15
|
+
- **Transaction credit/debit filter.** The Recent Transactions column gains an All / Credits / Debits toggle and a **Load more** control, with friendlier titles derived from each transaction's `trigger_id` (e.g. `presence.base` → "Watching reward", `spend.vanity.chat_color` → "Custom color") instead of raw `earn` labels.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Queue history pagination.** `GET /queue/history` now accepts `limit`/`offset` and returns a `total` count; the dashboard's middle column paginates through the full history with Prev/Next controls instead of showing only the most recent 20 entries.
|
|
20
|
+
|
|
21
|
+
## [0.14.2] — 2026-06-13
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Save all search/browse results to a playlist.** The Browse/search results page now shows an admin-only **Save results to playlist** button. It appends every catalog item matching the current search query or browse facets (across all pages, honouring the hidden-items toggle) to a playlist of your choosing. Where a season/episode marker is detectable in the title (`S01E02`, `1x02`, `Season 1 Episode 2`, …) items are laid out in proper series → season → episode order; everything else falls back to a stable alphabetical placement. Items already in the target playlist are skipped, so re-running is idempotent. Backend: `POST /admin/playlists/{id}/append-results` (admin-only) plus a de-duplicating bulk `Database.append_playlist_items` and a pure `playlists.ordering` helper.
|
|
26
|
+
|
|
9
27
|
## [0.14.1] — 2026-06-13
|
|
10
28
|
|
|
11
29
|
### Fixed
|
|
@@ -93,6 +93,21 @@ class ApiGateClient:
|
|
|
93
93
|
async def get_transactions(self, username: str, limit: int = 20, offset: int = 0) -> dict:
|
|
94
94
|
return await self.get(f"/economy/transactions/{username}", limit=limit, offset=offset)
|
|
95
95
|
|
|
96
|
+
async def get_account_summary(self, username: str) -> dict:
|
|
97
|
+
return await self.get(f"/economy/account/{username}")
|
|
98
|
+
|
|
99
|
+
async def set_vanity_greeting(self, username: str, value: str) -> dict:
|
|
100
|
+
return await self.post("/economy/vanity/greeting", json={
|
|
101
|
+
"username": username,
|
|
102
|
+
"value": value,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
async def set_vanity_color(self, username: str, value: str) -> dict:
|
|
106
|
+
return await self.post("/economy/vanity/color", json={
|
|
107
|
+
"username": username,
|
|
108
|
+
"value": value,
|
|
109
|
+
})
|
|
110
|
+
|
|
96
111
|
async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
|
|
97
112
|
return await self.post("/economy/queue-preview", json={
|
|
98
113
|
"username": username,
|
|
@@ -893,12 +893,19 @@ class Database:
|
|
|
893
893
|
[username, friendly_token, title, tier, z_cost],
|
|
894
894
|
)
|
|
895
895
|
|
|
896
|
-
async def get_user_queue_history(self, username: str, limit: int = 50) -> list[dict]:
|
|
896
|
+
async def get_user_queue_history(self, username: str, limit: int = 50, offset: int = 0) -> list[dict]:
|
|
897
897
|
return await self._fetch_all(
|
|
898
|
-
"SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ?",
|
|
899
|
-
[username, limit],
|
|
898
|
+
"SELECT * FROM queue_history WHERE username=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
|
899
|
+
[username, limit, offset],
|
|
900
900
|
)
|
|
901
901
|
|
|
902
|
+
async def get_user_queue_history_count(self, username: str) -> int:
|
|
903
|
+
row = await self._fetch_one(
|
|
904
|
+
"SELECT COUNT(*) AS c FROM queue_history WHERE username=?",
|
|
905
|
+
[username],
|
|
906
|
+
)
|
|
907
|
+
return int(row["c"]) if row else 0
|
|
908
|
+
|
|
902
909
|
# --- Saved playlists ---
|
|
903
910
|
|
|
904
911
|
async def get_saved_playlists(self) -> list[dict]:
|
|
@@ -980,6 +987,39 @@ class Database:
|
|
|
980
987
|
await self._db.commit()
|
|
981
988
|
return count
|
|
982
989
|
|
|
990
|
+
async def append_playlist_items(self, playlist_id: int, items: list[dict]) -> int:
|
|
991
|
+
"""Append many items to the end of a playlist, skipping any whose
|
|
992
|
+
``media_id`` is already present. Returns the number actually added."""
|
|
993
|
+
existing_rows = await self._fetch_all(
|
|
994
|
+
"SELECT media_id FROM saved_playlist_items WHERE playlist_id=?", [playlist_id]
|
|
995
|
+
)
|
|
996
|
+
seen = {r["media_id"] for r in existing_rows}
|
|
997
|
+
row = await self._fetch_one(
|
|
998
|
+
"SELECT COALESCE(MAX(position), -1) AS pos FROM saved_playlist_items WHERE playlist_id=?",
|
|
999
|
+
[playlist_id],
|
|
1000
|
+
)
|
|
1001
|
+
next_pos = (row["pos"] + 1) if row else 0
|
|
1002
|
+
added = 0
|
|
1003
|
+
for item in items:
|
|
1004
|
+
media_id = item.get("media_id")
|
|
1005
|
+
if not media_id or media_id in seen:
|
|
1006
|
+
continue
|
|
1007
|
+
seen.add(media_id)
|
|
1008
|
+
await self._db.execute(
|
|
1009
|
+
"INSERT INTO saved_playlist_items (playlist_id, position, media_type, media_id, title, duration_sec) "
|
|
1010
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
1011
|
+
[playlist_id, next_pos, item.get("media_type", "cm"), media_id,
|
|
1012
|
+
item.get("title"), item.get("duration_sec")],
|
|
1013
|
+
)
|
|
1014
|
+
next_pos += 1
|
|
1015
|
+
added += 1
|
|
1016
|
+
if added:
|
|
1017
|
+
await self._db.execute(
|
|
1018
|
+
"UPDATE saved_playlists SET updated_at=datetime('now') WHERE id=?", [playlist_id]
|
|
1019
|
+
)
|
|
1020
|
+
await self._db.commit()
|
|
1021
|
+
return added
|
|
1022
|
+
|
|
983
1023
|
async def get_most_recent_playlist(self, created_by: str) -> dict | None:
|
|
984
1024
|
"""The given admin's most recently *created* saved playlist, if any."""
|
|
985
1025
|
return await self._fetch_one(
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Season/episode aware ordering for playlists built from search results (0.14.2).
|
|
2
|
+
|
|
3
|
+
When an admin saves a whole result set to a playlist we try to lay episodic
|
|
4
|
+
content out in natural watch order: grouped by series, then by season, then by
|
|
5
|
+
episode. Items with no detectable season/episode marker fall back to an
|
|
6
|
+
alphabetical-by-title placement, and ties always preserve the original input
|
|
7
|
+
order (a stable sort) so the behaviour is deterministic.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# Ordered most-specific first. Each pattern must expose season as group 1 and
|
|
13
|
+
# episode as group 2.
|
|
14
|
+
_SE_PATTERNS = [
|
|
15
|
+
# S01E02, s1e2, S01 E02, S01.E02, S01-E02
|
|
16
|
+
re.compile(r"[Ss](\d{1,2})\s*[._\- ]?\s*[Ee](\d{1,3})"),
|
|
17
|
+
# Season 1 Episode 2 / Season 1, Episode 02
|
|
18
|
+
re.compile(r"[Ss]eason\s*(\d{1,2}).*?[Ee]pisode\s*(\d{1,3})"),
|
|
19
|
+
# 1x02, 01x003
|
|
20
|
+
re.compile(r"\b(\d{1,2})[Xx](\d{1,3})\b"),
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_season_episode(title: str | None) -> tuple[int, int, int, int] | None:
|
|
25
|
+
"""Parse a season/episode marker from ``title``.
|
|
26
|
+
|
|
27
|
+
Returns ``(season, episode, match_start, match_end)`` or ``None`` when no
|
|
28
|
+
recognised marker is present.
|
|
29
|
+
"""
|
|
30
|
+
if not title:
|
|
31
|
+
return None
|
|
32
|
+
for pattern in _SE_PATTERNS:
|
|
33
|
+
m = pattern.search(title)
|
|
34
|
+
if not m:
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
season = int(m.group(1))
|
|
38
|
+
episode = int(m.group(2))
|
|
39
|
+
except (TypeError, ValueError):
|
|
40
|
+
continue
|
|
41
|
+
return season, episode, m.start(), m.end()
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _series_base(title: str, match_start: int) -> str:
|
|
46
|
+
"""Best-effort series name: the text before the season/episode marker.
|
|
47
|
+
|
|
48
|
+
Falls back to the whole title when the marker sits at the very start.
|
|
49
|
+
"""
|
|
50
|
+
base = title[:match_start].strip(" \t-–—_.:|")
|
|
51
|
+
return (base or title).strip().lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def episode_sort_key(item: dict, index: int) -> tuple:
|
|
55
|
+
"""Sort key for a catalog item.
|
|
56
|
+
|
|
57
|
+
Episodic items group under their series name then sort by season/episode.
|
|
58
|
+
Non-episodic items sort by their (lowercased) title. ``index`` is appended
|
|
59
|
+
so equal keys retain the caller's original ordering.
|
|
60
|
+
"""
|
|
61
|
+
title = (item.get("title") or "").strip()
|
|
62
|
+
parsed = parse_season_episode(title)
|
|
63
|
+
if parsed:
|
|
64
|
+
season, episode, start, _ = parsed
|
|
65
|
+
return (_series_base(title, start), season, episode, index)
|
|
66
|
+
return (title.lower(), 0, 0, index)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def order_for_playlist(items: list[dict]) -> list[dict]:
|
|
70
|
+
"""Return ``items`` ordered for a playlist (season/episode aware, stable)."""
|
|
71
|
+
return [
|
|
72
|
+
item
|
|
73
|
+
for _, item in sorted(
|
|
74
|
+
enumerate(items),
|
|
75
|
+
key=lambda pair: episode_sort_key(pair[1], pair[0]),
|
|
76
|
+
)
|
|
77
|
+
]
|
|
@@ -166,6 +166,64 @@ async def append_item(request: Request, playlist_id: int, user: dict = Depends(r
|
|
|
166
166
|
return {"success": True, "playlist_id": playlist_id, "name": playlist["name"], "count": count}
|
|
167
167
|
|
|
168
168
|
|
|
169
|
+
@router.post("/{playlist_id}/append-results")
|
|
170
|
+
async def append_results(request: Request, playlist_id: int, user: dict = Depends(require_admin)):
|
|
171
|
+
"""Append every catalog item matching the current browse/search filters to a
|
|
172
|
+
playlist (0.14.2).
|
|
173
|
+
|
|
174
|
+
The browse/search facets are sent in the body so the server re-runs the same
|
|
175
|
+
(unpaginated) query the admin is looking at. Items are laid out in
|
|
176
|
+
season/episode order where a marker is detectable in the title, and any item
|
|
177
|
+
already present in the playlist is skipped.
|
|
178
|
+
"""
|
|
179
|
+
from ..playlists.ordering import order_for_playlist
|
|
180
|
+
|
|
181
|
+
body = await request.json()
|
|
182
|
+
db = request.app.state.db
|
|
183
|
+
playlist = await db.get_saved_playlist(playlist_id)
|
|
184
|
+
if not playlist:
|
|
185
|
+
raise HTTPException(404, "Playlist not found")
|
|
186
|
+
|
|
187
|
+
mode = body.get("mode", "browse")
|
|
188
|
+
show_hidden = bool(body.get("show_hidden"))
|
|
189
|
+
sort = body.get("sort") or "default"
|
|
190
|
+
|
|
191
|
+
if mode == "search":
|
|
192
|
+
q = (body.get("q") or "").strip()
|
|
193
|
+
if not q:
|
|
194
|
+
raise HTTPException(400, "Query required for search results")
|
|
195
|
+
total = await db.search_count(q, show_hidden=show_hidden)
|
|
196
|
+
items = await db.search(q, page=1, per_page=max(total, 1),
|
|
197
|
+
show_hidden=show_hidden, sort=sort)
|
|
198
|
+
else:
|
|
199
|
+
category = body.get("category") or None
|
|
200
|
+
tag = body.get("tag") or None
|
|
201
|
+
total = await db.browse_count(category=category, tag=tag, show_hidden=show_hidden)
|
|
202
|
+
items = await db.browse(category=category, tag=tag, page=1, per_page=max(total, 1),
|
|
203
|
+
show_hidden=show_hidden, sort=sort)
|
|
204
|
+
|
|
205
|
+
ordered = order_for_playlist(items)
|
|
206
|
+
playlist_items = [
|
|
207
|
+
{
|
|
208
|
+
"media_type": "cm",
|
|
209
|
+
"media_id": it["manifest_url"],
|
|
210
|
+
"title": it.get("title"),
|
|
211
|
+
"duration_sec": it.get("duration_sec"),
|
|
212
|
+
}
|
|
213
|
+
for it in ordered
|
|
214
|
+
if it.get("manifest_url")
|
|
215
|
+
]
|
|
216
|
+
added = await db.append_playlist_items(playlist_id, playlist_items)
|
|
217
|
+
count = len(await db.get_saved_playlist_items(playlist_id))
|
|
218
|
+
return {
|
|
219
|
+
"success": True,
|
|
220
|
+
"playlist_id": playlist_id,
|
|
221
|
+
"name": playlist["name"],
|
|
222
|
+
"added": added,
|
|
223
|
+
"count": count,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
169
227
|
@router.post("/parse-text")
|
|
170
228
|
async def parse_text(request: Request, user: dict = Depends(require_admin)):
|
|
171
229
|
"""Parse the plain-text playlist import format into resolved items.
|
|
@@ -223,11 +223,15 @@ async def cost_preview(request: Request, friendly_token: str, tier: str = "queue
|
|
|
223
223
|
|
|
224
224
|
|
|
225
225
|
@router.get("/history")
|
|
226
|
-
async def queue_history(request: Request,
|
|
227
|
-
|
|
226
|
+
async def queue_history(request: Request, limit: int = 20, offset: int = 0,
|
|
227
|
+
user: dict = Depends(get_current_user)):
|
|
228
|
+
"""Get user's queue history (paginated, most recent first)."""
|
|
229
|
+
limit = max(1, min(limit, 100))
|
|
230
|
+
offset = max(0, offset)
|
|
228
231
|
db = request.app.state.db
|
|
229
|
-
|
|
230
|
-
|
|
232
|
+
items = await db.get_user_queue_history(user["username"], limit=limit, offset=offset)
|
|
233
|
+
total = await db.get_user_queue_history_count(user["username"])
|
|
234
|
+
return {"items": items, "total": total, "limit": limit, "offset": offset}
|
|
231
235
|
|
|
232
236
|
|
|
233
237
|
@router.get("/next-schedule")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from ..auth.session import get_current_user
|
|
5
|
+
|
|
6
|
+
router = APIRouter(prefix="/user", tags=["user"])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@router.get("/balance")
|
|
10
|
+
async def get_balance(request: Request, user: dict = Depends(get_current_user)):
|
|
11
|
+
"""Get user's economy balance."""
|
|
12
|
+
api_gate = request.app.state.api_gate
|
|
13
|
+
return await api_gate.get_balance(user["username"])
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@router.get("/account")
|
|
17
|
+
async def get_account(request: Request, user: dict = Depends(get_current_user)):
|
|
18
|
+
"""Get the user's full economy account summary (rank, progress, perks, vanity)."""
|
|
19
|
+
api_gate = request.app.state.api_gate
|
|
20
|
+
return await api_gate.get_account_summary(user["username"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GreetingUpdate(BaseModel):
|
|
24
|
+
value: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ColorUpdate(BaseModel):
|
|
28
|
+
value: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/vanity/greeting")
|
|
32
|
+
async def set_vanity_greeting(
|
|
33
|
+
body: GreetingUpdate, request: Request, user: dict = Depends(get_current_user)
|
|
34
|
+
):
|
|
35
|
+
"""Purchase/update the current user's custom greeting."""
|
|
36
|
+
api_gate = request.app.state.api_gate
|
|
37
|
+
try:
|
|
38
|
+
return await api_gate.set_vanity_greeting(user["username"], body.value)
|
|
39
|
+
except Exception as exc: # noqa: BLE001
|
|
40
|
+
raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.post("/vanity/color")
|
|
44
|
+
async def set_vanity_color(
|
|
45
|
+
body: ColorUpdate, request: Request, user: dict = Depends(get_current_user)
|
|
46
|
+
):
|
|
47
|
+
"""Purchase/update the current user's custom chat color (6-digit hex)."""
|
|
48
|
+
api_gate = request.app.state.api_gate
|
|
49
|
+
try:
|
|
50
|
+
return await api_gate.set_vanity_color(user["username"], body.value)
|
|
51
|
+
except Exception as exc: # noqa: BLE001
|
|
52
|
+
raise HTTPException(status_code=400, detail=_economy_error(exc)) from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _economy_error(exc: Exception) -> str:
|
|
56
|
+
"""Extract a human-readable message from an api-gate HTTP error."""
|
|
57
|
+
import httpx
|
|
58
|
+
|
|
59
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
60
|
+
try:
|
|
61
|
+
detail = exc.response.json().get("detail")
|
|
62
|
+
if detail:
|
|
63
|
+
return str(detail)
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
pass
|
|
66
|
+
return "Purchase failed. Please try again."
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/transactions")
|
|
70
|
+
async def get_transactions(request: Request, limit: int = 20, offset: int = 0,
|
|
71
|
+
user: dict = Depends(get_current_user)):
|
|
72
|
+
"""Get user's transaction history."""
|
|
73
|
+
api_gate = request.app.state.api_gate
|
|
74
|
+
return await api_gate.get_transactions(user["username"], limit=limit, offset=offset)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@router.get("/profile")
|
|
78
|
+
async def get_profile(request: Request, user: dict = Depends(get_current_user)):
|
|
79
|
+
"""Get current user profile info from api-gate."""
|
|
80
|
+
api_gate = request.app.state.api_gate
|
|
81
|
+
try:
|
|
82
|
+
user_data = await api_gate.get_user(user["username"])
|
|
83
|
+
except Exception:
|
|
84
|
+
user_data = {}
|
|
85
|
+
return {
|
|
86
|
+
"username": user["username"],
|
|
87
|
+
"rank": user["rank"],
|
|
88
|
+
"online": user_data.get("online", False) if user_data else False,
|
|
89
|
+
}
|
|
@@ -150,6 +150,9 @@ a:hover {
|
|
|
150
150
|
.admin-hidden-notice a {
|
|
151
151
|
font-weight: 600;
|
|
152
152
|
}
|
|
153
|
+
.admin-results-actions {
|
|
154
|
+
margin-top: 0.85rem;
|
|
155
|
+
}
|
|
153
156
|
.search-form {
|
|
154
157
|
display: flex;
|
|
155
158
|
gap: 0.5rem;
|
|
@@ -787,6 +790,170 @@ a.np-chip {
|
|
|
787
790
|
color: var(--danger);
|
|
788
791
|
}
|
|
789
792
|
|
|
793
|
+
/* Account rank + progression (dashboard left column) */
|
|
794
|
+
.account-rank {
|
|
795
|
+
margin-top: 1.25rem;
|
|
796
|
+
padding-top: 1.25rem;
|
|
797
|
+
border-top: 1px solid var(--border);
|
|
798
|
+
}
|
|
799
|
+
.rank-name {
|
|
800
|
+
font-size: 1.1rem;
|
|
801
|
+
font-weight: 700;
|
|
802
|
+
}
|
|
803
|
+
.rank-level {
|
|
804
|
+
font-size: 0.8rem;
|
|
805
|
+
color: var(--text-secondary);
|
|
806
|
+
margin-top: 0.15rem;
|
|
807
|
+
}
|
|
808
|
+
.rank-progress {
|
|
809
|
+
margin-top: 0.85rem;
|
|
810
|
+
}
|
|
811
|
+
.rank-progress-head, .rank-progress-foot {
|
|
812
|
+
display: flex;
|
|
813
|
+
justify-content: space-between;
|
|
814
|
+
font-size: 0.8rem;
|
|
815
|
+
color: var(--text-secondary);
|
|
816
|
+
}
|
|
817
|
+
.rank-progress-head {
|
|
818
|
+
margin-bottom: 0.35rem;
|
|
819
|
+
}
|
|
820
|
+
.rank-progress-foot {
|
|
821
|
+
margin-top: 0.35rem;
|
|
822
|
+
}
|
|
823
|
+
.progress-track {
|
|
824
|
+
height: 8px;
|
|
825
|
+
border-radius: 999px;
|
|
826
|
+
background: var(--bg-secondary, #1a1a24);
|
|
827
|
+
overflow: hidden;
|
|
828
|
+
border: 1px solid var(--border);
|
|
829
|
+
}
|
|
830
|
+
.progress-fill {
|
|
831
|
+
height: 100%;
|
|
832
|
+
background: linear-gradient(90deg, var(--accent), var(--accent-hover));
|
|
833
|
+
transition: width 0.4s ease;
|
|
834
|
+
}
|
|
835
|
+
.account-perks {
|
|
836
|
+
margin-top: 1.25rem;
|
|
837
|
+
}
|
|
838
|
+
.account-perks h3 {
|
|
839
|
+
font-size: 0.85rem;
|
|
840
|
+
color: var(--text-secondary);
|
|
841
|
+
margin-bottom: 0.4rem;
|
|
842
|
+
}
|
|
843
|
+
.perk-list {
|
|
844
|
+
list-style: none;
|
|
845
|
+
padding: 0;
|
|
846
|
+
margin: 0;
|
|
847
|
+
display: flex;
|
|
848
|
+
flex-direction: column;
|
|
849
|
+
gap: 0.2rem;
|
|
850
|
+
}
|
|
851
|
+
.perk-list li {
|
|
852
|
+
font-size: 0.85rem;
|
|
853
|
+
}
|
|
854
|
+
.perk-list li::before {
|
|
855
|
+
content: "✓ ";
|
|
856
|
+
color: var(--success);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/* Vanity items (dashboard left column) */
|
|
860
|
+
.vanity-section {
|
|
861
|
+
margin-top: 1.25rem;
|
|
862
|
+
padding-top: 1.25rem;
|
|
863
|
+
border-top: 1px solid var(--border);
|
|
864
|
+
}
|
|
865
|
+
.vanity-section h3 {
|
|
866
|
+
font-size: 0.85rem;
|
|
867
|
+
color: var(--text-secondary);
|
|
868
|
+
margin-bottom: 0.6rem;
|
|
869
|
+
}
|
|
870
|
+
.vanity-item {
|
|
871
|
+
margin-bottom: 0.85rem;
|
|
872
|
+
}
|
|
873
|
+
.vanity-row {
|
|
874
|
+
display: flex;
|
|
875
|
+
align-items: center;
|
|
876
|
+
justify-content: space-between;
|
|
877
|
+
gap: 0.5rem;
|
|
878
|
+
}
|
|
879
|
+
.vanity-label {
|
|
880
|
+
font-size: 0.85rem;
|
|
881
|
+
font-weight: 600;
|
|
882
|
+
}
|
|
883
|
+
.vanity-value {
|
|
884
|
+
font-size: 0.85rem;
|
|
885
|
+
color: var(--text-primary);
|
|
886
|
+
margin-top: 0.25rem;
|
|
887
|
+
word-break: break-word;
|
|
888
|
+
display: flex;
|
|
889
|
+
align-items: center;
|
|
890
|
+
gap: 0.4rem;
|
|
891
|
+
}
|
|
892
|
+
.vanity-unset {
|
|
893
|
+
color: var(--text-secondary);
|
|
894
|
+
font-style: italic;
|
|
895
|
+
}
|
|
896
|
+
.color-swatch {
|
|
897
|
+
display: inline-block;
|
|
898
|
+
width: 1rem;
|
|
899
|
+
height: 1rem;
|
|
900
|
+
border-radius: 3px;
|
|
901
|
+
border: 1px solid var(--border);
|
|
902
|
+
flex-shrink: 0;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/* Color picker dialog */
|
|
906
|
+
.color-picker-row {
|
|
907
|
+
display: flex;
|
|
908
|
+
align-items: center;
|
|
909
|
+
gap: 0.75rem;
|
|
910
|
+
margin: 0.5rem 0 1rem;
|
|
911
|
+
}
|
|
912
|
+
.color-picker-row input[type="color"] {
|
|
913
|
+
width: 48px;
|
|
914
|
+
height: 40px;
|
|
915
|
+
padding: 0;
|
|
916
|
+
border: 1px solid var(--border);
|
|
917
|
+
border-radius: var(--radius);
|
|
918
|
+
background: transparent;
|
|
919
|
+
cursor: pointer;
|
|
920
|
+
}
|
|
921
|
+
.color-hex {
|
|
922
|
+
flex: 1;
|
|
923
|
+
text-transform: uppercase;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/* Transaction filter toggle */
|
|
927
|
+
.tx-toggle {
|
|
928
|
+
display: flex;
|
|
929
|
+
gap: 0.25rem;
|
|
930
|
+
margin: 0.5rem 0 0.75rem;
|
|
931
|
+
}
|
|
932
|
+
.tx-toggle-btn {
|
|
933
|
+
flex: 1;
|
|
934
|
+
padding: 0.35rem 0.5rem;
|
|
935
|
+
font-size: 0.8rem;
|
|
936
|
+
background: var(--bg-secondary, #1a1a24);
|
|
937
|
+
color: var(--text-secondary);
|
|
938
|
+
border: 1px solid var(--border);
|
|
939
|
+
border-radius: var(--radius);
|
|
940
|
+
cursor: pointer;
|
|
941
|
+
}
|
|
942
|
+
.tx-toggle-btn.active {
|
|
943
|
+
background: var(--accent);
|
|
944
|
+
border-color: var(--accent);
|
|
945
|
+
color: #fff;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/* Dashboard history / transaction pagers */
|
|
949
|
+
.history-pager, .tx-pager {
|
|
950
|
+
display: flex;
|
|
951
|
+
align-items: center;
|
|
952
|
+
justify-content: center;
|
|
953
|
+
gap: 0.5rem;
|
|
954
|
+
margin-top: 1rem;
|
|
955
|
+
}
|
|
956
|
+
|
|
790
957
|
/* Admin */
|
|
791
958
|
.admin-nav {
|
|
792
959
|
display: flex;
|
{kryten_webqueue-0.14.1 → kryten_webqueue-0.15.0}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
<a href="{{ request.url.include_query_params(show_hidden=1) }}">Show hidden items?</a>
|
|
40
40
|
{% endif %}
|
|
41
41
|
</div>
|
|
42
|
+
<div class="admin-results-actions">
|
|
43
|
+
<button class="btn btn-sm btn-admin" onclick="saveResultsToPlaylist()">Save results to playlist</button>
|
|
44
|
+
</div>
|
|
42
45
|
{% endif %}
|
|
43
46
|
</div>
|
|
44
47
|
|
|
@@ -233,6 +236,83 @@ async function submitAddToPlaylist(playlistId, token) {
|
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
// --- Save all current results to a playlist (rank >= 3) ---
|
|
240
|
+
|
|
241
|
+
async function saveResultsToPlaylist() {
|
|
242
|
+
let playlists = [];
|
|
243
|
+
try {
|
|
244
|
+
const resp = await fetch('/admin/playlists/', { credentials: 'same-origin' });
|
|
245
|
+
if (resp.ok) playlists = await resp.json();
|
|
246
|
+
} catch (e) { /* handled below */ }
|
|
247
|
+
if (!playlists.length) {
|
|
248
|
+
showToast('No playlists yet — create one first', 'error');
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
showResultsPlaylistPicker(playlists);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function showResultsPlaylistPicker(playlists) {
|
|
255
|
+
closePlaylistPickerModal();
|
|
256
|
+
const overlay = document.createElement('div');
|
|
257
|
+
overlay.id = 'playlist-picker-modal';
|
|
258
|
+
overlay.className = 'modal-overlay';
|
|
259
|
+
const opts = playlists.map(p => `<option value="${p.id}">${escapeHtml(p.name)}</option>`).join('');
|
|
260
|
+
const scope = CURRENT_QUERY ? 'search' : 'filter';
|
|
261
|
+
overlay.innerHTML = `
|
|
262
|
+
<div class="modal-box" role="dialog" aria-modal="true">
|
|
263
|
+
<h3>Save all results to playlist</h3>
|
|
264
|
+
<p class="muted">Every item matching the current ${scope} will be appended, ordered by season/episode where detected. Items already in the playlist are skipped.</p>
|
|
265
|
+
<div class="field">
|
|
266
|
+
<label for="playlist-picker-select">Playlist</label>
|
|
267
|
+
<select id="playlist-picker-select">${opts}</select>
|
|
268
|
+
</div>
|
|
269
|
+
<div class="modal-actions">
|
|
270
|
+
<button class="btn btn-secondary" data-action="cancel">Cancel</button>
|
|
271
|
+
<button class="btn btn-primary" data-action="save">Save results</button>
|
|
272
|
+
</div>
|
|
273
|
+
</div>`;
|
|
274
|
+
overlay.addEventListener('click', (e) => {
|
|
275
|
+
if (e.target === overlay) closePlaylistPickerModal();
|
|
276
|
+
const action = e.target.getAttribute('data-action');
|
|
277
|
+
if (action === 'cancel') closePlaylistPickerModal();
|
|
278
|
+
if (action === 'save') {
|
|
279
|
+
const pid = document.getElementById('playlist-picker-select').value;
|
|
280
|
+
closePlaylistPickerModal();
|
|
281
|
+
submitSaveResults(pid);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
document.body.appendChild(overlay);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function submitSaveResults(playlistId) {
|
|
288
|
+
const url = new URL(window.location.href);
|
|
289
|
+
const isSearch = url.pathname.indexOf('/catalog/search') !== -1;
|
|
290
|
+
const body = {
|
|
291
|
+
mode: isSearch ? 'search' : 'browse',
|
|
292
|
+
q: url.searchParams.get('q') || '',
|
|
293
|
+
category: url.searchParams.get('category') || '',
|
|
294
|
+
tag: url.searchParams.get('tag') || '',
|
|
295
|
+
sort: url.searchParams.get('sort') || 'default',
|
|
296
|
+
show_hidden: url.searchParams.get('show_hidden') ? 1 : 0,
|
|
297
|
+
};
|
|
298
|
+
showToast('Saving results…');
|
|
299
|
+
try {
|
|
300
|
+
const resp = await fetch(`/admin/playlists/${playlistId}/append-results`, {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: { 'Content-Type': 'application/json' },
|
|
303
|
+
credentials: 'same-origin',
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
});
|
|
306
|
+
const data = await resp.json();
|
|
307
|
+
showToast(
|
|
308
|
+
resp.ok ? `Added ${data.added} item(s) — playlist now ${data.count}`
|
|
309
|
+
: (data.detail || `Failed (${resp.status})`),
|
|
310
|
+
resp.ok ? 'success' : 'error');
|
|
311
|
+
} catch (e) {
|
|
312
|
+
showToast(`Network error: ${e.message}`, 'error');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
236
316
|
async function addToRecentPlaylist(token) {
|
|
237
317
|
try {
|
|
238
318
|
const resp = await fetch('/admin/playlists/recent/append', {
|