kryten-webqueue 0.32.2__tar.gz → 0.32.3__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.2 → kryten_webqueue-0.32.3}/CHANGELOG.md +7 -0
  2. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/app.py +10 -0
  4. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/db/_catalog.py +43 -16
  5. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/pyproject.toml +1 -1
  6. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_recently_played_hide.py +33 -0
  7. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/.github/workflows/python-publish.yml +0 -0
  8. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/.github/workflows/release.yml +0 -0
  9. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/.gitignore +0 -0
  10. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/README.md +0 -0
  11. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/config.example.json +0 -0
  12. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/deploy/kryten-webqueue.service +0 -0
  13. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/deploy/nginx-queue.conf +0 -0
  14. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/IMPLEMENTATION_SPEC.md +0 -0
  15. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/IMPL_API_GATE.md +0 -0
  16. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/IMPL_ECONOMY.md +0 -0
  17. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/IMPL_KRYTEN_PY.md +0 -0
  18. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/IMPL_ROBOT.md +0 -0
  19. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  20. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  21. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/Log for incorrect promo insertion problem.txt +0 -0
  22. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  23. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/docs/UX_POLISH_PLAN.md +0 -0
  27. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/auth/__init__.py +0 -0
  32. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/auth/otp.py +0 -0
  33. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/auth/rate_limit.py +0 -0
  34. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/auth/session.py +0 -0
  35. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/__init__.py +0 -0
  36. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/db/__init__.py +0 -0
  37. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/db/_connection.py +0 -0
  38. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  39. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  40. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/db/_queue.py +0 -0
  41. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/images.py +0 -0
  42. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/mediacms.py +0 -0
  43. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/catalog/sync.py +0 -0
  44. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/config.py +0 -0
  45. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/__init__.py +0 -0
  46. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  47. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  48. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  49. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  50. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  51. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  52. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  53. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  54. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/jobs/__init__.py +0 -0
  55. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  56. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/jobs/manager.py +0 -0
  57. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/jobs/tasks.py +0 -0
  58. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/logging_config.py +0 -0
  59. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/playlists/__init__.py +0 -0
  60. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/playlists/bulk_add.py +0 -0
  61. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/playlists/fire.py +0 -0
  62. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/playlists/importer.py +0 -0
  63. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/playlists/ordering.py +0 -0
  64. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/playlists/scheduler.py +0 -0
  65. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/promos/__init__.py +0 -0
  66. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/promos/director.py +0 -0
  67. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/__init__.py +0 -0
  68. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/completion.py +0 -0
  69. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/ordering.py +0 -0
  70. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/poller.py +0 -0
  71. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/presence.py +0 -0
  72. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/race_poller.py +0 -0
  73. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/queue/shadow.py +0 -0
  74. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/__init__.py +0 -0
  75. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_catalog.py +0 -0
  76. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_feedback.py +0 -0
  77. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_jobs.py +0 -0
  78. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_moderation.py +0 -0
  79. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_playlists.py +0 -0
  80. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_promos.py +0 -0
  81. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_queue.py +0 -0
  82. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/admin_schedules.py +0 -0
  83. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/auth.py +0 -0
  84. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/catalog.py +0 -0
  85. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/feedback.py +0 -0
  86. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/pages.py +0 -0
  87. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/queue.py +0 -0
  88. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/routes/user.py +0 -0
  89. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/css/main.css +0 -0
  90. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  91. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/js/admin-promos.js +0 -0
  92. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  93. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  94. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/js/admin.js +0 -0
  95. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/static/js/main.js +0 -0
  96. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/admin/index.html +0 -0
  97. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/admin/playlists.html +0 -0
  98. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/admin/promos.html +0 -0
  99. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  100. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/admin/schedules.html +0 -0
  101. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/auth/login.html +0 -0
  102. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/base.html +0 -0
  103. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/catalog/browse.html +0 -0
  104. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  105. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  106. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/feedback/index.html +0 -0
  107. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/queue/index.html +0 -0
  108. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/race.html +0 -0
  109. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/templates/user/dashboard.html +0 -0
  110. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/ws/__init__.py +0 -0
  111. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/ws/handler.py +0 -0
  112. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/kryten_webqueue/ws/manager.py +0 -0
  113. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/__init__.py +0 -0
  114. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_config_persistence.py +0 -0
  115. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_feedback.py +0 -0
  116. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_fetchurls_sharepoint.py +0 -0
  117. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_phase1.py +0 -0
  118. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_phase2_jobs.py +0 -0
  119. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_phase3_jobs.py +0 -0
  120. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_phase4_live_fixes.py +0 -0
  121. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_playlist_import.py +0 -0
  122. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_presence_refund.py +0 -0
  123. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_promo_director.py +0 -0
  124. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_promo_pool_exclusion.py +0 -0
  125. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_queue_announce.py +0 -0
  126. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_race_poller.py +0 -0
  127. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_save_results_to_playlist.py +0 -0
  128. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/tests/test_schedule_lock.py +0 -0
  129. {kryten_webqueue-0.32.2 → kryten_webqueue-0.32.3}/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.3] — 2026-07-09
