kryten-webqueue 0.15.1__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.1 → kryten_webqueue-0.15.2}/CHANGELOG.md +8 -0
  2. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/app.py +1 -0
  4. kryten_webqueue-0.15.2/kryten_webqueue/playlists/fire.py +136 -0
  5. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/importer.py +33 -21
  6. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/scheduler.py +4 -1
  7. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/director.py +71 -0
  8. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_playlists.py +1 -0
  9. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_schedules.py +1 -0
  10. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/pyproject.toml +1 -1
  11. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_promo_director.py +74 -0
  12. kryten_webqueue-0.15.1/kryten_webqueue/playlists/fire.py +0 -120
  13. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/.github/workflows/python-publish.yml +0 -0
  14. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/.github/workflows/release.yml +0 -0
  15. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/.gitignore +0 -0
  16. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/README.md +0 -0
  17. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/config.example.json +0 -0
  18. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/deploy/kryten-webqueue.service +0 -0
  19. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/deploy/nginx-queue.conf +0 -0
  20. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  21. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_API_GATE.md +0 -0
  22. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_ECONOMY.md +0 -0
  23. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  24. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/IMPL_ROBOT.md +0 -0
  25. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  26. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/PRE_PLAN_GAPS.md +0 -0
  27. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/PRODUCT_PLAN.md +0 -0
  28. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  29. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/__init__.py +0 -0
  30. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/__main__.py +0 -0
  31. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  32. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/api_gate/client.py +0 -0
  33. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/__init__.py +0 -0
  34. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/otp.py +0 -0
  35. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/rate_limit.py +0 -0
  36. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/auth/session.py +0 -0
  37. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/__init__.py +0 -0
  38. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/db.py +0 -0
  39. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/images.py +0 -0
  40. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/mediacms.py +0 -0
  41. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/catalog/sync.py +0 -0
  42. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/config.py +0 -0
  43. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/__init__.py +0 -0
  44. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  45. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  46. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  47. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  48. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  49. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  50. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  51. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  52. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/__init__.py +0 -0
  53. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  54. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/manager.py +0 -0
  55. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/jobs/tasks.py +0 -0
  56. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/logging_config.py +0 -0
  57. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/__init__.py +0 -0
  58. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
  59. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/playlists/ordering.py +0 -0
  60. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/promos/__init__.py +0 -0
  61. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/__init__.py +0 -0
  62. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/ordering.py +0 -0
  63. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/poller.py +0 -0
  64. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/presence.py +0 -0
  65. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/queue/shadow.py +0 -0
  66. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/__init__.py +0 -0
  67. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
  68. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
  69. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_promos.py +0 -0
  70. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  71. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/auth.py +0 -0
  72. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/catalog.py +0 -0
  73. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/pages.py +0 -0
  74. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/queue.py +0 -0
  75. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/routes/user.py +0 -0
  76. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/static/css/main.css +0 -0
  77. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/index.html +0 -0
  79. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
  86. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  90. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/__init__.py +0 -0
  91. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/handler.py +0 -0
  92. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/kryten_webqueue/ws/manager.py +0 -0
  93. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/__init__.py +0 -0
  94. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_config_persistence.py +0 -0
  95. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_fetchurls_sharepoint.py +0 -0
  96. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase1.py +0 -0
  97. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase2_jobs.py +0 -0
  98. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase3_jobs.py +0 -0
  99. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_phase4_live_fixes.py +0 -0
  100. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_playlist_import.py +0 -0
  101. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_presence_refund.py +0 -0
  102. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.15.1 → kryten_webqueue-0.15.2}/tests/test_save_results_to_playlist.py +0 -0
@@ -6,6 +6,14 @@ 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
+
9
17
  ## [0.15.1] — 2026-06-17
10
18
 
11
19
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.15.1
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
@@ -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
@@ -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
@@ -22,6 +22,7 @@ director is a no-op while an immutable scheduled event is locking the queue.
22
22
 
23
23
  import logging
24
24
  import random
25
+ from contextlib import contextmanager
25
26
  from datetime import datetime, UTC
26
27
 
27
28
  from ..queue.ordering import _now_playing_uid
@@ -83,6 +84,17 @@ class PromoDirector:
83
84
  self._last_clip_token: dict[str, str] = {} # promo_type -> last clip media_id
84
85
  self._seq_index: dict[str, int] = {} # promo_type -> next sequential index
85
86
 
