kryten-webqueue 0.15.2__tar.gz → 0.17.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 (105) hide show
  1. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/CHANGELOG.md +21 -0
  2. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/PKG-INFO +1 -1
  3. kryten_webqueue-0.17.0/docs/UX_POLISH_PLAN.md +246 -0
  4. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/db.py +42 -2
  5. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/catalog.py +8 -4
  6. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/pages.py +5 -4
  7. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/static/css/main.css +56 -1
  8. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/static/js/main.js +33 -0
  9. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/playlists.html +4 -4
  10. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/promos.html +3 -3
  11. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/schedules.html +1 -1
  12. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/base.html +17 -0
  13. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/catalog/browse.html +34 -6
  14. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/pyproject.toml +1 -1
  15. kryten_webqueue-0.17.0/tests/test_search_facets.py +81 -0
  16. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/.github/workflows/python-publish.yml +0 -0
  17. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/.github/workflows/release.yml +0 -0
  18. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/.gitignore +0 -0
  19. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/README.md +0 -0
  20. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/config.example.json +0 -0
  21. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  29. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/PRE_PLAN_GAPS.md +0 -0
  30. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/PRODUCT_PLAN.md +0 -0
  31. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  32. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/__init__.py +0 -0
  33. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/__main__.py +0 -0
  34. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  35. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/api_gate/client.py +0 -0
  36. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/app.py +0 -0
  37. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/__init__.py +0 -0
  38. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/otp.py +0 -0
  39. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  40. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/auth/session.py +0 -0
  41. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/__init__.py +0 -0
  42. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/images.py +0 -0
  43. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  44. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/catalog/sync.py +0 -0
  45. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/config.py +0 -0
  46. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/__init__.py +0 -0
  47. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  48. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  49. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  50. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  51. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  52. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  53. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  54. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  55. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/__init__.py +0 -0
  56. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  57. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/manager.py +0 -0
  58. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/jobs/tasks.py +0 -0
  59. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/logging_config.py +0 -0
  60. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/__init__.py +0 -0
  61. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  62. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/fire.py +0 -0
  63. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/importer.py +0 -0
  64. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/ordering.py +0 -0
  65. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  66. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/promos/__init__.py +0 -0
  67. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/promos/director.py +0 -0
  68. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/__init__.py +0 -0
  69. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/ordering.py +0 -0
  70. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/poller.py +0 -0
  71. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/presence.py +0 -0
  72. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/queue/shadow.py +0 -0
  73. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/__init__.py +0 -0
  74. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  75. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  76. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  77. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  78. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  79. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  80. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/auth.py +0 -0
  81. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/queue.py +0 -0
  82. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/routes/user.py +0 -0
  83. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/index.html +0 -0
  84. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  85. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/auth/login.html +0 -0
  86. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  90. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_promo_director.py +0 -0
  103. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_promo_pool_exclusion.py +0 -0
  104. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_queue_announce.py +0 -0
  105. {kryten_webqueue-0.15.2 → kryten_webqueue-0.17.0}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.17.0] — 2026-06-17
