kryten-webqueue 0.15.1__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 (105) hide show
  1. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/CHANGELOG.md +20 -0
  2. {kryten_webqueue-0.15.1 → 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.1 → kryten_webqueue-0.16.0}/kryten_webqueue/app.py +1 -0
  5. kryten_webqueue-0.16.0/kryten_webqueue/playlists/fire.py +136 -0
  6. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/importer.py +33 -21
  7. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/scheduler.py +4 -1
  8. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/promos/director.py +71 -0
  9. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_playlists.py +1 -0
  10. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_schedules.py +1 -0
  11. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/static/css/main.css +56 -1
  12. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/static/js/main.js +33 -0
  13. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/playlists.html +4 -4
  14. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/promos.html +3 -3
  15. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/schedules.html +1 -1
  16. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/base.html +17 -0
  17. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/pyproject.toml +1 -1
  18. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_promo_director.py +74 -0
  19. kryten_webqueue-0.15.1/kryten_webqueue/playlists/fire.py +0 -120
  20. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/.github/workflows/python-publish.yml +0 -0
  21. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/.github/workflows/release.yml +0 -0
  22. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/.gitignore +0 -0
  23. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/README.md +0 -0
  24. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/config.example.json +0 -0
  25. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/deploy/kryten-webqueue.service +0 -0
  26. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/deploy/nginx-queue.conf +0 -0
  27. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  28. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_API_GATE.md +0 -0
  29. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_ECONOMY.md +0 -0
  30. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  31. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_ROBOT.md +0 -0
  32. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  33. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/PRE_PLAN_GAPS.md +0 -0
  34. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/PRODUCT_PLAN.md +0 -0
  35. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  36. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/__init__.py +0 -0
  37. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/__main__.py +0 -0
  38. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  39. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/api_gate/client.py +0 -0
  40. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/__init__.py +0 -0
  41. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/otp.py +0 -0
  42. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  43. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/session.py +0 -0
  44. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/__init__.py +0 -0
  45. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/db.py +0 -0
  46. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/images.py +0 -0
  47. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  48. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/sync.py +0 -0
  49. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/config.py +0 -0
  50. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/__init__.py +0 -0
  51. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  52. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  53. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  54. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  55. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  56. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  57. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  58. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  59. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/__init__.py +0 -0
  60. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  61. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/manager.py +0 -0
  62. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/tasks.py +0 -0
  63. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/logging_config.py +0 -0
  64. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/__init__.py +0 -0
  65. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  66. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/ordering.py +0 -0
  67. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/promos/__init__.py +0 -0
  68. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/__init__.py +0 -0
  69. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/ordering.py +0 -0
  70. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/poller.py +0 -0
  71. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/presence.py +0 -0
  72. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/shadow.py +0 -0
  73. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/__init__.py +0 -0
  74. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  75. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  76. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  77. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  78. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/auth.py +0 -0
  79. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/catalog.py +0 -0
  80. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/pages.py +0 -0
  81. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/queue.py +0 -0
  82. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/user.py +0 -0
  83. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/index.html +0 -0
  84. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  85. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/auth/login.html +0 -0
  86. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  87. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/queue/index.html +0 -0
  90. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  91. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/__init__.py +0 -0
  92. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/handler.py +0 -0
  93. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/manager.py +0 -0
  94. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/__init__.py +0 -0
  95. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_config_persistence.py +0 -0
  96. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_fetchurls_sharepoint.py +0 -0
  97. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase1.py +0 -0
  98. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase2_jobs.py +0 -0
  99. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase3_jobs.py +0 -0
  100. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase4_live_fixes.py +0 -0
  101. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_playlist_import.py +0 -0
  102. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_presence_refund.py +0 -0
  103. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_promo_pool_exclusion.py +0 -0
  104. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_queue_announce.py +0 -0
  105. {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,26 @@ 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
+
21
+ ## [0.15.2] — 2026-06-17
22
+
23
+ ### Fixed
24
+
25
+ - **Promos were inserted into immutable playlists during a schedule fire.** A scheduled event clears the queue and loads its items over several seconds (throttled adds + 422 retries), but the event lock that suppresses promos was only recorded *after* the entire load finished. Meanwhile the state poller (every ~3s) saw a partially-built queue with no lock and slotted a general promo between the freshly-added immutable items. `PromoDirector` now exposes a re-entrant `suppressed()` guard that `fire_schedule` (and manual playlist import) holds for the whole load — spanning through `set_active_schedule`, so for an immutable event the persistent lock is already live by the time suppression lifts (a clean handoff with no race window). When suppression releases, the next poll re-baselines now-playing instead of treating the bulk load as content advancing, so no promo fires on the wrong boundary. This also satisfies the general rule: never evaluate promos while a bulk queue insert/append is in progress, regardless of playlist type.
26
+
27
+ [0.15.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.15.2
28
+
9
29
  ## [0.15.1] — 2026-06-17
10
30
 
11
31
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.15.1
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.
@@ -147,6 +147,7 @@ async def lifespan(app: FastAPI):
147
147
  db=db, api_gate=api_gate, shadow=shadow, ws_manager=ws_manager,
148
148
  add_delay_sec=config.playlist_bulk_add_delay_sec,
149
149
  add_max_retries=config.playlist_bulk_add_max_retries,
150
+ promo_director=promo_director,
150
151
  )
151
152
  await scheduler.start()
152
153
  app.state.scheduler = scheduler
@@ -0,0 +1,136 @@
1
+ import asyncio
2
+ import logging
3
+ from contextlib import nullcontext
4
+ from datetime import datetime, timedelta, UTC
5
+
6
+ from ..queue.ordering import refund_item
7
+ from .bulk_add import add_item_throttled
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ _queue_lock = asyncio.Lock()
12
+
13
+
14
+ async def fire_schedule(
15
+ *,
16
+ schedule_id: int,
17
+ api_gate,
18
+ db,
19
+ shadow,
20
+ ws_manager,
21
+ add_delay_sec: float = 0.0,
22
+ add_max_retries: int = 0,
23
+ promo_director=None,
24
+ ):
25
+ """Fire a scheduled playlist: clear queue, refund displaced pay items, load playlist.
26
+
27
+ Promo insertion is suppressed for the whole load (via ``promo_director``):
28
+ promos must never be slotted between items while the playlist is still being
29
+ built. Suppression spans through ``set_active_schedule`` so that, for an
30
+ immutable event, the persistent event lock is already recorded by the time
31
+ suppression lifts — a clean handoff with no window for a stray insertion.
32
+ """
33
+ async with _queue_lock:
34
+ schedule = await db.get_schedule(schedule_id)
35
+ if not schedule:
36
+ logger.error(f"Schedule {schedule_id} not found")
37
+ return
38
+
39
+ playlist_id = schedule["playlist_id"]
40
+ playlist = await db.get_saved_playlist(playlist_id)
41
+ if not playlist:
42
+ logger.error(f"Playlist {playlist_id} not found for schedule {schedule_id}")
43
+ return
44
+
45
+ suppress_ctx = (
46
+ promo_director.suppressed(f"schedule fire {schedule_id}")
47
+ if promo_director is not None
48
+ else nullcontext()
49
+ )
50
+ with suppress_ctx:
51
+ # Refund all pay items currently in queue
52
+ pay_items = await db.get_pay_items()
53
+ for item in pay_items:
54
+ await refund_item(api_gate=api_gate, db=db, uid=item["uid"], reason="schedule_displaced")
55
+
56
+ # Clear the CyTube playlist
57
+ await api_gate.playlist_clear()
58
+
59
+ # Load scheduled playlist items
60
+ items = await db.get_saved_playlist_items(playlist_id)
61
+ total_duration = 0
62
+ last_item_uid = None
63
+ for index, item in enumerate(items):
64
+ # Throttle consecutive adds so CyTube can validate each item before
65
+ # the next arrives (avoids transient queueFail/422 under load).
66
+ if index and add_delay_sec:
67
+ await asyncio.sleep(add_delay_sec)
68
+ try:
69
+ add_result = await add_item_throttled(
70
+ api_gate,
71
+ media_type=item["media_type"],
72
+ media_id=item["media_id"],
73
+ position="end",
74
+ max_retries=add_max_retries,
75
+ retry_delay_sec=add_delay_sec or 0.5,
76
+ )
77
+ if isinstance(add_result, dict) and add_result.get("uid") is not None:
78
+ last_item_uid = add_result["uid"]
79
+ total_duration += item.get("duration_sec", 0) or 0
80
+ except Exception as e:
81
+ logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
82
+
83
+ # Append the optional fallback (mutable) playlist AFTER the event items so
84
+ # the live queue isn't left empty once the event is exhausted. The
85
+ # fallback items are not part of the "scheduled event", so they do not
86
+ # change last_item_uid (the event lock still lifts when the last EVENT
87
+ # item begins) and they remain available for pay-to-play/search.
88
+ fallback_id = schedule.get("fallback_playlist_id")
89
+ if fallback_id:
90
+ fallback_items = await db.get_saved_playlist_items(fallback_id)
91
+ for index, item in enumerate(fallback_items):
92
+ if index and add_delay_sec:
93
+ await asyncio.sleep(add_delay_sec)
94
+ try:
95
+ await add_item_throttled(
96
+ api_gate,
97
+ media_type=item["media_type"],
98
+ media_id=item["media_id"],
99
+ position="end",
100
+ max_retries=add_max_retries,
101
+ retry_delay_sec=add_delay_sec or 0.5,
102
+ )
103
+ except Exception as e:
104
+ logger.warning(f"Schedule fire: failed to add fallback {item['media_id']}: {e}")
105
+ if fallback_items:
106
+ logger.info(
107
+ f"Schedule {schedule_id}: appended {len(fallback_items)} fallback item(s) "
108
+ f"from playlist {fallback_id}"
109
+ )
110
+
111
+ # Update active schedule (recorded *inside* the suppression window so
112
+ # the event lock is live before promos resume).
113
+ now = datetime.now(UTC)
114
+ await db.set_active_schedule(
115
+ schedule_id=schedule_id,
116
+ playlist_id=playlist_id,
117
+ is_immutable=playlist.get("is_immutable", False),
118
+ started_at=now.isoformat(),
119
+ estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
120
+ last_item_uid=last_item_uid,
121
+ )
122
+
123
+ # Mark schedule as fired
124
+ await db.mark_schedule_fired(schedule_id, now.isoformat())
125
+
126
+ # Notify WS clients
127
+ await ws_manager.broadcast({
128
+ "type": "schedule_fired",
129
+ "data": {
130
+ "schedule_id": schedule_id,
131
+ "playlist_name": playlist["name"],
132
+ "is_immutable": playlist.get("is_immutable", False),
133
+ },
134
+ })
135
+
136
+ logger.info(f"Schedule {schedule_id} fired: playlist '{playlist['name']}' ({len(items)} items)")
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  import logging
3
3
  import re
4
+ from contextlib import nullcontext
4
5
 
5
6
  from .bulk_add import add_item_throttled
6
7
 
@@ -62,12 +63,14 @@ def _manifest_url_for_token(token: str, mediacms_url: str | None) -> str | None:
62
63
  class PlaylistImporter:
63
64
  """Imports items from a saved playlist into the live CyTube queue."""
64
65
 
65
- def __init__(self, *, api_gate, db, shadow, add_delay_sec: float = 0.0, add_max_retries: int = 0):
66
+ def __init__(self, *, api_gate, db, shadow, add_delay_sec: float = 0.0, add_max_retries: int = 0,
67
+ promo_director=None):
66
68
  self._api_gate = api_gate
67
69
  self._db = db
68
70
  self._shadow = shadow
69
71
  self._add_delay_sec = add_delay_sec
70
72
  self._add_max_retries = add_max_retries
73
+ self._promo_director = promo_director
71
74
 
72
75
  async def import_playlist(self, playlist_id: int) -> dict:
73
76
  """Import all items from a saved playlist into the live queue."""
@@ -75,29 +78,38 @@ class PlaylistImporter:
75
78
  if not items:
76
79
  return {"success": False, "error": "Playlist is empty"}
77
80
 
81
+ # Suppress promo insertion for the whole load so promos aren't slotted
82
+ # between items while the playlist is still being built.
83
+ suppress_ctx = (
84
+ self._promo_director.suppressed(f"playlist import {playlist_id}")
85
+ if self._promo_director is not None
86
+ else nullcontext()
87
+ )
88
+
78
89
  added = 0
79
90
  errors = 0
80
- for index, item in enumerate(items):
81
- # Throttle consecutive adds so CyTube can validate each item before
82
- # the next arrives (avoids transient queueFail/422 under load).
83
- if index and self._add_delay_sec:
84
- await asyncio.sleep(self._add_delay_sec)
85
- try:
86
- result = await add_item_throttled(
87
- self._api_gate,
88
- media_type=item["media_type"],
89
- media_id=item["media_id"],
90
- position="end",
91
- max_retries=self._add_max_retries,
92
- retry_delay_sec=self._add_delay_sec or 0.5,
93
- )
94
- if result.get("success"):
95
- added += 1
96
- else:
91
+ with suppress_ctx:
92
+ for index, item in enumerate(items):
93
+ # Throttle consecutive adds so CyTube can validate each item before
94
+ # the next arrives (avoids transient queueFail/422 under load).
95
+ if index and self._add_delay_sec:
96
+ await asyncio.sleep(self._add_delay_sec)
97
+ try:
98
+ result = await add_item_throttled(
99
+ self._api_gate,
100
+ media_type=item["media_type"],
101
+ media_id=item["media_id"],
102
+ position="end",
103
+ max_retries=self._add_max_retries,
104
+ retry_delay_sec=self._add_delay_sec or 0.5,
105
+ )
106
+ if result.get("success"):
107
+ added += 1
108
+ else:
109
+ errors += 1
110
+ except Exception as e:
111
+ logger.warning(f"Failed to add {item['media_id']}: {e}")
97
112
  errors += 1
98
- except Exception as e:
99
- logger.warning(f"Failed to add {item['media_id']}: {e}")
100
- errors += 1
101
113
 
102
114
  return {"success": True, "added": added, "errors": errors}
103
115
 
@@ -27,13 +27,15 @@ def _next_occurrence(rrule_str: str, dtstart: datetime, after: datetime) -> date
27
27
  class PlaylistScheduler:
28
28
  """APScheduler-based scheduler for playlist fire events."""
29
29
 
30
- def __init__(self, *, db, api_gate, shadow, ws_manager, add_delay_sec: float = 0.0, add_max_retries: int = 0):
30
+ def __init__(self, *, db, api_gate, shadow, ws_manager, add_delay_sec: float = 0.0, add_max_retries: int = 0,
31
+ promo_director=None):
31
32
  self._db = db
32
33
  self._api_gate = api_gate
33
34
  self._shadow = shadow
34
35
  self._ws_manager = ws_manager
35
36
  self._add_delay_sec = add_delay_sec
36
37
  self._add_max_retries = add_max_retries
38
+ self._promo_director = promo_director
37
39
  self._scheduler = AsyncIOScheduler()
38
40
 
39
41
  async def start(self):
@@ -100,6 +102,7 @@ class PlaylistScheduler:
100
102
  ws_manager=self._ws_manager,
101
103
  add_delay_sec=self._add_delay_sec,
102
104
  add_max_retries=self._add_max_retries,
105
+ promo_director=self._promo_director,
103
106
  )
