kryten-webqueue 0.32.0__tar.gz → 0.32.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/CHANGELOG.md +18 -0
  2. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_catalog.py +88 -0
  4. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_connection.py +18 -0
  5. kryten_webqueue-0.32.2/kryten_webqueue/routes/admin_catalog.py +92 -0
  6. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/catalog/browse.html +36 -0
  7. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_recently_played_hide.py +89 -0
  9. kryten_webqueue-0.32.0/kryten_webqueue/routes/admin_catalog.py +0 -45
  10. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/.gitignore +0 -0
  13. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/README.md +0 -0
  14. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/config.example.json +0 -0
  15. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/deploy/kryten-webqueue.service +0 -0
  16. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/deploy/nginx-queue.conf +0 -0
  17. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  18. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/IMPL_API_GATE.md +0 -0
  19. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/IMPL_ECONOMY.md +0 -0
  20. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  21. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/IMPL_ROBOT.md +0 -0
  22. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  23. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  24. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/Log for incorrect promo insertion problem.txt +0 -0
  25. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  26. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/PRE_PLAN_GAPS.md +0 -0
  27. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/PRODUCT_PLAN.md +0 -0
  28. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  29. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/docs/UX_POLISH_PLAN.md +0 -0
  30. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/__init__.py +0 -0
  31. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/__main__.py +0 -0
  32. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  33. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/api_gate/client.py +0 -0
  34. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/app.py +0 -0
  35. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/__init__.py +0 -0
  41. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  42. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  43. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_queue.py +0 -0
  44. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/images.py +0 -0
  45. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/mediacms.py +0 -0
  46. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/sync.py +0 -0
  47. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/config.py +0 -0
  48. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/__init__.py +0 -0
  49. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  50. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  51. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  52. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  53. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  54. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  55. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  56. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  57. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/__init__.py +0 -0
  58. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  59. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/manager.py +0 -0
  60. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/tasks.py +0 -0
  61. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/logging_config.py +0 -0
  62. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/__init__.py +0 -0
  63. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
  64. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/fire.py +0 -0
  65. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/importer.py +0 -0
  66. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/ordering.py +0 -0
  67. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/scheduler.py +0 -0
  68. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/promos/__init__.py +0 -0
  69. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/promos/director.py +0 -0
  70. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/__init__.py +0 -0
  71. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/completion.py +0 -0
  72. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/ordering.py +0 -0
  73. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/poller.py +0 -0
  74. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/presence.py +0 -0
  75. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/race_poller.py +0 -0
  76. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/shadow.py +0 -0
  77. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/__init__.py +0 -0
  78. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_feedback.py +0 -0
  79. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
  80. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_moderation.py +0 -0
  81. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
  82. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_promos.py +0 -0
  83. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  84. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
  85. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/auth.py +0 -0
  86. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/catalog.py +0 -0
  87. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/feedback.py +0 -0
  88. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/pages.py +0 -0
  89. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/queue.py +0 -0
  90. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/user.py +0 -0
  91. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/css/main.css +0 -0
  92. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  93. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-promos.js +0 -0
  94. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  95. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  96. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin.js +0 -0
  97. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/main.js +0 -0
  98. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/index.html +0 -0
  99. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  100. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/promos.html +0 -0
  101. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  102. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  103. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/auth/login.html +0 -0
  104. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/base.html +0 -0
  105. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  106. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  107. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/feedback/index.html +0 -0
  108. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/queue/index.html +0 -0
  109. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/race.html +0 -0
  110. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  111. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/ws/__init__.py +0 -0
  112. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/ws/handler.py +0 -0
  113. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/kryten_webqueue/ws/manager.py +0 -0
  114. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/__init__.py +0 -0
  115. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_config_persistence.py +0 -0
  116. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_feedback.py +0 -0
  117. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_fetchurls_sharepoint.py +0 -0
  118. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_phase1.py +0 -0
  119. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_phase2_jobs.py +0 -0
  120. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_phase3_jobs.py +0 -0
  121. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_phase4_live_fixes.py +0 -0
  122. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_playlist_import.py +0 -0
  123. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_presence_refund.py +0 -0
  124. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_promo_director.py +0 -0
  125. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_promo_pool_exclusion.py +0 -0
  126. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_queue_announce.py +0 -0
  127. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_race_poller.py +0 -0
  128. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_save_results_to_playlist.py +0 -0
  129. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_schedule_lock.py +0 -0
  130. {kryten_webqueue-0.32.0 → kryten_webqueue-0.32.2}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.32.2] — 2026-07-09
