kryten-webqueue 0.6.5__tar.gz → 0.7.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 (67) hide show
  1. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/CHANGELOG.md +17 -0
  2. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/catalog/db.py +102 -3
  4. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/catalog/sync.py +35 -0
  5. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/queue/shadow.py +14 -0
  6. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/catalog.py +6 -5
  7. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/pages.py +10 -3
  8. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/static/css/main.css +96 -14
  9. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/catalog/browse.html +18 -7
  10. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/queue/index.html +34 -4
  11. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/pyproject.toml +1 -1
  12. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/.gitignore +0 -0
  15. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/README.md +0 -0
  16. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/config.example.json +0 -0
  17. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/PRE_PLAN_GAPS.md +0 -0
  25. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/docs/PRODUCT_PLAN.md +0 -0
  26. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/__init__.py +0 -0
  27. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/__main__.py +0 -0
  28. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  29. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/api_gate/client.py +0 -0
  30. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/app.py +0 -0
  31. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/auth/__init__.py +0 -0
  32. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/auth/otp.py +0 -0
  33. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  34. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/auth/session.py +0 -0
  35. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/catalog/__init__.py +0 -0
  36. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/catalog/images.py +0 -0
  37. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/config.py +0 -0
  38. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/jobs/__init__.py +0 -0
  39. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/jobs/manager.py +0 -0
  40. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/playlists/__init__.py +0 -0
  41. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/playlists/fire.py +0 -0
  42. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/playlists/importer.py +0 -0
  43. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  44. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/queue/__init__.py +0 -0
  45. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/queue/ordering.py +0 -0
  46. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/queue/poller.py +0 -0
  47. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/__init__.py +0 -0
  48. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  49. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  50. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  51. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  52. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/auth.py +0 -0
  53. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/queue.py +0 -0
  54. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/routes/user.py +0 -0
  55. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/static/js/main.js +0 -0
  56. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/admin/index.html +0 -0
  57. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  58. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  59. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  60. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/auth/login.html +0 -0
  61. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/base.html +0 -0
  62. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  63. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  64. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  65. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/ws/__init__.py +0 -0
  66. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/ws/handler.py +0 -0
  67. {kryten_webqueue-0.6.5 → kryten_webqueue-0.7.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  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
+ ## [0.7.0] - 2026-06-08
8
+
9
+ ### Added
10
+
11
+ - **Category & tag search facets on the catalog listing.** The category dropdown is now populated with the distinct MediaCMS categories that actually have items, and a new **Tags** dropdown sits beside it. Selecting either narrows the listing; both are preserved across pagination. Catalog sync now fetches per-item `categories_info`/`tags_info` from the MediaCMS media-detail endpoint (the bulk `manage_media` list omits them) and maintains the `categories`/`tags` join tables. Facet dropdowns only surface categories/tags with at least one item, and tags are ordered by usage.
12
+ - **"Hide Previous" toggle on the Queue page** — a switch in the Up Next header hides every item before the currently-playing one, for a cleaner view of what's still to come.
13
+
14
+ ### Fixed
15
+
16
+ - **Queue page no longer overflows the viewport width.** The three-column queue grid now uses `minmax(0, …)` tracks (and `min-width: 0` on its columns) so long titles or the no-wrap Now Playing times can't blow the layout past 100% width. The queue column was also slimmed slightly.
17
+
18
+ ## [0.6.6] - 2026-06-08
19
+
20
+ ### Changed
21
+
22
+ - **Queue view polish.** Three refinements to the Queue page: (1) **Predicted start times** now render reliably in the viewer's local timezone — the frontend defensively treats timezone-less timestamps as UTC before converting, so ETAs no longer risk being misread as server time. (2) **Now Playing card** is larger and easier to read — bigger cover art (128px), a larger title that wraps cleanly, and the elapsed/remaining times no longer wrap. (3) **The currently-playing item is now highlighted** in the queue list with an accent tint and ring. The now-playing playlist `uid` is resolved server-side (matching media id/type against the shadow playlist when CyTube's `changeMedia` payload omits it) so the matching queue item is identified reliably.
23
+
7
24
  ## [0.6.5] - 2026-06-08
8
25
 
9
26
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.6.5
3
+ Version: 0.7.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
@@ -1,8 +1,17 @@
1
1
  import aiosqlite
2
+ import re
2
3
  from pathlib import Path