104
107
  # After an automatic timed fire, advance recurring schedules to their
105
108
  # next occurrence and re-arm. (Manual "Fire Now" does NOT advance the
@@ -22,6 +22,7 @@ director is a no-op while an immutable scheduled event is locking the queue.
22
22
 
23
23
  import logging
24
24
  import random
25
+ from contextlib import contextmanager
25
26
  from datetime import datetime, UTC
26
27
 
27
28
  from ..queue.ordering import _now_playing_uid
@@ -83,6 +84,17 @@ class PromoDirector:
83
84
  self._last_clip_token: dict[str, str] = {} # promo_type -> last clip media_id
84
85
  self._seq_index: dict[str, int] = {} # promo_type -> next sequential index
85
86
 
87
+ # Suppression guard. Held (re-entrantly) by bulk live-queue loaders
88
+ # (schedule fire, playlist import) so promos are never inserted *into* a
89
+ # playlist while it is still being built. Without this, the poller runs
90
+ # every few seconds during the multi-second load and slots promos
91
+ # between freshly-added items — including immutable ones, because the
92
+ # event lock isn't recorded until the load finishes.
93
+ self._suppress_depth: int = 0
94
+ # Set when suppression lifts so the next poll re-baselines now-playing
95
+ # instead of treating the post-load discontinuity as content advancing.
96
+ self._needs_rebaseline: bool = False
97
+
86
98
  # Injectable for tests
