kryten-webqueue 0.14.0__tar.gz → 0.14.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 (100) hide show
  1. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/CHANGELOG.md +7 -0
  2. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +7 -3
  4. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/config.py +3 -2
  5. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/promos/director.py +7 -4
  6. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/presence.py +7 -2
  7. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_presence_refund.py +22 -6
  9. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/.gitignore +0 -0
  12. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/README.md +0 -0
  13. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/config.example.json +0 -0
  14. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/deploy/kryten-webqueue.service +0 -0
  15. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/deploy/nginx-queue.conf +0 -0
  16. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  17. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/IMPL_API_GATE.md +0 -0
  18. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/IMPL_ECONOMY.md +0 -0
  19. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  20. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/IMPL_ROBOT.md +0 -0
  21. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/PRE_PLAN_GAPS.md +0 -0
  22. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/PRODUCT_PLAN.md +0 -0
  23. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  24. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/__init__.py +0 -0
  25. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/__main__.py +0 -0
  26. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  27. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/api_gate/client.py +0 -0
  28. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/app.py +0 -0
  29. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/__init__.py +0 -0
  30. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/otp.py +0 -0
  31. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  32. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/auth/session.py +0 -0
  33. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/__init__.py +0 -0
  34. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/db.py +0 -0
  35. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/images.py +0 -0
  36. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  37. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/catalog/sync.py +0 -0
  38. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/__init__.py +0 -0
  39. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  40. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  41. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  42. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  43. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  44. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  45. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  46. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  47. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/__init__.py +0 -0
  48. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  49. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/manager.py +0 -0
  50. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/jobs/tasks.py +0 -0
  51. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/__init__.py +0 -0
  52. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  53. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/fire.py +0 -0
  54. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/importer.py +0 -0
  55. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  56. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/promos/__init__.py +0 -0
  57. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/__init__.py +0 -0
  58. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/ordering.py +0 -0
  59. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/poller.py +0 -0
  60. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/queue/shadow.py +0 -0
  61. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/__init__.py +0 -0
  62. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  63. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  64. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  65. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  66. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  67. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  68. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/auth.py +0 -0
  69. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/catalog.py +0 -0
  70. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/pages.py +0 -0
  71. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/queue.py +0 -0
  72. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/routes/user.py +0 -0
  73. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/static/css/main.css +0 -0
  74. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/static/js/main.js +0 -0
  75. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/index.html +0 -0
  76. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  77. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  78. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  79. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  80. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/auth/login.html +0 -0
  81. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/base.html +0 -0
  82. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  83. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  84. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  85. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/queue/index.html +0 -0
  86. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  87. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/ws/__init__.py +0 -0
  88. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/ws/handler.py +0 -0
  89. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/kryten_webqueue/ws/manager.py +0 -0
  90. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/__init__.py +0 -0
  91. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_config_persistence.py +0 -0
  92. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_fetchurls_sharepoint.py +0 -0
  93. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_phase1.py +0 -0
  94. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_phase2_jobs.py +0 -0
  95. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_phase3_jobs.py +0 -0
  96. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_phase4_live_fixes.py +0 -0
  97. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_playlist_import.py +0 -0
  98. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_promo_director.py +0 -0
  99. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_promo_pool_exclusion.py +0 -0
  100. {kryten_webqueue-0.14.0 → kryten_webqueue-0.14.1}/tests/test_queue_announce.py +0 -0
@@ -6,6 +6,13 @@ 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.14.1] — 2026-06-13
10
+
11
+ ### Fixed
12
+
13
+ - **Cancel/refund PM notifications only fire for AFK owners.** A user who *leaves* the channel is no longer connected to CyTube and cannot receive a PM, so the leave path now skips the notification entirely (the refund + WS state update still happen). AFK owners — who are still present — are PM'd as before. `presence_refund.notify_user` now governs the AFK case only.
14
+ - **`no_repeat` promo selection is now deterministic.** When a `no_repeat` random draw matched the previous clip it was retried up to 8 times and could still return a repeat (a flaky guarantee for small pools). It now draws from the pool excluding the last clip, so a consecutive repeat never occurs for pools of 2+.
15
+
9
16
  ## [0.14.0] — 2026-06-13