3
4
  from datetime import datetime, UTC
4
5
 
5
6
 
7
+ def _slugify(text: str) -> str:
8
+ """Derive a URL-safe slug from a category title."""
9
+ s = (text or "").strip().lower()
10
+ s = re.sub(r"[^\w\s-]", "", s)
11
+ s = re.sub(r"[\s_-]+", "-", s)
12
+ return s.strip("-") or "untitled"
13
+
14
+
6
15
  MIGRATIONS = [
7
16
  # v1: Migration tracking table
8
17
  """
@@ -236,7 +245,7 @@ class Database:
236
245
 
237
246
  # --- Catalog ---
238
247
 
239
- async def browse(self, *, category: str | None = None, page: int = 1, per_page: int = 24) -> list[dict]:
248
+ async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24) -> list[dict]:
240
249
  query = """
241
250
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url
242
251
  FROM catalog c
@@ -256,6 +265,15 @@ class Database:
256
265
  )
257
266
  """
258
267
  params.append(category)
268
+ if tag:
269
+ query += """
270
+ AND c.friendly_token IN (
271
+ SELECT ct.friendly_token FROM catalog_tags ct
272
+ JOIN tags t ON ct.tag_id = t.id
273
+ WHERE t.name = ?
274
+ )
275
+ """
276
+ params.append(tag)
259
277
  # Quality-weighted ordering so the landing page leads with presentable
260
278
  # items instead of alphabetical junk. No curation required — every signal
261
279
  # is derived from existing data:
@@ -278,7 +296,7 @@ class Database:
278
296
  params.extend([per_page, (page - 1) * per_page])
279
297
  return await self._fetch_all(query, params)
280
298
 
281
- async def browse_count(self, *, category: str | None = None) -> int:
299
+ async def browse_count(self, *, category: str | None = None, tag: str | None = None) -> int:
282
300
  query = """
283
301
  SELECT COUNT(*) as cnt FROM catalog c
284
302
  WHERE c.friendly_token NOT IN (
@@ -297,6 +315,15 @@ class Database:
297
315
  )
298
316
  """
299
317
  params.append(category)
318
+ if tag:
319
+ query += """
320
+ AND c.friendly_token IN (
321
+ SELECT ct.friendly_token FROM catalog_tags ct
322
+ JOIN tags t ON ct.tag_id = t.id
323
+ WHERE t.name = ?
324
+ )
325
+ """
326
+ params.append(tag)
300
327
  row = await self._fetch_one(query, params)
301
328
  return row["cnt"] if row else 0
302
329
 
@@ -383,7 +410,79 @@ class Database:
383
410
  return row is not None
384
411
 
385
412
  async def get_categories(self) -> list[dict]:
386
- return await self._fetch_all("SELECT id, name, slug FROM categories ORDER BY name")
413
+ """Distinct categories that have at least one catalog item, for facets."""
414
+ return await self._fetch_all(
415
+ """
416
+ SELECT c.id, c.name, c.slug, COUNT(cc.friendly_token) AS cnt
417
+ FROM categories c
418
+ JOIN catalog_categories cc ON cc.category_id = c.id
419
+ GROUP BY c.id, c.name, c.slug
420
+ ORDER BY c.name
421
+ """
422
+ )
423
+
424
+ async def get_tags(self, *, limit: int = 100) -> list[dict]:
425
+ """Most-used tags that have at least one catalog item, for facets."""
426
+ return await self._fetch_all(
427
+ """
428
+ SELECT t.id, t.name, COUNT(ct.friendly_token) AS cnt
429
+ FROM tags t
430
+ JOIN catalog_tags ct ON ct.tag_id = t.id
431
+ GROUP BY t.id, t.name
432
+ ORDER BY cnt DESC, t.name ASC
433
+ LIMIT ?
434
+ """,
435
+ [limit],
436
+ )
437
+
438
+ async def upsert_category(self, name: str) -> int:
439
+ """Insert a category by name (deriving a unique slug) and return its id."""
440
+ existing = await self._fetch_one("SELECT id FROM categories WHERE name = ?", [name])
441
+ if existing:
442
+ return existing["id"]
443
+ base = _slugify(name)
444
+ slug, n = base, 1
445
+ while await self._fetch_one("SELECT 1 FROM categories WHERE slug = ?", [slug]):
446
+ n += 1
447
+ slug = f"{base}-{n}"
448
+ cursor = await self._db.execute(
449
+ "INSERT INTO categories (name, slug) VALUES (?, ?)", [name, slug]
450
+ )
451
+ await self._db.commit()
452
+ return cursor.lastrowid
453
+
454
+ async def upsert_tag(self, name: str) -> int:
455
+ """Insert a tag by name and return its id."""
456
+ existing = await self._fetch_one("SELECT id FROM tags WHERE name = ?", [name])
457
+ if existing:
458
+ return existing["id"]
459
+ cursor = await self._db.execute("INSERT INTO tags (name) VALUES (?)", [name])
460
+ await self._db.commit()
461
+ return cursor.lastrowid
462
+
463
+ async def set_catalog_categories(self, friendly_token: str, category_ids: list[int]):
464
+ """Replace the category memberships for a catalog item."""
465
+ await self._db.execute(
466
+ "DELETE FROM catalog_categories WHERE friendly_token = ?", [friendly_token]
467
+ )
468
+ for cid in category_ids:
469
+ await self._db.execute(
470
+ "INSERT OR IGNORE INTO catalog_categories (friendly_token, category_id) VALUES (?, ?)",
471
+ [friendly_token, cid],
472
+ )
473
+ await self._db.commit()
474
+
475
+ async def set_catalog_tags(self, friendly_token: str, tag_ids: list[int]):
476
+ """Replace the tag memberships for a catalog item."""
477
+ await self._db.execute(
478
+ "DELETE FROM catalog_tags WHERE friendly_token = ?", [friendly_token]
479
+ )
480
+ for tid in tag_ids:
481
+ await self._db.execute(
482
+ "INSERT OR IGNORE INTO catalog_tags (friendly_token, tag_id) VALUES (?, ?)",
483
+ [friendly_token, tid],
484
+ )
485
+ await self._db.commit()
387
486
 
388
487
  async def insert_catalog(self, row: dict):
389
488
  sql = """
