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.
Files changed (104) hide show
  1. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/CHANGELOG.md +22 -0
  2. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/config.example.json +3 -0
  4. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/__main__.py +3 -1
  5. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/app.py +1 -0
  6. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/config.py +11 -0
  7. kryten_webqueue-0.15.2/kryten_webqueue/logging_config.py +81 -0
  8. kryten_webqueue-0.15.2/kryten_webqueue/playlists/fire.py +136 -0
  9. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/importer.py +33 -21
  10. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/scheduler.py +4 -1
  11. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/director.py +176 -4
  12. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_playlists.py +1 -0
  13. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_schedules.py +1 -0
  14. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/pyproject.toml +1 -1
  15. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_promo_director.py +74 -0
  16. kryten_webqueue-0.15.0/kryten_webqueue/playlists/fire.py +0 -120
  17. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/.github/workflows/python-publish.yml +0 -0
  18. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/.github/workflows/release.yml +0 -0
  19. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/.gitignore +0 -0
  20. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/README.md +0 -0
  21. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  29. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/PRE_PLAN_GAPS.md +0 -0
  30. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/PRODUCT_PLAN.md +0 -0
  31. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  32. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/__init__.py +0 -0
  33. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  34. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/client.py +0 -0
  35. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/db.py +0 -0
  41. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/images.py +0 -0
  42. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/mediacms.py +0 -0
  43. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/sync.py +0 -0
  44. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/__init__.py +0 -0
  45. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  46. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  47. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  48. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  49. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  50. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  51. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  52. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  53. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/__init__.py +0 -0
  54. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  55. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/manager.py +0 -0
  56. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/tasks.py +0 -0
  57. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/__init__.py +0 -0
  58. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
  59. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/ordering.py +0 -0
  60. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/__init__.py +0 -0
  61. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/__init__.py +0 -0
  62. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/ordering.py +0 -0
  63. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/poller.py +0 -0
  64. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/presence.py +0 -0
  65. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/shadow.py +0 -0
  66. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/__init__.py +0 -0
  67. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
  68. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
  69. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_promos.py +0 -0
  70. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  71. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/auth.py +0 -0
  72. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/catalog.py +0 -0
  73. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/pages.py +0 -0
  74. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/queue.py +0 -0
  75. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/user.py +0 -0
  76. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/static/css/main.css +0 -0
  77. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/index.html +0 -0
  79. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
  86. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  90. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.2}/tests/test_queue_announce.py +0 -0
  104. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.15.0
3
+ Version: 0.15.2
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
@@ -5,6 +5,9 @@
5
5
  "secret_key": "CHANGE_ME_long_random_string",
6
6
  "session_ttl_hours": 24,
7
7
 
8
+ "log_level": "INFO",
9
+ "promo_log_level": "DEBUG",
10
+
8
11
  "api_gate_url": "https://www.dropsugar.co:8443/",
9
12
  "api_gate_token": "CHANGE_ME",
10
13
 
@@ -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
- uvicorn.run(app, host=config.host, port=config.port, log_level="info")
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
- for index, item in enumerate(items):
81
- # Throttle consecutive adds so CyTube can validate each item before
82
- # the next arrives (avoids transient queueFail/422 under load).
83
- if index and self._add_delay_sec:
84
- await asyncio.sleep(self._add_delay_sec)
85
- try:
86
- result = await add_item_throttled(
87
- self._api_gate,
88
- media_type=item["media_type"],
89
- media_id=item["media_id"],
90
- position="end",
91
- max_retries=self._add_max_retries,
92
- retry_delay_sec=self._add_delay_sec or 0.5,
93
- )
94
- if result.get("success"):
95
- added += 1
96
- else:
91
+ with suppress_ctx:
92
+ for index, item in enumerate(items):
93
+ # Throttle consecutive adds so CyTube can validate each item before
94
+ # the next arrives (avoids transient queueFail/422 under load).
95
+ if index and self._add_delay_sec:
96
+ await asyncio.sleep(self._add_delay_sec)
97
+ try:
98
+ result = await add_item_throttled(
99
+ self._api_gate,
100
+ media_type=item["media_type"],
101
+ media_id=item["media_id"],
102
+ position="end",
103
+ max_retries=self._add_max_retries,
104
+ retry_delay_sec=self._add_delay_sec or 0.5,
105
+ )
106
+ if result.get("success"):
107
+ added += 1
108
+ else:
109
+ errors += 1
110
+ except Exception as e:
111
+ logger.warning(f"Failed to add {item['media_id']}: {e}")
97
112
  errors += 1
98
- except Exception as e:
99
- logger.warning(f"Failed to add {item['media_id']}: {e}")
100
- errors += 1
101
113
 
102
114
  return {"success": True, "added": added, "errors": errors}
103
115
 
@@ -27,13 +27,15 @@ def _next_occurrence(rrule_str: str, dtstart: datetime, after: datetime) -> date
27
27
  class PlaylistScheduler:
28
28
  """APScheduler-based scheduler for playlist fire events."""
29
29
 
30
- def __init__(self, *, db, api_gate, shadow, ws_manager, add_delay_sec: float = 0.0, add_max_retries: int = 0):
30
+ def __init__(self, *, db, api_gate, shadow, ws_manager, add_delay_sec: float = 0.0, add_max_retries: int = 0,
31
+ promo_director=None):
31
32
  self._db = db
32
33
  self._api_gate = api_gate
33
34
  self._shadow = shadow
34
35
  self._ws_manager = ws_manager
35
36
  self._add_delay_sec = add_delay_sec
36
37
  self._add_max_retries = add_max_retries
38
+ self._promo_director = promo_director
37
39
  self._scheduler = AsyncIOScheduler()
38
40
 
39
41
  async def start(self):
@@ -100,6 +102,7 @@ class PlaylistScheduler:
100
102
  ws_manager=self._ws_manager,
101
103
  add_delay_sec=self._add_delay_sec,
102
104
  add_max_retries=self._add_max_retries,
105
+ promo_director=self._promo_director,
103
106
  )
104
107
  # After an automatic timed fire, advance recurring schedules to their
105
108
  # next occurrence and re-arm. (Manual "Fire Now" does NOT advance the