9
+
10
+ ### Fixed
11
+
12
+ - **Promo clips are exempt from recently-played hiding.** Playing a promo-pool clip no longer records any hide state — promos are already excluded from the public catalog and must not be treated like normal mutable/immutable playlist items. `record_play_completion` now skips promo-pool members entirely. A one-time migration (v14) purges any promo hide state recorded before this fix; the same cleanup is also available on-demand via `purge_promo_hide_state()`.
13
+
14
+ ## [0.32.1] — 2026-07-09
15
+
16
+ ### Added
17
+
18
+ - **Admin controls to test recently-played hiding in situ** without waiting for real playback:
19
+ - `POST /admin/catalog/{token}/mark-played` — simulates a genuine completion via the same `record_play_completion` the poll loop uses, so it also exercises the mutable-playlist pass logic (marking episodes, resetting on the last item).
20
+ - `POST /admin/catalog/{token}/clear-played` — removes an item's completion and playlist-pass rows so it reappears for regular users immediately.
21
+ - `GET /admin/catalog/recently-played/debug` — read-only snapshot of exactly what regular users are not seeing and why (time-window vs mutable-playlist pass).
22
+ - The admin catalog browser gains **Mark played** and **Clear played** buttons per item.
23
+
8
24
  ## [0.32.0] — 2026-07-09
9
25
 
10
26
  ### Added
@@ -17,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
33
  - Promos are unaffected — promo/immutable-playlist items are already excluded from the public catalog.
18
34
 