@@ -129,6 +129,13 @@ class CatalogSync:
129
129
  await self._db.insert_catalog(row)
130
130
  stats["new"] += 1
131
131
 
132
+ # Categories & tags require a per-item detail fetch — the manage_media
133
+ # list serializer omits them. Best-effort; never fail the item over this.
134
+ try:
135
+ await self._sync_item_facets(token)
136
+ except Exception as e:
137
+ logger.debug(f"Facet sync failed for {token}: {e}")
138
+
132
139
  # Fetch TMDB/OMDB cover art if not already cached
133
140
  if self._cover_art and not (existing and existing.get("cover_art_path")):
134
141
  try:
@@ -137,6 +144,34 @@ class CatalogSync:
137
144
  logger.debug(f"Cover art resolve failed for {token}: {e}")
138
145
  await asyncio.sleep(0.25)
139
146
 
147
+ async def _sync_item_facets(self, token: str):
148
+ """Populate category/tag memberships from the media detail endpoint.
149
+
150
+ The list (manage_media) serializer omits categories/tags, so we fetch
151
+ the per-item detail (``/api/v1/media/{token}``) which exposes
152
+ ``categories_info`` and ``tags_info``.
153
+ """
154
+ resp = await self._client.get(f"{self._url}/api/v1/media/{token}")
155
+ if resp.status_code != 200:
156
+ return
157
+ data = resp.json()
158
+
159
+ cat_names = [
160
+ c.get("title")
161
+ for c in (data.get("categories_info") or [])
162
+ if isinstance(c, dict) and c.get("title")
163
+ ]
164
+ tag_names = []
165
+ for t in (data.get("tags_info") or []):
166
+ name = t.get("title") if isinstance(t, dict) else t
167
+ if name:
168
+ tag_names.append(name)
169
+
170
+ cat_ids = [await self._db.upsert_category(n) for n in cat_names]
171
+ tag_ids = [await self._db.upsert_tag(n) for n in tag_names]
172
+ await self._db.set_catalog_categories(token, cat_ids)
173
+ await self._db.set_catalog_tags(token, tag_ids)
174
+
140
175
  def _build_manifest_url(self, media: dict) -> str:
141
176
  # CyTube custom media ("cm") requires the manifest JSON URL, NOT the
142
177
  # human watch page (/view?m=TOKEN). MediaCMS exposes a CyTube manifest at
@@ -203,6 +203,20 @@ class QueueShadow:
203
203
  np.setdefault("thumbnail_url", meta.get("thumbnail_url"))
