kryten-webqueue 0.34.7__tar.gz → 0.34.10__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.7 → kryten_webqueue-0.34.10}/CHANGELOG.md +36 -0
  2. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/app.py +2 -3
  4. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_catalog.py +24 -121
  5. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_playlists.py +71 -0
  6. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/fire.py +0 -20
  7. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/importer.py +3 -23
  8. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/completion.py +6 -0
  9. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_catalog.py +2 -4
  10. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_playlists.py +2 -8
  11. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-playlists.js +4 -9
  12. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/playlists.html +1 -2
  13. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/pyproject.toml +1 -1
  14. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_recently_played_hide.py +156 -233
  15. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.github/workflows/gitleaks.yml +0 -0
  16. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.github/workflows/python-publish.yml +0 -0
  17. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.github/workflows/release.yml +0 -0
  18. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.gitignore +0 -0
  19. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.gitleaks.toml +0 -0
  20. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/.pre-commit-config.yaml +0 -0
  21. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/AGENT-WORKFLOW-GUIDE.md +0 -0
  22. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/README.md +0 -0
  23. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/config.example.json +0 -0
  24. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/deploy/kryten-webqueue.service +0 -0
  25. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/deploy/nginx-queue.conf +0 -0
  26. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPLEMENTATION_SPEC.md +0 -0
  27. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_API_GATE.md +0 -0
  28. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_ECONOMY.md +0 -0
  29. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_KRYTEN_PY.md +0 -0
  30. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/IMPL_ROBOT.md +0 -0
  31. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  32. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  33. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/Log for incorrect promo insertion problem.txt +0 -0
  34. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  35. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/PRE_PLAN_GAPS.md +0 -0
  36. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/PRODUCT_PLAN.md +0 -0
  37. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/SPEC_EMOTE_REHOST.md +0 -0
  38. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  39. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/docs/UX_POLISH_PLAN.md +0 -0
  40. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/__init__.py +0 -0
  41. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/__main__.py +0 -0
  42. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/api_gate/__init__.py +0 -0
  43. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/api_gate/client.py +0 -0
  44. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/__init__.py +0 -0
  45. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/otp.py +0 -0
  46. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/rate_limit.py +0 -0
  47. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/auth/session.py +0 -0
  48. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/__init__.py +0 -0
  49. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/__init__.py +0 -0
  50. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_connection.py +0 -0
  51. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  52. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/db/_queue.py +0 -0
  53. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/images.py +0 -0
  54. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/mediacms.py +0 -0
  55. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/catalog/sync.py +0 -0
  56. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/config.py +0 -0
  57. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/__init__.py +0 -0
  58. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  59. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  60. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  61. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  62. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  63. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  64. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  65. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  66. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  67. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  68. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/__init__.py +0 -0
  69. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  70. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  71. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/manager.py +0 -0
  72. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  73. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/jobs/tasks.py +0 -0
  74. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/logging_config.py +0 -0
  75. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/__init__.py +0 -0
  76. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/bulk_add.py +0 -0
  77. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/ordering.py +0 -0
  78. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/playlists/scheduler.py +0 -0
  79. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/promos/__init__.py +0 -0
  80. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/promos/director.py +0 -0
  81. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/__init__.py +0 -0
  82. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/ordering.py +0 -0
  83. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/poller.py +0 -0
  84. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/presence.py +0 -0
  85. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/race_poller.py +0 -0
  86. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/queue/shadow.py +0 -0
  87. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/__init__.py +0 -0
  88. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_feedback.py +0 -0
  89. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  90. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_jobs.py +0 -0
  91. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_moderation.py +0 -0
  92. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_promos.py +0 -0
  93. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_queue.py +0 -0
  94. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/admin_schedules.py +0 -0
  95. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/auth.py +0 -0
  96. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/catalog.py +0 -0
  97. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/feedback.py +0 -0
  98. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/pages.py +0 -0
  99. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/queue.py +0 -0
  100. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/routes/user.py +0 -0
  101. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/css/main.css +0 -0
  102. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-promos.js +0 -0
  103. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  104. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  105. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/admin.js +0 -0
  106. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/static/js/main.js +0 -0
  107. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/index.html +0 -0
  108. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/promos.html +0 -0
  109. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  110. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/admin/schedules.html +0 -0
  111. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/auth/login.html +0 -0
  112. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/base.html +0 -0
  113. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/catalog/browse.html +0 -0
  114. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  115. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  116. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/feedback/index.html +0 -0
  117. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/queue/index.html +0 -0
  118. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/race.html +0 -0
  119. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/templates/user/dashboard.html +0 -0
  120. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/ws/__init__.py +0 -0
  121. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/ws/handler.py +0 -0
  122. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/kryten_webqueue/ws/manager.py +0 -0
  123. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/__init__.py +0 -0
  124. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_config_persistence.py +0 -0
  125. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_feedback.py +0 -0
  126. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_fetchurls_sharepoint.py +0 -0
  127. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase1.py +0 -0
  128. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase2_jobs.py +0 -0
  129. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase3_jobs.py +0 -0
  130. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_phase4_live_fixes.py +0 -0
  131. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_playlist_import.py +0 -0
  132. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_presence_refund.py +0 -0
  133. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_promo_director.py +0 -0
  134. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_promo_pool_exclusion.py +0 -0
  135. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_queue_announce.py +0 -0
  136. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_race_poller.py +0 -0
  137. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_save_results_to_playlist.py +0 -0
  138. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_schedule_lock.py +0 -0
  139. {kryten_webqueue-0.34.7 → kryten_webqueue-0.34.10}/tests/test_search_facets.py +0 -0
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.34.10] - 2026-08-13
6
+
7
+ ### Fixed
8
+
9
+ - **Startup crash.** `app.py` referenced the now-removed `playlist_pass` key in
10
+ the return value of `purge_promo_hide_state()`.
11
+
12
+ ## [0.34.9] - 2026-08-13
13
+
14
+ ### Changed
15
+
16
+ - **Playlist continuation is now driven by rotation, not pass tracking.**
17
+ Replaced the broken `playlist_item_played` pass-continuation mechanism with
18
+ automatic playlist rotation: when an item plays past the 50% threshold,
19
+ `CompletionRecorder` calls `rotate_playlist_item_to_bottom`, which moves the
20
+ item to the end of every mutable playlist containing it. `fire_schedule` and
21
+ `PlaylistImporter` always load from position 0 — no filtering needed. Mutable
22
+ playlists now naturally advance across preemptions without ever getting stuck.
23
+
24
+ - **Recently-played hiding simplified.** All played items (episodes, movies,
25
+ everything) now record a `play_completions` time-window row. The `playlist_item_played`
26
+ table is no longer written to; the "release whole collection on last item"
27
+ behaviour is removed. The per-item time-window applies uniformly to all catalog
28
+ items.
29
+
30
+ - **Admin playlist UI.** "Load Entire List" button removed; "Import to Live"
31
+ always loads the full playlist in current order (which is the rotated order).
32
+
33
+ ### Removed
34
+
35
+ - `get_playlist_played_media_ids` DB method
36
+ - `skip_played` parameter from `PlaylistImporter.import_playlist`
37
+ - `?full=1` query parameter from `POST /admin/playlists/{id}/import`
38
+ - `by_playlist_pass` key from `GET /admin/catalog/recently-played/debug` response
39
+ - `playlist_pass` key from `POST /admin/catalog/{token}/clear-played` response
40
+
5
41
  ## [0.34.7] - 2026-08-13