87
+ # Suppression guard. Held (re-entrantly) by bulk live-queue loaders
88
+ # (schedule fire, playlist import) so promos are never inserted *into* a
89
+ # playlist while it is still being built. Without this, the poller runs
90
+ # every few seconds during the multi-second load and slots promos
91
+ # between freshly-added items — including immutable ones, because the
92
+ # event lock isn't recorded until the load finishes.
93
+ self._suppress_depth: int = 0
94
+ # Set when suppression lifts so the next poll re-baselines now-playing
95
+ # instead of treating the post-load discontinuity as content advancing.
96
+ self._needs_rebaseline: bool = False
97
+
86
98
  # Injectable for tests
87
99
  self._rng = random.Random()
88
100
  self._now = lambda: datetime.now(UTC)
@@ -97,6 +109,36 @@ class PromoDirector:
97
109
  self._config = config
98
110
  logger.info("PromoDirector config updated (enabled=%s)", getattr(config, "enabled", None))
99
111
 
112
+ @property
113
+ def is_suppressed(self) -> bool:
114
+ """True while a bulk live-queue operation has paused promo insertion."""
115
+ return self._suppress_depth > 0
116
+
117
+ @contextmanager
118
+ def suppressed(self, reason: str):
119
+ """Pause promo insertion for the duration of a bulk queue operation.
120
+
121
+ Re-entrant: nested holders each bump a depth counter; promos resume only
122
+ once the outermost holder exits. Use around any operation that adds a
123
+ run of items to the *live* queue (schedule fire, playlist import) so the
124
+ director never inserts a promo into a playlist that is still loading.
125
+
126
+ On release the next poll re-baselines now-playing rather than counting
127
+ the load as content advancing, so a promo doesn't fire on the wrong
128
+ boundary immediately afterwards.
129
+ """
130
+ self._suppress_depth += 1
131
+ if self._suppress_depth == 1:
132
+ logger.info("Promo insertion suppressed: %s", reason)
133
+ try:
134
+ yield
135
+ finally:
136
+ self._suppress_depth -= 1
137
+ if self._suppress_depth <= 0:
138
+ self._suppress_depth = 0
139
+ self._needs_rebaseline = True
140
+ logger.info("Promo insertion resumed (after: %s)", reason)
141
+
100
142
  # --- Play-order helpers -------------------------------------------------
101
143
 
102
144
  @staticmethod
