kryten-webqueue 0.7.5__tar.gz → 0.8.1__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.7.5 → kryten_webqueue-0.8.1}/CHANGELOG.md +20 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/PKG-INFO +2 -1
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/db.py +15 -0
- kryten_webqueue-0.8.1/kryten_webqueue/playlists/scheduler.py +131 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_playlists.py +15 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/queue.py +40 -2
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/static/css/main.css +138 -7
- kryten_webqueue-0.8.1/kryten_webqueue/templates/admin/playlists.html +303 -0
- kryten_webqueue-0.8.1/kryten_webqueue/templates/admin/queue_mgmt.html +186 -0
- kryten_webqueue-0.8.1/kryten_webqueue/templates/admin/schedules.html +179 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/queue/index.html +40 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/pyproject.toml +2 -1
- kryten_webqueue-0.7.5/kryten_webqueue/playlists/scheduler.py +0 -72
- kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/playlists.html +0 -14
- kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/queue_mgmt.html +0 -14
- kryten_webqueue-0.7.5/kryten_webqueue/templates/admin/schedules.html +0 -14
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/.gitignore +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/README.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/config.example.json +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.7.5 → kryten_webqueue-0.8.1}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
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
|
+
## [0.8.1] - 2026-06-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **True RRULE-based recurring schedules.** Recurring schedules now auto-re-arm: after an automatic timed fire, the scheduler computes the next occurrence from the schedule's `rrule` (anchored on its fire time), advances `fire_at`, clears `fired_at`, and registers the next job — no manual re-arming needed. On startup, recurring schedules whose fire time elapsed while the service was down are advanced to their next future occurrence. Manual "Fire Now" intentionally does **not** advance the recurrence; the originally scheduled occurrence stays armed. Unparseable or exhausted rules are logged and left inert. Adds an explicit `python-dateutil` dependency for RRULE parsing.
|
|
12
|
+
|
|
13
|
+
## [0.8.0] - 2026-06-08
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Admin Playlists UI.** The placeholder is replaced with a full management page: list/create/delete saved playlists, a two-column item editor (catalog search-to-add, drag-and-drop plus up/down reorder, per-item remove), bulk text import (`cm:`/`type:id`/bare-token, with unresolved-line reporting), rename + immutable toggle, and "Import to Live" to load a playlist into the CyTube queue. A new stateless `POST /admin/playlists/parse-text` endpoint exposes the existing text parser so parsed items merge into the editor and persist via the existing `PUT /{id}/items`.
|
|
18
|
+
- **Admin Schedules UI.** List of scheduled fires with playlist names, local fire times, lock window and status; create/edit/delete with `fire_at` (datetime-local → UTC), `pre_fire_lock_minutes`, `is_recurring`/`rrule`, and active toggle; "Fire Now"; and an active-schedule banner with "Clear Active".
|
|
19
|
+
- **Admin Queue Management UI.** Live `queue_shadow` table (auto-refreshing) with pay/scheduled metadata, ETA, paid-by and Z cost; remove (auto-refund), jump, an admin add-item modal (catalog search + placement mode), and the catalog sync log with a "Sync Now" trigger.
|
|
20
|
+
- **Upcoming-schedule announcement on the Queue page.** A public `GET /queue/next-schedule` feeds a banner with the next scheduled playlist, its fire time, and a live countdown (noting when pay-to-play is closed).
|
|
21
|
+
- Shared admin CSS for section headers, forms, modals, badges, the playlist editor list, drag-reorder, and catalog-add results.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- **Specific pre-fire-lock messaging.** Submitting during a lock window now returns `Pay-to-play is closed: "[event]" starts in N min.` instead of a generic locked error (surfaced in the existing toast).
|
|
26
|
+
|
|
7
27
|
## [0.7.5] - 2026-06-08
|
|
8
28
|
|
|
9
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
4
4
|
Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
|
|
5
5
|
Author: grobertson
|
|
6
6
|
License-Expression: MIT
|
|
@@ -13,6 +13,7 @@ Requires-Dist: jinja2>=3.1
|
|
|
13
13
|
Requires-Dist: pillow>=10.0
|
|
14
14
|
Requires-Dist: pydantic>=2.0
|
|
15
15
|
Requires-Dist: pyjwt>=2.8
|
|
16
|
+
Requires-Dist: python-dateutil>=2.8
|
|
16
17
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
17
18
|
Requires-Dist: websockets>=12.0
|
|
18
19
|
Provides-Extra: dev
|
|
@@ -881,6 +881,21 @@ class Database:
|
|
|
881
881
|
""")
|
|
882
882
|
return row is not None
|
|
883
883
|
|
|
884
|
+
async def get_active_pre_fire_lock(self) -> dict | None:
|
|
885
|
+
"""Return the schedule whose pre-fire lock window is currently active.
|
|
886
|
+
|
|
887
|
+
Used to give users a specific "pay-to-play closes before [event]"
|
|
888
|
+
message instead of a generic locked error.
|
|
889
|
+
"""
|
|
890
|
+
return await self._fetch_one("""
|
|
891
|
+
SELECT * FROM playlist_schedules
|
|
892
|
+
WHERE is_active = 1
|
|
893
|
+
AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
|
|
894
|
+
AND fire_at > datetime('now')
|
|
895
|
+
ORDER BY fire_at
|
|
896
|
+
LIMIT 1
|
|
897
|
+
""")
|
|
898
|
+
|
|
884
899
|
async def get_next_schedule(self) -> dict | None:
|
|
885
900
|
return await self._fetch_one(
|
|
886
901
|
"SELECT * FROM playlist_schedules WHERE is_active=1 AND fire_at > datetime('now') ORDER BY fire_at LIMIT 1"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
3
|
+
from apscheduler.triggers.date import DateTrigger
|
|
4
|
+
from datetime import datetime, UTC
|
|
5
|
+
|
|
6
|
+
from dateutil.rrule import rrulestr
|
|
7
|
+
|
|
8
|
+
from .fire import fire_schedule
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _next_occurrence(rrule_str: str, dtstart: datetime, after: datetime) -> datetime | None:
|
|
14
|
+
"""Return the next RRULE occurrence strictly after ``after``.
|
|
15
|
+
|
|
16
|
+
``dtstart`` anchors the recurrence pattern (typically the schedule's current
|
|
17
|
+
fire time). Returns None when the rule is exhausted or unparseable.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
rule = rrulestr(rrule_str, dtstart=dtstart)
|
|
21
|
+
return rule.after(after, inc=False)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.warning(f"Could not compute next occurrence for rrule {rrule_str!r}: {e}")
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlaylistScheduler:
|
|
28
|
+
"""APScheduler-based scheduler for playlist fire events."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, db, api_gate, shadow, ws_manager):
|
|
31
|
+
self._db = db
|
|
32
|
+
self._api_gate = api_gate
|
|
33
|
+
self._shadow = shadow
|
|
34
|
+
self._ws_manager = ws_manager
|
|
35
|
+
self._scheduler = AsyncIOScheduler()
|
|
36
|
+
|
|
37
|
+
async def start(self):
|
|
38
|
+
"""Start scheduler and load all pending schedules."""
|
|
39
|
+
self._scheduler.start()
|
|
40
|
+
await self._load_schedules()
|
|
41
|
+
logger.info("PlaylistScheduler started")
|
|
42
|
+
|
|
43
|
+
async def stop(self):
|
|
44
|
+
self._scheduler.shutdown(wait=False)
|
|
45
|
+
logger.info("PlaylistScheduler stopped")
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _parse_fire_at(value: str) -> datetime:
|
|
49
|
+
"""Parse a stored fire_at ISO string into a UTC-aware datetime."""
|
|
50
|
+
dt = datetime.fromisoformat(value)
|
|
51
|
+
if dt.tzinfo is None:
|
|
52
|
+
dt = dt.replace(tzinfo=UTC)
|
|
53
|
+
return dt
|
|
54
|
+
|
|
55
|
+
async def _load_schedules(self):
|
|
56
|
+
"""Load active schedules from DB and register jobs.
|
|
57
|
+
|
|
58
|
+
Recurring schedules whose fire time has already passed (e.g. while the
|
|
59
|
+
service was down) are advanced to their next future occurrence.
|
|
60
|
+
"""
|
|
61
|
+
schedules = await self._db.get_schedules()
|
|
62
|
+
now = datetime.now(UTC)
|
|
63
|
+
for sched in schedules:
|
|
64
|
+
if not sched.get("is_active"):
|
|
65
|
+
continue
|
|
66
|
+
fire_at = self._parse_fire_at(sched["fire_at"])
|
|
67
|
+
if fire_at > now:
|
|
68
|
+
self._add_job(sched["id"], fire_at)
|
|
69
|
+
elif sched.get("is_recurring") and sched.get("rrule"):
|
|
70
|
+
nxt = _next_occurrence(sched["rrule"], fire_at, now)
|
|
71
|
+
if nxt:
|
|
72
|
+
nxt_utc = nxt.astimezone(UTC)
|
|
73
|
+
await self._db.update_schedule(
|
|
74
|
+
sched["id"], fire_at=nxt_utc.isoformat(), fired_at=None
|
|
75
|
+
)
|
|
76
|
+
self._add_job(sched["id"], nxt_utc)
|
|
77
|
+
logger.info(
|
|
78
|
+
f"Advanced missed recurring schedule {sched['id']} to {nxt_utc}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _add_job(self, schedule_id: int, fire_at: datetime):
|
|
82
|
+
job_id = f"schedule_{schedule_id}"
|
|
83
|
+
self._scheduler.add_job(
|
|
84
|
+
self._fire,
|
|
85
|
+
trigger=DateTrigger(run_date=fire_at),
|
|
86
|
+
id=job_id,
|
|
87
|
+
replace_existing=True,
|
|
88
|
+
kwargs={"schedule_id": schedule_id},
|
|
89
|
+
)
|
|
90
|
+
logger.info(f"Scheduled job {job_id} for {fire_at}")
|
|
91
|
+
|
|
92
|
+
async def _fire(self, schedule_id: int):
|
|
93
|
+
await fire_schedule(
|
|
94
|
+
schedule_id=schedule_id,
|
|
95
|
+
api_gate=self._api_gate,
|
|
96
|
+
db=self._db,
|
|
97
|
+
shadow=self._shadow,
|
|
98
|
+
ws_manager=self._ws_manager,
|
|
99
|
+
)
|
|
100
|
+
# After an automatic timed fire, advance recurring schedules to their
|
|
101
|
+
# next occurrence and re-arm. (Manual "Fire Now" does NOT advance the
|
|
102
|
+
# recurrence — the originally scheduled occurrence stays armed.)
|
|
103
|
+
await self._reschedule_if_recurring(schedule_id)
|
|
104
|
+
|
|
105
|
+
async def _reschedule_if_recurring(self, schedule_id: int):
|
|
106
|
+
sched = await self._db.get_schedule(schedule_id)
|
|
107
|
+
if not sched or not sched.get("is_active"):
|
|
108
|
+
return
|
|
109
|
+
if not sched.get("is_recurring") or not sched.get("rrule"):
|
|
110
|
+
return
|
|
111
|
+
fired_from = self._parse_fire_at(sched["fire_at"])
|
|
112
|
+
nxt = _next_occurrence(sched["rrule"], fired_from, fired_from)
|
|
113
|
+
if not nxt:
|
|
114
|
+
logger.info(f"Recurring schedule {schedule_id} has no further occurrences")
|
|
115
|
+
return
|
|
116
|
+
nxt_utc = nxt.astimezone(UTC)
|
|
117
|
+
await self._db.update_schedule(
|
|
118
|
+
schedule_id, fire_at=nxt_utc.isoformat(), fired_at=None
|
|
119
|
+
)
|
|
120
|
+
self._add_job(schedule_id, nxt_utc)
|
|
121
|
+
logger.info(f"Recurring schedule {schedule_id} re-armed for {nxt_utc}")
|
|
122
|
+
|
|
123
|
+
async def add_schedule(self, schedule_id: int, fire_at: datetime):
|
|
124
|
+
self._add_job(schedule_id, fire_at)
|
|
125
|
+
|
|
126
|
+
async def remove_schedule(self, schedule_id: int):
|
|
127
|
+
job_id = f"schedule_{schedule_id}"
|
|
128
|
+
try:
|
|
129
|
+
self._scheduler.remove_job(job_id)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
@@ -96,3 +96,18 @@ async def import_to_live(request: Request, playlist_id: int, user: dict = Depend
|
|
|
96
96
|
)
|
|
97
97
|
result = await importer.import_playlist(playlist_id)
|
|
98
98
|
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.post("/parse-text")
|
|
102
|
+
async def parse_text(request: Request, user: dict = Depends(require_admin)):
|
|
103
|
+
"""Parse the plain-text playlist import format into resolved items.
|
|
104
|
+
|
|
105
|
+
Stateless: returns {items, errors} for the editor to merge into its working
|
|
106
|
+
list. Persistence happens via PUT /{id}/items when the admin saves.
|
|
107
|
+
"""
|
|
108
|
+
from ..playlists.importer import import_playlist_text
|
|
109
|
+
|
|
110
|
+
body = await request.json()
|
|
111
|
+
text = body.get("text", "")
|
|
112
|
+
db = request.app.state.db
|
|
113
|
+
return await import_playlist_text(db, text)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
2
|
+
from datetime import datetime, UTC
|
|
2
3
|
|
|
3
4
|
from ..auth.session import get_current_user
|
|
4
5
|
from ..queue.ordering import insert_pay_queue, insert_pay_playnext
|
|
@@ -6,6 +7,25 @@ from ..queue.ordering import insert_pay_queue, insert_pay_playnext
|
|
|
6
7
|
router = APIRouter(prefix="/queue", tags=["queue"])
|
|
7
8
|
|
|
8
9
|
|
|
10
|
+
async def _pre_fire_lock_detail(db) -> str:
|
|
11
|
+
"""Build a specific 'pay-to-play closes before [event]' message.
|
|
12
|
+
|
|
13
|
+
Falls back to a generic message if the locking schedule can't be read.
|
|
14
|
+
"""
|
|
15
|
+
lock = await db.get_active_pre_fire_lock()
|
|
16
|
+
if not lock:
|
|
17
|
+
return "Queue is locked: a scheduled playlist is firing soon."
|
|
18
|
+
label = lock.get("label") or "a scheduled event"
|
|
19
|
+
try:
|
|
20
|
+
fire_at = datetime.fromisoformat(lock["fire_at"])
|
|
21
|
+
if fire_at.tzinfo is None:
|
|
22
|
+
fire_at = fire_at.replace(tzinfo=UTC)
|
|
23
|
+
minutes = max(0, round((fire_at - datetime.now(UTC)).total_seconds() / 60))
|
|
24
|
+
return f'Pay-to-play is closed: "{label}" starts in {minutes} min. Try again after the event.'
|
|
25
|
+
except Exception:
|
|
26
|
+
return f'Pay-to-play is closed ahead of "{label}". Try again after the event.'
|
|
27
|
+
|
|
28
|
+
|
|
9
29
|
@router.get("/state")
|
|
10
30
|
async def get_queue_state(request: Request, user: dict = Depends(get_current_user)):
|
|
11
31
|
"""Get current queue state."""
|
|
@@ -30,7 +50,7 @@ async def add_to_queue(request: Request, user: dict = Depends(get_current_user))
|
|
|
30
50
|
|
|
31
51
|
# Check pre-fire lock
|
|
32
52
|
if await db.is_pre_fire_lock_active():
|
|
33
|
-
raise HTTPException(423,
|
|
53
|
+
raise HTTPException(423, await _pre_fire_lock_detail(db))
|
|
34
54
|
|
|
35
55
|
# Look up catalog item
|
|
36
56
|
item = await db.get_item(friendly_token)
|
|
@@ -86,7 +106,7 @@ async def play_next(request: Request, user: dict = Depends(get_current_user)):
|
|
|
86
106
|
|
|
87
107
|
# Check pre-fire lock
|
|
88
108
|
if await db.is_pre_fire_lock_active():
|
|
89
|
-
raise HTTPException(423,
|
|
109
|
+
raise HTTPException(423, await _pre_fire_lock_detail(db))
|
|
90
110
|
|
|
91
111
|
# Look up catalog item
|
|
92
112
|
item = await db.get_item(friendly_token)
|
|
@@ -187,3 +207,21 @@ async def queue_history(request: Request, user: dict = Depends(get_current_user)
|
|
|
187
207
|
db = request.app.state.db
|
|
188
208
|
history = await db.get_user_queue_history(user["username"])
|
|
189
209
|
return {"items": history}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@router.get("/next-schedule")
|
|
213
|
+
async def next_schedule(request: Request, user: dict = Depends(get_current_user)):
|
|
214
|
+
"""Public-facing info about the next scheduled playlist (for the queue page
|
|
215
|
+
announcement banner). Returns {} when nothing is scheduled.
|
|
216
|
+
"""
|
|
217
|
+
db = request.app.state.db
|
|
218
|
+
sched = await db.get_next_schedule()
|
|
219
|
+
if not sched:
|
|
220
|
+
return {}
|
|
221
|
+
lock_active = await db.is_pre_fire_lock_active()
|
|
222
|
+
return {
|
|
223
|
+
"label": sched.get("label"),
|
|
224
|
+
"fire_at": sched.get("fire_at"),
|
|
225
|
+
"pre_fire_lock_minutes": sched.get("pre_fire_lock_minutes"),
|
|
226
|
+
"lock_active": lock_active,
|
|
227
|
+
}
|
|
@@ -61,7 +61,7 @@ a:hover {
|
|
|
61
61
|
display: flex;
|
|
62
62
|
justify-content: space-between;
|
|
63
63
|
align-items: center;
|
|
64
|
-
padding: 0.
|
|
64
|
+
padding: 0.35rem 2rem;
|
|
65
65
|
background: var(--bg-secondary);
|
|
66
66
|
border-bottom: 1px solid var(--border);
|
|
67
67
|
position: sticky;
|
|
@@ -351,10 +351,10 @@ a:hover {
|
|
|
351
351
|
/* Title spans the full width of the card. */
|
|
352
352
|
.np-title {
|
|
353
353
|
margin: 0;
|
|
354
|
-
font-size:
|
|
355
|
-
line-height: 1.
|
|
354
|
+
font-size: 1rem;
|
|
355
|
+
line-height: 1.1;
|
|
356
356
|
overflow-wrap: anywhere;
|
|
357
|
-
padding-bottom: 0.
|
|
357
|
+
padding-bottom: 0.5rem;
|
|
358
358
|
border-bottom: 1px solid var(--border);
|
|
359
359
|
}
|
|
360
360
|
/* Image + time display sit side by side. */
|
|
@@ -522,10 +522,10 @@ a:hover {
|
|
|
522
522
|
}
|
|
523
523
|
/* Description + category/tag chips sit below the image / time row. */
|
|
524
524
|
.np-description {
|
|
525
|
-
font-size: 0.
|
|
526
|
-
line-height: 1.
|
|
525
|
+
font-size: 0.7rem;
|
|
526
|
+
line-height: 1.1;
|
|
527
527
|
color: var(--text-secondary);
|
|
528
|
-
padding-top: 0.
|
|
528
|
+
padding-top: 0.5rem;
|
|
529
529
|
border-top: 1px solid var(--border);
|
|
530
530
|
white-space: pre-line;
|
|
531
531
|
overflow-wrap: anywhere;
|
|
@@ -982,3 +982,134 @@ a.np-chip {
|
|
|
982
982
|
font-size: 0.85rem;
|
|
983
983
|
}
|
|
984
984
|
|
|
985
|
+
/* ===== Admin management UIs (playlists / schedules / queue) ===== */
|
|
986
|
+
.section-head {
|
|
987
|
+
display: flex;
|
|
988
|
+
align-items: center;
|
|
989
|
+
justify-content: space-between;
|
|
990
|
+
gap: 1rem;
|
|
991
|
+
flex-wrap: wrap;
|
|
992
|
+
margin-bottom: 1rem;
|
|
993
|
+
}
|
|
994
|
+
.section-head h2 { margin-bottom: 0; }
|
|
995
|
+
.btn-group {
|
|
996
|
+
display: flex;
|
|
997
|
+
gap: 0.5rem;
|
|
998
|
+
flex-wrap: wrap;
|
|
999
|
+
}
|
|
1000
|
+
.btn-xs {
|
|
1001
|
+
padding: 0.15rem 0.45rem;
|
|
1002
|
+
font-size: 0.72rem;
|
|
1003
|
+
border: 1px solid var(--border);
|
|
1004
|
+
border-radius: 4px;
|
|
1005
|
+
background: var(--bg-card);
|
|
1006
|
+
color: var(--text-primary);
|
|
1007
|
+
cursor: pointer;
|
|
1008
|
+
}
|
|
1009
|
+
.btn-xs:hover { background: var(--bg-hover); }
|
|
1010
|
+
.btn-xs:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
1011
|
+
.row-actions {
|
|
1012
|
+
display: flex;
|
|
1013
|
+
gap: 0.4rem;
|
|
1014
|
+
justify-content: flex-end;
|
|
1015
|
+
flex-wrap: wrap;
|
|
1016
|
+
}
|
|
1017
|
+
.muted { color: var(--text-secondary); font-size: 0.85rem; }
|
|
1018
|
+
|
|
1019
|
+
/* Badges */
|
|
1020
|
+
.badge {
|
|
1021
|
+
display: inline-block;
|
|
1022
|
+
font-size: 0.7rem;
|
|
1023
|
+
padding: 0.1rem 0.45rem;
|
|
1024
|
+
border-radius: 999px;
|
|
1025
|
+
background: var(--bg-card);
|
|
1026
|
+
border: 1px solid var(--border);
|
|
1027
|
+
color: var(--text-secondary);
|
|
1028
|
+
}
|
|
1029
|
+
.badge-warn { background: rgba(253, 203, 110, 0.15); border-color: var(--warning); color: var(--warning); }
|
|
1030
|
+
.badge-accent { background: rgba(108, 92, 231, 0.2); border-color: var(--accent); color: var(--text-primary); }
|
|
1031
|
+
|
|
1032
|
+
/* Modal form fields */
|
|
1033
|
+
.field {
|
|
1034
|
+
display: flex;
|
|
1035
|
+
flex-direction: column;
|
|
1036
|
+
gap: 0.3rem;
|
|
1037
|
+
margin-bottom: 0.85rem;
|
|
1038
|
+
}
|
|
1039
|
+
.field > span { font-size: 0.8rem; color: var(--text-secondary); }
|
|
1040
|
+
.field input, .field select, .field textarea {
|
|
1041
|
+
padding: 0.5rem 0.65rem;
|
|
1042
|
+
border: 1px solid var(--border);
|
|
1043
|
+
border-radius: var(--radius);
|
|
1044
|
+
background: var(--bg-secondary);
|
|
1045
|
+
color: var(--text-primary);
|
|
1046
|
+
font: inherit;
|
|
1047
|
+
width: 100%;
|
|
1048
|
+
}
|
|
1049
|
+
.check {
|
|
1050
|
+
display: flex;
|
|
1051
|
+
align-items: center;
|
|
1052
|
+
gap: 0.5rem;
|
|
1053
|
+
font-size: 0.85rem;
|
|
1054
|
+
margin-bottom: 0.85rem;
|
|
1055
|
+
cursor: pointer;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/* Playlist editor */
|
|
1059
|
+
.editor-grid {
|
|
1060
|
+
display: grid;
|
|
1061
|
+
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
|
|
1062
|
+
gap: 2rem;
|
|
1063
|
+
}
|
|
1064
|
+
@media (max-width: 800px) { .editor-grid { grid-template-columns: 1fr; } }
|
|
1065
|
+
.editor-list {
|
|
1066
|
+
list-style: none;
|
|
1067
|
+
display: flex;
|
|
1068
|
+
flex-direction: column;
|
|
1069
|
+
gap: 0.35rem;
|
|
1070
|
+
margin: 0;
|
|
1071
|
+
padding: 0;
|
|
1072
|
+
}
|
|
1073
|
+
.editor-row {
|
|
1074
|
+
display: grid;
|
|
1075
|
+
grid-template-columns: 1.2rem 1.6rem minmax(0, 1fr) auto auto auto;
|
|
1076
|
+
align-items: center;
|
|
1077
|
+
gap: 0.6rem;
|
|
1078
|
+
padding: 0.45rem 0.6rem;
|
|
1079
|
+
background: var(--bg-card);
|
|
1080
|
+
border-radius: var(--radius);
|
|
1081
|
+
border: 1px solid transparent;
|
|
1082
|
+
}
|
|
1083
|
+
.editor-row:hover { border-color: var(--border); }
|
|
1084
|
+
.drag-handle { cursor: grab; color: var(--text-secondary); user-select: none; }
|
|
1085
|
+
.editor-row .pos { color: var(--text-secondary); font-size: 0.8rem; }
|
|
1086
|
+
.ed-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1087
|
+
.ed-type, .ed-dur { font-size: 0.78rem; color: var(--text-secondary); }
|
|
1088
|
+
.ed-move { display: flex; gap: 0.25rem; }
|
|
1089
|
+
|
|
1090
|
+
/* Catalog add results */
|
|
1091
|
+
.cat-results { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.6rem; max-height: 340px; overflow-y: auto; }
|
|
1092
|
+
.cat-result {
|
|
1093
|
+
display: flex;
|
|
1094
|
+
align-items: center;
|
|
1095
|
+
justify-content: space-between;
|
|
1096
|
+
gap: 0.6rem;
|
|
1097
|
+
padding: 0.4rem 0.6rem;
|
|
1098
|
+
background: var(--bg-card);
|
|
1099
|
+
border-radius: var(--radius);
|
|
1100
|
+
}
|
|
1101
|
+
.import-text {
|
|
1102
|
+
width: 100%;
|
|
1103
|
+
padding: 0.5rem;
|
|
1104
|
+
border: 1px solid var(--border);
|
|
1105
|
+
border-radius: var(--radius);
|
|
1106
|
+
background: var(--bg-secondary);
|
|
1107
|
+
color: var(--text-primary);
|
|
1108
|
+
font-family: monospace;
|
|
1109
|
+
font-size: 0.85rem;
|
|
1110
|
+
margin-bottom: 0.5rem;
|
|
1111
|
+
resize: vertical;
|
|
1112
|
+
}
|
|
1113
|
+
.import-errors { font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.5rem; }
|
|
1114
|
+
.import-errors code { color: var(--warning); }
|
|
1115
|
+
|