6
42
 
7
43
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.34.7
3
+ Version: 0.34.10
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
@@ -57,11 +57,10 @@ async def lifespan(app: FastAPI):
57
57
  # recently-played rules). Cheap and idempotent; also cleans rows written by
58
58
  # older builds that classified promos only by pool membership.
59
59
  purged = await db.purge_promo_hide_state()
60
- if purged["completions"] or purged["playlist_pass"]:
60
+ if purged["completions"]:
61
61
  logger.info(
62
- "Purged promo recently-played state: %d completion(s), %d pass row(s)",
62
+ "Purged promo recently-played state: %d completion(s)",
63
63
  purged["completions"],
64
- purged["playlist_pass"],
65
64
  )
66
65
  # Any job run still marked 'running' is an orphan from a prior crash/restart
67
66
  # (the running flag is in-memory only). Reconcile before registering jobs.
@@ -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(
@@ -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(
@@ -63,19 +63,6 @@ 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
81
68
  last_item_media_id = None
@@ -115,13 +102,6 @@ async def fire_schedule(
115
102
  fallback_id = schedule.get("fallback_playlist_id")
116
103
  if fallback_id:
117
104
  fallback_items = await db.get_saved_playlist_items(fallback_id)
118
- # Same continuation logic as the main playlist: skip already-played
119
- # episodes of the (mutable) fallback so it advances across fires.
120
- fb_played = await db.get_playlist_played_media_ids(fallback_id)
121
- if fb_played:
122
- fallback_items = [
123
- it for it in fallback_items if it["media_id"] not in fb_played
124
- ]
125
105
  for index, item in enumerate(fallback_items):
126
106
  if index and add_delay_sec:
127
107
  await asyncio.sleep(add_delay_sec)
@@ -80,32 +80,12 @@ class PlaylistImporter:
80
80
  self._add_max_retries = add_max_retries
81
81
  self._promo_director = promo_director
82
82
 
83
- async def import_playlist(
84
- self, playlist_id: int, *, skip_played: bool = True
85
- ) -> dict:
86
- """Import items from a saved playlist into the live queue.
87
-
88
- By default (``skip_played=True``) already-played episodes of the current
89
- pass are skipped, so a mutable (TV-show) playlist continues where it left
90
- off instead of replaying from the start — the same rule automated firing
91
- uses. Pass ``skip_played=False`` to force-load the entire list regardless
92
- of pass state.
93
- """
83
+ async def import_playlist(self, playlist_id: int) -> dict:
84
+ """Import items from a saved playlist into the live queue."""
94
85
  items = await self._db.get_saved_playlist_items(playlist_id)
95
86
  if not items:
96
87
  return {"success": False, "error": "Playlist is empty"}
97
88
 
98
- skipped = 0
99
- if skip_played:
100
- played = await self._db.get_playlist_played_media_ids(playlist_id)
101
- if played:
102
- before = len(items)
103
- items = [it for it in items if it["media_id"] not in played]
104
- skipped = before - len(items)
105
- if not items:
106
- # Everything's already been played this pass (nothing left to add).
107
- return {"success": True, "added": 0, "errors": 0, "skipped": skipped}
108
-
109
89
  # Suppress promo insertion for the whole load so promos aren't slotted
110
90
  # between items while the playlist is still being built.
111
91
  suppress_ctx = (
@@ -139,7 +119,7 @@ class PlaylistImporter:
139
119
  logger.warning(f"Failed to add {item['media_id']}: {e}")
140
120
  errors += 1
141
121
 
142
- return {"success": True, "added": added, "errors": errors, "skipped": skipped}
122
+ return {"success": True, "added": added, "errors": errors}
143
123
 
144
124
 
145
125
  async def import_playlist_text(
@@ -99,3 +99,9 @@ class CompletionRecorder:
99
99
  logger.warning(
100
100
  "Failed to record play completion for %s", token, exc_info=True
101
101
  )
102
+ try:
103
+ await self._db.rotate_playlist_item_to_bottom(token)
104
+ except Exception:
105
+ logger.warning(
106
+ "Failed to rotate playlist item for %s", token, exc_info=True
107
+ )
@@ -66,10 +66,8 @@ async def mark_played(
66
66
  ):
67
67
  """Simulate a genuine play-completion for an item (testing aid).
68
68
 
69
- Routes through the same ``record_play_completion`` the poll loop uses, so it
70
- exercises the real rules: short (<1h) episodes of a mutable playlist mark the
71
- current pass (and reset it when the last item is marked), everything else
72
- gets a time-boxed completion. The item then hides from regular users exactly
69
+ Routes through the same ``record_play_completion`` the poll loop uses.
70
+ The item then hides from regular users for the configured window, exactly
73
71
  as it would after real playback.
74
72
  """
75
73
  db = request.app.state.db
@@ -108,15 +108,9 @@ async def replace_items(
108
108
  async def import_to_live(
109
109
  request: Request,
110
110
  playlist_id: int,
111
- full: int = 0,
112
111
  user: dict = Depends(require_admin),
113
112
  ):
114
- """Import a saved playlist into the live CyTube queue.
115
-
116
- By default this honors the current-pass played/skip rules (a mutable TV-show
117
- playlist continues where it left off). Pass ``?full=1`` to force-load the
118
- entire list regardless of what's already been played this pass.
119
- """
113
+ """Import a saved playlist into the live CyTube queue."""
120
114
  from ..playlists.importer import PlaylistImporter
121
115
 
122
116
  db = request.app.state.db
@@ -133,7 +127,7 @@ async def import_to_live(
133
127
  add_max_retries=config.playlist_bulk_add_max_retries,
134
128
  promo_director=getattr(request.app.state, "promo_director", None),
135
129
  )
136
- result = await importer.import_playlist(playlist_id, skip_played=not bool(full))
130
+ result = await importer.import_playlist(playlist_id)
137
131
  return result
138
132
 
139
133
 
@@ -218,18 +218,13 @@ async function saveItems() {
218
218
  showToast(resp.ok ? 'Saved' : 'Save failed', resp.ok ? 'success' : 'error');
219
219
  }
220
220
 
221
- async function importToLive(full = false) {
221
+ async function importToLive() {
222
222
  if (editorId === null) return;
223
- const prompt = full
224
- ? 'Load the ENTIRE playlist into the live CyTube queue now (ignoring what has already been played this pass)?'
225
- : 'Load this playlist into the live CyTube queue now?';
226
- if (!confirm(prompt)) return;
227
- const url = `/admin/playlists/${editorId}/import${full ? '?full=1' : ''}`;
228
- const resp = await fetch(url, {method: 'POST'});
223
+ if (!confirm('Load this playlist into the live CyTube queue now?')) return;
224
+ const resp = await fetch(`/admin/playlists/${editorId}/import`, {method: 'POST'});
229
225
  const data = await resp.json().catch(() => ({}));
230
226
  if (resp.ok && data.success) {
231
- const skipped = data.skipped ? `, skipped ${data.skipped} already-played` : '';
232
- showToast(`Imported ${data.added} item(s)${data.errors ? `, ${data.errors} errors` : ''}${skipped}`);
227
+ showToast(`Imported ${data.added} item(s)${data.errors ? `, ${data.errors} errors` : ''}`);
233
228
  } else {
234
229
  showToast(data.error || 'Import failed', 'error');
235
230
  }
@@ -27,8 +27,7 @@
27
27
  <button class="btn btn-secondary" onclick="closeEditor()">&larr; Back</button>
28
28
  <button class="btn" onclick="editMeta()">Rename</button>
29
29
  <button class="btn btn-primary" onclick="saveItems()">Save Items</button>
30
- <button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue, skipping episodes already played this pass">Import to Live</button>
31
- <button class="btn" onclick="importToLive(true)" title="Load every item in this playlist into the live queue, ignoring what's already been played this pass">Load Entire List</button>
30
+ <button class="btn" onclick="importToLive()" title="Load this playlist into the live CyTube queue">Import to Live</button>
32
31
  </div>
33
32
  </div>
34
33
  <p id="editor-meta" class="muted"></p>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.34.7"
3
+ version = "0.34.10"
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"