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