10
+
11
+ ### Added
12
+
13
+ - **Search now combines with category/tag filters.** A free-text search ANDs with the selected category and/or tag instead of ignoring them. `db.search()` / `db.search_count()` accept `category` and `tag`; the `/catalog/search` page and JSON route pass them through and keep the dropdowns populated/selected; `applyFacets()` includes the active facets when a query is present. (Shared `_facet_filter()` SQL helper keeps browse and search behavior identical.)
14
+ - **Clear empty-results messaging.** When a search and/or facet filter returns nothing, the catalog page now explains *why* — naming the active query and filters — and offers one-click escapes ("Search without filters", "Clear filters", "Back to browse") instead of a bare "No items found."
15
+
16
+ [0.17.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.17.0
17
+
18
+ ## [0.16.0] — 2026-06-17
19
+
20
+ ### Added
21
+
22
+ - **Light / dark theme toggle.** A navbar button switches between dark (default) and light themes. The choice persists in `localStorage`; first-time visitors follow their OS `prefers-color-scheme`. An inline pre-paint script in `base.html` applies the saved/preferred theme before first render to avoid a flash. Light/dark palettes are defined as `:root[data-theme="..."]` overrides of the existing CSS variables, so the whole UI re-themes without per-component changes.
23
+
24
+ ### Changed
25
+
26
+ - **Clearer playlist terminology (display only).** Admin playlist/schedule/promo screens now label reserved playlists as **Non-preemptable** and normal ones as **Preemptable** (previously "Immutable" / "Mutable"). This is a wording change only — the `is_immutable` data field, API payloads, and config keys are unchanged.
27
+
28
+ [0.16.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.16.0
29
+
9
30
  ## [0.15.2] — 2026-06-17
10
31
 
11
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.15.2
3
+ Version: 0.17.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
@@ -0,0 +1,246 @@
1
+ # kryten-webqueue — UX Polish & Convenience Plan
2
+
3
+ Status: **proposed (awaiting approval)**. No application code changed yet.
4
+
5
+ This plan covers seven UX/convenience items. Each can ship independently; grouped
6
+ into phases so each phase is independently verifiable and releasable.
7
+
8
+ Scope note: display-only relabeling and front-end live-refresh are deliberately
9
+ kept separate from data-model/API changes so nothing downstream (api-gate,
10
+ economy, DB schema) needs to move in lock-step.
11
+
12
+ ---
13
+
14
+ ## Item 1 — Active event shows as "active" long after it ended
15
+
16
+ **Root cause.** The `active_schedule` row (single row, `id=1`) is only removed by
17
+ the admin "Clear Active Schedule" button (`clear_active_schedule()` →
18
+ `DELETE FROM active_schedule`). The in-progress *lock* auto-lifts
19
+ (`disable_active_lock()` sets `lock_disabled=1` when the last scheduled item
20
+ begins), but the **row itself persists**, so the admin banner keeps showing the
21
+ event. `estimated_end_at` is computed once at fire time and never enforced.
22
+
23
+ Relevant code:
24
+ - `catalog/db.py` — `set_active_schedule()`, `get_active_schedule()`,
25
+ `clear_active_schedule()`, `disable_active_lock()`, `is_event_lock_active()`,
26
+ and `active_schedule` schema (`last_item_uid`, `lock_disabled`, `estimated_end_at`).
27
+ - `queue/shadow.py` — `_maybe_lift_event_lock()` already detects when the last
28
+ scheduled item is playing (event effectively over).
29
+ - `templates/admin/schedules.html` — `loadActive()` renders the banner once.
30
+
31
+ **Fix (event-driven primary + safety net).**
32
+ 1. Backend: when the scheduled event is truly over, clear the active row, don't
33
+ just lift the lock. Extend the existing `_maybe_lift_event_lock()` logic in
34
+ `shadow.apply_poll_result()`: once the last scheduled item (`last_item_uid`)
35
+ has played out (i.e. now-playing has advanced *past* it, or the row is gone
36
+ from the queue), call a new `db.expire_active_schedule_if_done()` that clears
37
+ the row. Keeps the existing "lift lock when last item *starts*" behavior, and
38
+ adds "clear active when last item *ends*".
39
+ 2. Backend safety net: in the same path, if `estimated_end_at` is more than a
40
+ small grace (e.g. 5 min) in the past, clear the active row. Guards against a
41
+ missed boundary (manual queue edits, restart during an event).
42
+ 3. Frontend: `loadActive()` treats a past `estimated_end_at` as "ended" (hide
43
+ banner) even before the backend clears it, and refreshes (see Item 2).
44
+
45
+ **Verification.** Fire a short immutable schedule; confirm the banner clears
46
+ shortly after the last item finishes (not on reload). Unit test the new db
47
+ helper: row present → played past last item → row cleared. Test the
48
+ `estimated_end_at` grace path.
49
+
50
+ ---
51
+
52
+ ## Item 2 — Admin page doesn't live-update (queue, now-playing, jobs)
53
+
54
+ **Root cause.** `templates/admin/index.html` calls `loadAdminData()` /
55
+ `loadJobs()` **once** on load. Unlike `templates/queue/index.html`, it never
56
+ opens the `/ws` WebSocket, so queue size / now-playing / job status only refresh
57
+ on a manual reload. The poller already broadcasts `{"type":"queue_state"}` every
58
+ ~3s (`queue/poller.py`).
59
+
60
+ **Fix.**
61
+ 1. Subscribe the admin page to `/ws` (reuse the queue page's connect pattern).
62
+ On `queue_state`, update the "Queue Status" block (items count + now-playing).
63
+ On `schedule_fired`, refresh the active-schedule banner + queue status.
64
+ 2. Jobs: lightweight `setInterval` (e.g. every 5s) re-fetch of `/admin/jobs` and
65
+ `/admin/jobs/runs?limit=10` while the tab is visible (pause via
66
+ `document.visibilityState` to avoid background churn). Jobs are DB-polled, not
67
+ broadcast, so polling is the pragmatic choice; interval is cheap.
68
+ 3. Active schedule banner (shared with Item 1): re-render on the same interval
69
+ and on `schedule_fired`.
70
+
71
+ Relevant code:
72
+ - `templates/admin/index.html` — `loadAdminData()`, `loadJobs()`, init at bottom.
73
+ - `templates/queue/index.html` — `connectWebSocket()` reference implementation.
74
+ - `templates/admin/schedules.html` — `loadActive()` (extract/reuse).
75
+
76
+ **Verification.** Open admin page; queue another item from CyTube → count and
77
+ now-playing update within a few seconds without reload. Run a job → its status
78
+ flips to running then completed live.
79
+
80
+ ---
81
+
82
+ ## Item 3 — Richer logging, especially fetchurls failures
83
+
84
+ **Current state.** `jobs/manager.py::_execute()` already logs unexpected failures
85
+ with a full traceback (`logger.exception`) and records `{type}: {msg}` to the
86
+ `job_runs.detail`; `JobError` logs a clean WARNING. The integration
87
+ (`integrations/cmsutils/fetchurls.py`) emits progress phases but uses `print()`
88
+ for per-URL/per-section detail, so that detail never reaches the app logger or
89
+ the job record.
90
+
91
+ Gaps to close:
92
+ - fetchurls per-section results (resolved/failed counts + the failing URLs &
93
+ Excel row numbers) are not summarized into the logger or `job_runs.detail`.
94
+ - SharePoint download failures wrap into a `RuntimeError` that loses the HTTP
95
+ status / response snippet.
96
+ - `JobContext.progress()` swallows DB errors at debug only (acceptable, but note).
97
+ - General: the global log format (added in `logging_config.py`) can include
98
+ `filename:lineno` to make every line more actionable.
99
+
100
+ **Fix.**
101
+ 1. `jobs/tasks.py::fetchurls_job` (and `_import_section_as_playlist`): after the
102
+ run, log an INFO summary per section (`name: resolved X / failed Y`) and a
103
+ WARNING listing each failed URL with its row number; fold a compact
104
+ `failures` array into the returned `result` so it lands in `job_runs.detail`
105
+ and shows in the admin "Detail" column.
106
+ 2. Enrich the SharePoint download error to include HTTP status + a short response
107
+ excerpt before raising.
108
+ 3. `logging_config.py`: extend the default formatter to
109
+ `%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d: %(message)s`
110
+ (app loggers only; keep uvicorn/access formats lean).
111
+ 4. Convert the most useful `print()` lines in the fetchurls integration to
112
+ `logger.info/warning` (guarded so standalone CLI use still prints).
113
+
114
+ **Verification.** Run fetchurls with a deliberately bad URL; confirm the admin
115
+ job "Detail" shows the failing URL + row, and the process log has an INFO
116
+ section summary + WARNING per failure with `file:line`.
117
+
118
+ ---
119
+
120
+ ## Item 4 — Search string and category/tag filters don't combine
121
+
122
+ **Root cause.** Two separate code paths. `db.browse()` ANDs category+tag via
123
+ subqueries; `db.search()` (FTS5 `MATCH`) accepts **no** category/tag. The
124
+ frontend `applyFacets()` drops the facet dropdowns entirely when a query is
125
+ active (`if (CURRENT_QUERY) {...} else { set category/tag }`), and
126
+ `/catalog/search` neither accepts facets nor returns facet lists.
127
+
128
+ **Fix (recommended: make them AND together).**
129
+ 1. `db.search()`: add optional `category` / `tag` params and append the same
130
+ `AND friendly_token IN (… categories …)` / `AND … IN (… tags …)` subqueries
131
+ `browse()` already uses (intersection with the FTS match).
132
+ 2. `routes/catalog.py::search`: accept `category` & `tag`, pass through, and also
133
+ return `categories`/`tags` facet lists (like `/browse`) plus `active_category`
134
+ / `active_tag` so the template can keep selections.
135
+ 3. `templates/catalog/browse.html::applyFacets()`: when a query is present,
136
+ include `category`/`tag` in the search URL instead of discarding them; keep
137
+ the dropdowns populated and selected on the results page.
138
+
139
+ Fallback option (if you'd rather not expand search): visually disable the facet
140
+ dropdowns on a search results page with a tooltip ("Clear search to filter by
141
+ category/tag"). Cheaper, but less capable. **Recommendation: do the real fix.**
142
+
143
+ **Verification.** Search "matrix" + pick a category → results are the
144
+ intersection. Remove the query → browse facets still AND as before. Add a small
145
+ test for `db.search(category=…, tag=…)`.
146
+
147
+ ---
148
+
149
+ ## Item 5 — Rename Mutable/Immutable → Preemptable/Non-preemptable (display only)
150
+
151
+ **Root cause.** Pure UX wording. The data field `is_immutable` (DB column, API
152
+ body, JS variable) must stay; only visible labels change.
153
+
154
+ **Fix (display-only).** In `templates/admin/playlists.html`:
155
+ - Badges: "Immutable" → "Non-preemptable"; "Mutable" → "Preemptable" (L~96).
156
+ - Create-modal checkbox label (L~128) and editor metadata text (L~164/170).
157
+ - Confirm dialog copy in `toggleImmutable()` if it references the words.
158
+ - Keep the button verbs ("Reserve"/"Release") as-is (decision 2 default).
159
+ In `templates/admin/schedules.html`: the active-banner "Immutable" badge (L~40)
160
+ → "Non-preemptable".
161
+
162
+ Do **not** change: `is_immutable` column, `set_active_schedule(is_immutable=…)`,
163
+ API request/response keys, JS variable names, or config keys.
164
+
165
+ **Verification.** Grep templates for user-visible "mutable"/"immutable" → none
166
+ remain (data attributes/keys excluded). Page renders new labels; toggle still
167
+ posts `is_immutable`.
168
+
169
+ ---
170
+
171
+ ## Item 6 — Zcoin dashboard: tabbed container + wider account column
172
+
173
+ **Current.** `templates/user/dashboard.html` is a 3-column grid
174
+ (`balance-card | history-card | transactions-card`), with vanity controls
175
+ crammed into the left balance card.
176
+
177
+ **Fix.** Two-region layout:
178
+ - **Left (widened) account card:** balance, rank/level, progress, perks. Remove
179
+ the vanity block from here.
180
+ - **Right (wide) tabbed container** with three tabs, lazy-loaded on first show:
181
+ 1. **Queue History** (existing `loadQueue()` + pager)
182
+ 2. **Recent Transactions** (existing `loadTransactions()` + credit/debit toggle)
183
+ 3. **Vanity Items** (moved here: greeting + chat-color editors, with room to
184
+ grow to other econ-surfaced properties later)
185
+
186
+ Implementation:
187
+ - Restructure `dashboard.html`: account card + `.tabs` (buttons) + `.tab-panel`s.
188
+ - Reuse existing JS (`loadAccount`, `loadQueue`, `loadTransactions`, vanity
189
+ dialogs); add a tiny tab controller that lazy-loads each panel once.
190
+ - `static/css/main.css`: change `.dashboard-grid` to a 2-column layout
191
+ (e.g. `minmax(280px, 360px) 1fr`), collapse to 1 column under ~900px; add
192
+ `.tabs`, `.tab-btn.active`, `.tab-panel[hidden]` styles (reuse `--accent`,
193
+ `--border`, etc.). Mirror the existing `.tx-toggle` styling for consistency.
194
+
195
+ **Verification.** Dashboard shows account card + tabs; switching tabs loads each
196
+ once; vanity edit/purchase still works from its tab; responsive collapse at
197
+ narrow widths. Existing economy endpoints unchanged.
198
+
199
+ ---
200
+
201
+ ## Item 7 — Light / dark mode
202
+
203
+ **Foundation.** `static/css/main.css` already drives the entire UI from CSS
204
+ variables on `:root` (`--bg-*`, `--text-*`, `--accent`, `--border`, …). Adding a
205
+ theme is mainly a palette swap + a toggle; no per-component CSS rewrite needed.
206
+
207
+ **Fix.**
208
+ 1. Define a light palette under `:root[data-theme="light"]` (and keep the current
209
+ dark values as the default `:root`). Tune `--bg-*`, `--text-*`, `--border`,
210
+ `--shadow`; keep `--accent` family. Add an explicit
211
+ `:root[data-theme="dark"]` block equal to the defaults so the toggle is
212
+ symmetric.
213
+ 2. Default behavior: respect `prefers-color-scheme` when the user hasn't chosen;
214
+ persist an explicit choice in `localStorage` (`wq_theme`).
215
+ 3. No-FOUC: a tiny inline script in `base.html <head>` sets
216
+ `document.documentElement.dataset.theme` from `localStorage`/media query
217
+ **before** CSS paints.
218
+ 4. Toggle control in the navbar (`base.html`), wired in `static/js/main.js`:
219
+ flips `data-theme`, saves to `localStorage`, updates the icon/label.
220
+ Default: icon-only (🌙/☀️) with `aria-label` (decision 3 default).
221
+ 5. Audit a few hard-coded colors (e.g. badge `rgba(...)` backgrounds, toast,
222
+ modal overlay) for acceptable contrast in light mode; promote any offenders
223
+ to variables.
224
+
225
+ **Verification.** Toggle flips instantly with no flash on reload; choice
226
+ persists; fresh visitor matches OS preference; spot-check catalog, queue, admin,
227
+ dashboard (incl. new tabs) and modals/toasts in both themes for contrast.
228
+
229
+ ---
230
+
231
+ ## Suggested phasing (each independently releasable)
232
+
233
+ - **Phase A (quick wins / low risk):** Item 5 (relabel), Item 7 (theme).
234
+ - **Phase B (admin live + lifecycle):** Item 1 (active-event expiry) + Item 2
235
+ (admin live-update) — they share the active-schedule banner refresh.
236
+ - **Phase C (catalog):** Item 4 (search × facets).
237
+ - **Phase D (dashboard):** Item 6 (tabs) — self-contained.
238
+ - **Phase E (observability):** Item 3 (logging) — can land anytime; complements
239
+ the earlier promo observability work.
240
+
241
+ ## Open questions / decisions (defaults chosen)
242
+ 1. Item 4: real combine **(recommended, default)** vs. disable-with-tooltip.
243
+ 2. Item 5: relabel **nouns only** (default), leave the "Reserve/Release" verbs.
244
+ 3. Item 7: navbar toggle **icon-only** (🌙/☀️) with `aria-label` (default).
245
+ 4. Versioning: **one minor (e.g. 0.16.0) per phase** as completed (default),
246
+ vs. batch all into one.
@@ -59,6 +59,36 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
59
59
  return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
60
60
 
61
61
 
62
+ def _facet_filter(alias: str, category: str | None, tag: str | None) -> tuple[str, list]:
63
+ """SQL fragment (+ params) AND-filtering by a category slug and/or tag name.
64
+
65
+ Each filter is an ``AND friendly_token IN (...)`` subquery on the catalog row
66
+ under ``alias``; an absent filter contributes nothing. Shared by browse() and
67
+ search() so the two paths narrow results identically.
68
+ """
69
+ sql = ""
70
+ params: list = []
71
+ if category:
72
+ sql += f"""
73
+ AND {alias}.friendly_token IN (
74
+ SELECT cc.friendly_token FROM catalog_categories cc
75
+ JOIN categories cat ON cc.category_id = cat.id
76
+ WHERE cat.slug = ?
77
+ )
78
+ """
79
+ params.append(category)
80
+ if tag:
81
+ sql += f"""
82
+ AND {alias}.friendly_token IN (
83
+ SELECT ct.friendly_token FROM catalog_tags ct
84
+ JOIN tags t ON ct.tag_id = t.id
85
+ WHERE t.name = ?
86
+ )
87
+ """
88
+ params.append(tag)
89
+ return sql, params
90
+
91
+
62
92
  # Default quality-weighted ordering (see browse() for rationale).
63
93
  _DEFAULT_ORDER = """
64
94
  ORDER BY
@@ -446,7 +476,8 @@ class Database:
446
476
  row = await self._fetch_one(query, params)
447
477
  return row["cnt"] if row else 0
448
478
 
449
- async def search(self, query_text: str, *, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
479
+ async def search(self, query_text: str, *, category: str | None = None, tag: str | None = None,
480
+ page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
450
481
  sql = """
451
482
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
452
483
  rank AS relevance
@@ -464,6 +495,11 @@ class Database:
464
495
  excl_sql, excl_params = _hidden_exclusion("c")
465
496
  sql += excl_sql
466
497
  params.extend(excl_params)
498
+ # Category/tag facets AND with the text match (same subqueries browse()
499
+ # uses), so a search can be narrowed by the selected facets.
500
+ facet_sql, facet_params = _facet_filter("c", category, tag)
501
+ sql += facet_sql
502
+ params.extend(facet_params)
467
503
  # Relevance is the natural default for a text query; other sort keys let
468
504
  # the user reorder the matched set explicitly.
469
505
  sql += " ORDER BY rank " if (sort or "default") == "default" else _browse_order_clause(sort)
@@ -471,7 +507,8 @@ class Database:
471
507
  params.extend([per_page, (page - 1) * per_page])
472
508
  return await self._fetch_all(sql, params)
473
509
 
474
- async def search_count(self, query_text: str, *, show_hidden: bool = False) -> int:
510
+ async def search_count(self, query_text: str, *, category: str | None = None, tag: str | None = None,
511
+ show_hidden: bool = False) -> int:
475
512
  sql = """
476
513
  SELECT COUNT(*) as cnt
477
514
  FROM catalog_fts fts
@@ -488,6 +525,9 @@ class Database:
488
525
  excl_sql, excl_params = _hidden_exclusion("c")
489
526
  sql += excl_sql
490
527
  params.extend(excl_params)
528
+ facet_sql, facet_params = _facet_filter("c", category, tag)
529
+ sql += facet_sql
530
+ params.extend(facet_params)
491
531
  row = await self._fetch_one(sql, params)
492
532
  return row["cnt"] if row else 0
493
533
 
@@ -19,15 +19,19 @@ async def browse(request: Request, category: str | None = None, tag: str | None
19
19
 
20
20
 
21
21
  @router.get("/search")
22
- async def search(request: Request, q: str = "", page: int = 1, show_hidden: int = 0,
22
+ async def search(request: Request, q: str = "", category: str | None = None, tag: str | None = None,
23
+ page: int = 1, show_hidden: int = 0,
23
24
  sort: str = "default", user: dict = Depends(get_current_user)):
24
- """Full-text search of catalog."""
25
+ """Full-text search of catalog, optionally narrowed by category/tag."""
25
26
  if not q.strip():
26
27
  raise HTTPException(400, "Query required")
27
28
  db = request.app.state.db
28
29
  show_hidden = bool(show_hidden) and (user.get("rank") or 0) >= 3
29
- items = await db.search(q, page=page, show_hidden=show_hidden, sort=sort)
30
- return {"items": items, "query": q, "page": page, "sort": sort}
30
+ items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort)
31
+ categories = await db.get_categories(show_hidden=show_hidden)
32
+ tags = await db.get_tags(show_hidden=show_hidden)
33
+ return {"items": items, "categories": categories, "tags": tags,
34
+ "query": q, "active_category": category, "active_tag": tag, "page": page, "sort": sort}
31
35
 
32
36
 
33
37
  @router.get("/item/{friendly_token}")
@@ -107,6 +107,7 @@ async def catalog_browse_page(request: Request, category: str | None = None,
107
107
 
108
108
  @router.get("/catalog/search", response_class=HTMLResponse)
109
109
  async def catalog_search_page(request: Request, q: str = "", page: int = 1,
110
+ category: str | None = None, tag: str | None = None,
110
111
  show_hidden: int = 0, sort: str = "default"):
111
112
  user = _get_user_or_none(request)
112
113
  if not user:
@@ -117,8 +118,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
117
118
  is_admin = (user.get("rank") or 0) >= 3
118
119
  show_hidden = bool(show_hidden) and is_admin
119
120
  sort = sort if sort in _VALID_SORTS else "default"
120
- items = await db.search(q, page=page, show_hidden=show_hidden, sort=sort)
121
- total = await db.search_count(q, show_hidden=show_hidden)
121
+ items = await db.search(q, category=category, tag=tag, page=page, show_hidden=show_hidden, sort=sort)
122
+ total = await db.search_count(q, category=category, tag=tag, show_hidden=show_hidden)
122
123
  total_pages = max(1, (total + 23) // 24)
123
124
  categories = await db.get_categories(show_hidden=show_hidden)
124
125
  tags = await db.get_tags(show_hidden=show_hidden)
@@ -130,8 +131,8 @@ async def catalog_search_page(request: Request, q: str = "", page: int = 1,
130
131
  "tags": tags,
131
132
  "page": page,
132
133
  "total_pages": total_pages,
133
- "active_category": None,
134
- "active_tag": None,
134
+ "active_category": category,
135
+ "active_tag": tag,
135
136
  "query": q,
136
137
  "is_admin": is_admin,
137
138
  "show_hidden": show_hidden,
@@ -1,6 +1,9 @@
1
1
  /* kryten-webqueue — Main Stylesheet */
2
2
 
3
3
  :root {
4
+ /* Default (dark) palette. An explicit [data-theme="dark"] block below mirrors
5
+ these so the navbar toggle is symmetric; [data-theme="light"] overrides
6
+ them. Theme-independent tokens (radius, fonts, nav height) live here. */
4
7
  --bg-primary: #0f0f14;
5
8
  --bg-secondary: #1a1a24;
6
9
  --bg-card: #22222e;
@@ -13,13 +16,48 @@
13
16
  --warning: #fdcb6e;
14
17
  --danger: #e17055;
15
18
  --border: #33334a;
16
- --radius: 8px;
17
19
  --shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
20
+ --radius: 8px;
18
21
  --nav-height: 4rem;
19
22
  --font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
20
23
  --font-heading: 'Sora', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
21
24
  }
22
25
 
26
+ /* Explicit dark theme (mirrors the defaults) — set by the toggle. */
27
+ :root[data-theme="dark"] {
28
+ --bg-primary: #0f0f14;
29
+ --bg-secondary: #1a1a24;
30
+ --bg-card: #22222e;
31
+ --bg-hover: #2a2a3a;
32
+ --text-primary: #e8e8f0;
33
+ --text-secondary: #9090a8;
34
+ --accent: #6c5ce7;
35
+ --accent-hover: #7f70f0;
36
+ --success: #00b894;
37
+ --warning: #fdcb6e;
38
+ --danger: #e17055;
39
+ --border: #33334a;
40
+ --shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
41
+ }
42
+
43
+ /* Light theme. Same accent family; lighter surfaces, darker text, and slightly
44
+ deepened semantic colors so they keep contrast on white. */
45
+ :root[data-theme="light"] {
46
+ --bg-primary: #f3f3f8;
47
+ --bg-secondary: #e9e9f1;
48
+ --bg-card: #ffffff;
49
+ --bg-hover: #e4e4ee;
50
+ --text-primary: #1b1b26;
51
+ --text-secondary: #5c5c72;
52
+ --accent: #6c5ce7;
53
+ --accent-hover: #5a4bd4;
54
+ --success: #00997a;
55
+ --warning: #b9810a;
56
+ --danger: #c84b30;
57
+ --border: #d6d6e2;
58
+ --shadow: 0 4px 12px rgba(20, 20, 40, 0.12);
59
+ }
60
+
23
61
  * {
24
62
  margin: 0;
25
63
  padding: 0;
@@ -85,6 +123,23 @@ a:hover {
85
123
  color: var(--text-primary);
86
124
  }
87
125
 
126
+ /* Theme toggle (navbar) */
127
+ .theme-toggle {
128
+ background: transparent;
129
+ border: 1px solid var(--border);
130
+ border-radius: var(--radius);
131
+ color: var(--text-secondary);
132
+ cursor: pointer;
133
+ font-size: 1rem;
134
+ line-height: 1;
135
+ padding: 0.3rem 0.5rem;
136
+ transition: color 0.2s, border-color 0.2s, background 0.2s;
137
+ }
138
+ .theme-toggle:hover {
139
+ color: var(--text-primary);
140
+ background: var(--bg-hover);
141
+ }
142
+
88
143
  /* Container */
89
144
  .container {
90
145
  max-width: 1400px;
@@ -1,5 +1,32 @@
1
1
  /* kryten-webqueue — Main JavaScript */
2
2
 
3
+ // --- Theme (light/dark) ---
4
+ // The initial theme is applied pre-paint by an inline script in base.html.
5
+ // Here we keep the toggle button's icon in sync and persist explicit choices.
6
+ function currentTheme() {
7
+ return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
8
+ }
9
+
10
+ function updateThemeToggle() {
11
+ const btn = document.getElementById('theme-toggle');
12
+ if (!btn) return;
13
+ const dark = currentTheme() === 'dark';
14
+ // Show the icon for the theme you'd switch TO.
15
+ btn.textContent = dark ? '\u2600\uFE0F' : '\uD83C\uDF19';
16
+ btn.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme');
17
+ btn.title = btn.getAttribute('aria-label');
18
+ }
19
+
20
+ function setTheme(theme) {
21
+ document.documentElement.dataset.theme = theme;
22
+ try { localStorage.setItem('wq_theme', theme); } catch (e) { /* ignore */ }
23
+ updateThemeToggle();
24
+ }
25
+
26
+ function toggleTheme() {
27
+ setTheme(currentTheme() === 'dark' ? 'light' : 'dark');
28
+ }
29
+
3
30
  // Toast notification system
4
31
  function showToast(message, type = 'success') {
5
32
  const toast = document.createElement('div');
@@ -15,6 +42,12 @@ function showToast(message, type = 'success') {
15
42
 
16
43
  // Logout handler
17
44
  document.addEventListener('DOMContentLoaded', () => {
45
+ updateThemeToggle();
46
+ const themeBtn = document.getElementById('theme-toggle');
47
+ if (themeBtn) {
48
+ themeBtn.addEventListener('click', toggleTheme);
49
+ }
50
+
18
51
  const logoutBtn = document.getElementById('logout-btn');
19
52
  if (logoutBtn) {
20
53
  logoutBtn.addEventListener('click', async (e) => {
@@ -93,7 +93,7 @@ async function loadPlaylists() {
93
93
  ${p.description ? `<div class="muted">${escapeHtml(p.description)}</div>` : ''}</td>
94
94
  <td>${p.promo_type
95
95
  ? `<span class="badge badge-accent" title="Promo pool">Promo: ${escapeHtml(p.promo_type)}</span>`
96
- : (p.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : '<span class="muted">Mutable</span>')}</td>
96
+ : (p.is_immutable ? '<span class="badge badge-warn">Non-preemptable</span>' : '<span class="muted">Preemptable</span>')}</td>
97
97
  <td>${escapeHtml(p.created_by || '')}</td>
98
98
  <td class="row-actions">
99
99
  <button class="btn btn-sm" onclick="toggleImmutable(${p.id}, ${p.is_immutable ? 1 : 0}, '${escapeHtml(p.name)}')"
@@ -125,7 +125,7 @@ function showCreateModal() {
125
125
  <h3>New Playlist</h3>
126
126
  <label class="field"><span>Name</span><input type="text" id="pl-name"></label>
127
127
  <label class="field"><span>Description</span><input type="text" id="pl-desc"></label>
128
- <label class="check"><input type="checkbox" id="pl-immut"> Immutable (reserve items — hidden from public catalog)</label>
128
+ <label class="check"><input type="checkbox" id="pl-immut"> Non-preemptable (reserve items — hidden from public catalog)</label>
129
129
  <div class="modal-actions">
130
130
  <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
131
131
  <button class="btn btn-primary" onclick="createPlaylist()">Create</button>
@@ -167,7 +167,7 @@ async function openEditor(id) {
167
167
  }));
168
168
  document.getElementById('editor-title').textContent = pl.name;
169
169
  document.getElementById('editor-meta').textContent =
170
- `${pl.is_immutable ? 'Immutable (reserved) · ' : ''}${pl.description || ''}`;
170
+ `${pl.is_immutable ? 'Non-preemptable (reserved) · ' : ''}${pl.description || ''}`;
171
171
  document.getElementById('list-view').classList.add('hidden');
172
172
  document.getElementById('editor-view').classList.remove('hidden');
173
173
  document.getElementById('cat-results').innerHTML = '';
@@ -307,7 +307,7 @@ function editMeta() {
307
307
  showModal(`
308
308
  <h3>Rename Playlist</h3>
309
309
  <label class="field"><span>Name</span><input type="text" id="em-name" value="${escapeHtml(document.getElementById('editor-title').textContent)}"></label>
310
- <label class="check"><input type="checkbox" id="em-immut" ${editorImmutable ? 'checked' : ''}> Immutable (reserve items)</label>
310
+ <label class="check"><input type="checkbox" id="em-immut" ${editorImmutable ? 'checked' : ''}> Non-preemptable (reserve items)</label>
311
311
  <div class="modal-actions">
312
312
  <button class="btn btn-secondary" onclick="closeModal()">Cancel</button>
313
313
  <button class="btn btn-primary" onclick="saveMeta()">Save</button>
@@ -87,17 +87,17 @@ async function loadPools() {
87
87
  ${p.description ? `<div class="muted">${escapeHtml(p.description)}</div>` : ''}</td>
88
88
  <td>${p.promo_type
89
89
  ? `<span class="badge badge-accent">${escapeHtml(typeLabel(p.promo_type))}</span>`
90
- : (p.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : '<span class="muted">Mutable</span>')}</td>
90
+ : (p.is_immutable ? '<span class="badge badge-warn">Non-preemptable</span>' : '<span class="muted">Preemptable</span>')}</td>
91
91
  <td>
92
92
  <select onchange="setPromoType(${p.id}, this.value, '${escapeHtml(p.name)}')"
93
- ${p.is_immutable ? 'disabled title="Release the immutable flag first"' : ''}>
93
+ ${p.is_immutable ? 'disabled title="Make the playlist preemptable first"' : ''}>
94
94
  ${options(p.promo_type || '')}
95
95
  </select>
96
96
  </td>
97
97
  </tr>`).join('')}
98
98
  </table>
99
99
  <p class="muted" style="margin-top:0.5rem;font-size:0.8rem;">
100
- Immutable playlists can't be promo pools — release them on the Playlists page first.
100
+ Non-preemptable playlists can't be promo pools — make them preemptable on the Playlists page first.
101
101
  Multiple playlists may share a type; their clips are unioned into that type's pool.
102
102
  </p>`;
103
103
  }
@@ -47,7 +47,7 @@ async function loadActive() {
47
47
  const locked = a.is_immutable && !a.lock_disabled;
48
48
  banner.innerHTML = `
49
49
  <strong>Active schedule running:</strong> ${escapeHtml(name)}
50
- ${a.is_immutable ? '<span class="badge badge-warn">Immutable</span>' : ''}
50
+ ${a.is_immutable ? '<span class="badge badge-warn">Non-preemptable</span>' : ''}
51
51
  ${locked ? '<span class="badge badge-warn">Pay-to-play locked</span>' : '<span class="badge">Unlocked</span>'}
52
52
  ${a.estimated_end_at ? `<div class="muted">Ends ~${formatLocalDateTime(a.estimated_end_at)}</div>` : ''}
53
53
  <div style="margin-top:0.5rem;">
@@ -4,6 +4,22 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>{% block title %}Queue{% endblock %} — Channel-Z</title>
7
+ <script>
8
+ // Set the theme before first paint to avoid a flash of the wrong theme.
9
+ // Explicit choice in localStorage wins; otherwise follow the OS preference.
10
+ (function () {
11
+ try {
12
+ var saved = localStorage.getItem('wq_theme');
13
+ var theme = (saved === 'light' || saved === 'dark')
14
+ ? saved
15
+ : (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
16
+ ? 'light' : 'dark');
17
+ document.documentElement.dataset.theme = theme;
18
+ } catch (e) {
19
+ document.documentElement.dataset.theme = 'dark';
20
+ }
21
+ })();
22
+ </script>
7
23
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
24
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
25
  <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Sora:wght@600;700&display=swap">
@@ -27,6 +43,7 @@
27
43
  {% else %}
28
44
  <a href="/auth/login">Login</a>
29
45
  {% endif %}
46
+ <button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle light/dark theme" title="Toggle light/dark theme"></button>
30
47
  </div>
31
48
  </nav>
32
49
 
@@ -101,13 +101,41 @@
101
101
  {% endfor %}
102
102
  </div>
103
103
 
104
+ {% set sort_q = ('&sort=' ~ sort) if (sort and sort != 'default') else '' %}
104
105
  {% if not items %}
105
106
  <div class="empty-state">
106
- <p>No items found.</p>
107
+ {% if query and (active_category or active_tag) %}
108
+ <p>No results for "{{ query }}" within the selected filter{% if active_category and active_tag %}s{% endif %}.</p>
109
+ <p class="muted">
110
+ Active:
111
+ {% if active_category %}<strong>category</strong>{% endif %}
112
+ {% if active_category and active_tag %} and {% endif %}
113
+ {% if active_tag %}<strong>tag “{{ active_tag }}”</strong>{% endif %}.
114
+ Try widening your search.
115
+ </p>
116
+ <p>
117
+ <a class="btn btn-sm" href="/catalog/search?q={{ query | urlencode }}{{ sort_q }}">Search without filters</a>
118
+ <a class="btn btn-sm" href="/catalog/browse">Clear search</a>
119
+ </p>
120
+ {% elif query %}
121
+ <p>No results for "{{ query }}".</p>
122
+ <p class="muted">Check the spelling or try fewer / different words.</p>
123
+ <p><a class="btn btn-sm" href="/catalog/browse">Back to browse</a></p>
124
+ {% elif active_category or active_tag %}
125
+ <p>No items match the selected filter{% if active_category and active_tag %}s{% endif %}.</p>
126
+ <p class="muted">
127
+ Active:
128
+ {% if active_category %}<strong>category</strong>{% endif %}
129
+ {% if active_category and active_tag %} and {% endif %}
130
+ {% if active_tag %}<strong>tag “{{ active_tag }}”</strong>{% endif %}.
131
+ </p>
132
+ <p><a class="btn btn-sm" href="/catalog/browse">Clear filters</a></p>
133
+ {% else %}
134
+ <p>No items found.</p>
135
+ {% endif %}
107
136
  </div>
108
137
  {% endif %}
109
138
 
110
- {% set sort_q = ('&sort=' ~ sort) if (sort and sort != 'default') else '' %}
111
139
  <div class="pagination">
112
140
  {% if page > 1 %}
113
141
  <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 %}{{ sort_q }}" class="btn btn-sm">&larr; Prev</a>
@@ -141,13 +169,13 @@ function applyFacets() {
141
169
  const sort = document.getElementById('sort-select').value;
142
170
  try { localStorage.setItem('browse_sort', sort); } catch (e) { /* ignore */ }
143
171
  const params = new URLSearchParams();
144
- // On a search results page, preserve the query and stay on /catalog/search.
172
+ // On a search results page keep the query AND apply the facets so search
173
+ // results are narrowed by category/tag (they AND together server-side).
145
174
  if (CURRENT_QUERY) {
146
175
  params.set('q', CURRENT_QUERY);
147
- } else {
148
- if (cat) params.set('category', cat);
149
- if (tag) params.set('tag', tag);
150
176
  }
177
+ if (cat) params.set('category', cat);
178
+ if (tag) params.set('tag', tag);
151
179
  if (sort && sort !== 'default') params.set('sort', sort);
152
180
  const base = CURRENT_QUERY ? '/catalog/search' : '/catalog/browse';
153
181
  const qs = params.toString();
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.15.2"
3
+ version = "0.17.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"
@@ -0,0 +1,81 @@
1
+ """Search combines with category/tag facets (Item 4 of the UX polish plan).
2
+
3
+ A free-text search now ANDs with the selected category and/or tag, mirroring how
4
+ browse() already filters. These tests pin that behavior at the DB layer.
5
+ """
6
+
7
+ import pytest
8
+
9
+ from kryten_webqueue.catalog.db import Database
10
+
11
+ MEDIACMS = "https://www.dropsugar.com"
12
+
13
+
14
+ @pytest.fixture
15
+ async def db(tmp_path):
16
+ database = Database(str(tmp_path / "search_facets.db"))
17
+ await database.connect()
18
+ await database.run_migrations()
19
+ yield database
20
+ await database.close()
21
+
22
+
23
+ async def _add_catalog(db, token, title):
24
+ await db.insert_catalog({
25
+ "friendly_token": token,
26
+ "title": title,
27
+ "description": "",
28
+ "duration_sec": 600,
29
+ "manifest_url": f"{MEDIACMS}/api/v1/media/cytube/{token}.json?format=json",
30
+ "thumbnail_url": "",
31
+ "synced_at": "2026-01-01T00:00:00+00:00",
32
+ })
33
+
34
+
35
+ async def test_search_ands_with_category_and_tag(db):
36
+ # Three items all matching the query "Dragon", differentiated by facets.
37
+ await _add_catalog(db, "tok_action", "Dragon Action")
38
+ await _add_catalog(db, "tok_comedy", "Dragon Comedy")
39
+ await _add_catalog(db, "tok_plain", "Dragon Plain")
40
+
41
+ action_id = await db.upsert_category("Action")
42
+ comedy_id = await db.upsert_category("Comedy")
43
+ await db.set_catalog_categories("tok_action", [action_id])
44
+ await db.set_catalog_categories("tok_comedy", [comedy_id])
45
+
46
+ epic_tag = await db.upsert_tag("Epic")
47
+ await db.set_catalog_tags("tok_action", [epic_tag])
48
+
49
+ # Resolve the Action slug (upsert derives it).
50
+ action_slug = next(c["slug"] for c in await db.get_categories() if c["name"] == "Action")
51
+
52
+ # Plain query: all three match.
53
+ assert {r["friendly_token"] for r in await db.search("Dragon")} == {
54
+ "tok_action", "tok_comedy", "tok_plain",
55
+ }
56
+ assert await db.search_count("Dragon") == 3
57
+
58
+ # Query + category: only the Action item.
59
+ cat_results = {r["friendly_token"] for r in await db.search("Dragon", category=action_slug)}
60
+ assert cat_results == {"tok_action"}
61
+ assert await db.search_count("Dragon", category=action_slug) == 1
62
+
63
+ # Query + tag: only the Epic-tagged item.
64
+ tag_results = {r["friendly_token"] for r in await db.search("Dragon", tag="Epic")}
65
+ assert tag_results == {"tok_action"}
66
+ assert await db.search_count("Dragon", tag="Epic") == 1
67
+
68
+ # Query + category + tag that don't co-occur: empty (true intersection).
69
+ assert await db.search("Dragon", category=action_slug, tag="Nonexistent") == []
70
+ assert await db.search_count("Dragon", category=action_slug, tag="Nonexistent") == 0
71
+
72
+
73
+ async def test_search_facet_with_no_text_match_is_empty(db):
74
+ await _add_catalog(db, "tok1", "Comedy Night")
75
+ comedy_id = await db.upsert_category("Comedy")
76
+ await db.set_catalog_categories("tok1", [comedy_id])
77
+ comedy_slug = next(c["slug"] for c in await db.get_categories() if c["name"] == "Comedy")
78
+
79
+ # The category matches the item, but the query does not -> no results.
80
+ assert await db.search("Horror", category=comedy_slug) == []
81
+ assert await db.search_count("Horror", category=comedy_slug) == 0