10
17
 
11
18
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.14.0
3
+ Version: 0.14.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
@@ -419,6 +419,10 @@ load-time. (FP and general promos stay purely poller-driven.)
419
419
  a notice.)
420
420
 
421
421
  **RESOLVED (v0.13.0):** a PM is now sent. On a presence-based cancel the owner
422
- receives a PM ("… cancelled and refunded because you left the channel / you
423
- went AFK"), gated by `presence_refund.notify_user` (default on) and best-effort
424
- so a failed PM never blocks the refund.
422
+ receives a PM ("… cancelled and refunded because you went AFK"), gated by
423
+ `presence_refund.notify_user` (default on) and best-effort so a failed PM never
424
+ blocks the refund.
425
+
426
+ **AMENDED (v0.14.1):** the PM only fires for **AFK** owners. A user who *left*
427
+ the channel is no longer connected to CyTube and cannot receive a PM, so the
428
+ leave path skips the notice (refund + WS state update still happen).
@@ -35,7 +35,8 @@ class PresenceRefundConfig(BaseModel):
35
35
  off if running against an older Robot whose ``meta.afk`` goes stale.
36
36
 
37
37
  ``notify_user`` PMs the owner when a pending paid item is cancelled & refunded
38
- so the cancellation isn't silent.
38
+ so the cancellation isn't silent. This only applies to **AFK** owners — a
39
+ user who left the channel is no longer connected and cannot receive a PM.
39
40
  """
40
41
 
41
42
  enabled: bool = True
@@ -43,7 +44,7 @@ class PresenceRefundConfig(BaseModel):
43
44
  on_afk: bool = True # needs Kryten-Robot >= 1.10.0 deployed
44
45
  grace_seconds: float = 60.0 # wait before acting; re-check after grace
45
46
  check_interval_seconds: float = 15.0 # how often to evaluate owners
46
- notify_user: bool = True # PM the owner on cancel/refund
47
+ notify_user: bool = True # PM the AFK owner on cancel/refund
47
48
 
48
49
 
49
50
  class PromoTypeConfig(BaseModel):
@@ -221,10 +221,13 @@ class PromoDirector:
221
221
  clip = self._rng.choice(pool)
222
222
  if self._config.general.no_repeat and len(pool) > 1:
223
223
  last = self._last_clip_token.get(promo_type)
224
- attempts = 0
225
- while clip.get("media_id") == last and attempts < 8:
226
- clip = self._rng.choice(pool)
227
- attempts += 1
224
+ if clip.get("media_id") == last:
225
+ # Draw from the rest of the pool so the no-repeat guarantee
226
+ # always holds (bounded random retries could otherwise give
227
+ # up and return a repeat).
228
+ alternatives = [c for c in pool if c.get("media_id") != last]
229
+ if alternatives:
230
+ clip = self._rng.choice(alternatives)
228
231
  self._last_clip_token[promo_type] = clip.get("media_id")
229
232
  return clip
230
233
 
@@ -178,19 +178,24 @@ class PresenceRefundMonitor:
178
178
  async def _notify_owner(self, item: dict, reason: str):
179
179
  """PM the owner that their paid item was cancelled & refunded.
180
180
 
181
+ Only AFK owners are notified: a user who *left* the channel is no longer
182
+ connected to CyTube and cannot receive a PM, so there is no point trying.
183
+ AFK users are still present and will see the message.
184
+
181
185
  Best-effort: a failed PM never blocks the cancel/refund itself.
182
186
  """
183
187
  if not getattr(self._config, "notify_user", False):
184
188
  return
189
+ if reason != "owner_afk":
190
+ return # owner left the channel — unreachable by PM
185
191
  owner = item.get("paid_by")
186
192
  if not owner:
187
193
  return
188
- why = "you left the channel" if reason == "owner_left" else "you went AFK"
189
194
  title = item.get("title") or "your queued item"
190
195
  try:
191
196
  await self._api_gate.send_pm(
192
197
  owner,
193
- f"Your queued item \"{title}\" was cancelled and refunded because {why}.",
198
+ f"Your queued item \"{title}\" was cancelled and refunded because you went AFK.",
194
199
  )
195
200
  except Exception:
196
201
  logger.debug("Presence cancel: failed to PM %s", owner, exc_info=True)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.14.0"
3
+ version = "0.14.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"
@@ -126,12 +126,13 @@ async def test_owner_offline_cancels_paid_keeps_free():
126
126
  assert ws.messages and ws.messages[-1]["type"] == "queue_state"
127
127
 
128
128
 
129
- async def test_cancel_pms_owner_when_notify_enabled():
129
+ async def test_afk_cancel_pms_owner_when_notify_enabled():
130
+ # AFK owners are still connected and CAN be PM'd.
130
131
  shadow = _FakeShadow([_paid(11, "alice", title="Cool Video")], now_playing={"uid": 10})
131
- api = _FakeApiGate({"alice": {"online": False}}, np={"uid": 10})
132
+ api = _FakeApiGate({"alice": {"online": True, "meta": {"afk": True}}}, np={"uid": 10})
132
133
  db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
133
134
  ws = _FakeWs()
134
- mon = _monitor(api, shadow, db, ws, notify_user=True)
135
+ mon = _monitor(api, shadow, db, ws, notify_user=True, on_afk=True)
135
136
 
136
137
  await mon.check_once() # first sighting: starts grace
137
138
  await mon.check_once() # grace elapsed: acts
@@ -140,21 +141,36 @@ async def test_cancel_pms_owner_when_notify_enabled():
140
141
  user, msg = api.pms[0]
141
142
  assert user == "alice"
142
143
  assert "Cool Video" in msg
143
- assert "left the channel" in msg
144
+ assert "AFK" in msg
144
145
 
145
146
 
146
- async def test_cancel_silent_when_notify_disabled():
147
+ async def test_left_channel_cancel_does_not_pm():
148
+ # A user who LEFT the channel is unreachable by PM — no PM attempted.
147
149
  shadow = _FakeShadow([_paid(11, "alice")], now_playing={"uid": 10})
148
150
  api = _FakeApiGate({"alice": {"online": False}}, np={"uid": 10})
149
151
  db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
150
152
  ws = _FakeWs()
151
- mon = _monitor(api, shadow, db, ws, notify_user=False)
153
+ mon = _monitor(api, shadow, db, ws, notify_user=True)
152
154
 
153
155
  await mon.check_once()
154
156
  await mon.check_once()
155
157
 
156
158
  assert api.refunds == [("alice", "r11", "owner_left")]
157
159
  assert api.pms == []
160
+
161
+
162
+ async def test_cancel_silent_when_notify_disabled():
163
+ shadow = _FakeShadow([_paid(11, "alice")], now_playing={"uid": 10})
164
+ api = _FakeApiGate({"alice": {"online": True, "meta": {"afk": True}}}, np={"uid": 10})
165
+ db = _FakeDb({11: {"request_id": "r11", "username": "alice"}})
166
+ ws = _FakeWs()
167
+ mon = _monitor(api, shadow, db, ws, notify_user=False, on_afk=True)
168
+
169
+ await mon.check_once()
170
+ await mon.check_once()
171
+
172
+ assert api.refunds == [("alice", "r11", "owner_afk")]
173
+ assert api.pms == []
158
174
  shadow = _FakeShadow([_paid(11, "bob")], now_playing={"uid": 10})
159
175
  api = _FakeApiGate({"bob": {"meta": {"afk": True}}}, np={"uid": 10})
160
176
  db = _FakeDb({11: {"request_id": "r11", "username": "bob"}})