204
204
  if not np.get("friendly_token"):
205
205
  np["friendly_token"] = meta.get("friendly_token")
206
+ # Ensure now-playing carries a playlist uid so the frontend can
207
+ # highlight the matching queue item. CyTube's changeMedia payload
208
+ # lacks a uid; recover it by matching media id/type against the
209
+ # shadow playlist (whose items do carry uids).
210
+ if np.get("uid") is None:
211
+ np_id = np.get("id")
212
+ np_type = np.get("type")
213
+ if np_id is not None:
214
+ for it in enriched_items:
215
+ if it.get("media_id") == np_id and (
216
+ np_type is None or it.get("media_type") == np_type
217
+ ):
218
+ np["uid"] = it.get("uid")
219
+ break
206
220
  state["now_playing"] = np
207
221
 
208
222
  return state
@@ -6,13 +6,14 @@ router = APIRouter(prefix="/catalog", tags=["catalog"])
6
6
 
7
7
 
8
8
  @router.get("/browse")
9
- async def browse(request: Request, category: str | None = None, page: int = 1,
10
- user: dict = Depends(get_current_user)):
11
- """Browse catalog with optional category filter."""
9
+ async def browse(request: Request, category: str | None = None, tag: str | None = None,
10
+ page: int = 1, user: dict = Depends(get_current_user)):
11
+ """Browse catalog with optional category/tag filter."""
12
12
  db = request.app.state.db
13
- items = await db.browse(category=category, page=page)
13
+ items = await db.browse(category=category, tag=tag, page=page)
14
14
  categories = await db.get_categories()
15
- return {"items": items, "categories": categories, "page": page}
15
+ tags = await db.get_tags()
16
+ return {"items": items, "categories": categories, "tags": tags, "page": page}
16
17
 
17
18
 
18
19
  @router.get("/search")
@@ -40,22 +40,26 @@ async def login_page(request: Request):
40
40
 
41
41
 
42
42
  @router.get("/catalog/browse", response_class=HTMLResponse)
43
- async def catalog_browse_page(request: Request, category: str | None = None, page: int = 1):
43
+ async def catalog_browse_page(request: Request, category: str | None = None,
44
+ tag: str | None = None, page: int = 1):
44
45
  user = _get_user_or_none(request)
45
46
  if not user:
46
47
  return RedirectResponse("/auth/login")
47
48
  db = request.app.state.db
48
- items = await db.browse(category=category, page=page)
49
- total = await db.browse_count(category=category)
49
+ items = await db.browse(category=category, tag=tag, page=page)
50
+ total = await db.browse_count(category=category, tag=tag)
50
51
  total_pages = max(1, (total + 23) // 24)
51
52
  categories = await db.get_categories()
53
+ tags = await db.get_tags()
52
54
  return templates.TemplateResponse(request, "catalog/browse.html", {
53
55
  "user": user,
54
56
  "items": items,
55
57
  "categories": categories,
58
+ "tags": tags,
56
59
  "page": page,
57
60
  "total_pages": total_pages,
58
61
  "active_category": category,
62
+ "active_tag": tag,
59
63
  "query": None,
60
64
  })
61
65
 
@@ -72,13 +76,16 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1):
72
76
  total = await db.search_count(q)
