kryten-webqueue 0.32.1__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 (129) hide show
  1. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/CHANGELOG.md +7 -0
  2. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_catalog.py +35 -0
  4. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_connection.py +18 -0
  5. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/pyproject.toml +1 -1
  6. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_recently_played_hide.py +48 -0
  7. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/.github/workflows/python-publish.yml +0 -0
  8. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/.github/workflows/release.yml +0 -0
  9. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/.gitignore +0 -0
  10. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/README.md +0 -0
  11. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/config.example.json +0 -0
  12. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/deploy/kryten-webqueue.service +0 -0
  13. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/deploy/nginx-queue.conf +0 -0
  14. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
  15. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/IMPL_API_GATE.md +0 -0
  16. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/IMPL_ECONOMY.md +0 -0
  17. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/IMPL_KRYTEN_PY.md +0 -0
  18. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/IMPL_ROBOT.md +0 -0
  19. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  20. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  21. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/Log for incorrect promo insertion problem.txt +0 -0
  22. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  23. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/docs/UX_POLISH_PLAN.md +0 -0
  27. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/__init__.py +0 -0
  38. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  39. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  40. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/db/_queue.py +0 -0
  41. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/images.py +0 -0
  42. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/mediacms.py +0 -0
  43. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/catalog/sync.py +0 -0
  44. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/config.py +0 -0
  45. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/__init__.py +0 -0
  46. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  47. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  48. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  49. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  50. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  51. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  52. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  53. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  54. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/__init__.py +0 -0
  55. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  56. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/manager.py +0 -0
  57. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/jobs/tasks.py +0 -0
  58. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/logging_config.py +0 -0
  59. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/__init__.py +0 -0
  60. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/bulk_add.py +0 -0
  61. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/fire.py +0 -0
  62. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/importer.py +0 -0
  63. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/ordering.py +0 -0
  64. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/playlists/scheduler.py +0 -0
  65. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/promos/__init__.py +0 -0
  66. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/promos/director.py +0 -0
  67. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/__init__.py +0 -0
  68. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/completion.py +0 -0
  69. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/ordering.py +0 -0
  70. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/poller.py +0 -0
  71. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/presence.py +0 -0
  72. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/race_poller.py +0 -0
  73. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/queue/shadow.py +0 -0
  74. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/__init__.py +0 -0
  75. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_catalog.py +0 -0
  76. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_feedback.py +0 -0
  77. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
  78. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_moderation.py +0 -0
  79. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
  80. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_promos.py +0 -0
  81. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_queue.py +0 -0
  82. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
  83. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/auth.py +0 -0
  84. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/catalog.py +0 -0
  85. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/feedback.py +0 -0
  86. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/pages.py +0 -0
  87. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/queue.py +0 -0
  88. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/routes/user.py +0 -0
  89. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/css/main.css +0 -0
  90. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  91. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-promos.js +0 -0
  92. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  93. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  94. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/admin.js +0 -0
  95. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/static/js/main.js +0 -0
  96. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/index.html +0 -0
  97. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
  98. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/promos.html +0 -0
  99. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  100. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
  101. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/auth/login.html +0 -0
  102. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/base.html +0 -0
  103. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
  104. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  105. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  106. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/feedback/index.html +0 -0
  107. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/queue/index.html +0 -0
  108. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/race.html +0 -0
  109. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
  110. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/ws/__init__.py +0 -0
  111. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/ws/handler.py +0 -0
  112. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/kryten_webqueue/ws/manager.py +0 -0
  113. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/__init__.py +0 -0
  114. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_config_persistence.py +0 -0
  115. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_feedback.py +0 -0
  116. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_fetchurls_sharepoint.py +0 -0
  117. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_phase1.py +0 -0
  118. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_phase2_jobs.py +0 -0
  119. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_phase3_jobs.py +0 -0
  120. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_phase4_live_fixes.py +0 -0
  121. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_playlist_import.py +0 -0
  122. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_presence_refund.py +0 -0
  123. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_promo_director.py +0 -0
  124. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_promo_pool_exclusion.py +0 -0
  125. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_queue_announce.py +0 -0
  126. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_race_poller.py +0 -0
  127. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_save_results_to_playlist.py +0 -0
  128. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_schedule_lock.py +0 -0
  129. {kryten_webqueue-0.32.1 → kryten_webqueue-0.32.2}/tests/test_search_facets.py +0 -0
@@ -5,6 +5,12 @@ 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
+
8
14
  ## [0.32.1] — 2026-07-09
9
15
 
10
16
  ### Added
@@ -28,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
28
34
 
29
35
  [0.32.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.0
30
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
31
38
 
32
39
  ## [0.31.0] — 2026-07-04
33
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.32.1
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,
@@ -435,6 +447,29 @@ class _CatalogMixin:
435
447
  )
436
448
  return {"completions": pc["c"] if pc else 0, "playlist_pass": pip["c"] if pip else 0}
437
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
+
438
473
  async def get_recently_played_debug(self, days: int) -> dict:
439
474
  """Snapshot of what the recently-played rules currently hide (admin test).
440
475
 
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.32.1"
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"
@@ -349,4 +349,52 @@ async def test_recently_played_debug_reports_both_signals(db):
349
349
  assert {r["media_id"] for r in debug["by_playlist_pass"]} == {"ep1"}
350
350
 
351
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
+
352
400