@@ -365,6 +407,13 @@ class PromoDirector:
365
407
  """
366
408
  if not self._config.enabled:
367
409
  return None
410
+ if self._suppress_depth > 0:
411
+ logger.debug(
412
+ "Viewer's-Choice skipped for paid uid=%s: insertion suppressed "
413
+ "(bulk queue operation in progress)",
414
+ content_uid,
415
+ )
416
+ return None
368
417
  items = self._shadow.items
369
418
  if self._has_lead_in(content_uid, items):
370
419
  logger.debug(
@@ -392,9 +441,31 @@ class PromoDirector:
392
441
  if not cfg.enabled:
393
442
  return
394
443
 
444
+ # Frozen while a bulk live-queue operation (schedule fire / playlist
445
+ # import) is loading items. Promos must never land *inside* a playlist
446
+ # that is still being built — this is what slips promos between immutable
447
+ # items before the event lock is recorded.
448
+ if self._suppress_depth > 0:
449
+ logger.debug(
450
+ "Promo on_poll skipped: insertion suppressed (bulk queue operation in progress)"
451
+ )
452
+ return
453
+
395
454
  np_uid = await _now_playing_uid(self._api_gate, self._shadow)
396
455
  np_is_promo = self._is_promo_uid(np_uid)
397
456
 
457
+ # After a bulk queue operation the playlist is discontinuous; re-baseline
458
+ # now-playing without counting it as content advancing so we don't fire a
459
+ # promo on the wrong boundary on the very next cycle.
460
+ if self._needs_rebaseline:
461
+ self._needs_rebaseline = False
462
+ self._last_np_uid = np_uid
463
+ self._last_np_is_promo = np_is_promo
464
+ logger.debug(
465
+ "Promo baseline reset after bulk queue operation (np_uid=%s)", np_uid
466
+ )
467
+ return
468
+
398
469
  # Advance detection: a finished *content* item bumps the cadence counter.
399
470
  if np_uid != self._last_np_uid:
400
471
  if self._last_np_uid is not None and not self._last_np_is_promo:
@@ -112,6 +112,7 @@ async def import_to_live(request: Request, playlist_id: int, user: dict = Depend
112
112
  shadow=request.app.state.shadow,
113
113
  add_delay_sec=config.playlist_bulk_add_delay_sec,
114
114
  add_max_retries=config.playlist_bulk_add_max_retries,
115
+ promo_director=getattr(request.app.state, "promo_director", None),
115
116
  )
116
117
  result = await importer.import_playlist(playlist_id)
117
118
  return result
@@ -121,6 +121,7 @@ async def fire_now(request: Request, schedule_id: int, user: dict = Depends(requ
121
121
  ws_manager=request.app.state.ws_manager,
122
122
  add_delay_sec=config.playlist_bulk_add_delay_sec,
123
123
  add_max_retries=config.playlist_bulk_add_max_retries,
124
+ promo_director=getattr(request.app.state, "promo_director", None),
124
125
  )
125
126
  return {"success": True}
126
127
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.15.1"
3
+ version = "0.15.2"
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"
@@ -323,6 +323,80 @@ async def test_noop_during_immutable_event():
323
323
  assert _promo_items(shadow) == []
324
324
 
325
325
 
326
+ async def test_noop_while_suppressed():
327
+ # While a bulk queue load holds the suppression guard, on_poll must insert
328
+ # nothing — even though a Feature-Presentation lead-in is otherwise due.
329
+ shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
330
+ api = _FakeApiGate(now_playing={"uid": 10})
331
+ db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
332
+ d = _director(api, shadow, db)
333
+
334
+ with d.suppressed("bulk load"):
335
+ assert d.is_suppressed is True
336
+ await d.on_poll()
337
+ assert _promo_items(shadow) == []
338
+ assert api.adds == []
339
+
340
+ # Guard released; depth back to zero.
341
+ assert d.is_suppressed is False
342
+
343
+
344
+ async def test_suppression_is_reentrant():
345
+ shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
346
+ api = _FakeApiGate(now_playing={"uid": 10})
347
+ db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
348
+ d = _director(api, shadow, db)
349
+
350
+ with d.suppressed("outer"):
351
+ with d.suppressed("inner"):
352
+ assert d.is_suppressed is True
353
+ await d.on_poll()
354
+ assert _promo_items(shadow) == []
355
+ # Inner released, outer still holds.
356
+ assert d.is_suppressed is True
357
+ await d.on_poll()
358
+ assert _promo_items(shadow) == []
359
+ assert d.is_suppressed is False
360
+
361
+
362
+ async def test_first_poll_after_suppression_rebaselines_without_inserting():
363
+ # When suppression lifts, the next poll re-baselines now-playing instead of
364
+ # treating the bulk load as content advancing — so no promo fires on that
365
+ # boundary. The following poll resumes normal behaviour.
366
+ shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
367
+ api = _FakeApiGate(now_playing={"uid": 10})
368
+ db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
369
+ d = _director(api, shadow, db)
370
+
371
+ with d.suppressed("bulk load"):
372
+ await d.on_poll()
373
+ assert _promo_items(shadow) == []
374
+
375
+ # First post-suppression poll: re-baseline only, no insertion.
376
+ await d.on_poll()
377
+ assert _promo_items(shadow) == []
378
+
379
+ # Second poll: normal evaluation resumes and the FP lead-in is inserted.
380
+ await d.on_poll()
381
+ promos = _promo_items(shadow)
382
+ assert len(promos) == 1
383
+ assert promos[0]["promo_type"] == "feature_presentation"
384
+
385
+
386
+ async def test_suppression_released_on_exception():
387
+ shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
388
+ api = _FakeApiGate(now_playing={"uid": 10})
389
+ db = _FakeDb(pools={"feature_presentation": [_clip("fp1")]})
390
+ d = _director(api, shadow, db)
391
+
392
+ try:
393
+ with d.suppressed("bulk load"):
394
+ raise RuntimeError("load failed")
395
+ except RuntimeError:
396
+ pass
397
+ assert d.is_suppressed is False
398
+
399
+
326
400
  async def test_disabled_director_noop():
327
401
  shadow = _FakeShadow([_content(10), _content(20, duration=3600)], now_playing={"uid": 10})
328
402
  api = _FakeApiGate(now_playing={"uid": 10})
@@ -1,120 +0,0 @@
1
- import asyncio
2
- import logging
3
- from datetime import datetime, timedelta, UTC
4
-
5
- from ..queue.ordering import refund_item
6
- from .bulk_add import add_item_throttled
7
-
8
- logger = logging.getLogger(__name__)
9
-
10
- _queue_lock = asyncio.Lock()
11
-
12
-
13
- async def fire_schedule(
14
- *,
15
- schedule_id: int,
16
- api_gate,
17
- db,
18
- shadow,
19
- ws_manager,
20
- add_delay_sec: float = 0.0,
21
- add_max_retries: int = 0,
22
- ):
23
- """Fire a scheduled playlist: clear queue, refund displaced pay items, load playlist."""
24
- async with _queue_lock:
25
- schedule = await db.get_schedule(schedule_id)
26
- if not schedule:
27
- logger.error(f"Schedule {schedule_id} not found")
28
- return
29
-
30
- playlist_id = schedule["playlist_id"]
31
- playlist = await db.get_saved_playlist(playlist_id)
32
- if not playlist:
33
- logger.error(f"Playlist {playlist_id} not found for schedule {schedule_id}")
34
- return
35
-
36
- # Refund all pay items currently in queue
37
- pay_items = await db.get_pay_items()
38
- for item in pay_items:
39
- await refund_item(api_gate=api_gate, db=db, uid=item["uid"], reason="schedule_displaced")
40
-
41
- # Clear the CyTube playlist
42
- await api_gate.playlist_clear()
43
-
44
- # Load scheduled playlist items
45
- items = await db.get_saved_playlist_items(playlist_id)
46
- total_duration = 0
47
- last_item_uid = None
48
- for index, item in enumerate(items):
49
- # Throttle consecutive adds so CyTube can validate each item before
50
- # the next arrives (avoids transient queueFail/422 under load).
51
- if index and add_delay_sec:
52
- await asyncio.sleep(add_delay_sec)
53
- try:
54
- add_result = await add_item_throttled(
55
- api_gate,
56
- media_type=item["media_type"],
57
- media_id=item["media_id"],
58
- position="end",
59
- max_retries=add_max_retries,
60
- retry_delay_sec=add_delay_sec or 0.5,
61
- )
62
- if isinstance(add_result, dict) and add_result.get("uid") is not None:
63
- last_item_uid = add_result["uid"]
64
- total_duration += item.get("duration_sec", 0) or 0
65
- except Exception as e:
66
- logger.warning(f"Schedule fire: failed to add {item['media_id']}: {e}")
67
-
68
- # Append the optional fallback (mutable) playlist AFTER the event items so
69
- # the live queue isn't left empty once the event is exhausted. The
70
- # fallback items are not part of the "scheduled event", so they do not
71
- # change last_item_uid (the event lock still lifts when the last EVENT
72
- # item begins) and they remain available for pay-to-play/search.
73
- fallback_id = schedule.get("fallback_playlist_id")
74
- if fallback_id:
75
- fallback_items = await db.get_saved_playlist_items(fallback_id)
76
- for index, item in enumerate(fallback_items):
77
- if index and add_delay_sec:
78
- await asyncio.sleep(add_delay_sec)
79
- try:
80
- await add_item_throttled(
81
- api_gate,
82
- media_type=item["media_type"],
83
- media_id=item["media_id"],
84
- position="end",
85
- max_retries=add_max_retries,
86
- retry_delay_sec=add_delay_sec or 0.5,
87
- )
88
- except Exception as e:
89
- logger.warning(f"Schedule fire: failed to add fallback {item['media_id']}: {e}")
90
- if fallback_items:
91
- logger.info(
92
- f"Schedule {schedule_id}: appended {len(fallback_items)} fallback item(s) "
93
- f"from playlist {fallback_id}"
94
- )
95
-
96
- # Update active schedule
97
- now = datetime.now(UTC)
98
- await db.set_active_schedule(
99
- schedule_id=schedule_id,
100
- playlist_id=playlist_id,
101
- is_immutable=playlist.get("is_immutable", False),
102
- started_at=now.isoformat(),
103
- estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
104
- last_item_uid=last_item_uid,
105
- )
106
-
107
- # Mark schedule as fired
108
- await db.mark_schedule_fired(schedule_id, now.isoformat())
109
-
110
- # Notify WS clients
111
- await ws_manager.broadcast({
112
- "type": "schedule_fired",
113
- "data": {
114
- "schedule_id": schedule_id,
115
- "playlist_name": playlist["name"],
116
- "is_immutable": playlist.get("is_immutable", False),
117
- },
118
- })
119
-
120
- logger.info(f"Schedule {schedule_id} fired: playlist '{playlist['name']}' ({len(items)} items)")