19
35
  [0.32.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.0
36
+ [0.32.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.1
37
+ [0.32.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.2
20
38
 
21
39
  ## [0.31.0] — 2026-07-04
22
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.32.0
3
+ Version: 0.32.2
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -360,7 +360,19 @@ class _CatalogMixin:
360
360
  collection releases together. Everything else (movies, standalone clips,
361
361
  long items even inside a mutable playlist) gets a time-boxed
362
362
  ``play_completions`` row instead.
363
+
364
+ Promo-pool clips are exempt entirely: they are already excluded from the
365
+ public catalog and must never be treated like normal (mutable/immutable)
366
+ playlist items, so playing one records nothing.
363
367
  """
368
+ promo = await self._fetch_one(
369
+ "SELECT 1 FROM saved_playlist_items spi "
370
+ "JOIN saved_playlists sp ON sp.id = spi.playlist_id "
371
+ "WHERE spi.media_id = ? AND spi.media_type = ? AND sp.promo_type IS NOT NULL LIMIT 1",
372
+ [friendly_token, media_type],
373
+ )
374
+ if promo:
375
+ return
364
376
  rows = await self._fetch_all(
365
377
  """
366
378
  SELECT spi.playlist_id AS playlist_id, spi.position AS position,
@@ -409,6 +421,82 @@ class _CatalogMixin:
409
421
  )
410
422
  return {r["media_id"] for r in rows}
411
423
 
424
+ async def clear_play_state(self, friendly_token: str, *, media_type: str = "cm") -> dict:
425
+ """Remove all recently-played hide state for an item (admin test helper).
426
+
427
+ Deletes both time-boxed ``play_completions`` rows and any
428
+ ``playlist_item_played`` (mutable-playlist pass) rows for the item, so it
429
+ immediately reappears in the public catalog. Returns the row counts
430
+ removed.
431
+ """
432
+ pc = await self._fetch_one(
433
+ "SELECT COUNT(*) AS c FROM play_completions WHERE media_id = ? AND media_type = ?",
434
+ [friendly_token, media_type],
435
+ )
436
+ pip = await self._fetch_one(
437
+ "SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
438
+ [friendly_token, media_type],
439
+ )
440
+ await self._execute(
441
+ "DELETE FROM play_completions WHERE media_id = ? AND media_type = ?",
442
+ [friendly_token, media_type],
443
+ )
444
+ await self._execute(
445
+ "DELETE FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
446
+ [friendly_token, media_type],
447
+ )
448
+ return {"completions": pc["c"] if pc else 0, "playlist_pass": pip["c"] if pip else 0}
449
+
450
+ async def purge_promo_hide_state(self) -> dict:
451
+ """Remove any recently-played hide state recorded for promo-pool clips.
452
+
453
+ Promos must never be subject to recently-played hiding. New completions
454
+ are already skipped in ``record_play_completion``; this purges rows
455
+ written before that exemption existed (also run once by migration v14).
456
+ Returns the row counts removed.
457
+ """
458
+ promo_subquery = (
459
+ "SELECT spi.media_id FROM saved_playlist_items spi "
460
+ "JOIN saved_playlists sp ON sp.id = spi.playlist_id "
461
+ "WHERE sp.promo_type IS NOT NULL AND spi.media_type = 'cm'"
462
+ )
463
+ pc = await self._fetch_one(
464
+ f"SELECT COUNT(*) AS c FROM play_completions WHERE media_id IN ({promo_subquery})"
465
+ )
466
+ pip = await self._fetch_one(
467
+ f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({promo_subquery})"
468
+ )
469
+ await self._execute(f"DELETE FROM play_completions WHERE media_id IN ({promo_subquery})")
470
+ await self._execute(f"DELETE FROM playlist_item_played WHERE media_id IN ({promo_subquery})")
471
+ return {"completions": pc["c"] if pc else 0, "playlist_pass": pip["c"] if pip else 0}
472
+
473
+ async def get_recently_played_debug(self, days: int) -> dict:
474
+ """Snapshot of what the recently-played rules currently hide (admin test).
475
+
476
+ Mirrors the two exclusion signals used by browse/search so an admin can
477
+ see exactly which titles a regular user would NOT see, and why.
478
+ """
479
+ by_completion = await self._fetch_all(
480
+ "SELECT pc.media_id, c.title, MAX(pc.completed_at) AS last_completed "
481
+ "FROM play_completions pc "
482
+ "LEFT JOIN catalog c ON c.friendly_token = pc.media_id "
483
+ "WHERE pc.media_type = 'cm' AND pc.completed_at >= datetime('now', ?) "
484
+ "GROUP BY pc.media_id ORDER BY last_completed DESC",
485
+ [f"-{int(days)} days"],
486
+ )
487
+ by_playlist_pass = await self._fetch_all(
488
+ "SELECT pip.media_id, c.title, pip.playlist_id, sp.name AS playlist_name, pip.position "
489
+ "FROM playlist_item_played pip "
490
+ "JOIN saved_playlists sp ON sp.id = pip.playlist_id "
491
+ "LEFT JOIN catalog c ON c.friendly_token = pip.media_id "
492
+ "WHERE pip.media_type = 'cm' ORDER BY sp.name, pip.position",
493
+ )
494
+ return {
495
+ "window_days": days,
496
+ "by_completion": by_completion,
497
+ "by_playlist_pass": by_playlist_pass,
498
+ }
499
+
412
500
  async def get_catalog_brief(self, tokens: list[str], manifest_urls: list[str]) -> dict[str, dict]:
413
501
  """Return a lookup of catalog metadata keyed by BOTH friendly_token and
414
502
  manifest_url, for enriching queue-shadow items that may only carry one.
@@ -291,6 +291,24 @@ MIGRATIONS = [
291
291
  );
292
292
  CREATE INDEX IF NOT EXISTS idx_playlist_item_played_media ON playlist_item_played(media_id);
293
293
  """,
294
+ # v14: One-time cleanup of recently-played hide state wrongly recorded for
295
+ # promo-pool clips. Promos are excluded from the public catalog and must
296
+ # never be treated like normal playlist items, so any hide state recorded for
297
+ # a promo clip (before the exemption in record_play_completion) is purged.
298
+ """
299
+ DELETE FROM play_completions
300
+ WHERE media_id IN (
301
+ SELECT spi.media_id FROM saved_playlist_items spi
302
+ JOIN saved_playlists sp ON sp.id = spi.playlist_id
303
+ WHERE sp.promo_type IS NOT NULL AND spi.media_type = 'cm'
304
+ );
305
+ DELETE FROM playlist_item_played
306
+ WHERE media_id IN (
307
+ SELECT spi.media_id FROM saved_playlist_items spi
308
+ JOIN saved_playlists sp ON sp.id = spi.playlist_id
309
+ WHERE sp.promo_type IS NOT NULL AND spi.media_type = 'cm'
310
+ );
311
+ """,
294
312
  ]
295
313
 
296
314
 
@@ -0,0 +1,92 @@
1
+ from fastapi import APIRouter, Request, Depends, HTTPException
2
+
3
+ from ..auth.session import require_admin
4
+ from ..catalog.db import HIDDEN_ITEM_TAG
5
+ from ..catalog.mediacms import MediaCMSClient
6
+
7
+ router = APIRouter(prefix="/admin/catalog", tags=["admin"])
8
+
9
+
10
+ def _mediacms_client(request: Request) -> MediaCMSClient:
11
+ config = request.app.state.config
12
+ return MediaCMSClient(mediacms_url=config.mediacms_url, token=config.mediacms_token)
13
+
14
+
15
+ @router.post("/{friendly_token}/hide")
16
+ async def hide_item(request: Request, friendly_token: str, user: dict = Depends(require_admin)):
17
+ """Tag an item ``kryten-hidden`` in MediaCMS and hide it locally (B6).
18
+
19
+ The local catalog is updated immediately so the admin sees the item
20
+ disappear; the next sync re-reads the tag from MediaCMS (the source of
21
+ truth) and the hide persists.
22
+ """
23
+ db = request.app.state.db
24
+ item = await db.get_item_admin(friendly_token)
25
+ if not item:
26
+ raise HTTPException(404, "Catalog item not found")
27
+
28
+ # Local mirror first so the UI hides immediately regardless of the remote
29
+ # write outcome (which can be retried/confirmed by sync).
30
+ await db.add_catalog_tag(friendly_token, HIDDEN_ITEM_TAG)
31
+ remote_ok = await _mediacms_client(request).set_tag(friendly_token, HIDDEN_ITEM_TAG, present=True)
32
+ return {"success": True, "remote_ok": remote_ok}
33
+
34
+
35
+ @router.post("/{friendly_token}/unhide")
36
+ async def unhide_item(request: Request, friendly_token: str, user: dict = Depends(require_admin)):
37
+ """Remove the ``kryten-hidden`` tag in MediaCMS and locally (B6)."""
38
+ db = request.app.state.db
39
+ item = await db.get_item_admin(friendly_token)
40
+ if not item:
41
+ raise HTTPException(404, "Catalog item not found")
42
+
43
+ await db.remove_catalog_tag(friendly_token, HIDDEN_ITEM_TAG)
44
+ remote_ok = await _mediacms_client(request).set_tag(friendly_token, HIDDEN_ITEM_TAG, present=False)
45
+ return {"success": True, "remote_ok": remote_ok}
46
+
47
+
48
+ # --- Recently-played test helpers (admin-only) -----------------------------
49
+ #
50
+ # These let an admin exercise the v0.32 "hide recently-played" rules in situ
51
+ # without waiting for real playback: simulate a completion, clear an item's
52
+ # hide state, or inspect exactly what a regular user would not see.
53
+
54
+ @router.post("/{friendly_token}/mark-played")
55
+ async def mark_played(request: Request, friendly_token: str, user: dict = Depends(require_admin)):
56
+ """Simulate a genuine play-completion for an item (testing aid).
57
+
58
+ Routes through the same ``record_play_completion`` the poll loop uses, so it
59
+ exercises the real rules: short (<1h) episodes of a mutable playlist mark the
60
+ current pass (and reset it when the last item is marked), everything else
61
+ gets a time-boxed completion. The item then hides from regular users exactly
62
+ as it would after real playback.
63
+ """
64
+ db = request.app.state.db
65
+ item = await db.get_item_admin(friendly_token)
66
+ if not item:
67
+ raise HTTPException(404, "Catalog item not found")
68
+ await db.record_play_completion(
69
+ friendly_token=friendly_token,
70
+ duration_sec=item.get("duration_sec"),
71
+ )
72
+ return {"success": True}
73
+
74
+
75
+ @router.post("/{friendly_token}/clear-played")
76
+ async def clear_played(request: Request, friendly_token: str, user: dict = Depends(require_admin)):
77
+ """Clear an item's recently-played hide state so it reappears immediately."""
78
+ db = request.app.state.db
79
+ item = await db.get_item_admin(friendly_token)
80
+ if not item:
81
+ raise HTTPException(404, "Catalog item not found")
82
+ removed = await db.clear_play_state(friendly_token)
83
+ return {"success": True, "removed": removed}
84
+
85
+
86
+ @router.get("/recently-played/debug")
87
+ async def recently_played_debug(request: Request, user: dict = Depends(require_admin)):
88
+ """List what the recently-played rules currently hide from regular users."""
89
+ db = request.app.state.db
90
+ days = request.app.state.config.catalog_recently_played_hide_days
91
+ return await db.get_recently_played_debug(days)
92
+
@@ -94,6 +94,8 @@
94
94
  <button class="btn btn-sm btn-admin" onclick="queueAsAdmin('{{ item.friendly_token }}')">Queue as Admin</button>
95
95
  <button class="btn btn-sm" onclick="addToPlaylist('{{ item.friendly_token }}')">Add to playlist</button>
96
96
  <button class="btn btn-sm" onclick="addToRecentPlaylist('{{ item.friendly_token }}')">+ Recent</button>
97
+ <button class="btn btn-sm" onclick="markPlayed('{{ item.friendly_token }}')" title="Simulate a play-completion so this item hides from regular users (testing)">Mark played</button>
98
+ <button class="btn btn-sm" onclick="clearPlayed('{{ item.friendly_token }}')" title="Clear recently-played hide state so this item reappears for regular users">Clear played</button>
97
99
  <button class="btn btn-sm btn-danger" onclick="hideItem('{{ item.friendly_token }}')">Hide</button>
98
100
  {% endif %}
99
101
  </div>
@@ -378,6 +380,40 @@ async function hideItem(token) {
378
380
  showToast(`Network error: ${e.message}`, 'error');
379
381
  }
380
382
  }