87
99
  self._rng = random.Random()
88
100
  self._now = lambda: datetime.now(UTC)
@@ -97,6 +109,36 @@ class PromoDirector:
97
109
  self._config = config
98
110
  logger.info("PromoDirector config updated (enabled=%s)", getattr(config, "enabled", None))
99
111
 
112
+ @property
113
+ def is_suppressed(self) -> bool:
114
+ """True while a bulk live-queue operation has paused promo insertion."""
115
+ return self._suppress_depth > 0
116
+
117
+ @contextmanager
118
+ def suppressed(self, reason: str):
119
+ """Pause promo insertion for the duration of a bulk queue operation.
120
+
121
+ Re-entrant: nested holders each bump a depth counter; promos resume only
122
+ once the outermost holder exits. Use around any operation that adds a
123
+ run of items to the *live* queue (schedule fire, playlist import) so the
124
+ director never inserts a promo into a playlist that is still loading.
125
+
126
+ On release the next poll re-baselines now-playing rather than counting
127
+ the load as content advancing, so a promo doesn't fire on the wrong
128
+ boundary immediately afterwards.
129
+ """
130
+ self._suppress_depth += 1
131
+ if self._suppress_depth == 1:
132
+ logger.info("Promo insertion suppressed: %s", reason)
133
+ try:
134
+ yield
135
+ finally:
136
+ self._suppress_depth -= 1
137
+ if self._suppress_depth <= 0:
138
+ self._suppress_depth = 0
139
+ self._needs_rebaseline = True
140
+ logger.info("Promo insertion resumed (after: %s)", reason)
141
+
100
142
  # --- Play-order helpers -------------------------------------------------
