kryten-webqueue 0.34.4__tar.gz → 0.34.7__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 (139) hide show
  1. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/CHANGELOG.md +32 -0
  2. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/db/_connection.py +11 -4
  4. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/db/_playlists.py +5 -2
  5. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/images.py +1 -1
  6. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/fetchurls.py +3 -1
  7. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/motdposters.py +15 -4
  8. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/playedmovies.py +18 -4
  9. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/jobs/job_scheduler.py +13 -4
  10. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/playlists/fire.py +5 -0
  11. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/shadow.py +27 -8
  12. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_job_schedules.py +6 -2
  13. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_schedules.py +17 -7
  14. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/pyproject.toml +1 -1
  15. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_phase4_live_fixes.py +132 -0
  16. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/.github/workflows/gitleaks.yml +0 -0
  17. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/.github/workflows/python-publish.yml +0 -0
  18. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/.github/workflows/release.yml +0 -0
  19. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/.gitignore +0 -0
  20. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/.gitleaks.toml +0 -0
  21. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/.pre-commit-config.yaml +0 -0
  22. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/AGENT-WORKFLOW-GUIDE.md +0 -0
  23. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/README.md +0 -0
  24. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/config.example.json +0 -0
  25. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/deploy/kryten-webqueue.service +0 -0
  26. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/deploy/nginx-queue.conf +0 -0
  27. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/IMPLEMENTATION_SPEC.md +0 -0
  28. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/IMPL_API_GATE.md +0 -0
  29. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/IMPL_ECONOMY.md +0 -0
  30. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/IMPL_KRYTEN_PY.md +0 -0
  31. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/IMPL_ROBOT.md +0 -0
  32. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  33. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  34. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/Log for incorrect promo insertion problem.txt +0 -0
  35. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  36. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/PRE_PLAN_GAPS.md +0 -0
  37. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/PRODUCT_PLAN.md +0 -0
  38. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/SPEC_EMOTE_REHOST.md +0 -0
  39. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  40. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/docs/UX_POLISH_PLAN.md +0 -0
  41. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/__init__.py +0 -0
  42. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/__main__.py +0 -0
  43. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/api_gate/__init__.py +0 -0
  44. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/api_gate/client.py +0 -0
  45. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/app.py +0 -0
  46. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/auth/__init__.py +0 -0
  47. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/auth/otp.py +0 -0
  48. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/auth/rate_limit.py +0 -0
  49. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/auth/session.py +0 -0
  50. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/__init__.py +0 -0
  51. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/db/__init__.py +0 -0
  52. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  53. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  54. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/db/_queue.py +0 -0
  55. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/mediacms.py +0 -0
  56. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/catalog/sync.py +0 -0
  57. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/config.py +0 -0
  58. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/__init__.py +0 -0
  59. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  60. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  61. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  62. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  63. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  64. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  65. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  66. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/jobs/__init__.py +0 -0
  67. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  68. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/jobs/manager.py +0 -0
  69. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  70. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/jobs/tasks.py +0 -0
  71. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/logging_config.py +0 -0
  72. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/playlists/__init__.py +0 -0
  73. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/playlists/bulk_add.py +0 -0
  74. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/playlists/importer.py +0 -0
  75. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/playlists/ordering.py +0 -0
  76. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/playlists/scheduler.py +0 -0
  77. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/promos/__init__.py +0 -0
  78. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/promos/director.py +0 -0
  79. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/__init__.py +0 -0
  80. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/completion.py +0 -0
  81. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/ordering.py +0 -0
  82. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/poller.py +0 -0
  83. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/presence.py +0 -0
  84. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/queue/race_poller.py +0 -0
  85. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/__init__.py +0 -0
  86. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_catalog.py +0 -0
  87. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_feedback.py +0 -0
  88. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_jobs.py +0 -0
  89. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_moderation.py +0 -0
  90. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_playlists.py +0 -0
  91. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_promos.py +0 -0
  92. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/admin_queue.py +0 -0
  93. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/auth.py +0 -0
  94. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/catalog.py +0 -0
  95. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/feedback.py +0 -0
  96. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/pages.py +0 -0
  97. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/queue.py +0 -0
  98. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/routes/user.py +0 -0
  99. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/css/main.css +0 -0
  100. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  101. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/js/admin-promos.js +0 -0
  102. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  103. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  104. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/js/admin.js +0 -0
  105. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/static/js/main.js +0 -0
  106. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/admin/index.html +0 -0
  107. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/admin/playlists.html +0 -0
  108. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/admin/promos.html +0 -0
  109. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  110. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/admin/schedules.html +0 -0
  111. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/auth/login.html +0 -0
  112. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/base.html +0 -0
  113. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/catalog/browse.html +0 -0
  114. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  115. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  116. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/feedback/index.html +0 -0
  117. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/queue/index.html +0 -0
  118. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/race.html +0 -0
  119. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/templates/user/dashboard.html +0 -0
  120. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/ws/__init__.py +0 -0
  121. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/ws/handler.py +0 -0
  122. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/kryten_webqueue/ws/manager.py +0 -0
  123. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/__init__.py +0 -0
  124. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_config_persistence.py +0 -0
  125. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_feedback.py +0 -0
  126. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_fetchurls_sharepoint.py +0 -0
  127. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_phase1.py +0 -0
  128. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_phase2_jobs.py +0 -0
  129. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_phase3_jobs.py +0 -0
  130. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_playlist_import.py +0 -0
  131. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_presence_refund.py +0 -0
  132. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_promo_director.py +0 -0
  133. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_promo_pool_exclusion.py +0 -0
  134. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_queue_announce.py +0 -0
  135. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_race_poller.py +0 -0
  136. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_recently_played_hide.py +0 -0
  137. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_save_results_to_playlist.py +0 -0
  138. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_schedule_lock.py +0 -0
  139. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.7}/tests/test_search_facets.py +0 -0
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.34.7] - 2026-08-13
6
+
7
+ ### Fixed
8
+
9
+ - **Event lock lifts at the correct time (Bug #1).** The scheduled-event
10
+ pay-to-play lock was lifting ~30 minutes late when `last_item_uid` was never
11
+ captured (e.g. a CyTube `queue`-event timeout during bulk add left the field
12
+ `NULL`). With no uid anchor `_maybe_lift_event_lock` returned early and the
13
+ only fallback was the `estimated_end_at + 5 min` time safety net, which fires
14
+ roughly when the *last* item finishes rather than when it *starts*.
15
+ Fix: `fire_schedule` now always records `last_item_media_id` (the manifest
16
+ URL of the last scheduled item) even when the uid is unavailable. The lock
17
+ lift logic now matches on `media_id` first and falls back to `last_item_uid`,
18
+ so pay-to-play reopens as soon as the last scheduled item begins playing
19
+ regardless of whether the uid was captured. DB migration v12 adds the new
20
+ `last_item_media_id TEXT` column to `active_schedule`.
21
+
22
+ - **Schedule modifications apply immediately (Bug #2).** When a schedule was
23
+ updated via the admin UI, the APScheduler job was re-registered only if
24
+ `fire_at` was present in the request body. The `update_schedule` route now
25
+ always re-registers the job after any modification, and it reads the
26
+ authoritative `fire_at` from the database (not from the request body) to
27
+ avoid stale or mismatched values. If the updated `fire_at` is already in the
28
+ past, the job is not re-armed (preventing an accidental immediate re-fire).
29
+
30
+ - Cover art downloads for movies: corrected TMDB API parameter from `year` to
31
+ `primary_release_year` in movie search. TV searches already used the correct
32
+ `first_air_date_year` parameter, which is why cover art worked for TV but
33
+ not movies.
34
+
3
35
  ## [0.34.4] - 2026-08-12
4
36
 
5
37
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.34.4
3
+ Version: 0.34.7
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
@@ -227,7 +227,14 @@ MIGRATIONS = [
227
227
  ALTER TABLE queue_shadow ADD COLUMN promo_type TEXT;
228
228
  ALTER TABLE queue_shadow ADD COLUMN lead_in_for_uid INTEGER;
229
229
  """,
230
- # v12: Viewer feedback + movie-title suggestions, each with a simple
230
+ # v12: Media-id fallback for the event-lock auto-lift. Stores the media_id
231
+ # of the last scheduled item so _maybe_lift_event_lock can detect when it
232
+ # begins playing even when last_item_uid was never captured (e.g. a CyTube
233
+ # queue-event timeout during bulk add left the uid null).
234
+ """
235
+ ALTER TABLE active_schedule ADD COLUMN last_item_media_id TEXT;
236
+ """,
237
+ # v13: Viewer feedback + movie-title suggestions, each with a simple
231
238
  # admin-triage queue (status new|read). Title suggestions record the
232
239
  # resolved match against TMDB/OMDB (NULL when unresolved) and whether we
233
240
  # already have the title in the catalog (resolution + catalog_token).
@@ -257,7 +264,7 @@ MIGRATIONS = [
257
264
  );
258
265
  CREATE INDEX IF NOT EXISTS idx_title_suggestions_status ON title_suggestions(status, created_at);
259
266
  """,
260
- # v13: Play-completion tracking that drives hiding recently-played catalog
267
+ # v14: Play-completion tracking that drives hiding recently-played catalog
261
268
  # items from regular users.
262
269
  #
263
270
  # play_completions records a *genuine* completion (an item that reached the
@@ -290,7 +297,7 @@ MIGRATIONS = [
290
297
  );
291
298
  CREATE INDEX IF NOT EXISTS idx_playlist_item_played_media ON playlist_item_played(media_id);
292
299
  """,
293
- # v14: One-time cleanup of recently-played hide state wrongly recorded for
300
+ # v15: One-time cleanup of recently-played hide state wrongly recorded for
294
301
  # promo-pool clips. Promos are excluded from the public catalog and must
295
302
  # never be treated like normal playlist items, so any hide state recorded for
296
303
  # a promo clip (before the exemption in record_play_completion) is purged.
@@ -308,7 +315,7 @@ MIGRATIONS = [
308
315
  WHERE sp.promo_type IS NOT NULL AND spi.media_type = 'cm'
309
316
  );
310
317
  """,
311
- # v15: Cron-based job schedule persistence (separate from playlist_schedules,
318
+ # v16: Cron-based job schedule persistence (separate from playlist_schedules,
312
319
  # which fires playlists onto the queue). One row per job; UNIQUE on job_name.
313
320
  # run_next_job chains another job to run immediately after this one completes.
314
321
  """
@@ -242,11 +242,13 @@ class _PlaylistsMixin:
242
242
  started_at: str,
243
243
  estimated_end_at: str,
244
244
  last_item_uid: int | None = None,
245
+ last_item_media_id: str | None = None,
245
246
  ):
246
247
  await self._execute(
247
248
  "INSERT OR REPLACE INTO active_schedule "
248
- "(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at, last_item_uid, lock_disabled) "
249
- "VALUES (1, ?, ?, ?, ?, ?, ?, 0)",
249
+ "(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at, "
250
+ "last_item_uid, last_item_media_id, lock_disabled) "
251
+ "VALUES (1, ?, ?, ?, ?, ?, ?, ?, 0)",
250
252
  [
251
253
  schedule_id,
252
254
  playlist_id,
@@ -254,6 +256,7 @@ class _PlaylistsMixin:
254
256
  started_at,
255
257
  estimated_end_at,
256
258
  last_item_uid,
259
+ last_item_media_id,
257
260
  ],
258
261
  )
259
262
 
@@ -180,7 +180,7 @@ class CoverArtResolver:
180
180
  """Search a specific TMDB media type. Returns (poster_url_or_None, popularity)."""
181
181
  params: dict = {"api_key": self._tmdb_key, "query": title}
182
182
  if year and media_type == "movie":
183
- params["year"] = year
183
+ params["primary_release_year"] = year
184
184
  elif year and media_type == "tv":
185
185
  params["first_air_date_year"] = year
186
186
  try:
@@ -1627,7 +1627,9 @@ def run(params: dict, *, config, progress=None) -> dict:
1627
1627
  dry_run=dry_run,
1628
1628
  progress=_emit,
1629
1629
  )
1630
- except Exception as _pm_exc: # noqa: BLE001 - non-fatal; keep fetchurls result intact
1630
+ except (
1631
+ Exception
1632
+ ) as _pm_exc: # noqa: BLE001 - non-fatal; keep fetchurls result intact
1631
1633
  logger.warning("played_movies sync failed: %s", _pm_exc, exc_info=True)
1632
1634
 
1633
1635
  _emit(
@@ -210,7 +210,9 @@ def _pick_placeholder(placeholder_dir: str) -> bytes | None:
210
210
  p = Path(placeholder_dir).expanduser()
211
211
  if not p.is_dir():
212
212
  return None
213
- candidates = [f for f in p.iterdir() if f.is_file() and f.suffix.lower() in _IMAGE_EXTS]
213
+ candidates = [
214
+ f for f in p.iterdir() if f.is_file() and f.suffix.lower() in _IMAGE_EXTS
215
+ ]
214
216
  if not candidates:
215
217
  return None
216
218
  return random.choice(candidates).read_bytes()
@@ -239,7 +241,11 @@ def _generate_html(night_entries: list[dict], poster_base_url: str) -> str:
239
241
 
240
242
  img_url = f"{base}/{entry['filename']}"
241
243
  imdb_id = entry.get("imdb_id") or ""
242
- href = f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else "https://www.imdb.com/"
244
+ href = (
245
+ f"https://www.imdb.com/title/{imdb_id}/"
246
+ if imdb_id
247
+ else "https://www.imdb.com/"
248
+ )
243
249
 
244
250
  lines.append(
245
251
  f' <a href="{escape(href)}" target="_blank" rel="noopener noreferrer">'
@@ -393,7 +399,10 @@ def run(params: dict, *, config, progress=None) -> dict:
393
399
  entry["imdb_id"] = info["imdb_id"]
394
400
  if dry_run:
395
401
  logger.info(
396
- "motd_posters [dry-run]: %s → %s (%s)", title, info["imdb_id"], filename
402
+ "motd_posters [dry-run]: %s → %s (%s)",
403
+ title,
404
+ info["imdb_id"],
405
+ filename,
397
406
  )
398
407
  resolved += 1
399
408
  else:
@@ -431,7 +440,9 @@ def run(params: dict, *, config, progress=None) -> dict:
431
440
  output_path = output_dir / f"motd-{sheet_name}.html"
432
441
 
433
442
  if dry_run:
434
- logger.info("motd_posters [dry-run]: HTML snippet (not written):\n%s", html_snippet)
443
+ logger.info(
444
+ "motd_posters [dry-run]: HTML snippet (not written):\n%s", html_snippet
445
+ )
435
446
  else:
436
447
  output_path.write_text(html_snippet, encoding="utf-8")
437
448
  logger.info("motd_posters: wrote HTML snippet → %s", output_path)
@@ -161,7 +161,10 @@ def _write_rows(
161
161
 
162
162
  sess_resp = requests.post(
163
163
  f"{wb_base}/createSession",
164
- headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
164
+ headers={
165
+ "Authorization": f"Bearer {token}",
166
+ "Content-Type": "application/json",
167
+ },
165
168
  data=json.dumps({"persistChanges": True}),
166
169
  timeout=WRITEBACK_TIMEOUT,
167
170
  )
@@ -169,7 +172,10 @@ def _write_rows(
169
172
  if sess_resp.status_code in (200, 201):
170
173
  sid = sess_resp.json().get("id", "")
171
174
 
172
- headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
175
+ headers = {
176
+ "Authorization": f"Bearer {token}",
177
+ "Content-Type": "application/json",
178
+ }
173
179
  if sid:
174
180
  headers["workbook-session-id"] = sid
175
181
 
@@ -261,7 +267,13 @@ def sync_played_movies(
261
267
 
262
268
  if not new_rows:
263
269
  logger.info("played_movies: all %d movie(s) already recorded", len(movies))
264
- return {"found": len(movies), "added": 0, "skipped": skipped, "failed": 0, "dry_run": dry_run}
270
+ return {
271
+ "found": len(movies),
272
+ "added": 0,
273
+ "skipped": skipped,
274
+ "failed": 0,
275
+ "dry_run": dry_run,
276
+ }
265
277
 
266
278
  start_row = last_played_row(wb_bytes) + 1
267
279
 
@@ -282,7 +294,9 @@ def sync_played_movies(
282
294
  if progress:
283
295
  progress({"phase": "played_movies_write", "count": len(new_rows)})
284
296
 
285
- ok, fail = _write_rows(graph_token, drive_id, item_id, new_rows, start_row, progress=progress)
297
+ ok, fail = _write_rows(
298
+ graph_token, drive_id, item_id, new_rows, start_row, progress=progress
299
+ )
286
300
  logger.info(
287
301
  "played_movies: added %d/%d, skipped %d, failed %d",
288
302
  ok,
@@ -68,7 +68,10 @@ class JobScheduler:
68
68
  id=f"jsch_{job_name}",
69
69
  replace_existing=True,
70
70
  )
71
- except (ValueError, Exception) as exc: # noqa: BLE001 - bad stored cron must not crash startup
71
+ except (
72
+ ValueError,
73
+ Exception,
74
+ ) as exc: # noqa: BLE001 - bad stored cron must not crash startup
72
75
  logger.warning(
73
76
  "JobScheduler: skipping invalid cron %r for %r: %s",
74
77
  cron_expression,
@@ -125,11 +128,17 @@ class JobScheduler:
125
128
  minute, hour, day, month, day_of_week = parts
126
129
  try:
127
130
  CronTrigger(
128
- minute=minute, hour=hour, day=day,
129
- month=month, day_of_week=day_of_week, timezone="UTC",
131
+ minute=minute,
132
+ hour=hour,
133
+ day=day,
134
+ month=month,
135
+ day_of_week=day_of_week,
136
+ timezone="UTC",
130
137
  )
131
138
  except Exception as exc:
132
- raise ValueError(f"Invalid cron expression {cron_expression!r}: {exc}") from exc
139
+ raise ValueError(
140
+ f"Invalid cron expression {cron_expression!r}: {exc}"
141
+ ) from exc
133
142
 
134
143
  params_json = json.dumps(params) if params is not None else None
135
144
  await self._db.upsert_job_schedule(
@@ -78,6 +78,7 @@ async def fire_schedule(
78
78
  )
79
79
  total_duration = 0
80
80
  last_item_uid = None
81
+ last_item_media_id = None
81
82
  for index, item in enumerate(items):
82
83
  # Throttle consecutive adds so CyTube can validate each item before
83
84
  # the next arrives (avoids transient queueFail/422 under load).
@@ -97,6 +98,9 @@ async def fire_schedule(
97
98
  and add_result.get("uid") is not None
98
99
  ):
99
100
  last_item_uid = add_result["uid"]
101
+ # Always track the last scheduled item's media_id so the
102
+ # event-lock can lift even when the uid was not captured.
103
+ last_item_media_id = item.get("media_id")
100
104
  total_duration += item.get("duration_sec", 0) or 0
101
105
  except Exception as e:
102
106
  logger.warning(
@@ -150,6 +154,7 @@ async def fire_schedule(
150
154
  started_at=now.isoformat(),
151
155
  estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
152
156
  last_item_uid=last_item_uid,
157
+ last_item_media_id=last_item_media_id,
153
158
  )
154
159
 
155
160
  # Mark schedule as fired
@@ -147,25 +147,44 @@ class QueueShadow:
147
147
  scheduled fire, the curated event is effectively over (only the final
148
148
  item remains), so pay-to-play should reopen for content that plays after
149
149
  it. Idempotent: a no-op once the lock is already lifted.
150
+
151
+ Matching uses two anchors in priority order:
152
+ 1. ``last_item_uid`` — the CyTube playlist uid assigned at add time.
153
+ Most reliable when the bot hasn't reconnected between fire and now.
154
+ 2. ``last_item_media_id`` — the media id (manifest URL) of the last
155
+ scheduled item, tracked even when the uid was never captured (e.g.
156
+ the CyTube ``queue`` event timed out in the robot during bulk add).
150
157
  """
151
158
  active = await self._db.get_active_schedule()
152
159
  if not active or active.get("lock_disabled"):
153
160
  return
154
161
  last_uid = active.get("last_item_uid")
155
- if last_uid is None:
162
+ last_media_id = active.get("last_item_media_id")
163
+ if last_uid is None and last_media_id is None:
156
164
  return
157
165
  idx = self._now_playing_index()
158
166
  if idx is None:
159
167
  return
160
- cur_uid = self._items[idx].get("uid")
168
+ cur_item = self._items[idx]
169
+ cur_uid = cur_item.get("uid")
170
+ cur_media_id = cur_item.get("media_id")
161
171
  try:
162
- if cur_uid is not None and int(cur_uid) == int(last_uid):
163
- await self._db.disable_active_lock()
164
- logger.info(
165
- "Scheduled-event lock lifted: last scheduled item now playing"
166
- )
172
+ uid_match = (
173
+ last_uid is not None
174
+ and cur_uid is not None
175
+ and int(cur_uid) == int(last_uid)
176
+ )
167
177
  except (TypeError, ValueError):
168
- return
178
+ uid_match = False
179
+ media_match = last_media_id is not None and cur_media_id == last_media_id
180
+ if uid_match or media_match:
181
+ await self._db.disable_active_lock()
182
+ logger.info(
183
+ "Scheduled-event lock lifted: last scheduled item now playing"
184
+ " (uid_match=%s media_match=%s)",
185
+ uid_match,
186
+ media_match,
187
+ )
169
188
 
170
189
  async def _maybe_expire_active_schedule(self):
171
190
  """Clear the active-schedule row once the event is genuinely over.
@@ -37,7 +37,9 @@ async def upsert_job_schedule(request: Request, user: dict = Depends(require_adm
37
37
  if not cron_expression:
38
38
  raise HTTPException(400, "cron_expression is required")
39
39
  if len(cron_expression.split()) != 5:
40
- raise HTTPException(400, "cron_expression must have exactly 5 fields (min hour dom mon dow)")
40
+ raise HTTPException(
41
+ 400, "cron_expression must have exactly 5 fields (min hour dom mon dow)"
42
+ )
41
43
 
42
44
  job_scheduler = request.app.state.job_scheduler
43
45
  try:
@@ -64,7 +66,9 @@ async def update_job_schedule(
64
66
  if not cron_expression:
65
67
  raise HTTPException(400, "cron_expression is required")
66
68
  if len(cron_expression.split()) != 5:
67
- raise HTTPException(400, "cron_expression must have exactly 5 fields (min hour dom mon dow)")
69
+ raise HTTPException(
70
+ 400, "cron_expression must have exactly 5 fields (min hour dom mon dow)"
71
+ )
68
72
 
69
73
  job_scheduler = request.app.state.job_scheduler
70
74
  try:
@@ -1,5 +1,5 @@
1
1
  from fastapi import APIRouter, Request, Depends, HTTPException
2
- from datetime import datetime
2
+ from datetime import datetime, UTC
3
3
 
4
4
  from ..auth.session import require_admin
5
5
 
@@ -71,7 +71,13 @@ async def create_schedule(request: Request, user: dict = Depends(require_admin))
71
71
  async def update_schedule(
72
72
  request: Request, schedule_id: int, user: dict = Depends(require_admin)
73
73
  ):
74
- """Update a schedule."""
74
+ """Update a schedule.
75
+
76
+ Always re-registers the APScheduler job from the authoritative DB value
77
+ after the update, regardless of which fields changed. This ensures that
78
+ modifications made close to the fire time (including playlist-only changes)
79
+ take effect for the immediate scheduled item.
80
+ """
75
81
  body = await request.json()
76
82
  db = request.app.state.db
77
83
  scheduler = request.app.state.scheduler
@@ -82,11 +88,15 @@ async def update_schedule(
82
88
 
83
89
  await db.update_schedule(schedule_id, **body)
84
90
 
85
- # Re-register if fire_at changed
86
- if "fire_at" in body:
87
- await scheduler.remove_schedule(schedule_id)
88
- fire_at = datetime.fromisoformat(body["fire_at"])
89
- await scheduler.add_schedule(schedule_id, fire_at)
91
+ # Re-read from DB so the scheduler always uses the authoritative fire_at
92
+ # (not the body value, which may differ due to serialization or race with
93
+ # a recurring re-arm). Only arm a future job; past schedules won't re-fire.
94
+ updated = await db.get_schedule(schedule_id)
95
+ await scheduler.remove_schedule(schedule_id)
96
+ if updated and updated.get("is_active"):
97
+ fire_at = scheduler._parse_fire_at(updated["fire_at"])
98
+ if fire_at > datetime.now(UTC):
99
+ await scheduler.add_schedule(schedule_id, fire_at)
90
100
 
91
101
  return {"success": True}
92
102
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.34.4"
3
+ version = "0.34.7"
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"
@@ -338,3 +338,135 @@ async def test_fire_without_fallback_only_adds_event(db):
338
338
  schedule_id=sid, api_gate=api, db=db, shadow=shadow, ws_manager=_FakeWs()
339
339
  )
340
340
  assert [a["media_id"] for a in api.added] == ["e1"]
341
+
342
+
343
+ # --- Bug #1 regression: media_id fallback for event-lock lift ---
344
+
345
+
346
+ async def test_event_lock_lifts_via_media_id_when_uid_absent(db):
347
+ """Lock lifts when now-playing matches last_item_media_id, even if
348
+ last_item_uid is None (uid not captured during bulk add)."""
349
+ pid = await db.create_saved_playlist(
350
+ name="E", description=None, is_immutable=True, created_by="admin"
351
+ )
352
+ sid = await db.create_schedule(
353
+ playlist_id=pid,
354
+ label="E",
355
+ fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
356
+ is_active=1,
357
+ created_by="admin",
358
+ )
359
+ now = datetime.now(UTC)
360
+ await db.set_active_schedule(
361
+ schedule_id=sid,
362
+ playlist_id=pid,
363
+ is_immutable=True,
364
+ started_at=now.isoformat(),
365
+ estimated_end_at=(now + timedelta(hours=1)).isoformat(),
366
+ last_item_uid=None, # uid never captured
367
+ last_item_media_id="https://cms.example/media/last.json",
368
+ )
369
+ assert await db.is_event_lock_active() is True
370
+
371
+ shadow = QueueShadow(db)
372
+ # now_playing has no uid — CyTube's changeMedia payload — only id/type.
373
+ items = [_polled(1), _polled(2)]
374
+ # Override the second item's media_id to match last_item_media_id.
375
+ items[1] = {
376
+ "uid": 2,
377
+ "queueby": None,
378
+ "media": {
379
+ "id": "https://cms.example/media/last.json",
380
+ "type": "cm",
381
+ "title": "Last Item",
382
+ "seconds": 100,
383
+ },
384
+ }
385
+ np_no_uid = {
386
+ "id": "https://cms.example/media/last.json",
387
+ "type": "cm",
388
+ "seconds": 100,
389
+ "currentTime": 0,
390
+ }
391
+ await shadow.apply_poll_result(items, np_no_uid)
392
+ assert await db.is_event_lock_active() is False
393
+
394
+
395
+ async def test_event_lock_stays_when_earlier_item_plays_by_media_id(db):
396
+ """Lock must NOT lift when an earlier item (not the last) is playing."""
397
+ pid = await db.create_saved_playlist(
398
+ name="E", description=None, is_immutable=True, created_by="admin"
399
+ )
400
+ sid = await db.create_schedule(
401
+ playlist_id=pid,
402
+ label="E",
403
+ fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
404
+ is_active=1,
405
+ created_by="admin",
406
+ )
407
+ now = datetime.now(UTC)
408
+ await db.set_active_schedule(
409
+ schedule_id=sid,
410
+ playlist_id=pid,
411
+ is_immutable=True,
412
+ started_at=now.isoformat(),
413
+ estimated_end_at=(now + timedelta(hours=1)).isoformat(),
414
+ last_item_uid=None,
415
+ last_item_media_id="https://cms.example/media/last.json",
416
+ )
417
+ shadow = QueueShadow(db)
418
+ items = [_polled(1), _polled(2)]
419
+ items[1] = {
420
+ "uid": 2,
421
+ "queueby": None,
422
+ "media": {
423
+ "id": "https://cms.example/media/last.json",
424
+ "type": "cm",
425
+ "title": "Last",
426
+ "seconds": 100,
427
+ },
428
+ }
429
+ # Earlier item (uid=1 / id=m1) is playing — lock must stay on.
430
+ np_earlier = {"id": "m1", "type": "cm", "seconds": 100, "currentTime": 0}
431
+ await shadow.apply_poll_result(items, np_earlier)
432
+ assert await db.is_event_lock_active() is True
433
+
434
+
435
+ async def test_fire_stores_last_item_media_id(db):
436
+ """fire_schedule must store last_item_media_id on the active_schedule row."""
437
+ from kryten_webqueue.playlists.fire import fire_schedule
438
+
439
+ pid = await db.create_saved_playlist(
440
+ name="Event", description=None, is_immutable=True, created_by="admin"
441
+ )
442
+ await db.replace_playlist_items(
443
+ pid,
444
+ [
445
+ {
446
+ "media_type": "cm",
447
+ "media_id": "https://cms/a.json",
448
+ "title": "A",
449
+ "duration_sec": 60,
450
+ },
451
+ {
452
+ "media_type": "cm",
453
+ "media_id": "https://cms/b.json",
454
+ "title": "B",
455
+ "duration_sec": 60,
456
+ },
457
+ ],
458
+ )
459
+ sid = await db.create_schedule(
460
+ playlist_id=pid,
461
+ label="Test",
462
+ fire_at=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S"),
463
+ is_active=1,
464
+ created_by="admin",
465
+ )
466
+ api = _FakeApiGate()
467
+ shadow = QueueShadow(db)
468
+ await fire_schedule(
469
+ schedule_id=sid, api_gate=api, db=db, shadow=shadow, ws_manager=_FakeWs()
470
+ )
471
+ active = await db.get_active_schedule()
472
+ assert active["last_item_media_id"] == "https://cms/b.json"