kryten-webqueue 0.15.0__tar.gz → 0.15.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.
Files changed (103) hide show
  1. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/CHANGELOG.md +14 -0
  2. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/config.example.json +3 -0
  4. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/__main__.py +3 -1
  5. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/config.py +11 -0
  6. kryten_webqueue-0.15.1/kryten_webqueue/logging_config.py +81 -0
  7. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/promos/director.py +105 -4
  8. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/pyproject.toml +1 -1
  9. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/.gitignore +0 -0
  12. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/README.md +0 -0
  13. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/deploy/kryten-webqueue.service +0 -0
  14. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/deploy/nginx-queue.conf +0 -0
  15. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  16. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/IMPL_API_GATE.md +0 -0
  17. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/IMPL_ECONOMY.md +0 -0
  18. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  19. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/IMPL_ROBOT.md +0 -0
  20. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  21. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/PRE_PLAN_GAPS.md +0 -0
  22. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/PRODUCT_PLAN.md +0 -0
  23. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  24. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/__init__.py +0 -0
  25. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  26. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/api_gate/client.py +0 -0
  27. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/app.py +0 -0
  28. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/__init__.py +0 -0
  29. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/otp.py +0 -0
  30. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  31. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/auth/session.py +0 -0
  32. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/__init__.py +0 -0
  33. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/db.py +0 -0
  34. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/images.py +0 -0
  35. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  36. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/catalog/sync.py +0 -0
  37. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/__init__.py +0 -0
  38. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  39. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  40. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  41. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  42. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  43. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  44. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  45. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  46. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/__init__.py +0 -0
  47. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  48. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/manager.py +0 -0
  49. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/jobs/tasks.py +0 -0
  50. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/__init__.py +0 -0
  51. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  52. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/fire.py +0 -0
  53. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/importer.py +0 -0
  54. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/ordering.py +0 -0
  55. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  56. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/promos/__init__.py +0 -0
  57. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/__init__.py +0 -0
  58. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/ordering.py +0 -0
  59. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/poller.py +0 -0
  60. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/presence.py +0 -0
  61. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/queue/shadow.py +0 -0
  62. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/__init__.py +0 -0
  63. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  64. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  65. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  66. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  67. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  68. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  69. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/auth.py +0 -0
  70. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/catalog.py +0 -0
  71. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/pages.py +0 -0
  72. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/queue.py +0 -0
  73. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/routes/user.py +0 -0
  74. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/static/css/main.css +0 -0
  75. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/static/js/main.js +0 -0
  76. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/index.html +0 -0
  77. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  78. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  79. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  80. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  81. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/auth/login.html +0 -0
  82. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/base.html +0 -0
  83. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  84. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  85. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  86. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/queue/index.html +0 -0
  87. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  88. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/__init__.py +0 -0
  89. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/handler.py +0 -0
  90. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/kryten_webqueue/ws/manager.py +0 -0
  91. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/__init__.py +0 -0
  92. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_config_persistence.py +0 -0
  93. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_fetchurls_sharepoint.py +0 -0
  94. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_phase1.py +0 -0
  95. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_phase2_jobs.py +0 -0
  96. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_phase3_jobs.py +0 -0
  97. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_phase4_live_fixes.py +0 -0
  98. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_playlist_import.py +0 -0
  99. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_presence_refund.py +0 -0
  100. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_promo_director.py +0 -0
  101. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_promo_pool_exclusion.py +0 -0
  102. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_queue_announce.py +0 -0
  103. {kryten_webqueue-0.15.0 → kryten_webqueue-0.15.1}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,20 @@ 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.1] — 2026-06-17
10
+
11
+ ### Fixed
12
+
13
+ - **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.
14
+ - **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.
15
+
16
+ ### Added
17
+
18
+ - **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).
19
+ - **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).
20
+
21
+ [0.15.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.15.1
22
+
9
23
  ## [0.15.0] — 2026-06-14
10
24
 
11
25
  ### 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.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
@@ -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__":
@@ -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
+ }
@@ -186,10 +186,18 @@ class PromoDirector:
186
186
  def _general_due(self, now: datetime) -> bool:
187
187
  g = self._config.general
188
188
  if self._content_since_last_general >= g.every_n_items:
189
+ logger.debug(
190
+ "General promo due: content_since_last=%d >= every_n_items=%d",
191
+ self._content_since_last_general, g.every_n_items,
192
+ )
189
193
  return True
190
194
  if self._last_general_at is not None:
191
195
  elapsed_min = (now - self._last_general_at).total_seconds() / 60.0
192
196
  if elapsed_min >= g.every_m_minutes:
197
+ logger.debug(
198
+ "General promo due: elapsed=%.1fmin >= every_m_minutes=%.1f",
199
+ elapsed_min, g.every_m_minutes,
200
+ )
193
201
  return True
194
202
  return False
195
203
 