101
143
 
102
144
  @staticmethod
@@ -365,6 +407,13 @@ class PromoDirector:
365
407
  """
366
408
  if not self._config.enabled:
367
409
  return None
410
+ if self._suppress_depth > 0:
411
+ logger.debug(
412
+ "Viewer's-Choice skipped for paid uid=%s: insertion suppressed "
413
+ "(bulk queue operation in progress)",
414
+ content_uid,
415
+ )
416
+ return None
368
417
  items = self._shadow.items
369
418
  if self._has_lead_in(content_uid, items):
370
419
  logger.debug(
@@ -392,9 +441,31 @@ class PromoDirector:
392
441
  if not cfg.enabled:
393
442
  return
394
443
 
444
+ # Frozen while a bulk live-queue operation (schedule fire / playlist
445
+ # import) is loading items. Promos must never land *inside* a playlist
446
+ # that is still being built — this is what slips promos between immutable
447
+ # items before the event lock is recorded.
448
+ if self._suppress_depth > 0:
449
+ logger.debug(
450
+ "Promo on_poll skipped: insertion suppressed (bulk queue operation in progress)"
451
+ )
452
+ return
453
+
395
454
  np_uid = await _now_playing_uid(self._api_gate, self._shadow)
396
455
  np_is_promo = self._is_promo_uid(np_uid)
397
456
 
457
+ # After a bulk queue operation the playlist is discontinuous; re-baseline
458
+ # now-playing without counting it as content advancing so we don't fire a
459
+ # promo on the wrong boundary on the very next cycle.
460
+ if self._needs_rebaseline:
461
+ self._needs_rebaseline = False
462
+ self._last_np_uid = np_uid
463
+ self._last_np_is_promo = np_is_promo
464
+ logger.debug(
465
+ "Promo baseline reset after bulk queue operation (np_uid=%s)", np_uid
466
+ )
467
+ return
468
+
398
469
  # Advance detection: a finished *content* item bumps the cadence counter.
399
470
  if np_uid != self._last_np_uid:
400
471
  if self._last_np_uid is not None and not self._last_np_is_promo:
@@ -112,6 +112,7 @@ async def import_to_live(request: Request, playlist_id: int, user: dict = Depend
112
112
  shadow=request.app.state.shadow,
113
113
  add_delay_sec=config.playlist_bulk_add_delay_sec,
114
114
  add_max_retries=config.playlist_bulk_add_max_retries,
115
+ promo_director=getattr(request.app.state, "promo_director", None),
115
116
  )
116
117
  result = await importer.import_playlist(playlist_id)
117
118
  return result
@@ -121,6 +121,7 @@ async def fire_now(request: Request, schedule_id: int, user: dict = Depends(requ
121
121
  ws_manager=request.app.state.ws_manager,
122
122
  add_delay_sec=config.playlist_bulk_add_delay_sec,
123
123
  add_max_retries=config.playlist_bulk_add_max_retries,
124
+ promo_director=getattr(request.app.state, "promo_director", None),
124
125
  )
125
126
  return {"success": True}
126
127