383
+
384
+ async function markPlayed(token) {
385
+ try {
386
+ const resp = await fetch(`/admin/catalog/${encodeURIComponent(token)}/mark-played`, {
387
+ method: 'POST', credentials: 'same-origin'
388
+ });
389
+ if (resp.ok) {
390
+ showToast('Marked played — now hidden from regular users');
391
+ } else {
392
+ const data = await resp.json().catch(() => ({}));
393
+ showToast(data.detail || `Failed (${resp.status})`, 'error');
394
+ }
395
+ } catch (e) {
396
+ showToast(`Network error: ${e.message}`, 'error');
397
+ }
398
+ }
399
+
400
+ async function clearPlayed(token) {
401
+ try {
402
+ const resp = await fetch(`/admin/catalog/${encodeURIComponent(token)}/clear-played`, {
403
+ method: 'POST', credentials: 'same-origin'
404
+ });
405
+ if (resp.ok) {
406
+ const data = await resp.json().catch(() => ({}));
407
+ const r = data.removed || {};
408
+ showToast(`Cleared play state (${r.completions || 0} completion, ${r.playlist_pass || 0} pass)`);
409
+ } else {
410
+ const data = await resp.json().catch(() => ({}));
411
+ showToast(data.detail || `Failed (${resp.status})`, 'error');
412
+ }
413
+ } catch (e) {
414
+ showToast(`Network error: ${e.message}`, 'error');
415
+ }
416
+ }
381
417
  // queueItem / playNext / queueAsAdmin / showToast / escapeHtml are provided by /static/js/main.js