@@ -197,37 +205,70 @@ class PromoDirector:
197
205
  """Weighted choice among enabled general types with a non-empty pool."""
198
206
  candidates: list[str] = []
199
207
  weights: list[int] = []
208
+ skipped: list[str] = []
200
209
  for t in GENERAL_PROMO_TYPES:
201
210
  tc = self._config.types.get(t)
202
211
  if not tc or not tc.enabled or tc.weight <= 0:
212
+ skipped.append(f"{t}(disabled/weight)")
203
213
  continue
204
214
  pool = await self._db.get_promo_pool_items(t)
205
215
  if not pool:
216
+ skipped.append(f"{t}(empty-pool)")
206
217
  continue
207
218
  candidates.append(t)
208
219
  weights.append(tc.weight)
209
220
  if not candidates:
221
+ logger.warning(
222
+ "Promo general-type pick found no eligible types (skipped=%s)", skipped
223
+ )
210
224
  return None
211
- return self._rng.choices(candidates, weights=weights, k=1)[0]
225
+ chosen = self._rng.choices(candidates, weights=weights, k=1)[0]
226
+ logger.debug(
227
+ "Promo general-type pick: chosen=%s candidates=%s weights=%s skipped=%s",
228
+ chosen, candidates, weights, skipped,
229
+ )
230
+ return chosen
212
231
 
213
232
  def _select_clip(self, promo_type: str, pool: list[dict]) -> dict:
214
233
  tc = self._config.types.get(promo_type)
215
234
  order = tc.order if tc else "random"
235
+ pool_size = len(pool)
216
236
  if order == "sequential":
217
- idx = self._seq_index.get(promo_type, 0) % len(pool)
237
+ raw_index = self._seq_index.get(promo_type, 0)
238
+ idx = raw_index % pool_size
218
239
  self._seq_index[promo_type] = idx + 1
219
240
  clip = pool[idx]
241
+ logger.debug(
242
+ "Promo clip select [%s] order=sequential pool_size=%d raw_index=%d "
243
+ "-> idx=%d media_id=%s title=%r next_index=%d",
244
+ promo_type, pool_size, raw_index, idx,
245
+ clip.get("media_id"), clip.get("title"), idx + 1,
246
+ )
220
247
  else:
221
248
  clip = self._rng.choice(pool)
222
- if self._config.general.no_repeat and len(pool) > 1:
249
+ repeated = False
250
+ if self._config.general.no_repeat and pool_size > 1:
223
251
  last = self._last_clip_token.get(promo_type)
224
252
  if clip.get("media_id") == last:
253
+ repeated = True
225
254
  # Draw from the rest of the pool so the no-repeat guarantee
226
255
  # always holds (bounded random retries could otherwise give
227
256
  # up and return a repeat).
228
257
  alternatives = [c for c in pool if c.get("media_id") != last]
229
258
  if alternatives:
230
259
  clip = self._rng.choice(alternatives)
260
+ logger.debug(
261
+ "Promo clip select [%s] order=random pool_size=%d no_repeat=%s "
262
+ "avoided_repeat=%s -> media_id=%s title=%r",
263
+ promo_type, pool_size, self._config.general.no_repeat,
264
+ repeated, clip.get("media_id"), clip.get("title"),
265
+ )
266
+ if pool_size == 1:
267
+ logger.warning(
268
+ "Promo pool for %r has a single clip; it will repeat every time "
269
+ "(media_id=%s). Add more clips to vary this promo type.",
270
+ promo_type, clip.get("media_id"),
271
+ )
231
272
  self._last_clip_token[promo_type] = clip.get("media_id")
232
273
  return clip
233
274
 
@@ -262,14 +303,30 @@ class PromoDirector:
262
303
  logger.warning("Promo add failed (%s)", promo_type, exc_info=True)
263
304
  return None
264
305
  if not add_result or not add_result.get("success"):
306
+ logger.warning(
307
+ "Promo add rejected (%s media_id=%s): result=%r",
308
+ promo_type, clip.get("media_id"), add_result,
309
+ )
265
310
  return None
266
311
  uid = add_result.get("uid")
267
312
  if uid is None:
313
+ # CyTube accepted the add but api-gate could not resolve a uid. The
314
+ # shadow can't track this promo, so the idempotency guard will never
315
+ # see it -> the cadence counter never resets and we'd re-add every
316
+ # poll. Bail loudly so this shows up in logs instead of silently
317
+ # spamming the queue.
318
+ logger.error(
319
+ "Promo add for %s (media_id=%s) returned success but NO uid; "
320
+ "cannot track in shadow (result=%r). Skipping shadow insert to "
321
+ "avoid an untracked, repeatable insertion.",
322
+ promo_type, clip.get("media_id"), add_result,
323
+ )
268
324
  return None
269
325
 
270
326
  if after_uid is not None:
271
327
  try:
272
328
  await self._api_gate.playlist_move(uid, after_uid)
