kryten-webqueue 0.3.2__tar.gz → 0.4.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.
Files changed (62) hide show
  1. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/PKG-INFO +1 -1
  2. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/catalog/db.py +37 -0
  3. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/catalog/images.py +6 -4
  4. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/pages.py +30 -0
  5. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/static/css/main.css +111 -13
  6. kryten_webqueue-0.4.0/kryten_webqueue/templates/admin/playlists.html +14 -0
  7. kryten_webqueue-0.4.0/kryten_webqueue/templates/admin/queue_mgmt.html +14 -0
  8. kryten_webqueue-0.4.0/kryten_webqueue/templates/admin/schedules.html +14 -0
  9. kryten_webqueue-0.4.0/kryten_webqueue/templates/catalog/browse.html +130 -0
  10. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/templates/queue/index.html +54 -15
  11. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/templates/user/dashboard.html +45 -6
  12. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/pyproject.toml +1 -1
  13. kryten_webqueue-0.3.2/kryten_webqueue/templates/catalog/browse.html +0 -108
  14. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/.github/workflows/python-publish.yml +0 -0
  15. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/.github/workflows/release.yml +0 -0
  16. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/.gitignore +0 -0
  17. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/README.md +0 -0
  18. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/config.example.json +0 -0
  19. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/deploy/kryten-webqueue.service +0 -0
  20. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/deploy/nginx-queue.conf +0 -0
  21. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  22. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/IMPL_API_GATE.md +0 -0
  23. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/IMPL_ECONOMY.md +0 -0
  24. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  25. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/IMPL_ROBOT.md +0 -0
  26. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/PRE_PLAN_GAPS.md +0 -0
  27. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/docs/PRODUCT_PLAN.md +0 -0
  28. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/__init__.py +0 -0
  29. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/__main__.py +0 -0
  30. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  31. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/api_gate/client.py +0 -0
  32. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/app.py +0 -0
  33. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/auth/__init__.py +0 -0
  34. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/auth/otp.py +0 -0
  35. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  36. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/auth/session.py +0 -0
  37. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/catalog/__init__.py +0 -0
  38. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/catalog/sync.py +0 -0
  39. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/config.py +0 -0
  40. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/playlists/__init__.py +0 -0
  41. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/playlists/fire.py +0 -0
  42. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/playlists/importer.py +0 -0
  43. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  44. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/queue/__init__.py +0 -0
  45. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/queue/ordering.py +0 -0
  46. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/queue/poller.py +0 -0
  47. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/queue/shadow.py +0 -0
  48. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/__init__.py +0 -0
  49. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  50. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  51. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  52. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/auth.py +0 -0
  53. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/catalog.py +0 -0
  54. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/queue.py +0 -0
  55. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/routes/user.py +0 -0
  56. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/static/js/main.js +0 -0
  57. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/templates/admin/index.html +0 -0
  58. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/templates/auth/login.html +0 -0
  59. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/templates/base.html +0 -0
  60. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/ws/__init__.py +0 -0
  61. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/ws/handler.py +0 -0
  62. {kryten_webqueue-0.3.2 → kryten_webqueue-0.4.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -242,6 +242,28 @@ class Database:
242
242
  params.extend([per_page, (page - 1) * per_page])
243
243
  return await self._fetch_all(query, params)
244
244
 
245
+ async def browse_count(self, *, category: str | None = None) -> int:
246
+ query = """
247
+ SELECT COUNT(*) as cnt FROM catalog c
248
+ WHERE c.friendly_token NOT IN (
249
+ SELECT spi.media_id FROM saved_playlist_items spi
250
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
251
+ WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
252
+ )
253
+ """
254
+ params: list = []
255
+ if category:
256
+ query += """
257
+ AND c.friendly_token IN (
258
+ SELECT cc.friendly_token FROM catalog_categories cc
259
+ JOIN categories cat ON cc.category_id = cat.id
260
+ WHERE cat.slug = ?
261
+ )
262
+ """
263
+ params.append(category)
264
+ row = await self._fetch_one(query, params)
265
+ return row["cnt"] if row else 0
266
+
245
267
  async def search(self, query_text: str, *, page: int = 1, per_page: int = 24) -> list[dict]:
246
268
  sql = """
247
269
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url,
@@ -259,6 +281,21 @@ class Database:
259
281
  """
260
282
  return await self._fetch_all(sql, [query_text, per_page, (page - 1) * per_page])
261
283
 
284
+ async def search_count(self, query_text: str) -> int:
285
+ sql = """
286
+ SELECT COUNT(*) as cnt
287
+ FROM catalog_fts fts
288
+ JOIN catalog c ON c.rowid = fts.rowid
289
+ WHERE catalog_fts MATCH ?
290
+ AND c.friendly_token NOT IN (
291
+ SELECT spi.media_id FROM saved_playlist_items spi
292
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
293
+ WHERE sp.is_immutable = 1 AND spi.media_type = 'cm'
294
+ )
295
+ """
296
+ row = await self._fetch_one(sql, [query_text])
297
+ return row["cnt"] if row else 0
298
+
262
299
  async def get_item(self, friendly_token: str) -> dict | None:
263
300
  sql = """
264
301
  SELECT * FROM catalog
@@ -81,10 +81,12 @@ class CoverArtResolver:
81
81
  logger.warning(f"TMDB API returned {resp.status_code} for {title!r}")
82
82
  return None
83
83
  results = resp.json().get("results", [])
84
- if results:
85
- poster = results[0].get("poster_path")
86
- if poster:
87
- return f"https://image.tmdb.org/t/p/w500{poster}"
84
+ # Prefer movie/tv results (have poster_path); skip person results
85
+ for result in results:
86
+ if result.get("media_type") in ("movie", "tv"):
87
+ poster = result.get("poster_path")
88
+ if poster:
89
+ return f"https://image.tmdb.org/t/p/w500{poster}"
88
90
  except Exception as e:
89
91
  logger.warning(f"TMDB search error for {title!r}: {e}")
90
92
  return None
@@ -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(200px, 1fr));
147
- gap: 1.5rem;
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.85rem;
189
+ font-size: 0.8rem;
184
190
  font-weight: 600;
185
- margin-bottom: 0.25rem;
186
- white-space: nowrap;
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.75rem;
198
+ font-size: 0.7rem;
192
199
  color: var(--text-secondary);
193
200
  }
194
201
  .card-actions {
195
- padding: 0 0.75rem 0.75rem;
202
+ padding: 0.4rem 0.75rem 0.6rem;
196
203
  display: flex;
197
- gap: 0.5rem;
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-title {
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.75rem;
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: 1rem;
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">&larr; 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">&larr; 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">&larr; 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">&larr; 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 &rarr;</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">Nothing playing</p>
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">Queue is empty</p>
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>Connected: <span id="ws-status" class="ws-disconnected">●</span></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').className = 'ws-connected';
45
- document.getElementById('ws-status').title = 'Connected';
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').className = 'ws-disconnected';
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: ${((np.currentTime || 0) / (np.duration || 1) * 100)}%"></div>
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
- <span class="qi-title">${escapeHtml(item.title || 'Unknown')}</span>
96
- <span class="qi-duration">${formatTime(item.duration_sec || 0)}</span>
97
- ${item.is_pay ? `<span class="qi-badge badge-paid">${item.tier}</span>` : ''}
98
- ${item.paid_by ? `<span class="qi-user">${escapeHtml(item.paid_by)}</span>` : ''}
99
- <span class="qi-eta">${item.estimated_start_at ? formatEta(item.estimated_start_at) : ''}</span>
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
- const m = Math.floor(sec / 60);
109
- const s = Math.floor(sec % 60);
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 %}
@@ -4,12 +4,16 @@
4
4
 
5
5
  {% block content %}
6
6
  <div class="user-dashboard">
7
- <h1>{{ user.username }}</h1>
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 = `${bal.balance || 0} Z`;
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} Z</span>
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.description || t.type || '')}</span>
70
- <span class="tx-amount ${t.amount > 0 ? 'tx-credit' : 'tx-debit'}">${t.amount > 0 ? '+' : ''}${t.amount} Z</span>
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.3.2"
3
+ version = "0.4.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 %}