9
+
10
+ ### Fixed
11
+
12
+ - **Tag/category-classified promos are now also exempt from recently-played hiding.** v0.32.2 only exempted promo-*pool* clips, but most station promos/bumpers (e.g. “CHANNEL Z …”) are classified by hidden category (`Z Channel Promos`) or tag (`channelz`, `bumpers`, …) instead, so they were still being recorded and appeared in the recently-played debug list. `record_play_completion` now skips any item hidden from the public catalog by a promo pool **or** a hidden category/tag. On startup the service self-heals by purging any such rows written by earlier builds (`purge_promo_hide_state`, now covering the tag/category case).
13
+
8
14
  ## [0.32.2] — 2026-07-09
9
15
 
10
16
  ### Fixed
@@ -35,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
35
41
  [0.32.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.0
36
42
  [0.32.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.1
37
43
  [0.32.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.2
44
+ [0.32.3]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.3
38
45
 
39
46
  ## [0.31.0] — 2026-07-04
40
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.32.2
3
+ Version: 0.32.3
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
@@ -51,6 +51,16 @@ async def lifespan(app: FastAPI):
51
51
  db = Database(config.db_path)
52
52
  await db.connect()
53
53
  await db.run_migrations()
54
+ # Self-heal: purge any recently-played hide state recorded for promos/bumpers
55
+ # (they are excluded from the public catalog and must never be hidden by the
56
+ # recently-played rules). Cheap and idempotent; also cleans rows written by
57
+ # older builds that classified promos only by pool membership.
58
+ purged = await db.purge_promo_hide_state()
59
+ if purged["completions"] or purged["playlist_pass"]:
60
+ logger.info(
61
+ "Purged promo recently-played state: %d completion(s), %d pass row(s)",
62
+ purged["completions"], purged["playlist_pass"],
63
+ )
54
64
  # Any job run still marked 'running' is an orphan from a prior crash/restart
55
65
  # (the running flag is in-memory only). Reconcile before registering jobs.
56
66
  orphaned = await db.reconcile_orphaned_job_runs()
@@ -72,6 +72,33 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
72
72
  return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
73
73
 
74
74
 
75
+ def _promo_hidden_media_subquery() -> tuple[str, list]:
76
+ """Subquery (+ params) yielding media_ids that are promos/bumpers.
77
+
78
+ "Promo/bumper" content is anything hidden from the public catalog by a promo
79
+ pool OR a hidden category OR a hidden tag (e.g. ``Z Channel Promos`` /
80
+ ``channelz``). These items never appear to regular users, so they must never
81
+ be recorded as recently-played. Immutable *event* playlists are intentionally
82
+ excluded here — they are their own class, not promos.
83
+ """
84
+ cat_ph = ",".join("?" * len(HIDDEN_CATEGORY_NAMES))
85
+ tag_ph = ",".join("?" * len(HIDDEN_TAG_NAMES))
86
+ sql = f"""
87
+ SELECT spi.media_id FROM saved_playlist_items spi
88
+ JOIN saved_playlists sp ON sp.id = spi.playlist_id
89
+ WHERE sp.promo_type IS NOT NULL AND spi.media_type = 'cm'
90
+ UNION
91
+ SELECT cc.friendly_token FROM catalog_categories cc
92
+ JOIN categories cat ON cc.category_id = cat.id
93
+ WHERE cat.name IN ({cat_ph})
94
+ UNION
95
+ SELECT ct.friendly_token FROM catalog_tags ct
96
+ JOIN tags t ON ct.tag_id = t.id
97
+ WHERE t.name IN ({tag_ph})
98
+ """
99
+ return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
100
+
101
+
75
102
  def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
76
103
  """SQL fragment (+ params) hiding recently-played items from regular users.
77
104
 
@@ -363,13 +390,14 @@ class _CatalogMixin:
363
390
 
364
391
  Promo-pool clips are exempt entirely: they are already excluded from the
365
392
  public catalog and must never be treated like normal (mutable/immutable)
366
- playlist items, so playing one records nothing.
393
+ playlist items, so playing one records nothing. The same exemption covers
394
+ any promo/bumper hidden by category or tag (e.g. ``Z Channel Promos`` /
395
+ ``channelz``), which is how most station promos are actually classified.
367
396
  """
397
+ sub, sub_params = _promo_hidden_media_subquery()
368
398
  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],
399
+ f"SELECT 1 WHERE ? IN ({sub})",
400
+ [friendly_token, *sub_params],
373
401
  )
374
402
  if promo:
375
403
  return
@@ -448,26 +476,25 @@ class _CatalogMixin:
448
476
  return {"completions": pc["c"] if pc else 0, "playlist_pass": pip["c"] if pip else 0}
449
477
 
450
478
  async def purge_promo_hide_state(self) -> dict:
451
- """Remove any recently-played hide state recorded for promo-pool clips.
479
+ """Remove any recently-played hide state recorded for promos/bumpers.
452
480
 
453
481
  Promos must never be subject to recently-played hiding. New completions
454
482
  are already skipped in ``record_play_completion``; this purges rows
455
- written before that exemption existed (also run once by migration v14).
483
+ written before that exemption existed (run once at startup). Covers promo
484
+ pools plus hidden-category/tag promos (the usual station-promo case).
456
485
  Returns the row counts removed.
457
486
  """
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
- )
487
+ sub, sub_params = _promo_hidden_media_subquery()
463
488
  pc = await self._fetch_one(
464
- f"SELECT COUNT(*) AS c FROM play_completions WHERE media_id IN ({promo_subquery})"
489
+ f"SELECT COUNT(*) AS c FROM play_completions WHERE media_id IN ({sub})",
490
+ sub_params,
465
491
  )
466
492
  pip = await self._fetch_one(
467
- f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({promo_subquery})"
493
+ f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({sub})",
494
+ sub_params,
468
495
  )
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})")
496
+ await self._execute(f"DELETE FROM play_completions WHERE media_id IN ({sub})", sub_params)
497
+ await self._execute(f"DELETE FROM playlist_item_played WHERE media_id IN ({sub})", sub_params)
471
498
  return {"completions": pc["c"] if pc else 0, "playlist_pass": pip["c"] if pip else 0}
472
499
 
473
500
  async def get_recently_played_debug(self, days: int) -> dict:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.32.2"
3
+ version = "0.32.3"
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"
@@ -395,6 +395,39 @@ async def test_purge_promo_hide_state_removes_stale_rows(db):
395
395
  assert (await db.get_recently_played_debug(21))["by_completion"] == []
396
396
 
397
397
 
398
+ async def test_hidden_tag_promo_is_exempt_from_hiding(db):
399
+ # Station promos/bumpers are usually classified by hidden tag/category
400
+ # (e.g. `channelz`), not promo-pool membership. They must still be exempt.
401
+ await _add_catalog(db, "bumper", "CHANNEL Z - ITS 10PM", duration_sec=15)
402
+ tid = await db.upsert_tag("channelz")
403
+ await db.set_catalog_tags("bumper", [tid])
404
+
405
+ await db.record_play_completion(friendly_token="bumper", duration_sec=15)
406
+ assert await db._fetch_all("SELECT media_id FROM play_completions") == []
407
+
408
+
409
+ async def test_hidden_category_promo_is_exempt_from_hiding(db):
410
+ await _add_catalog(db, "bumper", "Z Promo", duration_sec=15)
411
+ cid = await db.upsert_category("Z Channel Promos")
412
+ await db.set_catalog_categories("bumper", [cid])
413
+
414
+ await db.record_play_completion(friendly_token="bumper", duration_sec=15)
415
+ assert await db._fetch_all("SELECT media_id FROM play_completions") == []
416
+
417
+
418
+ async def test_purge_removes_hidden_tag_promo_rows(db):
419
+ # A stale row written before the tag/category exemption is cleaned by purge.
420
+ await _add_catalog(db, "bumper", "CHANNEL Z - ITS 10PM", duration_sec=15)
421
+ tid = await db.upsert_tag("channelz")
422
+ await db.set_catalog_tags("bumper", [tid])
423
+ await db._execute("INSERT INTO play_completions (media_type, media_id) VALUES ('cm', 'bumper')")
424
+
425
+ removed = await db.purge_promo_hide_state()
426
+ assert removed["completions"] == 1
427
+ assert await db._fetch_all("SELECT media_id FROM play_completions") == []
428
+
429
+
430
+
398
431
 
399
432
 
400
433