kryten-webqueue 0.15.0__tar.gz → 0.15.2__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.0 → kryten_webqueue-0.15.2}/CHANGELOG.md +22 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/PKG-INFO +1 -1
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/config.example.json +3 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/__main__.py +3 -1
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/app.py +1 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/config.py +11 -0
- kryten_webqueue-0.15.2/kryten_webqueue/logging_config.py +81 -0
- kryten_webqueue-0.15.2/kryten_webqueue/playlists/fire.py +136 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/importer.py +33 -21
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/scheduler.py +4 -1
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/director.py +176 -4
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_playlists.py +1 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_schedules.py +1 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/pyproject.toml +1 -1
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_promo_director.py +74 -0
- kryten_webqueue-0.15.0/kryten_webqueue/playlists/fire.py +0 -120
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/.gitignore +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/README.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/tasks.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/__init__.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_save_results_to_playlist.py +0 -0
|
@@ -6,6 +6,28 @@ 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.15.2] — 2026-06-17
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
|
|
15
|
+
[0.15.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.15.2
|
|
16
|
+
|
|
17
|
+
## [0.15.1] — 2026-06-17
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **Application logs were silently dropped.** The app never configured Python logging — `uvicorn.run(log_level="info")` only sets up uvicorn's own loggers, leaving the `kryten_webqueue` hierarchy with no handler, so Python's "last resort" path emitted only `WARNING`+. Every `logger.info(...)` (including all promo-insertion diagnostics) was discarded. A new `logging_config.build_log_config()` installs a `dictConfig` (passed to uvicorn via `log_config`) that attaches a console handler to the application loggers.
|
|
22
|
+
- **Silent failure in the promo poll loop.** `PromoDirector.on_poll()` swallowed every exception from the immutable-event-lock check with a bare `except: return` and no log line, hiding faults. It now logs at `WARNING` with a traceback before skipping the cycle.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Configurable log levels.** New `log_level` (default `INFO`) and `promo_log_level` (default falls back to `log_level`) config fields. Set `promo_log_level` to `DEBUG` for a full per-poll trace of promo decisions without flooding the rest of the app (the `kryten_webqueue.promos` logger is independently tunable).
|
|
27
|
+
- **Deep promo observability.** `PromoDirector` now emits detailed diagnostics across the whole insertion path: now-playing advance + cadence counter, clip selection (order, pool size, sequential index, random no-repeat avoidance), weighted type pick (candidates/weights/skipped), cadence-due reason, idempotency-guard skips, lead-in decisions, and the add/move/uid result. It warns on single-clip pools (which always repeat) and **errors** when an add succeeds but returns no uid (an untracked insertion that would otherwise repeat every poll).
|
|
28
|
+
|
|
29
|
+
[0.15.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.15.1
|
|
30
|
+
|
|
9
31
|
## [0.15.0] — 2026-06-14
|
|
10
32
|
|
|
11
33
|
### Added
|
|
@@ -3,13 +3,15 @@ import uvicorn
|
|
|
3
3
|
|
|
4
4
|
from .config import Config
|
|
5
5
|
from .app import create_app
|
|
6
|
+
from .logging_config import build_log_config
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def main():
|
|
9
10
|
config_path = os.environ.get("WQ_CONFIG", "/etc/kryten-webqueue/config.json")
|
|
10
11
|
config = Config.from_file(config_path)
|
|
11
12
|
app = create_app(config)
|
|
12
|
-
|
|
13
|
+
log_config = build_log_config(config.log_level, config.promo_log_level)
|
|
14
|
+
uvicorn.run(app, host=config.host, port=config.port, log_config=log_config)
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
if __name__ == "__main__":
|
|
@@ -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
|
|
@@ -104,6 +104,17 @@ class Config(BaseModel):
|
|
|
104
104
|
secret_key: str
|
|
105
105
|
session_ttl_hours: int = 24
|
|
106
106
|
|
|
107
|
+
# Logging
|
|
108
|
+
# Root application log level for the ``kryten_webqueue`` logger hierarchy.
|
|
109
|
+
# Without explicit configuration Python only emits WARNING+ via its
|
|
110
|
+
# "last resort" handler, so INFO diagnostics (e.g. promo insertions) are
|
|
111
|
+
# silently dropped. ``__main__`` installs a dictConfig using these values.
|
|
112
|
+
log_level: str = "INFO"
|
|
113
|
+
# Independent level for the promo subsystem (``kryten_webqueue.promos``).
|
|
114
|
+
# Set to "DEBUG" for a full per-poll trace of promo decisions without
|
|
115
|
+
# flooding the rest of the app. Falls back to ``log_level`` when None.
|
|
116
|
+
promo_log_level: str | None = None
|
|
117
|
+
|
|
107
118
|
# API Gate
|
|
108
119
|
api_gate_url: str = "http://127.0.0.1:24444"
|
|
109
120
|
api_gate_token: str
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Centralised logging configuration.
|
|
2
|
+
|
|
3
|
+
The app previously relied on ``uvicorn.run(log_level="info")`` which only
|
|
4
|
+
configures uvicorn's *own* loggers (``uvicorn``/``uvicorn.access``/
|
|
5
|
+
``uvicorn.error``). Application loggers in the ``kryten_webqueue`` hierarchy had
|
|
6
|
+
no handler, so Python's "last resort" handler emitted only ``WARNING`` and
|
|
7
|
+
above — silently dropping every ``logger.info(...)`` call (e.g. all promo
|
|
8
|
+
insertion diagnostics). This module builds a single ``dictConfig`` that installs
|
|
9
|
+
a console handler for both uvicorn and the application loggers, with an
|
|
10
|
+
independently tunable level for the promo subsystem.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _normalize_level(level: str | None, default: str) -> str:
|
|
17
|
+
if not level:
|
|
18
|
+
return default
|
|
19
|
+
candidate = str(level).strip().upper()
|
|
20
|
+
valid = {"CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG", "NOTSET"}
|
|
21
|
+
return candidate if candidate in valid else default
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_log_config(log_level: str = "INFO", promo_log_level: str | None = None) -> dict:
|
|
25
|
+
"""Return a ``logging.config.dictConfig`` dict for uvicorn + the app.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
log_level: Level for the root and ``kryten_webqueue`` loggers.
|
|
29
|
+
promo_log_level: Level for ``kryten_webqueue.promos`` (the promo
|
|
30
|
+
director). Falls back to ``log_level`` when not provided. Set to
|
|
31
|
+
``DEBUG`` for a full per-poll trace of promo decisions.
|
|
32
|
+
"""
|
|
33
|
+
app_level = _normalize_level(log_level, "INFO")
|
|
34
|
+
promo_level = _normalize_level(promo_log_level, app_level)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"version": 1,
|
|
38
|
+
# Never tear down loggers created at import time (module-level
|
|
39
|
+
# ``getLogger`` calls); we only attach handlers/levels.
|
|
40
|
+
"disable_existing_loggers": False,
|
|
41
|
+
"formatters": {
|
|
42
|
+
"default": {
|
|
43
|
+
"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
44
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
45
|
+
},
|
|
46
|
+
"access": {
|
|
47
|
+
"format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
|
|
48
|
+
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
"handlers": {
|
|
52
|
+
"console": {
|
|
53
|
+
"class": "logging.StreamHandler",
|
|
54
|
+
"formatter": "default",
|
|
55
|
+
"stream": "ext://sys.stderr",
|
|
56
|
+
},
|
|
57
|
+
"access": {
|
|
58
|
+
"class": "logging.StreamHandler",
|
|
59
|
+
"formatter": "access",
|
|
60
|
+
"stream": "ext://sys.stdout",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
"root": {"handlers": ["console"], "level": app_level},
|
|
64
|
+
"loggers": {
|
|
65
|
+
"kryten_webqueue": {
|
|
66
|
+
"level": app_level,
|
|
67
|
+
"handlers": ["console"],
|
|
68
|
+
"propagate": False,
|
|
69
|
+
},
|
|
70
|
+
# Promo subsystem: independently tunable so operators can crank it to
|
|
71
|
+
# DEBUG for a deep dive without flooding the rest of the app.
|
|
72
|
+
"kryten_webqueue.promos": {
|
|
73
|
+
"level": promo_level,
|
|
74
|
+
"handlers": ["console"],
|
|
75
|
+
"propagate": False,
|
|
76
|
+
},
|
|
77
|
+
"uvicorn": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
|
78
|
+
"uvicorn.error": {"level": "INFO", "handlers": ["console"], "propagate": False},
|
|
79
|
+
"uvicorn.access": {"level": "INFO", "handlers": ["access"], "propagate": False},
|
|
80
|
+
},
|
|
81
|
+
}
|
|
@@ -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
|