kryten-webqueue 0.15.2__tar.gz → 0.16.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 (104) hide show
  1. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/CHANGELOG.md +12 -0
  2. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/PKG-INFO +1 -1
  3. kryten_webqueue-0.16.0/docs/UX_POLISH_PLAN.md +246 -0
  4. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/static/css/main.css +56 -1
  5. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/static/js/main.js +33 -0
  6. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/playlists.html +4 -4
  7. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/promos.html +3 -3
  8. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/schedules.html +1 -1
  9. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/base.html +17 -0
  10. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/pyproject.toml +1 -1
  11. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/.github/workflows/python-publish.yml +0 -0
  12. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/.github/workflows/release.yml +0 -0
  13. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/.gitignore +0 -0
  14. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/README.md +0 -0
  15. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/config.example.json +0 -0
  16. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/deploy/kryten-webqueue.service +0 -0
  17. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/deploy/nginx-queue.conf +0 -0
  18. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  19. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/IMPL_API_GATE.md +0 -0
  20. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/IMPL_ECONOMY.md +0 -0
  21. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  22. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/IMPL_ROBOT.md +0 -0
  23. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  24. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/PRE_PLAN_GAPS.md +0 -0
  25. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/PRODUCT_PLAN.md +0 -0
  26. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  27. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/db.py +0 -0
  38. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/images.py +0 -0
  39. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  40. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/sync.py +0 -0
  41. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/config.py +0 -0
  42. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/__init__.py +0 -0
  43. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  44. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  45. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  46. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  47. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  48. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  49. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  50. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  51. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/__init__.py +0 -0
  52. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  53. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/manager.py +0 -0
  54. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/tasks.py +0 -0
  55. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/logging_config.py +0 -0
  56. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/__init__.py +0 -0
  57. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  58. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/fire.py +0 -0
  59. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/importer.py +0 -0
  60. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/ordering.py +0 -0
  61. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  62. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/promos/__init__.py +0 -0
  63. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/promos/director.py +0 -0
  64. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/__init__.py +0 -0
  65. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/ordering.py +0 -0
  66. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/poller.py +0 -0
  67. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/presence.py +0 -0
  68. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/shadow.py +0 -0
  69. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/__init__.py +0 -0
  70. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  71. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  72. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  73. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  74. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  75. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  76. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/auth.py +0 -0
  77. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/catalog.py +0 -0
  78. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/pages.py +0 -0
  79. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/queue.py +0 -0
  80. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/user.py +0 -0
  81. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/index.html +0 -0
  82. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  83. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  85. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  86. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  87. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/queue/index.html +0 -0
  88. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  89. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.15.2 → kryten_webqueue-0.16.0}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,18 @@ 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.16.0] — 2026-06-17
10
+
11
+ ### Added
12
+
13
+ - **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.
14
+
15
+ ### Changed
16
+
17
+ - **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.
18
+
19
+ [0.16.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.16.0
20
+
9
21
  ## [0.15.2] — 2026-06-17
10
22
 
11
23
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.15.2
3
+ Version: 0.16.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.
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.15.2"
3
+ version = "0.16.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"