73
77
  total_pages = max(1, (total + 23) // 24)
74
78
  categories = await db.get_categories()
79
+ tags = await db.get_tags()
75
80
  return templates.TemplateResponse(request, "catalog/browse.html", {
76
81
  "user": user,
77
82
  "items": items,
78
83
  "categories": categories,
84
+ "tags": tags,
79
85
  "page": page,
80
86
  "total_pages": total_pages,
81
87
  "active_category": None,
88
+ "active_tag": None,
82
89
  "query": q,
83
90
  })
84
91
 
@@ -139,12 +139,18 @@ a:hover {
139
139
  color: var(--text-primary);
140
140
  width: 300px;
141
141
  }
142
+ .category-filter {
143
+ display: flex;
144
+ gap: 0.5rem;
145
+ flex-wrap: wrap;
146
+ }
142
147
  .category-filter select {
143
148
  padding: 0.5rem;
144
149
  border: 1px solid var(--border);
145
150
  border-radius: var(--radius);
146
151
  background: var(--bg-secondary);
147
152
  color: var(--text-primary);
153
+ max-width: 220px;
148
154
  }
149
155
 
150
156
  .catalog-grid {
@@ -225,15 +231,77 @@ a:hover {
225
231
  /* Queue Page */
226
232
  .queue-layout {
227
233
  display: grid;
228
- grid-template-columns: 1fr 2fr 250px;
234
+ /* minmax(0, …) lets grid tracks shrink below their content's intrinsic
235
+ width, which prevents a long title or nowrap row from blowing the grid
236
+ (and the page) past 100% viewport width. */
237
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.7fr) 220px;
229
238
  gap: 2rem;
230
239
  }
240
+ /* Allow each column's flex/overflow children to shrink instead of overflowing. */
241
+ .now-playing-section,
242
+ .queue-section,
243
+ .queue-info-sidebar {
244
+ min-width: 0;
245
+ }
231
246
  @media (max-width: 900px) {
232
247
  .queue-layout {
233
248
  grid-template-columns: 1fr;
234
249
  }
235
250
  }
236
251
 
252
+ /* Up Next header with the Hide Previous toggle */
253
+ .queue-section-header {
254
+ display: flex;
255
+ align-items: center;
256
+ justify-content: space-between;
257
+ gap: 1rem;
258
+ flex-wrap: wrap;
259
+ }
260
+ .toggle-switch {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 0.5rem;
264
+ cursor: pointer;
265
+ user-select: none;
266
+ font-size: 0.85rem;
267
+ color: var(--text-secondary);
268
+ }
269
+ .toggle-switch input {
270
+ position: absolute;
271
+ opacity: 0;
272
+ width: 0;
273
+ height: 0;
274
+ }
275
+ .toggle-slider {
276
+ position: relative;
277
+ width: 38px;
278
+ height: 20px;
279
+ background: var(--border);
280
+ border-radius: 10px;
281
+ transition: background 0.15s ease;
282
+ flex-shrink: 0;
283
+ }
284
+ .toggle-slider::before {
285
+ content: "";
286
+ position: absolute;
287
+ top: 2px;
288
+ left: 2px;
289
+ width: 16px;
290
+ height: 16px;
291
+ background: #fff;
292
+ border-radius: 50%;
293
+ transition: transform 0.15s ease;
294
+ }
295
+ .toggle-switch input:checked + .toggle-slider {
296
+ background: var(--accent);
297
+ }
298
+ .toggle-switch input:checked + .toggle-slider::before {
299
+ transform: translateX(18px);
300
+ }
301
+ .toggle-switch input:focus-visible + .toggle-slider {
302
+ box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.5);
303
+ }
304
+
237
305
  /* Keep the now-playing card pinned while scrolling a long queue. */
238
306
  .now-playing-section {
239
307
  position: sticky;
@@ -249,10 +317,10 @@ a:hover {
249
317
  .now-playing-card {
250
318
  background: var(--bg-card);
251
319
  border-radius: var(--radius);
252
- padding: 1.5rem;
320
+ padding: 2rem;
253
321
  display: flex;
254
- gap: 1rem;
255
- align-items: flex-start;
322
+ gap: 1.5rem;
323
+ align-items: center;
256
324
  /* Highlight the currently-playing item: subtle accent ring + glow. */
257
325
  border: 1px solid var(--accent);
258
326
  box-shadow: 0 0 0 1px rgba(108, 92, 231, 0.25), 0 6px 18px rgba(108, 92, 231, 0.18);
@@ -263,13 +331,16 @@ a:hover {
263
331
  }
264
332
  .np-info h3 {
265
333
  margin-bottom: 0.5rem;
334
+ font-size: 1.4rem;
335
+ line-height: 1.25;
336
+ overflow-wrap: anywhere;
266
337
  }
267
338
  .np-progress {
268
- height: 4px;
339
+ height: 6px;
269
340
  background: var(--border);
270
- border-radius: 2px;
341
+ border-radius: 3px;
271
342
  overflow: hidden;
272
- margin: 0.5rem 0;
343
+ margin: 0.75rem 0;
273
344
  }
274
345
  .progress-bar {
275
346
  height: 100%;
@@ -277,7 +348,7 @@ a:hover {
277
348
  transition: width 1s linear;
278
349
  }
279
350
  .np-time {
280
- font-size: 0.8rem;
351
+ font-size: 0.9rem;
281
352
  color: var(--text-secondary);
282
353
  }
283
354
 
@@ -304,6 +375,15 @@ a:hover {
304
375
  .queue-item-paid {
305
376
  border-left-color: var(--accent);
306
377
  }
378
+ .queue-item-now-playing {
379
+ border-left-color: var(--accent);
380
+ background: linear-gradient(90deg, rgba(108, 92, 231, 0.18), var(--bg-card) 60%);
381
+ box-shadow: inset 0 0 0 1px rgba(108, 92, 231, 0.35);
382
+ }
383
+ .queue-item-now-playing .qi-title {
384
+ color: var(--accent);
385
+ font-weight: 600;
386
+ }
307
387
  .qi-pos {
308
388
  font-size: 0.75rem;
309
389
  color: var(--text-secondary);
@@ -332,10 +412,10 @@ a:hover {
332
412
  display: block;
333
413
  }
334
414
  .np-cover {
335
- width: 96px;
336
- height: 96px;
415
+ width: 128px;
416
+ height: 128px;
337
417
  flex-shrink: 0;
338
- border-radius: 6px;
418
+ border-radius: 8px;
339
419
  overflow: hidden;
340
420
  background: var(--bg-elevated, #222);
341
421
  }
@@ -397,15 +477,17 @@ a:hover {
397
477
  color: var(--text-secondary);
398
478
  }
399
479
  .np-meta {
400
- font-size: 0.8rem;
480
+ font-size: 0.9rem;
401
481
  color: var(--text-secondary);
402
482
  margin: 0.25rem 0;
403
483
  }
404
484
  .np-times {
405
485
  display: flex;
406
486
  justify-content: space-between;
407
- font-size: 0.8rem;
408
- margin-top: 0.25rem;
487
+ gap: 1rem;
488
+ font-size: 0.9rem;
489
+ margin-top: 0.5rem;
490
+ white-space: nowrap;
409
491
  }
410
492
  .np-remaining {
411
493
  color: var(--text-secondary);
@@ -11,12 +11,18 @@
11
11
  <button type="submit" class="btn btn-sm">Search</button>
12
12
  </form>
13
13
  <div class="category-filter">
14
- <select id="category-select" onchange="filterCategory(this.value)">
14
+ <select id="category-select" onchange="applyFacets()">
15
15
  <option value="">All Categories</option>
16
16
  {% for cat in categories %}
17
17
  <option value="{{ cat.slug }}" {% if active_category == cat.slug %}selected{% endif %}>{{ cat.name }}</option>
18
18
  {% endfor %}
19
19
  </select>
20
+ <select id="tag-select" onchange="applyFacets()">
21
+ <option value="">All Tags</option>
22
+ {% for tag in tags %}
23
+ <option value="{{ tag.name }}" {% if active_tag == tag.name %}selected{% endif %}>{{ tag.name }}</option>
24
+ {% endfor %}
25
+ </select>
20
26
  </div>
21
27
  </div>
22
28
  </div>
@@ -66,7 +72,7 @@
66
72
 
67
73
  <div class="pagination">
68
74
  {% if page > 1 %}
69
- <a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">&larr; Prev</a>
75
+ <a href="?page={{ page - 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}" class="btn btn-sm">&larr; Prev</a>
70
76
  {% endif %}
71
77
 
72
78
  {% set start_page = [1, page - 2] | max %}
@@ -75,23 +81,28 @@
75
81
  {% if p == page %}
76
82
  <span class="page-num page-current">{{ p }}</span>
77
83
  {% else %}
78
- <a href="?page={{ p }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm btn-page">{{ p }}</a>
84
+ <a href="?page={{ p }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}" class="btn btn-sm btn-page">{{ p }}</a>
79
85
  {% endif %}
80
86
  {% endfor %}
81
87
 
82
88
  <span class="page-info">of {{ total_pages }}</span>
83
89
 
84
90
  {% if page < total_pages %}
85
- <a href="?page={{ page + 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}" class="btn btn-sm">Next &rarr;</a>
91
+ <a href="?page={{ page + 1 }}{% if query %}&q={{ query }}{% endif %}{% if active_category %}&category={{ active_category }}{% endif %}{% if active_tag %}&tag={{ active_tag | urlencode }}{% endif %}" class="btn btn-sm">Next &rarr;</a>
86
92
  {% endif %}
87
93
  </div>
88
94
  {% endblock %}
89
95
 
90
96
  {% block scripts %}
91
97
  <script>
92
- function filterCategory(slug) {
93
- const url = slug ? `/catalog/browse?category=${slug}` : '/catalog/browse';
94
- window.location.href = url;
98
+ function applyFacets() {
99
+ const cat = document.getElementById('category-select').value;
100
+ const tag = document.getElementById('tag-select').value;
101
+ const params = new URLSearchParams();
102
+ if (cat) params.set('category', cat);
103
+ if (tag) params.set('tag', tag);
104
+ const qs = params.toString();
105
+ window.location.href = '/catalog/browse' + (qs ? '?' + qs : '');
95
106
  }
96
107
  // queueItem / playNext / queueAsAdmin are provided by /static/js/main.js
97
108
  </script>
@@ -12,7 +12,14 @@
12
12
  </div>
13
13
 
14
14
  <div class="queue-section">
15
- <h2>Up Next</h2>
15
+ <div class="queue-section-header">
16
+ <h2>Up Next</h2>
17
+ <label class="toggle-switch" title="Hide items before the currently playing one">
18
+ <input type="checkbox" id="hide-previous-toggle" onchange="toggleHidePrevious(this.checked)">
19
+ <span class="toggle-slider"></span>
20
+ <span>Hide Previous</span>
21
+ </label>
22
+ </div>
16
23
  <div id="queue-list" class="queue-list">
17
24
  <p class="empty-state">Connecting...</p>
18
25
  </div>
@@ -36,6 +43,13 @@
36
43
  <script>
37
44
  let ws = null;
38
45
  let reconnectTimer = null;
46
+ let hidePrevious = false;
47
+ let lastState = null;
48
+
49
+ function toggleHidePrevious(checked) {
50
+ hidePrevious = checked;
51
+ if (lastState) renderQueue(lastState);
52
+ }
39
53
 
40
54
  function connectWebSocket() {
41
55
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -75,6 +89,7 @@ function connectWebSocket() {
75
89
  }
76
90
 
77
91
  function renderQueue(state) {
92
+ lastState = state;
78
93
  const npEl = document.getElementById('now-playing');
79
94
  const qlEl = document.getElementById('queue-list');
80
95
  const sumEl = document.getElementById('queue-summary');
@@ -110,8 +125,15 @@ function renderQueue(state) {
110
125
  const totalDuration = state.items.reduce((sum, i) => sum + (i.duration_sec || 0), 0);
111
126
  sumEl.innerHTML = `<span>${state.items.length} items · ${formatTime(totalDuration)} total</span>`;
112
127
 
113
- qlEl.innerHTML = state.items.map((item, i) => `
114
- <div class="queue-item ${item.is_pay ? 'queue-item-paid' : ''}" data-uid="${item.uid}">
128
+ const nowUid = state.now_playing && state.now_playing.uid != null
129
+ ? String(state.now_playing.uid) : null;
130
+ const nowIndex = nowUid != null
131
+ ? state.items.findIndex(it => String(it.uid) === nowUid) : -1;
132
+ qlEl.innerHTML = state.items
133
+ .map((item, i) => ({ item, i }))
134
+ .filter(({ i }) => !(hidePrevious && nowIndex >= 0 && i < nowIndex))
135
+ .map(({ item, i }) => `
136
+ <div class="queue-item ${item.is_pay ? 'queue-item-paid' : ''} ${nowUid != null && String(item.uid) === nowUid ? 'queue-item-now-playing' : ''}" data-uid="${item.uid}">
115
137
  <span class="qi-drag" title="Drag to reorder">☰</span>
116
138
  <span class="qi-pos">${i + 1}</span>
117
139
  <div class="qi-cover">${coverHtml(item)}</div>
@@ -166,7 +188,15 @@ function coverHtml(item) {
166
188
  }
167
189
 
168
190
  function formatEta(isoStr) {
169
- const d = new Date(isoStr);
191
+ // Server emits UTC ISO timestamps. If a value arrives without an explicit
192
+ // timezone designator, treat it as UTC so the browser converts it to the
193
+ // viewer's local timezone rather than misreading it as local time.
194
+ let s = String(isoStr);
195
+ if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) {
196
+ s += 'Z';
197
+ }
198
+ const d = new Date(s);
199
+ if (isNaN(d.getTime())) return '';
170
200
  return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
171
201
  }
172
202
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.6.5"
3
+ version = "0.7.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"