kryten-webqueue 0.34.4__tar.gz → 0.34.9__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.9}/CHANGELOG.md +61 -0
  2. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_catalog.py +24 -121
  4. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_connection.py +11 -4
  5. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_playlists.py +76 -2
  6. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/images.py +1 -1
  7. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py +3 -1
  8. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/motdposters.py +15 -4
  9. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/playedmovies.py +18 -4
  10. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/job_scheduler.py +13 -4
  11. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/fire.py +5 -20
  12. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/importer.py +3 -23
  13. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/completion.py +6 -0
  14. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/shadow.py +27 -8
  15. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_catalog.py +2 -4
  16. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_job_schedules.py +6 -2
  17. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_playlists.py +2 -8
  18. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_schedules.py +17 -7
  19. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-playlists.js +4 -9
  20. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/playlists.html +1 -2
  21. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/pyproject.toml +1 -1
  22. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase4_live_fixes.py +132 -0
  23. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_recently_played_hide.py +156 -233
  24. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.github/workflows/gitleaks.yml +0 -0
  25. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.github/workflows/python-publish.yml +0 -0
  26. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.github/workflows/release.yml +0 -0
  27. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.gitignore +0 -0
  28. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.gitleaks.toml +0 -0
  29. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/.pre-commit-config.yaml +0 -0
  30. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/AGENT-WORKFLOW-GUIDE.md +0 -0
  31. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/README.md +0 -0
  32. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/config.example.json +0 -0
  33. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/deploy/kryten-webqueue.service +0 -0
  34. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/deploy/nginx-queue.conf +0 -0
  35. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPLEMENTATION_SPEC.md +0 -0
  36. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_API_GATE.md +0 -0
  37. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_ECONOMY.md +0 -0
  38. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_KRYTEN_PY.md +0 -0
  39. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/IMPL_ROBOT.md +0 -0
  40. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  41. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  42. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/Log for incorrect promo insertion problem.txt +0 -0
  43. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  44. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/PRE_PLAN_GAPS.md +0 -0
  45. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/PRODUCT_PLAN.md +0 -0
  46. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/SPEC_EMOTE_REHOST.md +0 -0
  47. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  48. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/docs/UX_POLISH_PLAN.md +0 -0
  49. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/__init__.py +0 -0
  50. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/__main__.py +0 -0
  51. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/api_gate/__init__.py +0 -0
  52. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/api_gate/client.py +0 -0
  53. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/app.py +0 -0
  54. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/__init__.py +0 -0
  55. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/otp.py +0 -0
  56. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/rate_limit.py +0 -0
  57. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/auth/session.py +0 -0
  58. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/__init__.py +0 -0
  59. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/__init__.py +0 -0
  60. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  61. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/db/_queue.py +0 -0
  62. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/mediacms.py +0 -0
  63. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/catalog/sync.py +0 -0
  64. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/config.py +0 -0
  65. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/__init__.py +0 -0
  66. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  67. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  68. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  69. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  70. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  71. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  72. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  73. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/__init__.py +0 -0
  74. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  75. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/manager.py +0 -0
  76. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  77. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/jobs/tasks.py +0 -0
  78. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/logging_config.py +0 -0
  79. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/__init__.py +0 -0
  80. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/bulk_add.py +0 -0
  81. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/ordering.py +0 -0
  82. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/playlists/scheduler.py +0 -0
  83. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/promos/__init__.py +0 -0
  84. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/promos/director.py +0 -0
  85. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/__init__.py +0 -0
  86. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/ordering.py +0 -0
  87. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/poller.py +0 -0
  88. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/presence.py +0 -0
  89. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/queue/race_poller.py +0 -0
  90. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/__init__.py +0 -0
  91. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_feedback.py +0 -0
  92. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_jobs.py +0 -0
  93. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_moderation.py +0 -0
  94. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_promos.py +0 -0
  95. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/admin_queue.py +0 -0
  96. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/auth.py +0 -0
  97. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/catalog.py +0 -0
  98. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/feedback.py +0 -0
  99. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/pages.py +0 -0
  100. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/queue.py +0 -0
  101. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/routes/user.py +0 -0
  102. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/css/main.css +0 -0
  103. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-promos.js +0 -0
  104. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  105. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  106. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/admin.js +0 -0
  107. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/static/js/main.js +0 -0
  108. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/index.html +0 -0
  109. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/promos.html +0 -0
  110. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  111. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/admin/schedules.html +0 -0
  112. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/auth/login.html +0 -0
  113. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/base.html +0 -0
  114. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/catalog/browse.html +0 -0
  115. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  116. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  117. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/feedback/index.html +0 -0
  118. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/queue/index.html +0 -0
  119. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/race.html +0 -0
  120. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/templates/user/dashboard.html +0 -0
  121. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/ws/__init__.py +0 -0
  122. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/ws/handler.py +0 -0
  123. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/kryten_webqueue/ws/manager.py +0 -0
  124. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/__init__.py +0 -0
  125. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_config_persistence.py +0 -0
  126. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_feedback.py +0 -0
  127. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_fetchurls_sharepoint.py +0 -0
  128. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase1.py +0 -0
  129. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase2_jobs.py +0 -0
  130. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_phase3_jobs.py +0 -0
  131. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_playlist_import.py +0 -0
  132. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_presence_refund.py +0 -0
  133. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_promo_director.py +0 -0
  134. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_promo_pool_exclusion.py +0 -0
  135. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_queue_announce.py +0 -0
  136. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_race_poller.py +0 -0
  137. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_save_results_to_playlist.py +0 -0
  138. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_schedule_lock.py +0 -0
  139. {kryten_webqueue-0.34.4 → kryten_webqueue-0.34.9}/tests/test_search_facets.py +0 -0
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.34.9] - 2026-08-13
6
+
7
+ ### Changed
8
+
9
+ - **Playlist continuation is now driven by rotation, not pass tracking.**
10
+ Replaced the broken `playlist_item_played` pass-continuation mechanism with
11
+ automatic playlist rotation: when an item plays past the 50% threshold,
12
+ `CompletionRecorder` calls `rotate_playlist_item_to_bottom`, which moves the
13
+ item to the end of every mutable playlist containing it. `fire_schedule` and
14
+ `PlaylistImporter` always load from position 0 — no filtering needed. Mutable
15
+ playlists now naturally advance across preemptions without ever getting stuck.
16
+
17
+ - **Recently-played hiding simplified.** All played items (episodes, movies,
18
+ everything) now record a `play_completions` time-window row. The `playlist_item_played`
19
+ table is no longer written to; the "release whole collection on last item"
20
+ behaviour is removed. The per-item time-window applies uniformly to all catalog
21
+ items.
22
+
23
+ - **Admin playlist UI.** "Load Entire List" button removed; "Import to Live"
24
+ always loads the full playlist in current order (which is the rotated order).
25
+
26
+ ### Removed
27
+
28
+ - `get_playlist_played_media_ids` DB method
29
+ - `skip_played` parameter from `PlaylistImporter.import_playlist`
30
+ - `?full=1` query parameter from `POST /admin/playlists/{id}/import`
31
+ - `by_playlist_pass` key from `GET /admin/catalog/recently-played/debug` response
32
+ - `playlist_pass` key from `POST /admin/catalog/{token}/clear-played` response
33
+
34
+ ## [0.34.7] - 2026-08-13
35
+
36
+ ### Fixed
37
+
38
+ - **Event lock lifts at the correct time (Bug #1).** The scheduled-event
39
+ pay-to-play lock was lifting ~30 minutes late when `last_item_uid` was never
40
+ captured (e.g. a CyTube `queue`-event timeout during bulk add left the field
41
+ `NULL`). With no uid anchor `_maybe_lift_event_lock` returned early and the
42
+ only fallback was the `estimated_end_at + 5 min` time safety net, which fires
43
+ roughly when the *last* item finishes rather than when it *starts*.
44
+ Fix: `fire_schedule` now always records `last_item_media_id` (the manifest
45
+ URL of the last scheduled item) even when the uid is unavailable. The lock
46
+ lift logic now matches on `media_id` first and falls back to `last_item_uid`,
47
+ so pay-to-play reopens as soon as the last scheduled item begins playing
48
+ regardless of whether the uid was captured. DB migration v12 adds the new
49
+ `last_item_media_id TEXT` column to `active_schedule`.
50
+
51
+ - **Schedule modifications apply immediately (Bug #2).** When a schedule was
52
+ updated via the admin UI, the APScheduler job was re-registered only if
53
+ `fire_at` was present in the request body. The `update_schedule` route now
54
+ always re-registers the job after any modification, and it reads the
55
+ authoritative `fire_at` from the database (not from the request body) to
56
+ avoid stale or mismatched values. If the updated `fire_at` is already in the
57
+ past, the job is not re-armed (preventing an accidental immediate re-fire).
58
+
59
+ - Cover art downloads for movies: corrected TMDB API parameter from `year` to
60
+ `primary_release_year` in movie search. TV searches already used the correct
61
+ `first_air_date_year` parameter, which is why cover art worked for TV but
62
+ not movies.
63
+
3
64
  ## [0.34.4] - 2026-08-12
4
65
 
5
66
  ### 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.9
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
@@ -102,22 +102,13 @@ def _promo_hidden_media_subquery() -> tuple[str, list]:
102
102
  def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
103
103
  """SQL fragment (+ params) hiding recently-played items from regular users.
104
104
 
105
- Two independent signals hide an item; either one is enough:
106
-
107
- 1. **Time window** — the item has a genuine ``play_completions`` row within
108
- the last ``days`` days. A completion is only recorded when an item
109
- actually played past a threshold (see ``CompletionRecorder``), so an item
110
- that was queued and then refunded/removed before playing never counts.
111
-
112
- 2. **Mutable-playlist pass** — the item is a short (<1h) episode of a mutable
113
- (TV-show) playlist that has played in the current pass and not yet reset
114
- (``playlist_item_played``). These are governed by playlist position, not
115
- the day window, so a whole show collection releases together when its last
116
- item plays.
105
+ Items with a ``play_completions`` row within the last ``days`` days are
106
+ hidden. A completion is only recorded when an item played past a threshold
107
+ (see ``CompletionRecorder``), so refunded/skipped items never hide.
117
108
 
118
109
  The fragment is prefixed with ``AND`` so it can be appended to an existing
119
110
  WHERE clause referencing the catalog row under ``alias``. Caller must ensure
120
- ``days > 0`` before applying (a non-positive window disables both signals).
111
+ ``days > 0`` before applying (a non-positive window disables this).
121
112
  """
122
113
  sql = f"""
123
114
  AND {alias}.friendly_token NOT IN (
@@ -125,10 +116,6 @@ def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
125
116
  WHERE pc.media_type = 'cm'
126
117
  AND pc.completed_at >= datetime('now', ?)
127
118
  )
128
- AND {alias}.friendly_token NOT IN (
129
- SELECT pip.media_id FROM playlist_item_played pip
130
- WHERE pip.media_type = 'cm'
131
- )
132
119
  """
133
120
  return sql, [f"-{int(days)} days"]
134
121
 
@@ -419,23 +406,17 @@ class _CatalogMixin:
419
406
  )
420
407
 
421
408
  async def record_play_completion(
422
- self, *, friendly_token: str, duration_sec: int | None, media_type: str = "cm"
409
+ self,
410
+ *,
411
+ friendly_token: str,
412
+ duration_sec: int | None = None,
413
+ media_type: str = "cm",
423
414
  ) -> None:
424
- """Record a genuine play-completion, routing it to the correct hide rule.
425
-
426
- Short (<1h) episodes that belong to a mutable (TV-show) playlist are
427
- governed by playlist position instead of the day window: each such
428
- completion marks the episode played for the current pass, and reaching
429
- the playlist's last item (MAX position) clears the whole pass so the
430
- collection releases together. Everything else (movies, standalone clips,
431
- long items even inside a mutable playlist) gets a time-boxed
432
- ``play_completions`` row instead.
433
-
434
- Promo-pool clips are exempt entirely: they are already excluded from the
435
- public catalog and must never be treated like normal (mutable/immutable)
436
- playlist items, so playing one records nothing. The same exemption covers
437
- any promo/bumper hidden by category or tag (e.g. ``Z Channel Promos`` /
438
- ``channelz``), which is how most station promos are actually classified.
415
+ """Record a genuine play-completion for catalog-hide purposes.
416
+
417
+ Promo-pool clips and hidden-category/tag items are exempt. Everything
418
+ else gets a time-boxed ``play_completions`` row that hides the item from
419
+ regular users until the configured window expires.
439
420
  """
440
421
  sub, sub_params = _promo_hidden_media_subquery()
441
422
  promo = await self._fetch_one(
@@ -444,84 +425,28 @@ class _CatalogMixin:
444
425
  )
445
426
  if promo:
446
427
  return
447
- rows = await self._fetch_all(
448
- """
449
- SELECT spi.playlist_id AS playlist_id, spi.position AS position,
450
- (SELECT MAX(position) FROM saved_playlist_items
451
- WHERE playlist_id = spi.playlist_id) AS max_pos
452
- FROM saved_playlist_items spi
453
- JOIN saved_playlists sp ON sp.id = spi.playlist_id
454
- WHERE spi.media_id = ? AND spi.media_type = ?
455
- AND sp.is_immutable = 0 AND sp.promo_type IS NULL
456
- """,
457
- [friendly_token, media_type],
458
- )
459
- governed = False
460
- if duration_sec is not None and duration_sec < 3600:
461
- for r in rows:
462
- governed = True
463
- if r["position"] == r["max_pos"]:
464
- # Last item of the pass reached — release the whole collection.
465
- await self._execute(
466
- "DELETE FROM playlist_item_played WHERE playlist_id = ?",
467
- [r["playlist_id"]],
468
- )
469
- else:
470
- await self._execute(
471
- "INSERT OR REPLACE INTO playlist_item_played "
472
- "(playlist_id, position, media_type, media_id) VALUES (?, ?, ?, ?)",
473
- [r["playlist_id"], r["position"], media_type, friendly_token],
474
- )
475
- if not governed:
476
- await self._execute(
477
- "INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
478
- [media_type, friendly_token],
479
- )
480
-
481
- async def get_playlist_played_media_ids(self, playlist_id: int) -> set[str]:
482
- """media_ids already played in the current pass of a mutable playlist.
483
-
484
- Backed by ``playlist_item_played`` (populated only for short episodes of
485
- mutable playlists and cleared when the playlist's last item plays), so
486
- firing can skip already-played episodes and continue the pass instead of
487
- replaying from the start.
488
- """
489
- rows = await self._fetch_all(
490
- "SELECT media_id FROM playlist_item_played WHERE playlist_id = ?",
491
- [playlist_id],
428
+ await self._execute(
429
+ "INSERT INTO play_completions (media_type, media_id) VALUES (?, ?)",
430
+ [media_type, friendly_token],
492
431
  )
493
- return {r["media_id"] for r in rows}
494
432
 
495
433
  async def clear_play_state(
496
434
  self, friendly_token: str, *, media_type: str = "cm"
497
435
  ) -> dict:
498
- """Remove all recently-played hide state for an item (admin test helper).
436
+ """Remove recently-played hide state for an item (admin helper).
499
437
 
500
- Deletes both time-boxed ``play_completions`` rows and any
501
- ``playlist_item_played`` (mutable-playlist pass) rows for the item, so it
502
- immediately reappears in the public catalog. Returns the row counts
503
- removed.
438
+ Deletes ``play_completions`` rows so the item reappears in the public
439
+ catalog immediately. Returns the count removed.
504
440
  """
505
441
  pc = await self._fetch_one(
506
442
  "SELECT COUNT(*) AS c FROM play_completions WHERE media_id = ? AND media_type = ?",
507
443
  [friendly_token, media_type],
508
444
  )
509
- pip = await self._fetch_one(
510
- "SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
511
- [friendly_token, media_type],
512
- )
513
445
  await self._execute(
514
446
  "DELETE FROM play_completions WHERE media_id = ? AND media_type = ?",
515
447
  [friendly_token, media_type],
516
448
  )
517
- await self._execute(
518
- "DELETE FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
519
- [friendly_token, media_type],
520
- )
521
- return {
522
- "completions": pc["c"] if pc else 0,
523
- "playlist_pass": pip["c"] if pip else 0,
524
- }
449
+ return {"completions": pc["c"] if pc else 0}
525
450
 
526
451
  async def purge_promo_hide_state(self) -> dict:
527
452
  """Remove any recently-played hide state recorded for promos/bumpers.
@@ -530,34 +455,20 @@ class _CatalogMixin:
530
455
  are already skipped in ``record_play_completion``; this purges rows
531
456
  written before that exemption existed (run once at startup). Covers promo
532
457
  pools plus hidden-category/tag promos (the usual station-promo case).
533
- Returns the row counts removed.
458
+ Returns the row count removed.
534
459
  """
535
460
  sub, sub_params = _promo_hidden_media_subquery()
536
461
  pc = await self._fetch_one(
537
462
  f"SELECT COUNT(*) AS c FROM play_completions WHERE media_id IN ({sub})",
538
463
  sub_params,
539
464
  )
540
- pip = await self._fetch_one(
541
- f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({sub})",
542
- sub_params,
543
- )
544
465
  await self._execute(
545
466
  f"DELETE FROM play_completions WHERE media_id IN ({sub})", sub_params
546
467
  )
547
- await self._execute(
548
- f"DELETE FROM playlist_item_played WHERE media_id IN ({sub})", sub_params
549
- )
550
- return {
551
- "completions": pc["c"] if pc else 0,
552
- "playlist_pass": pip["c"] if pip else 0,
553
- }
468
+ return {"completions": pc["c"] if pc else 0}
554
469
 
555
470
  async def get_recently_played_debug(self, days: int) -> dict:
556
- """Snapshot of what the recently-played rules currently hide (admin test).
557
-
558
- Mirrors the two exclusion signals used by browse/search so an admin can
559
- see exactly which titles a regular user would NOT see, and why.
560
- """
471
+ """Snapshot of what the recently-played rules currently hide (admin test)."""
561
472
  by_completion = await self._fetch_all(
562
473
  "SELECT pc.media_id, c.title, MAX(pc.completed_at) AS last_completed "
563
474
  "FROM play_completions pc "
@@ -566,17 +477,9 @@ class _CatalogMixin:
566
477
  "GROUP BY pc.media_id ORDER BY last_completed DESC",
567
478
  [f"-{int(days)} days"],
568
479
  )
569
- by_playlist_pass = await self._fetch_all(
570
- "SELECT pip.media_id, c.title, pip.playlist_id, sp.name AS playlist_name, pip.position "
571
- "FROM playlist_item_played pip "
572
- "JOIN saved_playlists sp ON sp.id = pip.playlist_id "
573
- "LEFT JOIN catalog c ON c.friendly_token = pip.media_id "
574
- "WHERE pip.media_type = 'cm' ORDER BY sp.name, pip.position",
575
- )
576
480
  return {
577
481
  "window_days": days,
578
482
  "by_completion": by_completion,
579
- "by_playlist_pass": by_playlist_pass,
580
483
  }
581
484
 
582
485
  async def get_catalog_brief(
@@ -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
  """
@@ -1,3 +1,8 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger(__name__)
4
+
5
+
1
6
  class _PlaylistsMixin:
2
7
  """Saved playlists, promo pools, schedules, and active schedule methods."""
3
8
 
@@ -159,6 +164,72 @@ class _PlaylistsMixin:
159
164
  await self._db.commit()
160
165
  return added
161
166
 
167
+ async def rotate_playlist_item_to_bottom(
168
+ self, media_id: str, media_type: str = "cm"
169
+ ) -> int:
170
+ """Move a played item to the end of every mutable playlist containing it.
171
+
172
+ Played items sink to the bottom so the next fire always loads the
173
+ oldest-played item last. Idempotent when the item is already at
174
+ MAX(position). Returns the number of playlists rotated.
175
+ """
176
+ playlists = await self._fetch_all(
177
+ """
178
+ SELECT spi.playlist_id
179
+ FROM saved_playlist_items spi
180
+ JOIN saved_playlists sp ON sp.id = spi.playlist_id
181
+ WHERE spi.media_id = ? AND spi.media_type = ?
182
+ AND sp.is_immutable = 0 AND sp.promo_type IS NULL
183
+ """,
184
+ [media_id, media_type],
185
+ )
186
+ rotated = 0
187
+ for r in playlists:
188
+ playlist_id = r["playlist_id"]
189
+ items = await self._fetch_all(
190
+ "SELECT * FROM saved_playlist_items WHERE playlist_id = ? ORDER BY position",
191
+ [playlist_id],
192
+ )
193
+ target_idx = next(
194
+ (
195
+ i
196
+ for i, it in enumerate(items)
197
+ if it["media_id"] == media_id and it["media_type"] == media_type
198
+ ),
199
+ None,
200
+ )
201
+ if target_idx is None or target_idx == len(items) - 1:
202
+ continue # not found or already at bottom
203
+ # Move target to end; delete-then-reinsert avoids position UNIQUE conflicts.
204
+ reordered = (
205
+ items[:target_idx] + items[target_idx + 1 :] + [items[target_idx]]
206
+ )
207
+ await self._db.execute(
208
+ "DELETE FROM saved_playlist_items WHERE playlist_id=?", [playlist_id]
209
+ )
210
+ for new_pos, item in enumerate(reordered):
211
+ await self._db.execute(
212
+ "INSERT INTO saved_playlist_items "
213
+ "(playlist_id, position, media_type, media_id, title, duration_sec) "
214
+ "VALUES (?, ?, ?, ?, ?, ?)",
215
+ [
216
+ playlist_id,
217
+ new_pos,
218
+ item["media_type"],
219
+ item["media_id"],
220
+ item.get("title"),
221
+ item.get("duration_sec"),
222
+ ],
223
+ )
224
+ await self._db.execute(
225
+ "UPDATE saved_playlists SET updated_at=datetime('now') WHERE id=?",
226
+ [playlist_id],
227
+ )
228
+ await self._db.commit()
229
+ logger.info("Rotated %r to bottom of playlist %d", media_id, playlist_id)
230
+ rotated += 1
231
+ return rotated
232
+
162
233
  async def get_most_recent_playlist(self, created_by: str) -> dict | None:
163
234
  """The given admin's most recently *created* saved playlist, if any."""
164
235
  return await self._fetch_one(
@@ -242,11 +313,13 @@ class _PlaylistsMixin:
242
313
  started_at: str,
243
314
  estimated_end_at: str,
244
315
  last_item_uid: int | None = None,
316
+ last_item_media_id: str | None = None,
245
317
  ):
246
318
  await self._execute(
247
319
  "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)",
320
+ "(id, schedule_id, playlist_id, is_immutable, started_at, estimated_end_at, "
321
+ "last_item_uid, last_item_media_id, lock_disabled) "
322
+ "VALUES (1, ?, ?, ?, ?, ?, ?, ?, 0)",
250
323
  [
251
324
  schedule_id,
252
325
  playlist_id,
@@ -254,6 +327,7 @@ class _PlaylistsMixin:
254
327
  started_at,
255
328
  estimated_end_at,
256
329
  last_item_uid,
330
+ last_item_media_id,
257
331
  ],
258
332
  )
259
333
 
@@ -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(
@@ -63,21 +63,9 @@ async def fire_schedule(
63
63
 
64
64
  # Load scheduled playlist items
65
65
  items = await db.get_saved_playlist_items(playlist_id)
66
- # For a mutable (TV-show) playlist, skip episodes already played in the
67
- # current pass so a re-fire continues where it left off instead of
68
- # replaying season 1. Immutable/promo playlists never have played rows,
69
- # so this is a no-op for them.
70
- played = await db.get_playlist_played_media_ids(playlist_id)
71
- if played:
72
- before = len(items)
73
- items = [it for it in items if it["media_id"] not in played]
74
- if before != len(items):
75
- logger.info(
76
- f"Schedule {schedule_id}: skipped {before - len(items)} already-played "
77
- f"episode(s) from mutable playlist {playlist_id}"
78
- )
79
66
  total_duration = 0
80
67
  last_item_uid = None
68
+ last_item_media_id = None
81
69
  for index, item in enumerate(items):
82
70
  # Throttle consecutive adds so CyTube can validate each item before
83
71
  # the next arrives (avoids transient queueFail/422 under load).
@@ -97,6 +85,9 @@ async def fire_schedule(
97
85
  and add_result.get("uid") is not None
98
86
  ):
99
87
  last_item_uid = add_result["uid"]
88
+ # Always track the last scheduled item's media_id so the
89
+ # event-lock can lift even when the uid was not captured.
90
+ last_item_media_id = item.get("media_id")
100
91
  total_duration += item.get("duration_sec", 0) or 0
101
92
  except Exception as e:
102
93
  logger.warning(
@@ -111,13 +102,6 @@ async def fire_schedule(
111
102
  fallback_id = schedule.get("fallback_playlist_id")
112
103
  if fallback_id:
113
104
  fallback_items = await db.get_saved_playlist_items(fallback_id)
114
- # Same continuation logic as the main playlist: skip already-played
115
- # episodes of the (mutable) fallback so it advances across fires.
116
- fb_played = await db.get_playlist_played_media_ids(fallback_id)
117
- if fb_played:
118
- fallback_items = [
119
- it for it in fallback_items if it["media_id"] not in fb_played
120
- ]
121
105
  for index, item in enumerate(fallback_items):
122
106
  if index and add_delay_sec:
123
107
  await asyncio.sleep(add_delay_sec)
@@ -150,6 +134,7 @@ async def fire_schedule(
150
134
  started_at=now.isoformat(),
151
135
  estimated_end_at=(now + timedelta(seconds=total_duration)).isoformat(),
152
136
  last_item_uid=last_item_uid,
137
+ last_item_media_id=last_item_media_id,
153
138
  )
154
139
 
155
140
  # Mark schedule as fired