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.
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/CHANGELOG.md +20 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/PKG-INFO +1 -1
- kryten_webqueue-0.16.0/docs/UX_POLISH_PLAN.md +246 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/app.py +1 -0
- kryten_webqueue-0.16.0/kryten_webqueue/playlists/fire.py +136 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/importer.py +33 -21
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/scheduler.py +4 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/promos/director.py +71 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_playlists.py +1 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_schedules.py +1 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/static/css/main.css +56 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/static/js/main.js +33 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/playlists.html +4 -4
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/promos.html +3 -3
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/schedules.html +1 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/base.html +17 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/pyproject.toml +1 -1
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_promo_director.py +74 -0
- kryten_webqueue-0.15.1/kryten_webqueue/playlists/fire.py +0 -120
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/.gitignore +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/README.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/config.example.json +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.15.1 → kryten_webqueue-0.16.0}/tests/test_queue_announce.py +0 -0
- {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
|
|
@@ -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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|