kryten-webqueue 0.3.2__tar.gz → 0.4.1__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.3.2 → kryten_webqueue-0.4.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/db.py +42 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/images.py +86 -28
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/pages.py +30 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/static/css/main.css +111 -13
- kryten_webqueue-0.4.1/kryten_webqueue/templates/admin/playlists.html +14 -0
- kryten_webqueue-0.4.1/kryten_webqueue/templates/admin/queue_mgmt.html +14 -0
- kryten_webqueue-0.4.1/kryten_webqueue/templates/admin/schedules.html +14 -0
- kryten_webqueue-0.4.1/kryten_webqueue/templates/catalog/browse.html +130 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/queue/index.html +54 -15
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/user/dashboard.html +45 -6
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/pyproject.toml +1 -1
- kryten_webqueue-0.3.2/kryten_webqueue/templates/catalog/browse.html +0 -108
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/.gitignore +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/README.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/config.example.json +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -164,6 +164,11 @@ MIGRATIONS = [
|
|
|
164
164
|
);
|
|
165
165
|
CREATE INDEX IF NOT EXISTS idx_queue_history_user ON queue_history(username);
|
|
166
166
|
""",
|
|
167
|
+
# v3: Clear all cached cover art to force a full repoll (fixes TMDB poster/person
|
|
168
|
+
# preference and /static/images -> /images path correction)
|
|
169
|
+
"""
|
|
170
|
+
UPDATE catalog SET cover_art_path = NULL, cover_art_source = NULL;
|
|
171
|
+
""",
|
|
167
172
|
]
|
|
168
173
|
|
|
169
174
|
|
|
@@ -242,6 +247,28 @@ class Database:
|
|
|
242
247
|
params.extend([per_page, (page - 1) * per_page])
|
|
243
248
|
return await self._fetch_all(query, params)
|
|
244
249
|
|
|
250
|
+
async def browse_count(self, *, category: str | None = None) -> int:
|
|
251
|
+
query = """
|
|
252
|
+
SELECT COUNT(*) as cnt FROM catalog c
|
|
253
|
+
WHERE c.friendly_token NOT IN (
|
|
254
|
+
SELECT spi.media_id FROM saved_playlist_items spi
|
|
255
|
+
JOIN saved_playlists sp ON spi.playlist_id = sp.id
|
|
256
|
+
WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
|
|
257
|
+
)
|
|
258
|
+
"""
|
|
259
|
+
params: list = []
|
|
260
|
+
if category:
|
|
261
|
+
query += """
|
|
262
|
+
AND c.friendly_token IN (
|
|
263
|
+
SELECT cc.friendly_token FROM catalog_categories cc
|
|
264
|
+
JOIN categories cat ON cc.category_id = cat.id
|
|
265
|
+
WHERE cat.slug = ?
|
|
266
|
+
)
|
|
267
|
+
"""
|
|
268
|
+
params.append(category)
|
|
269
|
+
row = await self._fetch_one(query, params)
|
|
270
|
+
return row["cnt"] if row else 0
|
|
271
|
+
|
|
245
272
|
async def search(self, query_text: str, *, page: int = 1, per_page: int = 24) -> list[dict]:
|
|
246
273
|
sql = """
|
|
247
274
|
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url,
|
|
@@ -259,6 +286,21 @@ class Database:
|
|
|
259
286
|
"""
|
|
260
287
|
return await self._fetch_all(sql, [query_text, per_page, (page - 1) * per_page])
|
|
261
288
|
|
|
289
|
+
async def search_count(self, query_text: str) -> int:
|
|
290
|
+
sql = """
|
|
291
|
+
SELECT COUNT(*) as cnt
|
|
292
|
+
FROM catalog_fts fts
|
|
293
|
+
JOIN catalog c ON c.rowid = fts.rowid
|
|
294
|
+
WHERE catalog_fts MATCH ?
|
|
295
|
+
AND c.friendly_token NOT IN (
|
|
296
|
+
SELECT spi.media_id FROM saved_playlist_items spi
|
|
297
|
+
JOIN saved_playlists sp ON spi.playlist_id = sp.id
|
|
298
|
+
WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
|
|
299
|
+
)
|
|
300
|
+
"""
|
|
301
|
+
row = await self._fetch_one(sql, [query_text])
|
|
302
|
+
return row["cnt"] if row else 0
|
|
303
|
+
|
|
262
304
|
async def get_item(self, friendly_token: str) -> dict | None:
|
|
263
305
|
sql = """
|
|
264
306
|
SELECT * FROM catalog
|
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import io
|
|
2
4
|
import logging
|
|
5
|
+
import re
|
|
3
6
|
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
4
9
|
from PIL import Image
|
|
5
|
-
import io
|
|
6
|
-
import hashlib
|
|
7
10
|
|
|
8
11
|
logger = logging.getLogger(__name__)
|
|
9
12
|
|
|
10
13
|
|
|
14
|
+
def _clean_title(title: str) -> tuple[str, str | None]:
|
|
15
|
+
"""Return (cleaned_title, year_or_None) stripping common noise."""
|
|
16
|
+
# Extract 4-digit year in parens or at end: "Title (2019)" or "Title 2019"
|
|
17
|
+
year = None
|
|
18
|
+
m = re.search(r"\b((?:19|20)\d{2})\b", title)
|
|
19
|
+
if m:
|
|
20
|
+
year = m.group(1)
|
|
21
|
+
# Remove year, episode tags, resolution tags, etc.
|
|
22
|
+
cleaned = re.sub(r"\s*[\(\[]?(?:19|20)\d{2}[\)\]]?", "", title)
|
|
23
|
+
cleaned = re.sub(r"\s*[Ss]\d{1,2}[Ee]\d{1,2}.*", "", cleaned)
|
|
24
|
+
cleaned = re.sub(r"\s*\b(?:720p|1080p|2160p|4K|HDR|BluRay|BDRip|WEB[-.]?DL|HDTV)\b.*", "", cleaned, flags=re.IGNORECASE)
|
|
25
|
+
cleaned = cleaned.strip(" .-")
|
|
26
|
+
return cleaned or title, year
|
|
27
|
+
|
|
28
|
+
|
|
11
29
|
class CoverArtResolver:
|
|
12
30
|
"""Downloads and caches cover art for catalog items."""
|
|
13
31
|
|
|
@@ -37,9 +55,9 @@ class CoverArtResolver:
|
|
|
37
55
|
logger.warning("No TMDB or OMDB API keys configured — cover art lookup skipped")
|
|
38
56
|
return None
|
|
39
57
|
|
|
40
|
-
# Try TMDB first
|
|
41
58
|
image_url = None
|
|
42
59
|
source = None
|
|
60
|
+
|
|
43
61
|
if self._tmdb_key:
|
|
44
62
|
image_url = await self._search_tmdb(title)
|
|
45
63
|
if image_url:
|
|
@@ -47,6 +65,7 @@ class CoverArtResolver:
|
|
|
47
65
|
logger.debug(f"TMDB found art for {friendly_token!r}: {title!r}")
|
|
48
66
|
else:
|
|
49
67
|
logger.debug(f"TMDB found no art for {friendly_token!r}: {title!r}")
|
|
68
|
+
|
|
50
69
|
if not image_url and self._omdb_key:
|
|
51
70
|
image_url = await self._search_omdb(title)
|
|
52
71
|
if image_url:
|
|
@@ -55,10 +74,15 @@ class CoverArtResolver:
|
|
|
55
74
|
else:
|
|
56
75
|
logger.debug(f"OMDB found no art for {friendly_token!r}: {title!r}")
|
|
57
76
|
|
|
77
|
+
# Last resort: use the MediaCMS thumbnail already in the DB
|
|
78
|
+
if not image_url and existing and existing.get("thumbnail_url"):
|
|
79
|
+
image_url = existing["thumbnail_url"]
|
|
80
|
+
source = "thumbnail"
|
|
81
|
+
logger.debug(f"Falling back to thumbnail for {friendly_token!r}")
|
|
82
|
+
|
|
58
83
|
if not image_url:
|
|
59
84
|
return None
|
|
60
85
|
|
|
61
|
-
# Download and generate responsive variants
|
|
62
86
|
try:
|
|
63
87
|
resp = await self._client.get(image_url)
|
|
64
88
|
if resp.status_code != 200:
|
|
@@ -72,38 +96,72 @@ class CoverArtResolver:
|
|
|
72
96
|
return None
|
|
73
97
|
|
|
74
98
|
async def _search_tmdb(self, title: str) -> str | None:
|
|
99
|
+
"""Search TMDB for a poster: tries movie+TV in parallel, retries with cleaned title."""
|
|
100
|
+
result = await self._tmdb_search_both(title)
|
|
101
|
+
if result:
|
|
102
|
+
return result
|
|
103
|
+
# Retry with cleaned title if it differs
|
|
104
|
+
cleaned, year = _clean_title(title)
|
|
105
|
+
if cleaned != title:
|
|
106
|
+
result = await self._tmdb_search_both(cleaned, year=year)
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
async def _tmdb_search_both(self, title: str, year: str | None = None) -> str | None:
|
|
110
|
+
"""Search TMDB movie and TV endpoints in parallel, pick best poster by popularity."""
|
|
111
|
+
movie_task = asyncio.create_task(self._tmdb_search_type("movie", title, year))
|
|
112
|
+
tv_task = asyncio.create_task(self._tmdb_search_type("tv", title, year))
|
|
113
|
+
movie_hit, tv_hit = await asyncio.gather(movie_task, tv_task)
|
|
114
|
+
|
|
115
|
+
# Pick whichever has a poster; prefer movie if both have one and similar popularity
|
|
116
|
+
candidates = [h for h in (movie_hit, tv_hit) if h and h[0]]
|
|
117
|
+
if not candidates:
|
|
118
|
+
return None
|
|
119
|
+
# Sort by popularity descending, return the best poster URL
|
|
120
|
+
candidates.sort(key=lambda h: h[1], reverse=True)
|
|
121
|
+
return candidates[0][0]
|
|
122
|
+
|
|
123
|
+
async def _tmdb_search_type(self, media_type: str, title: str, year: str | None = None) -> tuple[str | None, float]:
|
|
124
|
+
"""Search a specific TMDB media type. Returns (poster_url_or_None, popularity)."""
|
|
125
|
+
params: dict = {"api_key": self._tmdb_key, "query": title}
|
|
126
|
+
if year and media_type == "movie":
|
|
127
|
+
params["year"] = year
|
|
128
|
+
elif year and media_type == "tv":
|
|
129
|
+
params["first_air_date_year"] = year
|
|
75
130
|
try:
|
|
76
131
|
resp = await self._client.get(
|
|
77
|
-
"https://api.themoviedb.org/3/search/
|
|
78
|
-
params=
|
|
132
|
+
f"https://api.themoviedb.org/3/search/{media_type}",
|
|
133
|
+
params=params,
|
|
79
134
|
)
|
|
80
135
|
if resp.status_code != 200:
|
|
81
|
-
logger.warning(f"TMDB
|
|
82
|
-
return None
|
|
136
|
+
logger.warning(f"TMDB {media_type} search returned {resp.status_code} for {title!r}")
|
|
137
|
+
return None, 0.0
|
|
83
138
|
results = resp.json().get("results", [])
|
|
84
|
-
|
|
85
|
-
poster =
|
|
139
|
+
for result in results:
|
|
140
|
+
poster = result.get("poster_path")
|
|
86
141
|
if poster:
|
|
87
|
-
|
|
142
|
+
# w780 gives better quality than w500
|
|
143
|
+
url = f"https://image.tmdb.org/t/p/w780{poster}"
|
|
144
|
+
return url, float(result.get("popularity", 0))
|
|
88
145
|
except Exception as e:
|
|
89
|
-
logger.warning(f"TMDB search error for {title!r}: {e}")
|
|
90
|
-
return None
|
|
146
|
+
logger.warning(f"TMDB {media_type} search error for {title!r}: {e}")
|
|
147
|
+
return None, 0.0
|
|
91
148
|
|
|
92
149
|
async def _search_omdb(self, title: str) -> str | None:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
cleaned, year = _clean_title(title)
|
|
151
|
+
for t in dict.fromkeys([title, cleaned]): # try original then cleaned, deduped
|
|
152
|
+
params: dict = {"apikey": self._omdb_key, "t": t}
|
|
153
|
+
if year:
|
|
154
|
+
params["y"] = year
|
|
155
|
+
try:
|
|
156
|
+
resp = await self._client.get("https://www.omdbapi.com/", params=params)
|
|
157
|
+
if resp.status_code != 200:
|
|
158
|
+
continue
|
|
159
|
+
data = resp.json()
|
|
160
|
+
poster = data.get("Poster")
|
|
161
|
+
if poster and poster != "N/A":
|
|
162
|
+
return poster
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"OMDB search error for {t!r}: {e}")
|
|
107
165
|
return None
|
|
108
166
|
|
|
109
167
|
async def _save_responsive(self, friendly_token: str, data: bytes, source: str, db) -> str:
|
|
@@ -46,12 +46,15 @@ async def catalog_browse_page(request: Request, category: str | None = None, pag
|
|
|
46
46
|
return RedirectResponse("/auth/login")
|
|
47
47
|
db = request.app.state.db
|
|
48
48
|
items = await db.browse(category=category, page=page)
|
|
49
|
+
total = await db.browse_count(category=category)
|
|
50
|
+
total_pages = max(1, (total + 23) // 24)
|
|
49
51
|
categories = await db.get_categories()
|
|
50
52
|
return templates.TemplateResponse(request, "catalog/browse.html", {
|
|
51
53
|
"user": user,
|
|
52
54
|
"items": items,
|
|
53
55
|
"categories": categories,
|
|
54
56
|
"page": page,
|
|
57
|
+
"total_pages": total_pages,
|
|
55
58
|
"active_category": category,
|
|
56
59
|
"query": None,
|
|
57
60
|
})
|
|
@@ -66,12 +69,15 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1):
|
|
|
66
69
|
return RedirectResponse("/catalog/browse")
|
|
67
70
|
db = request.app.state.db
|
|
68
71
|
items = await db.search(q, page=page)
|
|
72
|
+
total = await db.search_count(q)
|
|
73
|
+
total_pages = max(1, (total + 23) // 24)
|
|
69
74
|
categories = await db.get_categories()
|
|
70
75
|
return templates.TemplateResponse(request, "catalog/browse.html", {
|
|
71
76
|
"user": user,
|
|
72
77
|
"items": items,
|
|
73
78
|
"categories": categories,
|
|
74
79
|
"page": page,
|
|
80
|
+
"total_pages": total_pages,
|
|
75
81
|
"active_category": None,
|
|
76
82
|
"query": q,
|
|
77
83
|
})
|
|
@@ -99,3 +105,27 @@ async def admin_page(request: Request):
|
|
|
99
105
|
if not user or user.get("rank", 0) < 3:
|
|
100
106
|
return RedirectResponse("/auth/login")
|
|
101
107
|
return templates.TemplateResponse(request, "admin/index.html", {"user": user})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("/admin/playlists", response_class=HTMLResponse)
|
|
111
|
+
async def admin_playlists_page(request: Request):
|
|
112
|
+
user = _get_user_or_none(request)
|
|
113
|
+
if not user or user.get("rank", 0) < 3:
|
|
114
|
+
return RedirectResponse("/auth/login")
|
|
115
|
+
return templates.TemplateResponse(request, "admin/playlists.html", {"user": user})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@router.get("/admin/schedules", response_class=HTMLResponse)
|
|
119
|
+
async def admin_schedules_page(request: Request):
|
|
120
|
+
user = _get_user_or_none(request)
|
|
121
|
+
if not user or user.get("rank", 0) < 3:
|
|
122
|
+
return RedirectResponse("/auth/login")
|
|
123
|
+
return templates.TemplateResponse(request, "admin/schedules.html", {"user": user})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@router.get("/admin/queue-mgmt", response_class=HTMLResponse)
|
|
127
|
+
async def admin_queue_mgmt_page(request: Request):
|
|
128
|
+
user = _get_user_or_none(request)
|
|
129
|
+
if not user or user.get("rank", 0) < 3:
|
|
130
|
+
return RedirectResponse("/auth/login")
|
|
131
|
+
return templates.TemplateResponse(request, "admin/queue_mgmt.html", {"user": user})
|
|
@@ -143,14 +143,16 @@ a:hover {
|
|
|
143
143
|
|
|
144
144
|
.catalog-grid {
|
|
145
145
|
display: grid;
|
|
146
|
-
grid-template-columns: repeat(auto-fill, minmax(
|
|
147
|
-
gap: 1.
|
|
146
|
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
|
147
|
+
gap: 1.25rem;
|
|
148
148
|
}
|
|
149
149
|
.catalog-card {
|
|
150
150
|
background: var(--bg-card);
|
|
151
151
|
border-radius: var(--radius);
|
|
152
152
|
overflow: hidden;
|
|
153
153
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
154
|
+
display: flex;
|
|
155
|
+
flex-direction: column;
|
|
154
156
|
}
|
|
155
157
|
.catalog-card:hover {
|
|
156
158
|
transform: translateY(-4px);
|
|
@@ -177,34 +179,41 @@ a:hover {
|
|
|
177
179
|
background: linear-gradient(135deg, var(--bg-secondary), var(--bg-card));
|
|
178
180
|
}
|
|
179
181
|
.card-info {
|
|
180
|
-
padding: 0.75rem;
|
|
182
|
+
padding: 0.6rem 0.75rem 0.25rem;
|
|
183
|
+
flex: 1;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
gap: 0.2rem;
|
|
181
187
|
}
|
|
182
188
|
.card-title {
|
|
183
|
-
font-size: 0.
|
|
189
|
+
font-size: 0.8rem;
|
|
184
190
|
font-weight: 600;
|
|
185
|
-
|
|
186
|
-
|
|
191
|
+
line-height: 1.3;
|
|
192
|
+
display: -webkit-box;
|
|
193
|
+
-webkit-line-clamp: 3;
|
|
194
|
+
-webkit-box-orient: vertical;
|
|
187
195
|
overflow: hidden;
|
|
188
|
-
text-overflow: ellipsis;
|
|
189
196
|
}
|
|
190
197
|
.card-duration {
|
|
191
|
-
font-size: 0.
|
|
198
|
+
font-size: 0.7rem;
|
|
192
199
|
color: var(--text-secondary);
|
|
193
200
|
}
|
|
194
201
|
.card-actions {
|
|
195
|
-
padding: 0 0.75rem 0.
|
|
202
|
+
padding: 0.4rem 0.75rem 0.6rem;
|
|
196
203
|
display: flex;
|
|
197
|
-
gap: 0.
|
|
204
|
+
gap: 0.4rem;
|
|
198
205
|
}
|
|
199
206
|
.btn-queue {
|
|
200
207
|
background: var(--accent);
|
|
201
208
|
border-color: var(--accent);
|
|
202
209
|
color: white;
|
|
210
|
+
flex: 1;
|
|
203
211
|
}
|
|
204
212
|
.btn-playnext {
|
|
205
213
|
background: var(--warning);
|
|
206
214
|
border-color: var(--warning);
|
|
207
215
|
color: #333;
|
|
216
|
+
flex: 1;
|
|
208
217
|
}
|
|
209
218
|
|
|
210
219
|
/* Queue Page */
|
|
@@ -249,6 +258,12 @@ a:hover {
|
|
|
249
258
|
flex-direction: column;
|
|
250
259
|
gap: 0.5rem;
|
|
251
260
|
}
|
|
261
|
+
.queue-summary {
|
|
262
|
+
font-size: 0.8rem;
|
|
263
|
+
color: var(--text-secondary);
|
|
264
|
+
margin-top: 0.75rem;
|
|
265
|
+
text-align: right;
|
|
266
|
+
}
|
|
252
267
|
.queue-item {
|
|
253
268
|
display: flex;
|
|
254
269
|
align-items: center;
|
|
@@ -266,13 +281,31 @@ a:hover {
|
|
|
266
281
|
color: var(--text-secondary);
|
|
267
282
|
min-width: 1.5rem;
|
|
268
283
|
}
|
|
269
|
-
.qi-
|
|
284
|
+
.qi-info {
|
|
270
285
|
flex: 1;
|
|
286
|
+
min-width: 0;
|
|
287
|
+
display: flex;
|
|
288
|
+
flex-direction: column;
|
|
289
|
+
gap: 0.2rem;
|
|
290
|
+
}
|
|
291
|
+
.qi-title {
|
|
271
292
|
font-size: 0.9rem;
|
|
272
293
|
white-space: nowrap;
|
|
273
294
|
overflow: hidden;
|
|
274
295
|
text-overflow: ellipsis;
|
|
275
296
|
}
|
|
297
|
+
.qi-meta {
|
|
298
|
+
display: flex;
|
|
299
|
+
gap: 0.5rem;
|
|
300
|
+
align-items: center;
|
|
301
|
+
}
|
|
302
|
+
.qi-right {
|
|
303
|
+
display: flex;
|
|
304
|
+
flex-direction: column;
|
|
305
|
+
align-items: flex-end;
|
|
306
|
+
gap: 0.15rem;
|
|
307
|
+
white-space: nowrap;
|
|
308
|
+
}
|
|
276
309
|
.qi-duration {
|
|
277
310
|
font-size: 0.8rem;
|
|
278
311
|
color: var(--text-secondary);
|
|
@@ -292,7 +325,21 @@ a:hover {
|
|
|
292
325
|
color: var(--text-secondary);
|
|
293
326
|
}
|
|
294
327
|
.qi-eta {
|
|
295
|
-
font-size: 0.
|
|
328
|
+
font-size: 0.7rem;
|
|
329
|
+
color: var(--text-secondary);
|
|
330
|
+
}
|
|
331
|
+
.np-meta {
|
|
332
|
+
font-size: 0.8rem;
|
|
333
|
+
color: var(--text-secondary);
|
|
334
|
+
margin: 0.25rem 0;
|
|
335
|
+
}
|
|
336
|
+
.np-times {
|
|
337
|
+
display: flex;
|
|
338
|
+
justify-content: space-between;
|
|
339
|
+
font-size: 0.8rem;
|
|
340
|
+
margin-top: 0.25rem;
|
|
341
|
+
}
|
|
342
|
+
.np-remaining {
|
|
296
343
|
color: var(--text-secondary);
|
|
297
344
|
}
|
|
298
345
|
|
|
@@ -340,6 +387,27 @@ a:hover {
|
|
|
340
387
|
}
|
|
341
388
|
|
|
342
389
|
/* User Dashboard */
|
|
390
|
+
.user-header {
|
|
391
|
+
display: flex;
|
|
392
|
+
align-items: baseline;
|
|
393
|
+
gap: 1rem;
|
|
394
|
+
margin-bottom: 1rem;
|
|
395
|
+
}
|
|
396
|
+
.user-rank {
|
|
397
|
+
font-size: 0.85rem;
|
|
398
|
+
color: var(--text-secondary);
|
|
399
|
+
background: var(--bg-card);
|
|
400
|
+
padding: 0.2rem 0.6rem;
|
|
401
|
+
border-radius: var(--radius);
|
|
402
|
+
}
|
|
403
|
+
.user-online {
|
|
404
|
+
border: 1px solid var(--success);
|
|
405
|
+
}
|
|
406
|
+
.balance-meta {
|
|
407
|
+
font-size: 0.8rem;
|
|
408
|
+
color: var(--text-secondary);
|
|
409
|
+
margin-top: 0.5rem;
|
|
410
|
+
}
|
|
343
411
|
.dashboard-grid {
|
|
344
412
|
display: grid;
|
|
345
413
|
grid-template-columns: 1fr 1fr 1fr;
|
|
@@ -365,10 +433,24 @@ a:hover {
|
|
|
365
433
|
.history-item, .tx-item {
|
|
366
434
|
display: flex;
|
|
367
435
|
justify-content: space-between;
|
|
436
|
+
align-items: center;
|
|
437
|
+
gap: 0.5rem;
|
|
368
438
|
padding: 0.5rem 0;
|
|
369
439
|
border-bottom: 1px solid var(--border);
|
|
370
440
|
font-size: 0.85rem;
|
|
371
441
|
}
|
|
442
|
+
.tx-desc, .hi-title {
|
|
443
|
+
flex: 1;
|
|
444
|
+
min-width: 0;
|
|
445
|
+
overflow: hidden;
|
|
446
|
+
text-overflow: ellipsis;
|
|
447
|
+
white-space: nowrap;
|
|
448
|
+
}
|
|
449
|
+
.tx-time {
|
|
450
|
+
font-size: 0.75rem;
|
|
451
|
+
color: var(--text-secondary);
|
|
452
|
+
white-space: nowrap;
|
|
453
|
+
}
|
|
372
454
|
.tx-credit {
|
|
373
455
|
color: var(--success);
|
|
374
456
|
}
|
|
@@ -418,13 +500,29 @@ a:hover {
|
|
|
418
500
|
display: flex;
|
|
419
501
|
align-items: center;
|
|
420
502
|
justify-content: center;
|
|
421
|
-
gap:
|
|
503
|
+
gap: 0.5rem;
|
|
422
504
|
margin-top: 2rem;
|
|
505
|
+
flex-wrap: wrap;
|
|
423
506
|
}
|
|
424
507
|
.page-num {
|
|
425
508
|
color: var(--text-secondary);
|
|
426
509
|
font-size: 0.9rem;
|
|
427
510
|
}
|
|
511
|
+
.page-current {
|
|
512
|
+
background: var(--accent);
|
|
513
|
+
color: white;
|
|
514
|
+
padding: 0.3rem 0.6rem;
|
|
515
|
+
border-radius: var(--radius);
|
|
516
|
+
font-weight: 700;
|
|
517
|
+
}
|
|
518
|
+
.page-info {
|
|
519
|
+
color: var(--text-secondary);
|
|
520
|
+
font-size: 0.85rem;
|
|
521
|
+
}
|
|
522
|
+
.btn-page {
|
|
523
|
+
min-width: 2rem;
|
|
524
|
+
text-align: center;
|
|
525
|
+
}
|
|
428
526
|
|
|
429
527
|
/* Toast */
|
|
430
528
|
.toast {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Playlists - Admin{% endblock %}
|
|
3
|
+
{% block body_class %}admin-page{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="admin-dashboard">
|
|
7
|
+
<h1>Playlists</h1>
|
|
8
|
+
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
+
|
|
10
|
+
<div class="admin-section">
|
|
11
|
+
<p class="empty-state">Playlist management coming soon.</p>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Queue Management - Admin{% endblock %}
|
|
3
|
+
{% block body_class %}admin-page{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="admin-dashboard">
|
|
7
|
+
<h1>Queue Management</h1>
|
|
8
|
+
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
+
|
|
10
|
+
<div class="admin-section">
|
|
11
|
+
<p class="empty-state">Queue management coming soon.</p>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Schedules - Admin{% endblock %}
|
|
3
|
+
{% block body_class %}admin-page{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="admin-dashboard">
|
|
7
|
+
<h1>Schedules</h1>
|
|
8
|
+
<p><a href="/admin">← Back to Admin</a></p>
|
|
9
|
+
|
|
10
|
+
<div class="admin-section">
|
|
11
|
+
<p class="empty-state">Schedule management coming soon.</p>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}{% if query %}Search: {{ query }}{% else %}Browse{% endif %}{% endblock %}
|
|
3
|
+
{% block body_class %}catalog-page{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<div class="catalog-header">
|
|
7
|
+
<h1>{% if query %}Results for "{{ query }}"{% else %}Browse Catalog{% endif %}</h1>
|
|
8
|
+
<div class="catalog-controls">
|
|
9
|
+
<form class="search-form" action="/catalog/search" method="get">
|
|
10
|
+
<input type="text" name="q" placeholder="Search movies & shows..." value="{{ query or '' }}">
|
|
11
|
+
<button type="submit" class="btn btn-sm">Search</button>
|
|
12
|
+
</form>
|
|
13
|
+
<div class="category-filter">
|
|
14
|
+
<select id="category-select" onchange="filterCategory(this.value)">
|
|
15
|
+
<option value="">All Categories</option>
|
|
16
|
+
{% for cat in categories %}
|
|
17
|
+
<option value="{{ cat.slug }}" {% if active_category == cat.slug %}selected{% endif %}>{{ cat.name }}</option>
|
|
18
|
+
{% endfor %}
|
|
19
|
+
</select>
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div class="catalog-grid">
|
|
25
|
+
{% for item in items %}
|
|
26
|
+
<div class="catalog-card" data-token="{{ item.friendly_token }}">
|
|
27
|
+
<div class="card-poster">
|
|
28
|
+
{% if item.cover_art_path %}
|
|
29
|
+
<img src="/images/{{ item.cover_art_path }}/400.webp"
|
|
30
|
+
srcset="/images/{{ item.cover_art_path }}/200.webp 200w,
|
|
31
|
+
/images/{{ item.cover_art_path }}/400.webp 400w,
|
|
32
|
+
/images/{{ item.cover_art_path }}/800.webp 800w"
|
|
33
|
+
sizes="(max-width: 600px) 200px, 400px"
|
|
34
|
+
alt="{{ item.title }}" loading="lazy">
|
|
35
|
+
{% elif item.thumbnail_url %}
|
|
36
|
+
<img src="{{ item.thumbnail_url }}"
|
|
37
|
+
alt="{{ item.title }}" loading="lazy">
|
|
38
|
+
{% else %}
|
|
39
|
+
<div class="card-poster-placeholder">
|
|
40
|
+
<span>{{ item.title[:1] }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
{% endif %}
|
|
43
|
+
</div>
|
|
44
|
+
<div class="card-info">
|
|
45
|
+
<h3 class="card-title" title="{{ item.title }}">{{ item.title }}</h3>
|
|
46
|
+
<span class="card-duration">{{ (item.duration_sec // 60) }}m</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div class="card-actions">
|
|
49
|
+
<button class="btn btn-sm btn-queue" onclick="queueItem('{{ item.friendly_token }}')">Queue</button>
|
|
50
|
+
<button class="btn btn-sm btn-playnext" onclick="playNext('{{ item.friendly_token }}')">Play Next</button>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
{% endfor %}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{% if not items %}
|
|
57
|
+
<div class="empty-state">
|
|
58
|
+
<p>No items found.</p>
|
|
59
|
+
</div>
|
|
60
|
+
{% endif %}
|
|
61
|
+
|
|
62
|
+
<div class="pagination">
|
|
63
|
+
{% if page > 1 %}
|
|
64
|
+
<a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">← Prev</a>
|
|
65
|
+
{% endif %}
|
|
66
|
+
|
|
67
|
+
{% set start_page = [1, page - 2] | max %}
|
|
68
|
+
{% set end_page = [total_pages, page + 2] | min %}
|
|
69
|
+
{% for p in range(start_page, end_page + 1) %}
|
|
70
|
+
{% if p == page %}
|
|
71
|
+
<span class="page-num page-current">{{ p }}</span>
|
|
72
|
+
{% else %}
|
|
73
|
+
<a href="?page={{ p }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm btn-page">{{ p }}</a>
|
|
74
|
+
{% endif %}
|
|
75
|
+
{% endfor %}
|
|
76
|
+
|
|
77
|
+
<span class="page-info">of {{ total_pages }}</span>
|
|
78
|
+
|
|
79
|
+
{% if page < total_pages %}
|
|
80
|
+
<a href="?page={{ page + 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">Next →</a>
|
|
81
|
+
{% endif %}
|
|
82
|
+
</div>
|
|
83
|
+
{% endblock %}
|
|
84
|
+
|
|
85
|
+
{% block scripts %}
|
|
86
|
+
<script>
|
|
87
|
+
function filterCategory(slug) {
|
|
88
|
+
const url = slug ? `/catalog/browse?category=${slug}` : '/catalog/browse';
|
|
89
|
+
window.location.href = url;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function queueItem(token) {
|
|
93
|
+
try {
|
|
94
|
+
const resp = await fetch('/queue/add', {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {'Content-Type': 'application/json'},
|
|
97
|
+
credentials: 'same-origin',
|
|
98
|
+
body: JSON.stringify({friendly_token: token, tier: 'queue'})
|
|
99
|
+
});
|
|
100
|
+
const data = await resp.json();
|
|
101
|
+
if (resp.ok) {
|
|
102
|
+
showToast('Added to queue!');
|
|
103
|
+
} else {
|
|
104
|
+
showToast(data.detail || `Failed (${resp.status})`, 'error');
|
|
105
|
+
}
|
|
106
|
+
} catch (e) {
|
|
107
|
+
showToast(`Network error: ${e.message}`, 'error');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function playNext(token) {
|
|
112
|
+
try {
|
|
113
|
+
const resp = await fetch('/queue/playnext', {
|
|
114
|
+
method: 'POST',
|
|
115
|
+
headers: {'Content-Type': 'application/json'},
|
|
116
|
+
credentials: 'same-origin',
|
|
117
|
+
body: JSON.stringify({friendly_token: token})
|
|
118
|
+
});
|
|
119
|
+
const data = await resp.json();
|
|
120
|
+
if (resp.ok) {
|
|
121
|
+
showToast('Playing next!');
|
|
122
|
+
} else {
|
|
123
|
+
showToast(data.detail || `Failed (${resp.status})`, 'error');
|
|
124
|
+
}
|
|
125
|
+
} catch (e) {
|
|
126
|
+
showToast(`Network error: ${e.message}`, 'error');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
{% endblock %}
|
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
<div class="now-playing-section">
|
|
8
8
|
<h2>Now Playing</h2>
|
|
9
9
|
<div id="now-playing" class="now-playing-card">
|
|
10
|
-
<p class="empty-state">
|
|
10
|
+
<p class="empty-state">Connecting...</p>
|
|
11
11
|
</div>
|
|
12
12
|
</div>
|
|
13
13
|
|
|
14
14
|
<div class="queue-section">
|
|
15
15
|
<h2>Up Next</h2>
|
|
16
16
|
<div id="queue-list" class="queue-list">
|
|
17
|
-
<p class="empty-state">
|
|
17
|
+
<p class="empty-state">Connecting...</p>
|
|
18
18
|
</div>
|
|
19
|
+
<div id="queue-summary" class="queue-summary"></div>
|
|
19
20
|
</div>
|
|
20
21
|
|
|
21
22
|
<div class="queue-info-sidebar">
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
<p id="schedule-time"></p>
|
|
26
27
|
</div>
|
|
27
28
|
<div class="queue-stats">
|
|
28
|
-
<p>
|
|
29
|
+
<p>Status: <span id="ws-status" class="ws-disconnected">● Disconnected</span></p>
|
|
29
30
|
</div>
|
|
30
31
|
</div>
|
|
31
32
|
</div>
|
|
@@ -41,12 +42,15 @@ function connectWebSocket() {
|
|
|
41
42
|
ws = new WebSocket(`${proto}//${location.host}/ws`);
|
|
42
43
|
|
|
43
44
|
ws.onopen = () => {
|
|
44
|
-
document.getElementById('ws-status')
|
|
45
|
-
|
|
45
|
+
const el = document.getElementById('ws-status');
|
|
46
|
+
el.className = 'ws-connected';
|
|
47
|
+
el.textContent = '● Live';
|
|
46
48
|
};
|
|
47
49
|
|
|
48
50
|
ws.onclose = () => {
|
|
49
|
-
document.getElementById('ws-status')
|
|
51
|
+
const el = document.getElementById('ws-status');
|
|
52
|
+
el.className = 'ws-disconnected';
|
|
53
|
+
el.textContent = '● Disconnected';
|
|
50
54
|
reconnectTimer = setTimeout(connectWebSocket, 3000);
|
|
51
55
|
};
|
|
52
56
|
|
|
@@ -72,16 +76,26 @@ function connectWebSocket() {
|
|
|
72
76
|
function renderQueue(state) {
|
|
73
77
|
const npEl = document.getElementById('now-playing');
|
|
74
78
|
const qlEl = document.getElementById('queue-list');
|
|
79
|
+
const sumEl = document.getElementById('queue-summary');
|
|
75
80
|
|
|
76
81
|
if (state.now_playing) {
|
|
77
82
|
const np = state.now_playing;
|
|
83
|
+
const elapsed = np.currentTime || 0;
|
|
84
|
+
const total = np.duration || 0;
|
|
85
|
+
const remaining = Math.max(0, total - elapsed);
|
|
78
86
|
npEl.innerHTML = `
|
|
79
87
|
<div class="np-info">
|
|
80
88
|
<h3>${escapeHtml(np.title || 'Unknown')}</h3>
|
|
89
|
+
<div class="np-meta">
|
|
90
|
+
${np.paid_by ? `<span class="np-user">Queued by ${escapeHtml(np.paid_by)}</span>` : ''}
|
|
91
|
+
</div>
|
|
81
92
|
<div class="np-progress">
|
|
82
|
-
<div class="progress-bar" style="width: ${
|
|
93
|
+
<div class="progress-bar" style="width: ${total > 0 ? (elapsed / total * 100) : 0}%"></div>
|
|
94
|
+
</div>
|
|
95
|
+
<div class="np-times">
|
|
96
|
+
<span class="np-time">${formatTime(elapsed)} / ${formatTime(total)}</span>
|
|
97
|
+
<span class="np-remaining">${formatTime(remaining)} remaining</span>
|
|
83
98
|
</div>
|
|
84
|
-
<span class="np-time">${formatTime(np.currentTime || 0)} / ${formatTime(np.duration || 0)}</span>
|
|
85
99
|
</div>
|
|
86
100
|
`;
|
|
87
101
|
} else {
|
|
@@ -89,24 +103,37 @@ function renderQueue(state) {
|
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
if (state.items && state.items.length > 0) {
|
|
106
|
+
const totalDuration = state.items.reduce((sum, i) => sum + (i.duration_sec || 0), 0);
|
|
107
|
+
sumEl.innerHTML = `<span>${state.items.length} items · ${formatTime(totalDuration)} total</span>`;
|
|
108
|
+
|
|
92
109
|
qlEl.innerHTML = state.items.map((item, i) => `
|
|
93
110
|
<div class="queue-item ${item.is_pay ? 'queue-item-paid' : ''}" data-uid="${item.uid}">
|
|
94
111
|
<span class="qi-pos">${i + 1}</span>
|
|
95
|
-
<
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
112
|
+
<div class="qi-info">
|
|
113
|
+
<span class="qi-title">${escapeHtml(item.title || 'Unknown')}</span>
|
|
114
|
+
<span class="qi-meta">
|
|
115
|
+
${item.paid_by ? `<span class="qi-user">by ${escapeHtml(item.paid_by)}</span>` : ''}
|
|
116
|
+
${item.is_pay ? `<span class="qi-badge badge-paid">${item.tier}</span>` : ''}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="qi-right">
|
|
120
|
+
<span class="qi-duration">${formatTime(item.duration_sec || 0)}</span>
|
|
121
|
+
${item.estimated_start_at ? `<span class="qi-eta">~${formatEta(item.estimated_start_at)}</span>` : ''}
|
|
122
|
+
</div>
|
|
100
123
|
</div>
|
|
101
124
|
`).join('');
|
|
102
125
|
} else {
|
|
103
126
|
qlEl.innerHTML = '<p class="empty-state">Queue is empty</p>';
|
|
127
|
+
sumEl.innerHTML = '';
|
|
104
128
|
}
|
|
105
129
|
}
|
|
106
130
|
|
|
107
131
|
function formatTime(sec) {
|
|
108
|
-
|
|
109
|
-
const
|
|
132
|
+
sec = Math.floor(sec);
|
|
133
|
+
const h = Math.floor(sec / 3600);
|
|
134
|
+
const m = Math.floor((sec % 3600) / 60);
|
|
135
|
+
const s = sec % 60;
|
|
136
|
+
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
|
110
137
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
111
138
|
}
|
|
112
139
|
|
|
@@ -121,6 +148,18 @@ function escapeHtml(str) {
|
|
|
121
148
|
return div.innerHTML;
|
|
122
149
|
}
|
|
123
150
|
|
|
151
|
+
// Initial load via HTTP (in case WS takes a moment)
|
|
152
|
+
async function initialLoad() {
|
|
153
|
+
try {
|
|
154
|
+
const resp = await fetch('/queue/state', {credentials: 'same-origin'});
|
|
155
|
+
if (resp.ok) {
|
|
156
|
+
const state = await resp.json();
|
|
157
|
+
renderQueue(state);
|
|
158
|
+
}
|
|
159
|
+
} catch (e) { /* WS will take over */ }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
initialLoad();
|
|
124
163
|
connectWebSocket();
|
|
125
164
|
</script>
|
|
126
165
|
{% endblock %}
|
{kryten_webqueue-0.3.2 → kryten_webqueue-0.4.1}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
@@ -4,12 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
{% block content %}
|
|
6
6
|
<div class="user-dashboard">
|
|
7
|
-
<
|
|
7
|
+
<div class="user-header">
|
|
8
|
+
<h1>{{ user.username }}</h1>
|
|
9
|
+
<span class="user-rank" id="user-rank">Rank {{ user.rank }}</span>
|
|
10
|
+
</div>
|
|
8
11
|
|
|
9
12
|
<div class="dashboard-grid">
|
|
10
13
|
<div class="balance-card">
|
|
11
|
-
<h2>Balance</h2>
|
|
14
|
+
<h2>Z Coin Balance</h2>
|
|
12
15
|
<p class="balance-amount" id="balance-amount">Loading...</p>
|
|
16
|
+
<div class="balance-meta" id="balance-meta"></div>
|
|
13
17
|
</div>
|
|
14
18
|
|
|
15
19
|
<div class="history-card">
|
|
@@ -31,12 +35,46 @@
|
|
|
31
35
|
|
|
32
36
|
{% block scripts %}
|
|
33
37
|
<script>
|
|
38
|
+
const RANK_NAMES = {1: 'Viewer', 2: 'Regular', 3: 'Moderator', 4: 'Admin', 5: 'Owner'};
|
|
39
|
+
|
|
40
|
+
function formatZ(amount) {
|
|
41
|
+
return Number(amount).toLocaleString() + ' Z';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function describeTx(t) {
|
|
45
|
+
const type = (t.type || '').toLowerCase();
|
|
46
|
+
if (t.description) return t.description;
|
|
47
|
+
if (type === 'queue_spend') return `Queued: ${t.title || t.media_id || 'item'}`;
|
|
48
|
+
if (type === 'playnext_spend') return `Play Next: ${t.title || t.media_id || 'item'}`;
|
|
49
|
+
if (type === 'refund') return `Refund: ${t.reason || 'queue item removed'}`;
|
|
50
|
+
if (type === 'daily_bonus' || type === 'bonus') return 'Daily bonus';
|
|
51
|
+
if (type === 'watch_reward') return 'Watch reward';
|
|
52
|
+
if (type === 'gift') return `Gift from ${t.from_user || 'system'}`;
|
|
53
|
+
if (type === 'admin_grant') return 'Admin grant';
|
|
54
|
+
return type || 'Transaction';
|
|
55
|
+
}
|
|
56
|
+
|
|
34
57
|
async function loadDashboard() {
|
|
58
|
+
// Profile + rank
|
|
59
|
+
const profResp = await fetch('/user/profile');
|
|
60
|
+
if (profResp.ok) {
|
|
61
|
+
const prof = await profResp.json();
|
|
62
|
+
const rankEl = document.getElementById('user-rank');
|
|
63
|
+
const rankName = RANK_NAMES[prof.rank] || `Rank ${prof.rank}`;
|
|
64
|
+
rankEl.textContent = rankName;
|
|
65
|
+
if (prof.online) rankEl.classList.add('user-online');
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
// Balance
|
|
36
69
|
const balResp = await fetch('/user/balance');
|
|
37
70
|
if (balResp.ok) {
|
|
38
71
|
const bal = await balResp.json();
|
|
39
|
-
document.getElementById('balance-amount').textContent =
|
|
72
|
+
document.getElementById('balance-amount').textContent = formatZ(bal.balance || 0);
|
|
73
|
+
const meta = document.getElementById('balance-meta');
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (bal.earned_today != null) parts.push(`Today: +${formatZ(bal.earned_today)}`);
|
|
76
|
+
if (bal.spent_today != null) parts.push(`Spent: ${formatZ(bal.spent_today)}`);
|
|
77
|
+
meta.textContent = parts.join(' · ');
|
|
40
78
|
}
|
|
41
79
|
|
|
42
80
|
// Queue history
|
|
@@ -48,7 +86,7 @@ async function loadDashboard() {
|
|
|
48
86
|
el.innerHTML = hist.items.slice(0, 20).map(h => `
|
|
49
87
|
<div class="history-item">
|
|
50
88
|
<span class="hi-title">${escapeHtml(h.title || 'Unknown')}</span>
|
|
51
|
-
<span class="hi-cost">${h.z_cost}
|
|
89
|
+
<span class="hi-cost">${formatZ(h.z_cost)}</span>
|
|
52
90
|
<span class="hi-tier badge-${h.tier}">${h.tier}</span>
|
|
53
91
|
</div>
|
|
54
92
|
`).join('');
|
|
@@ -66,8 +104,9 @@ async function loadDashboard() {
|
|
|
66
104
|
if (items.length > 0) {
|
|
67
105
|
el.innerHTML = items.slice(0, 20).map(t => `
|
|
68
106
|
<div class="tx-item">
|
|
69
|
-
<span class="tx-desc">${escapeHtml(t
|
|
70
|
-
<span class="tx-amount ${t.amount > 0 ? 'tx-credit' : 'tx-debit'}">${t.amount > 0 ? '+' : ''}${t.amount}
|
|
107
|
+
<span class="tx-desc">${escapeHtml(describeTx(t))}</span>
|
|
108
|
+
<span class="tx-amount ${t.amount > 0 ? 'tx-credit' : 'tx-debit'}">${t.amount > 0 ? '+' : ''}${formatZ(t.amount)}</span>
|
|
109
|
+
<span class="tx-time">${t.created_at ? new Date(t.created_at).toLocaleDateString() : ''}</span>
|
|
71
110
|
</div>
|
|
72
111
|
`).join('');
|
|
73
112
|
} else {
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
{% extends "base.html" %}
|
|
2
|
-
{% block title %}Browse{% endblock %}
|
|
3
|
-
{% block body_class %}catalog-page{% endblock %}
|
|
4
|
-
|
|
5
|
-
{% block content %}
|
|
6
|
-
<div class="catalog-header">
|
|
7
|
-
<h1>Browse Catalog</h1>
|
|
8
|
-
<div class="catalog-controls">
|
|
9
|
-
<form class="search-form" action="/catalog/search" method="get">
|
|
10
|
-
<input type="text" name="q" placeholder="Search movies & shows..." value="{{ query or '' }}">
|
|
11
|
-
<button type="submit" class="btn btn-sm">Search</button>
|
|
12
|
-
</form>
|
|
13
|
-
<div class="category-filter">
|
|
14
|
-
<select id="category-select" onchange="filterCategory(this.value)">
|
|
15
|
-
<option value="">All Categories</option>
|
|
16
|
-
{% for cat in categories %}
|
|
17
|
-
<option value="{{ cat.slug }}" {% if active_category == cat.slug %}selected{% endif %}>{{ cat.name }}</option>
|
|
18
|
-
{% endfor %}
|
|
19
|
-
</select>
|
|
20
|
-
</div>
|
|
21
|
-
</div>
|
|
22
|
-
</div>
|
|
23
|
-
|
|
24
|
-
<div class="catalog-grid">
|
|
25
|
-
{% for item in items %}
|
|
26
|
-
<div class="catalog-card" data-token="{{ item.friendly_token }}">
|
|
27
|
-
<div class="card-poster">
|
|
28
|
-
{% if item.cover_art_path %}
|
|
29
|
-
<img src="/static/images/{{ item.cover_art_path }}/400.webp"
|
|
30
|
-
srcset="/static/images/{{ item.cover_art_path }}/200.webp 200w,
|
|
31
|
-
/static/images/{{ item.cover_art_path }}/400.webp 400w,
|
|
32
|
-
/static/images/{{ item.cover_art_path }}/800.webp 800w"
|
|
33
|
-
sizes="(max-width: 600px) 200px, 400px"
|
|
34
|
-
alt="{{ item.title }}" loading="lazy">
|
|
35
|
-
{% elif item.thumbnail_url %}
|
|
36
|
-
<img src="{{ item.thumbnail_url }}"
|
|
37
|
-
alt="{{ item.title }}" loading="lazy">
|
|
38
|
-
{% else %}
|
|
39
|
-
<div class="card-poster-placeholder">
|
|
40
|
-
<span>{{ item.title[:1] }}</span>
|
|
41
|
-
</div>
|
|
42
|
-
{% endif %}
|
|
43
|
-
</div>
|
|
44
|
-
<div class="card-info">
|
|
45
|
-
<h3 class="card-title">{{ item.title }}</h3>
|
|
46
|
-
<span class="card-duration">{{ (item.duration_sec // 60) }}m</span>
|
|
47
|
-
</div>
|
|
48
|
-
<div class="card-actions">
|
|
49
|
-
<button class="btn btn-sm btn-queue" onclick="queueItem('{{ item.friendly_token }}')">Queue</button>
|
|
50
|
-
<button class="btn btn-sm btn-playnext" onclick="playNext('{{ item.friendly_token }}')">Play Next</button>
|
|
51
|
-
</div>
|
|
52
|
-
</div>
|
|
53
|
-
{% endfor %}
|
|
54
|
-
</div>
|
|
55
|
-
|
|
56
|
-
{% if not items %}
|
|
57
|
-
<div class="empty-state">
|
|
58
|
-
<p>No items found.</p>
|
|
59
|
-
</div>
|
|
60
|
-
{% endif %}
|
|
61
|
-
|
|
62
|
-
<div class="pagination">
|
|
63
|
-
{% if page > 1 %}
|
|
64
|
-
<a href="?page={{ page - 1 }}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">← Prev</a>
|
|
65
|
-
{% endif %}
|
|
66
|
-
<span class="page-num">Page {{ page }}</span>
|
|
67
|
-
{% if items | length == 24 %}
|
|
68
|
-
<a href="?page={{ page + 1 }}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">Next →</a>
|
|
69
|
-
{% endif %}
|
|
70
|
-
</div>
|
|
71
|
-
{% endblock %}
|
|
72
|
-
|
|
73
|
-
{% block scripts %}
|
|
74
|
-
<script>
|
|
75
|
-
function filterCategory(slug) {
|
|
76
|
-
const url = slug ? `/catalog/browse?category=${slug}` : '/catalog/browse';
|
|
77
|
-
window.location.href = url;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function queueItem(token) {
|
|
81
|
-
const resp = await fetch('/queue/add', {
|
|
82
|
-
method: 'POST',
|
|
83
|
-
headers: {'Content-Type': 'application/json'},
|
|
84
|
-
body: JSON.stringify({friendly_token: token, tier: 'queue'})
|
|
85
|
-
});
|
|
86
|
-
const data = await resp.json();
|
|
87
|
-
if (resp.ok) {
|
|
88
|
-
showToast('Added to queue!');
|
|
89
|
-
} else {
|
|
90
|
-
showToast(data.detail || 'Failed to queue', 'error');
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async function playNext(token) {
|
|
95
|
-
const resp = await fetch('/queue/playnext', {
|
|
96
|
-
method: 'POST',
|
|
97
|
-
headers: {'Content-Type': 'application/json'},
|
|
98
|
-
body: JSON.stringify({friendly_token: token})
|
|
99
|
-
});
|
|
100
|
-
const data = await resp.json();
|
|
101
|
-
if (resp.ok) {
|
|
102
|
-
showToast('Playing next!');
|
|
103
|
-
} else {
|
|
104
|
-
showToast(data.detail || 'Failed', 'error');
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
</script>
|
|
108
|
-
{% endblock %}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|