329
+ logger.debug("Promo move ok: uid=%s after_uid=%s", uid, after_uid)
273
330
  except Exception:
274
331
  logger.warning("Promo move failed (uid=%s after=%s)", uid, after_uid, exc_info=True)
275
332
 
@@ -310,10 +367,21 @@ class PromoDirector:
310
367
  return None
311
368
  items = self._shadow.items
312
369
  if self._has_lead_in(content_uid, items):
370
+ logger.debug(
371
+ "Viewer's-Choice lead-in already present for paid uid=%s; skipping",
372
+ content_uid,
373
+ )
313
374
  return None
314
375
  pred = self._direct_pred_uid(content_uid, items)
315
376
  if pred is None:
377
+ logger.debug(
378
+ "Viewer's-Choice skipped: no predecessor for paid uid=%s", content_uid
379
+ )
316
380
  return None
381
+ logger.info(
382
+ "Viewer's-Choice lead-in (pay path) for paid uid=%s after_uid=%s",
383
+ content_uid, pred,
384
+ )
317
385
  return await self._insert_promo(
318
386
  "viewers_choice", after_uid=pred, target_uid=content_uid, lead_in=True
319
387
  )
@@ -331,6 +399,10 @@ class PromoDirector:
331
399
  if np_uid != self._last_np_uid:
332
400
  if self._last_np_uid is not None and not self._last_np_is_promo:
333
401
  self._content_since_last_general += 1
402
+ logger.debug(
403
+ "Now-playing advanced: %s -> %s (is_promo=%s) content_since_last_general=%d",
404
+ self._last_np_uid, np_uid, np_is_promo, self._content_since_last_general,
405
+ )
334
406
  self._last_np_uid = np_uid
335
407
  self._last_np_is_promo = np_is_promo
336
408
 
@@ -341,8 +413,13 @@ class PromoDirector:
341
413
  # No-op during an immutable scheduled event (curated content plays as built).
342
414
  try:
343
415
  if await self._db.is_event_lock_active():
416
+ logger.debug("Promo on_poll skipped: immutable event lock active")
344
417
  return
345
418
  except Exception:
419
+ logger.warning(
420
+ "Promo on_poll: is_event_lock_active() check failed; skipping this cycle",
421
+ exc_info=True,
422
+ )
346
423
  return
347
424
 
348
425
  items = self._shadow.items
@@ -350,6 +427,7 @@ class PromoDirector:
350
427
  return
351
428
  target = self._next_content(np_uid, items)
352
429
  if target is None:
430
+ logger.debug("Promo on_poll: no upcoming content item found")
353
431
  return
354
432
  target_uid = target.get("uid")
355
433
  direct_pred = self._direct_pred_uid(target_uid, items)
@@ -358,12 +436,24 @@ class PromoDirector:
358
436
  # Lead-in first so a same-cycle general promo lands ahead of it.
359
437
  leadin_type = self._leadin_type_for(target)
360
438
  if leadin_type and not self._has_lead_in(target_uid, items):
439
+ logger.info(
440
+ "Promo lead-in due: type=%s target_uid=%s (is_pay=%s dur=%.0fs) after_uid=%s",
441
+ leadin_type, target_uid, target.get("is_pay"),
442
+ _duration_seconds(target), direct_pred,
443
+ )
361
444
  await self._insert_promo(
362
445
  leadin_type, after_uid=direct_pred, target_uid=target_uid, lead_in=True
363
446
  )
447
+ elif leadin_type:
448
+ logger.debug(
449
+ "Promo lead-in already present for target_uid=%s (type=%s)",
450
+ target_uid, leadin_type,
451
+ )
364
452
 
365
453
  # General cadence promo.
366
- if self._general_due(now) and not self._has_general_before(target_uid, self._shadow.items):
454
+ due = self._general_due(now)
455
+ has_general = self._has_general_before(target_uid, self._shadow.items)
456
+ if due and not has_general:
367
457
  chosen = await self._pick_general_type()
368
458
  if chosen:
369
459
  pool = await self._db.get_promo_pool_items(chosen)
@@ -371,5 +461,16 @@ class PromoDirector:
371
461
  chosen, after_uid=content_pred, target_uid=None, lead_in=False, pool=pool
372
462
  )
373
463
  if inserted is not None:
464
+ logger.info(
465
+ "Promo general inserted: type=%s uid=%s before content_uid=%s "
466
+ "(reset cadence counter from %d)",
467
+ chosen, inserted, target_uid, self._content_since_last_general,
468
+ )
374
469
  self._content_since_last_general = 0
375
470
  self._last_general_at = now
471
+ elif due and has_general:
472
+ logger.debug(
473
+ "Promo general due but one already precedes target_uid=%s; "
474
+ "skipping (idempotency guard)",
475
+ target_uid,
476
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.15.0"
3
+ version = "0.15.1"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"