382
418
  </script>
383
419
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.32.0"
3
+ version = "0.32.2"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -309,3 +309,92 @@ async def test_import_full_loads_entire_list(db):
309
309
  assert result["skipped"] == 0
310
310
 
311
311
 
312
+ # --- Admin test helpers (mark-played / clear / debug) ---------------------
313
+
314
+ async def test_clear_play_state_unhides_time_window_item(db):
315
+ await _add_catalog(db, "movie", "Movie", duration_sec=7200)
316
+ await db.record_play_completion(friendly_token="movie", duration_sec=7200)
317
+ assert await _browse_tokens(db, recently_played_days=21) == set()
318
+
319
+ removed = await db.clear_play_state("movie")
320
+ assert removed == {"completions": 1, "playlist_pass": 0}
321
+ assert await _browse_tokens(db, recently_played_days=21) == {"movie"}
322
+
323
+
324
+ async def test_clear_play_state_unhides_playlist_episode(db):
325
+ tokens = ["ep1", "ep2", "ep3"]
326
+ for t in tokens:
327
+ await _add_catalog(db, t, f"Episode {t}", duration_sec=1500)
328
+ await _make_mutable_playlist(db, tokens, duration_sec=1500)
329
+ await db.record_play_completion(friendly_token="ep1", duration_sec=1500)
330
+ assert "ep1" not in await _browse_tokens(db, recently_played_days=21)
331
+
332
+ removed = await db.clear_play_state("ep1")
333
+ assert removed["playlist_pass"] == 1
334
+ assert "ep1" in await _browse_tokens(db, recently_played_days=21)
335
+
336
+
337
+ async def test_recently_played_debug_reports_both_signals(db):
338
+ await _add_catalog(db, "movie", "Movie", duration_sec=7200)
339
+ await _add_catalog(db, "ep1", "Episode 1", duration_sec=1500)
340
+ await _add_catalog(db, "ep2", "Episode 2", duration_sec=1500)
341
+ await _make_mutable_playlist(db, ["ep1", "ep2"], duration_sec=1500)
342
+
343
+ await db.record_play_completion(friendly_token="movie", duration_sec=7200) # time window
344
+ await db.record_play_completion(friendly_token="ep1", duration_sec=1500) # playlist pass
345
+
346
+ debug = await db.get_recently_played_debug(21)
347
+ assert debug["window_days"] == 21
348
+ assert {r["media_id"] for r in debug["by_completion"]} == {"movie"}
349
+ assert {r["media_id"] for r in debug["by_playlist_pass"]} == {"ep1"}
350
+
351
+
352
+ async def test_promo_clips_are_exempt_from_hiding(db):
353
+ # A clip in a promo pool (promo_type set) must never be recorded as played
354
+ # nor appear in the recently-played debug list.
355
+ await _add_catalog(db, "promo1", "Bumper", duration_sec=30)
356
+ pid = await db.create_saved_playlist(
357
+ name="Bumpers", description=None, is_immutable=False,
358
+ created_by="admin", promo_type="general",
359
+ )
360
+ await db.replace_playlist_items(pid, [
361
+ {"media_type": "cm", "media_id": "promo1", "title": "Bumper", "duration_sec": 30},
362
+ ])
363
+
364
+ await db.record_play_completion(friendly_token="promo1", duration_sec=30)
365
+
366
+ rows = await db._fetch_all("SELECT media_id FROM play_completions")
367
+ assert rows == []
368
+ pip = await db._fetch_all("SELECT media_id FROM playlist_item_played")
369
+ assert pip == []
370
+ debug = await db.get_recently_played_debug(21)
371
+ assert debug["by_completion"] == []
372
+ assert debug["by_playlist_pass"] == []
373
+
374
+
375
+ async def test_purge_promo_hide_state_removes_stale_rows(db):
376
+ # Rows recorded before the promo exemption (e.g. in production) are cleaned up
377
+ # at the data layer by purge_promo_hide_state (also run by migration v14).
378
+ await _add_catalog(db, "promo1", "Bumper", duration_sec=30)
379
+ pid = await db.create_saved_playlist(
380
+ name="Bumpers", description=None, is_immutable=False,
381
+ created_by="admin", promo_type="general",
382
+ )
383
+ await db.replace_playlist_items(pid, [
384
+ {"media_type": "cm", "media_id": "promo1", "title": "Bumper", "duration_sec": 30},
385
+ ])
386
+ # Simulate a stale row written before the exemption existed.
387
+ await db._execute("INSERT INTO play_completions (media_type, media_id) VALUES ('cm', 'promo1')")
388
+ # Before the purge it shows in the (unfiltered) debug view.
389
+ assert {r["media_id"] for r in (await db.get_recently_played_debug(21))["by_completion"]} == {"promo1"}
390
+
391
+ removed = await db.purge_promo_hide_state()
392
+ assert removed["completions"] == 1
393
+
394
+ assert await db._fetch_all("SELECT media_id FROM play_completions") == []
395
+ assert (await db.get_recently_played_debug(21))["by_completion"] == []
396
+
397
+
398
+
399
+
400
+
@@ -1,45 +0,0 @@
1
- from fastapi import APIRouter, Request, Depends, HTTPException
2
-
3
- from ..auth.session import require_admin
4
- from ..catalog.db import HIDDEN_ITEM_TAG
5
- from ..catalog.mediacms import MediaCMSClient
6
-
7
- router = APIRouter(prefix="/admin/catalog", tags=["admin"])
8
-
9
-
10
- def _mediacms_client(request: Request) -> MediaCMSClient:
11
- config = request.app.state.config
12
- return MediaCMSClient(mediacms_url=config.mediacms_url, token=config.mediacms_token)
13
-
14
-
15
- @router.post("/{friendly_token}/hide")
16
- async def hide_item(request: Request, friendly_token: str, user: dict = Depends(require_admin)):
17
- """Tag an item ``kryten-hidden`` in MediaCMS and hide it locally (B6).
18
-
19
- The local catalog is updated immediately so the admin sees the item
20
- disappear; the next sync re-reads the tag from MediaCMS (the source of
21
- truth) and the hide persists.
22
- """
23
- db = request.app.state.db
24
- item = await db.get_item_admin(friendly_token)
25
- if not item:
26
- raise HTTPException(404, "Catalog item not found")
27
-
28
- # Local mirror first so the UI hides immediately regardless of the remote
29
- # write outcome (which can be retried/confirmed by sync).
30
- await db.add_catalog_tag(friendly_token, HIDDEN_ITEM_TAG)
31
- remote_ok = await _mediacms_client(request).set_tag(friendly_token, HIDDEN_ITEM_TAG, present=True)
32
- return {"success": True, "remote_ok": remote_ok}
33
-
34
-
35
- @router.post("/{friendly_token}/unhide")
36
- async def unhide_item(request: Request, friendly_token: str, user: dict = Depends(require_admin)):
37
- """Remove the ``kryten-hidden`` tag in MediaCMS and locally (B6)."""
38
- db = request.app.state.db
39
- item = await db.get_item_admin(friendly_token)
40
- if not item:
41
- raise HTTPException(404, "Catalog item not found")
42
-
43
- await db.remove_catalog_tag(friendly_token, HIDDEN_ITEM_TAG)
44
- remote_ok = await _mediacms_client(request).set_tag(friendly_token, HIDDEN_ITEM_TAG, present=False)
45
- return {"success": True, "remote_ok": remote_ok}