kryten-webqueue 0.40.4__tar.gz → 0.41.0__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 (188) hide show
  1. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/CHANGELOG.md +35 -0
  2. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/app.py +8 -0
  4. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/__init__.py +2 -8
  5. kryten_webqueue-0.41.0/kryten_webqueue/catalog/db/_blackouts.py +55 -0
  6. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_catalog.py +105 -40
  7. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_connection.py +17 -0
  8. kryten_webqueue-0.41.0/kryten_webqueue/integrations/cmsutils/blackout_scan.py +181 -0
  9. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/jobs/tasks.py +70 -0
  10. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_queue.py +3 -1
  11. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/pages.py +9 -1
  12. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/css/main.css +23 -0
  13. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/catalog/browse.html +3 -1
  14. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/catalog/item_detail.html +6 -0
  15. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/pyproject.toml +1 -1
  16. kryten_webqueue-0.41.0/tests/test_catalog_blackout.py +188 -0
  17. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/.github/workflows/gitleaks.yml +0 -0
  18. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/.github/workflows/python-publish.yml +0 -0
  19. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/.github/workflows/release.yml +0 -0
  20. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/.gitignore +0 -0
  21. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/.gitleaks.toml +0 -0
  22. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/.pre-commit-config.yaml +0 -0
  23. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  24. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/AGENTS.md +0 -0
  25. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/README.md +0 -0
  26. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/config.example.json +0 -0
  27. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/deploy/kryten-webqueue.service +0 -0
  28. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/deploy/nginx-queue.conf +0 -0
  29. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  30. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/IMPL_API_GATE.md +0 -0
  31. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/IMPL_ECONOMY.md +0 -0
  32. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  33. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/IMPL_ROBOT.md +0 -0
  34. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  35. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  36. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  37. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  38. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  39. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/PRE_PLAN_GAPS.md +0 -0
  40. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/PRODUCT_PLAN.md +0 -0
  41. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  42. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  43. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  44. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  45. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  46. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  47. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/docs/UX_POLISH_PLAN.md +0 -0
  48. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/__init__.py +0 -0
  49. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/__main__.py +0 -0
  50. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  51. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/api_gate/client.py +0 -0
  52. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/auth/__init__.py +0 -0
  53. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/auth/otp.py +0 -0
  54. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  55. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/auth/session.py +0 -0
  56. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/__init__.py +0 -0
  57. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  58. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  59. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
  60. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  61. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  62. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  63. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  64. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  65. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  66. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  67. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  68. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  69. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  70. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  71. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  72. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  73. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  74. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  75. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  76. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  77. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  78. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/images.py +0 -0
  79. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  80. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/sync.py +0 -0
  81. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  82. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  83. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  84. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  85. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  86. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  87. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  88. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/config.py +0 -0
  89. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/__init__.py +0 -0
  90. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  91. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  92. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  93. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  94. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  95. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  96. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  97. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  98. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  99. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  100. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/jobs/__init__.py +0 -0
  101. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  102. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  103. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/jobs/manager.py +0 -0
  104. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  105. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/logging_config.py +0 -0
  106. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/playlists/__init__.py +0 -0
  107. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  108. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/playlists/fire.py +0 -0
  109. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/playlists/importer.py +0 -0
  110. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/playlists/ordering.py +0 -0
  111. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  112. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/promos/__init__.py +0 -0
  113. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/promos/director.py +0 -0
  114. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/__init__.py +0 -0
  115. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/completion.py +0 -0
  116. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/ordering.py +0 -0
  117. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/poller.py +0 -0
  118. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/presence.py +0 -0
  119. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/race_poller.py +0 -0
  120. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/queue/shadow.py +0 -0
  121. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/__init__.py +0 -0
  122. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  123. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  124. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  125. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  126. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  127. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  128. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  129. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  130. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/auth.py +0 -0
  131. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/catalog.py +0 -0
  132. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/feedback.py +0 -0
  133. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/queue.py +0 -0
  134. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/routes/user.py +0 -0
  135. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  136. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  137. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  138. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  139. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/admin.js +0 -0
  140. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/keybindings.js +0 -0
  141. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/static/js/main.js +0 -0
  142. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/admin/index.html +0 -0
  143. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  144. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  145. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  146. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  147. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/auth/login.html +0 -0
  148. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/base.html +0 -0
  149. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  150. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  151. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  152. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/queue/index.html +0 -0
  153. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/race.html +0 -0
  154. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  155. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  156. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/ws/__init__.py +0 -0
  157. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/ws/handler.py +0 -0
  158. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/kryten_webqueue/ws/manager.py +0 -0
  159. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/scripts/AGENTS.md +0 -0
  160. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  161. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/scripts/patch_mediacms_tags.py +0 -0
  162. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/scripts/trigger_enrich.py +0 -0
  163. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/__init__.py +0 -0
  164. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_catalog_delete.py +0 -0
  165. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_classify.py +0 -0
  166. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_config_persistence.py +0 -0
  167. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_feedback.py +0 -0
  168. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_fetchurls_sharepoint.py +0 -0
  169. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_identify_step.py +0 -0
  170. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_people_facets.py +0 -0
  171. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_phase1.py +0 -0
  172. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_phase2_jobs.py +0 -0
  173. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_phase3_jobs.py +0 -0
  174. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_phase4_live_fixes.py +0 -0
  175. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_playlist_import.py +0 -0
  176. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_presence_refund.py +0 -0
  177. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_promo_director.py +0 -0
  178. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_promo_pool_exclusion.py +0 -0
  179. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_providers.py +0 -0
  180. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_queue_announce.py +0 -0
  181. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_race_poller.py +0 -0
  182. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_recently_played_hide.py +0 -0
  183. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_save_results_to_playlist.py +0 -0
  184. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_schedule_lock.py +0 -0
  185. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_search_facets.py +0 -0
  186. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_title_normalization.py +0 -0
  187. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_tmdb_index.py +0 -0
  188. {kryten_webqueue-0.40.4 → kryten_webqueue-0.41.0}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.41.0] - 2026-08-24
6
+
7
+ ### Fixed
8
+
9
+ - **Reserved (immutable / promo-pool) items were leaking into the public
10
+ catalog.** The browse/search/`get_item`/`is_restricted`/tag filters excluded
11
+ reserved items by comparing `catalog.friendly_token` against
12
+ `saved_playlist_items.media_id` — but `media_id` stores the CyTube **manifest
13
+ URL** (e.g. `.../api/v1/media/cytube/<token>.json?format=json`), never the bare
14
+ token, so the `NOT IN` never matched and **every** item in Friday Night /
15
+ Saturday / promo pools stayed visible and pay-queueable. All six filter sites
16
+ now resolve `media_id` (manifest URL *or* legacy bare token) back to a
17
+ friendly_token via the catalog before excluding. This restores the intended
18
+ "reserved items are hidden from browse/search and rejected by pay-to-play"
19
+ behaviour.
20
+
21
+ ### Added
22
+
23
+ - **Weekend blackout window (hides upcoming-weekend items until they've aired).**
24
+ Items scheduled on an upcoming weekend sheet must not appear in the catalog —
25
+ but only the *next* weekend is ever materialised as an immutable playlist, so
26
+ films on later weekends (or added directly to column F) had nothing hiding
27
+ them. A new `catalog_blackouts` table (migration v26) hides a rehosted item
28
+ from regular users until the end of the **following** weekend, keeping replays
29
+ hidden through that week. Keyed on the bare `friendly_token`.
30
+ - New standalone **`catalog_blackout`** maintenance job scans the workbook's
31
+ current/future weekend sheets (columns **E** and **F**) for dropsugar URLs,
32
+ resolves them to friendly_tokens, and blacks each out. It downloads nothing;
33
+ it reuses the fetchurls SharePoint/local workbook source. Runnable from the
34
+ admin Jobs tab and schedulable via cron.
35
+ - Blacked-out items are hidden from browse, search, `get_item`, pay-to-play,
36
+ and the item detail page for regular users. **Admins** still see them (with
37
+ "Show hidden items" on), visually **dimmed** with a `BLACKOUT` badge, and can
38
+ queue them manually (`/admin/queue/add` bypasses the visibility filters).
39
+
5
40
  ## [0.40.4] - 2026-08-21
6
41
 
7
42
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.40.4
3
+ Version: 0.41.0
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
@@ -99,6 +99,7 @@ async def lifespan(app: FastAPI):
99
99
  # with a clear message rather than crashing startup.
100
100
  from .jobs.tasks import (
101
101
  catalog_enrich_job,
102
+ catalog_blackout_job,
102
103
  fetch_job,
103
104
  fetch_queue_add_job,
104
105
  fetch_queue_drain_job,
@@ -107,6 +108,7 @@ async def lifespan(app: FastAPI):
107
108
  tmdb_index_refresh_job,
108
109
  tmdb_coverage_report_job,
109
110
  CATALOG_ENRICH_SCHEMA,
111
+ CATALOG_BLACKOUT_SCHEMA,
110
112
  FETCH_SCHEMA,
111
113
  FETCH_QUEUE_ADD_SCHEMA,
112
114
  FETCH_QUEUE_DRAIN_SCHEMA,
@@ -143,6 +145,12 @@ async def lifespan(app: FastAPI):
143
145
  label="Fetch URLs (weekend workbook)",
144
146
  schema=FETCHURLS_SCHEMA,
145
147
  )
148
+ job_manager.register(
149
+ "catalog_blackout",
150
+ catalog_blackout_job,
151
+ label="Catalog Blackout (hide upcoming-weekend items)",
152
+ schema=CATALOG_BLACKOUT_SCHEMA,
153
+ )
146
154
  job_manager.register(
147
155
  "motd_posters",
148
156
  motd_posters_job,
@@ -12,6 +12,7 @@ from ._watchlist import _WatchlistMixin
12
12
  from ._people import _PeopleMixin
13
13
  from ._enrichment import _EnrichmentMixin
14
14
  from ._fetch_queue import _FetchQueueMixin
15
+ from ._blackouts import _BlackoutMixin
15
16
 
16
17
 
17
18
  class Database(
@@ -23,6 +24,7 @@ class Database(
23
24
  _PeopleMixin,
24
25
  _EnrichmentMixin,
25
26
  _FetchQueueMixin,
27
+ _BlackoutMixin,
26
28
  _DBBase,
27
29
  ):
28
30
  """Async SQLite database wrapper."""
@@ -34,11 +36,3 @@ __all__ = [
34
36
  "HIDDEN_CATEGORY_NAMES",
35
37
  "HIDDEN_TAG_NAMES",
36
38
  ]
37
-
38
-
39
- __all__ = [
40
- "Database",
41
- "HIDDEN_ITEM_TAG",
42
- "HIDDEN_CATEGORY_NAMES",
43
- "HIDDEN_TAG_NAMES",
44
- ]
@@ -0,0 +1,55 @@
1
+ class _BlackoutMixin:
2
+ """Weekend blackout window CRUD (see migration v26).
3
+
4
+ A blackout hides a rehosted catalog item from regular users until
5
+ ``expires_at`` (the end of the *following* weekend). Keyed on the bare
6
+ friendly_token. Populated by the standalone ``catalog_blackout`` job; the
7
+ catalog browse/search filters consult it via ``_blackout_exclusion``.
8
+ """
9
+
10
+ async def upsert_blackout(
11
+ self, friendly_token: str, *, reason: str, expires_at: str
12
+ ) -> None:
13
+ """Insert or extend a blackout. Keeps the latest (max) expiry on conflict."""
14
+ await self._db.execute(
15
+ """
16
+ INSERT INTO catalog_blackouts (friendly_token, reason, expires_at)
17
+ VALUES (?, ?, ?)
18
+ ON CONFLICT(friendly_token) DO UPDATE SET
19
+ reason = excluded.reason,
20
+ expires_at = MAX(catalog_blackouts.expires_at, excluded.expires_at)
21
+ """,
22
+ [friendly_token, reason, expires_at],
23
+ )
24
+ await self._db.commit()
25
+
26
+ async def prune_expired_blackouts(self) -> int:
27
+ """Delete blackout rows whose window has fully elapsed. Returns count removed."""
28
+ cursor = await self._db.execute(
29
+ "DELETE FROM catalog_blackouts WHERE expires_at <= datetime('now')"
30
+ )
31
+ await self._db.commit()
32
+ return cursor.rowcount or 0
33
+
34
+ async def is_blackout(self, friendly_token: str) -> bool:
35
+ """True when the token is under an active (unexpired) blackout."""
36
+ row = await self._fetch_one(
37
+ "SELECT 1 FROM catalog_blackouts "
38
+ "WHERE friendly_token = ? AND expires_at > datetime('now') LIMIT 1",
39
+ [friendly_token],
40
+ )
41
+ return row is not None
42
+
43
+ async def count_active_blackouts(self) -> int:
44
+ row = await self._fetch_one(
45
+ "SELECT COUNT(*) AS cnt FROM catalog_blackouts "
46
+ "WHERE expires_at > datetime('now')"
47
+ )
48
+ return row["cnt"] if row else 0
49
+
50
+ async def list_active_blackouts(self) -> list[dict]:
51
+ return await self._fetch_all(
52
+ "SELECT friendly_token, reason, expires_at, created_at "
53
+ "FROM catalog_blackouts WHERE expires_at > datetime('now') "
54
+ "ORDER BY expires_at"
55
+ )
@@ -115,6 +115,57 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
115
115
  return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
116
116
 
117
117
 
118
+ # Saved-playlist items usually store ``media_id`` as the CyTube *manifest URL*
119
+ # (e.g. ``.../api/v1/media/cytube/<token>.json?format=json``), though some
120
+ # legacy/admin-added rows carry the bare friendly_token. A naive
121
+ # ``friendly_token = media_id`` compare misses the URL form, so resolve either
122
+ # shape back to a friendly_token via the catalog to exclude reserved
123
+ # (immutable / promo-pool) items from public results.
124
+ def _reserved_exclusion(alias: str = "c") -> tuple[str, list]:
125
+ """SQL AND-fragment (+ params) excluding reserved (immutable/promo) items.
126
+
127
+ Prefixed with ``AND`` so it can be appended to an existing WHERE clause that
128
+ references the catalog row under ``alias``.
129
+ """
130
+ sql = f"""
131
+ AND {alias}.friendly_token NOT IN (
132
+ SELECT c2.friendly_token FROM saved_playlist_items spi
133
+ JOIN saved_playlists sp ON spi.playlist_id = sp.id
134
+ JOIN catalog c2
135
+ ON c2.manifest_url = spi.media_id
136
+ OR c2.friendly_token = spi.media_id
137
+ WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL)
138
+ AND spi.media_type = 'cm'
139
+ )
140
+ """
141
+ return sql, []
142
+
143
+
144
+ def _blackout_exclusion(alias: str = "c") -> tuple[str, list]:
145
+ """SQL AND-fragment (+ params) hiding items under an active weekend blackout.
146
+
147
+ Keyed on the bare friendly_token; ``expires_at`` is compared against
148
+ ``datetime('now')`` (UTC). Prefixed with ``AND`` for appending to a WHERE.
149
+ """
150
+ sql = f"""
151
+ AND {alias}.friendly_token NOT IN (
152
+ SELECT friendly_token FROM catalog_blackouts
153
+ WHERE expires_at > datetime('now')
154
+ )
155
+ """
156
+ return sql, []
157
+
158
+
159
+ # Computed SELECT column: 1 when the catalog row (alias ``c``) is under an active
160
+ # weekend blackout. Regular users never receive these rows; it lets admin views
161
+ # dim blacked-out items to show "a normal user would not see this".
162
+ _BLACKOUT_ACTIVE_COL = (
163
+ "EXISTS (SELECT 1 FROM catalog_blackouts b "
164
+ "WHERE b.friendly_token = c.friendly_token "
165
+ "AND b.expires_at > datetime('now')) AS blackout_active"
166
+ )
167
+
168
+
118
169
  def _promo_hidden_media_subquery() -> tuple[str, list]:
119
170
  """Subquery (+ params) yielding media_ids that are promos/bumpers.
120
171
 
@@ -262,19 +313,24 @@ class _CatalogMixin:
262
313
  ) -> list[dict]:
263
314
  query = """
264
315
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
265
- (SELECT MAX(pc.completed_at) FROM play_completions pc WHERE pc.media_id = c.friendly_token AND pc.media_type = 'cm') AS played_at
316
+ (SELECT MAX(pc.completed_at) FROM play_completions pc WHERE pc.media_id = c.friendly_token AND pc.media_type = 'cm') AS played_at,
317
+ {blackout_col}
266
318
  FROM catalog c
267
- WHERE c.friendly_token NOT IN (
268
- SELECT spi.media_id FROM saved_playlist_items spi
269
- JOIN saved_playlists sp ON spi.playlist_id = sp.id
270
- WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
271
- )
272
- """
319
+ WHERE 1=1
320
+ """.format(
321
+ blackout_col=_BLACKOUT_ACTIVE_COL
322
+ )
273
323
  params: list = []
324
+ resv_sql, resv_params = _reserved_exclusion("c")
325
+ query += resv_sql
326
+ params.extend(resv_params)
274
327
  if not show_hidden:
275
328
  excl_sql, excl_params = _hidden_exclusion("c")
276
329
  query += excl_sql
277
330
  params.extend(excl_params)
331
+ bo_sql, bo_params = _blackout_exclusion("c")
332
+ query += bo_sql
333
+ params.extend(bo_params)
278
334
  if recently_played_days > 0:
279
335
  rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
280
336
  query += rp_sql
@@ -352,17 +408,19 @@ class _CatalogMixin:
352
408
  ) -> int:
353
409
  query = """
354
410
  SELECT COUNT(*) as cnt FROM catalog c
355
- WHERE c.friendly_token NOT IN (
356
- SELECT spi.media_id FROM saved_playlist_items spi
357
- JOIN saved_playlists sp ON spi.playlist_id = sp.id
358
- WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
359
- )
411
+ WHERE 1=1
360
412
  """
361
413
  params: list = []
414
+ resv_sql, resv_params = _reserved_exclusion("c")
415
+ query += resv_sql
416
+ params.extend(resv_params)
362
417
  if not show_hidden:
363
418
  excl_sql, excl_params = _hidden_exclusion("c")
364
419
  query += excl_sql
365
420
  params.extend(excl_params)
421
+ bo_sql, bo_params = _blackout_exclusion("c")
422
+ query += bo_sql
423
+ params.extend(bo_params)
366
424
  if recently_played_days > 0:
367
425
  rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
368
426
  query += rp_sql
@@ -434,21 +492,25 @@ class _CatalogMixin:
434
492
  sql = """
435
493
  SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
436
494
  (SELECT MAX(pc.completed_at) FROM play_completions pc WHERE pc.media_id = c.friendly_token AND pc.media_type = 'cm') AS played_at,
437
- rank AS relevance
495
+ rank AS relevance,
496
+ {blackout_col}
438
497
  FROM catalog_fts fts
439
498
  JOIN catalog c ON c.rowid = fts.rowid
440
499
  WHERE catalog_fts MATCH ?
441
- AND c.friendly_token NOT IN (
442
- SELECT spi.media_id FROM saved_playlist_items spi
443
- JOIN saved_playlists sp ON spi.playlist_id = sp.id
444
- WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
445
- )
446
- """
500
+ """.format(
501
+ blackout_col=_BLACKOUT_ACTIVE_COL
502
+ )
447
503
  params: list = [sanitized]
504
+ resv_sql, resv_params = _reserved_exclusion("c")
505
+ sql += resv_sql
506
+ params.extend(resv_params)
448
507
  if not show_hidden:
449
508
  excl_sql, excl_params = _hidden_exclusion("c")
450
509
  sql += excl_sql
451
510
  params.extend(excl_params)
511
+ bo_sql, bo_params = _blackout_exclusion("c")
512
+ sql += bo_sql
513
+ params.extend(bo_params)
452
514
  if recently_played_days > 0:
453
515
  rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
454
516
  sql += rp_sql
@@ -495,17 +557,18 @@ class _CatalogMixin:
495
557
  FROM catalog_fts fts
496
558
  JOIN catalog c ON c.rowid = fts.rowid
497
559
  WHERE catalog_fts MATCH ?
498
- AND c.friendly_token NOT IN (
499
- SELECT spi.media_id FROM saved_playlist_items spi
500
- JOIN saved_playlists sp ON spi.playlist_id = sp.id
501
- WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
502
- )
503
560
  """
504
561
  params: list = [sanitized]
562
+ resv_sql, resv_params = _reserved_exclusion("c")
563
+ sql += resv_sql
564
+ params.extend(resv_params)
505
565
  if not show_hidden:
506
566
  excl_sql, excl_params = _hidden_exclusion("c")
507
567
  sql += excl_sql
508
568
  params.extend(excl_params)
569
+ bo_sql, bo_params = _blackout_exclusion("c")
570
+ sql += bo_sql
571
+ params.extend(bo_params)
509
572
  if recently_played_days > 0:
510
573
  rp_sql, rp_params = _recently_played_exclusion("c", recently_played_days)
511
574
  sql += rp_sql
@@ -523,16 +586,10 @@ class _CatalogMixin:
523
586
  return row["cnt"] if row else 0
524
587
 
525
588
  async def get_item(self, friendly_token: str) -> dict | None:
526
- sql = """
527
- SELECT * FROM catalog
528
- WHERE friendly_token = ?
529
- AND friendly_token NOT IN (
530
- SELECT spi.media_id FROM saved_playlist_items spi
531
- JOIN saved_playlists sp ON spi.playlist_id = sp.id
532
- WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
533
- )
534
- """
535
- return await self._fetch_one(sql, [friendly_token])
589
+ resv_sql, resv_params = _reserved_exclusion("catalog")
590
+ bo_sql, bo_params = _blackout_exclusion("catalog")
591
+ sql = "SELECT * FROM catalog WHERE friendly_token = ?" + resv_sql + bo_sql
592
+ return await self._fetch_one(sql, [friendly_token, *resv_params, *bo_params])
536
593
 
537
594
  async def get_item_admin(self, friendly_token: str) -> dict | None:
538
595
  return await self._fetch_one(
@@ -726,12 +783,18 @@ class _CatalogMixin:
726
783
  }
727
784
 
728
785
  async def is_restricted(self, friendly_token: str) -> bool:
786
+ # media_id holds the manifest URL (or, for legacy rows, the bare token),
787
+ # so resolve either shape back to a friendly_token via the catalog
788
+ # rather than comparing the token to a URL directly.
729
789
  sql = """
730
790
  SELECT 1 FROM saved_playlist_items spi
731
791
  JOIN saved_playlists sp ON spi.playlist_id = sp.id
792
+ JOIN catalog c2
793
+ ON c2.manifest_url = spi.media_id
794
+ OR c2.friendly_token = spi.media_id
732
795
  WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL)
733
796
  AND spi.media_type = 'cm'
734
- AND spi.media_id = ?
797
+ AND c2.friendly_token = ?
735
798
  LIMIT 1
736
799
  """
737
800
  row = await self._fetch_one(sql, [friendly_token])
@@ -772,14 +835,16 @@ class _CatalogMixin:
772
835
  FROM tags t
773
836
  JOIN catalog_tags ct ON ct.tag_id = t.id
774
837
  JOIN catalog c ON ct.friendly_token = c.friendly_token
775
- WHERE c.friendly_token NOT IN (
776
- SELECT spi.media_id FROM saved_playlist_items spi
777
- JOIN saved_playlists sp ON spi.playlist_id = sp.id
778
- WHERE (sp.is_immutable = 1 OR sp.promo_type IS NOT NULL) AND spi.media_type = 'cm'
779
- )
838
+ WHERE 1=1
780
839
  """
781
840
  params: list = []
841
+ resv_sql, resv_params = _reserved_exclusion("c")
842
+ sql += resv_sql
843
+ params.extend(resv_params)
782
844
  if not show_hidden:
845
+ bo_sql, bo_params = _blackout_exclusion("c")
846
+ sql += bo_sql
847
+ params.extend(bo_params)
783
848
  ph = ",".join("?" * len(HIDDEN_TAG_NAMES))
784
849
  sql += f" AND t.name NOT IN ({ph})"
785
850
  params.extend(HIDDEN_TAG_NAMES)
@@ -457,6 +457,23 @@ MIGRATIONS = [
457
457
  """
458
458
  ALTER TABLE catalog ADD COLUMN override_artwork_tt_id TEXT;
459
459
  """,
460
+ # v26: Weekend blackout window. Items that appear in an upcoming weekend
461
+ # sheet (column E/F dropsugar URLs) are hidden from regular users until
462
+ # ``expires_at`` — set to the end of the *following* weekend so replays stay
463
+ # hidden through that week. Keyed on the bare friendly_token (which also
464
+ # sidesteps the manifest_url stored in saved_playlist_items.media_id).
465
+ # Populated by the standalone ``catalog_blackout`` maintenance job; admins
466
+ # bypass the filter but see a blackout indicator. Compared against
467
+ # ``datetime('now')`` (UTC); the multi-day window makes the tz offset moot.
468
+ """
469
+ CREATE TABLE IF NOT EXISTS catalog_blackouts (
470
+ friendly_token TEXT PRIMARY KEY,
471
+ reason TEXT,
472
+ expires_at TIMESTAMP NOT NULL,
473
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
474
+ );
475
+ CREATE INDEX IF NOT EXISTS idx_catalog_blackouts_expires ON catalog_blackouts(expires_at);
476
+ """,
460
477
  ]
461
478
 
462
479
 
@@ -0,0 +1,181 @@
1
+ """Scan the Channel Z workbook for upcoming-weekend dropsugar URLs to black out.
2
+
3
+ A *blackout* hides a rehosted catalog item from regular users until the end of
4
+ the weekend **following** the one it is scheduled for, so replays stay hidden
5
+ through that week. This module only *reads* the workbook (SharePoint or a local
6
+ ``.xlsx``) and resolves dropsugar URLs in columns **E** and **F** to MediaCMS
7
+ friendly_tokens; it downloads nothing. The async job wrapper
8
+ (:func:`kryten_webqueue.jobs.tasks.catalog_blackout_job`) persists the result.
9
+
10
+ Follows the vendored-tool contract: ``run(params, *, config, progress=None)``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import datetime as _dt
16
+ import io
17
+ import logging
18
+ from typing import Optional
19
+
20
+ from ...playlists.importer import extract_dropsugar_token
21
+ from .fetchurls import (
22
+ _SHEET_DATE_RE,
23
+ acquire_graph_token_silent,
24
+ download_sharepoint_xlsx,
25
+ is_dropsugar,
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ # Column indices (0-based): E = source URLs, F = resolved dropsugar URLs.
31
+ _COL_E = 4
32
+ _COL_F = 5
33
+
34
+ # Days from the sheet's Friday to the blackout expiry. The item airs that
35
+ # weekend (Fri..Sun = Friday+2); it stays hidden through the *following* weekend
36
+ # (Sun = Friday+9), lifting the Monday after (Friday+10, 00:00).
37
+ _BLACKOUT_DAYS = 10
38
+
39
+
40
+ def _sheet_weekend_dates(
41
+ name: str, today: _dt.date
42
+ ) -> Optional[tuple[_dt.date, _dt.date]]:
43
+ """Return (friday, sunday) for a ``M.D-M.D`` sheet name, or None.
44
+
45
+ The sheet name carries no year, so the year is inferred as the nearest
46
+ occurrence to ``today`` (handles year-boundary sheets like ``12.26-12.27``).
47
+ """
48
+ m = _SHEET_DATE_RE.match(name.strip())
49
+ if not m:
50
+ return None
51
+ month, day = int(m.group(1)), int(m.group(2))
52
+ best: Optional[_dt.date] = None
53
+ for year in (today.year - 1, today.year, today.year + 1):
54
+ try:
55
+ cand = _dt.date(year, month, day)
56
+ except ValueError:
57
+ continue
58
+ if best is None or abs((cand - today).days) < abs((best - today).days):
59
+ best = cand
60
+ if best is None:
61
+ return None
62
+ return best, best + _dt.timedelta(days=2)
63
+
64
+
65
+ def _expiry_for_friday(friday: _dt.date) -> str:
66
+ """Blackout expiry (SQLite-comparable ``YYYY-MM-DD HH:MM:SS``) for a weekend."""
67
+ monday = friday + _dt.timedelta(days=_BLACKOUT_DAYS)
68
+ return _dt.datetime.combine(monday, _dt.time.min).isoformat(sep=" ")
69
+
70
+
71
+ def scan_workbook_blackouts(wb_bytes: bytes, today: _dt.date) -> tuple[list[dict], int]:
72
+ """Return (blackouts, sheets_scanned) for current/future weekend sheets.
73
+
74
+ ``blackouts`` is a list of ``{"token", "reason", "expires_at"}``; a token
75
+ seen across multiple sheets keeps the latest expiry.
76
+ """
77
+ import openpyxl
78
+
79
+ wb = openpyxl.load_workbook(io.BytesIO(wb_bytes), read_only=True, data_only=True)
80
+ try:
81
+ found: dict[str, dict] = {}
82
+ sheets_scanned = 0
83
+ for name in wb.sheetnames:
84
+ dates = _sheet_weekend_dates(name, today)
85
+ if not dates:
86
+ continue
87
+ friday, sunday = dates
88
+ if sunday < today: # weekend already fully past
89
+ continue
90
+ sheets_scanned += 1
91
+ expires_at = _expiry_for_friday(friday)
92
+ reason = f"weekend:{name.strip()}"
93
+ ws = wb[name]
94
+ for row in ws.iter_rows(values_only=True):
95
+ for idx in (_COL_E, _COL_F):
96
+ if len(row) <= idx or row[idx] is None:
97
+ continue
98
+ val = str(row[idx]).strip()
99
+ if not val or not is_dropsugar(val):
100
+ continue
101
+ token = extract_dropsugar_token(val)
102
+ if not token:
103
+ continue
104
+ prev = found.get(token)
105
+ if prev is None or expires_at > prev["expires_at"]:
106
+ found[token] = {
107
+ "token": token,
108
+ "reason": reason,
109
+ "expires_at": expires_at,
110
+ }
111
+ return list(found.values()), sheets_scanned
112
+ finally:
113
+ wb.close()
114
+
115
+
116
+ def run(params: dict, *, config, progress=None) -> dict:
117
+ """Resolve upcoming-weekend dropsugar URLs into blackout entries.
118
+
119
+ Source precedence mirrors :func:`fetchurls.run`:
120
+ 1. ``params['workbook_path']`` (local override)
121
+ 2. SharePoint via Graph when ``config.fetchurls.sharepoint_*`` are set
122
+ 3. ``config.fetchurls.workbook_path`` (local fallback)
123
+
124
+ Returns ``{"source", "sheets_scanned", "blackouts": [...]}``. Never writes
125
+ the workbook or downloads media.
126
+ """
127
+ from pathlib import Path
128
+
129
+ def _emit(detail: dict) -> None:
130
+ if progress:
131
+ progress(detail)
132
+
133
+ cfg = getattr(config, "fetchurls", None)
134
+ local_override = params.get("workbook_path") or ""
135
+ sp_tenant = getattr(cfg, "sharepoint_tenant_id", "") if cfg else ""
136
+ sp_client = getattr(cfg, "sharepoint_client_id", "") if cfg else ""
137
+ sp_share = getattr(cfg, "sharepoint_sharing_url", "") if cfg else ""
138
+ sp_cache = getattr(cfg, "token_cache_path", "") if cfg else ""
139
+ cfg_local = getattr(cfg, "workbook_path", "") if cfg else ""
140
+
141
+ use_sharepoint = bool(sp_tenant and sp_client and sp_share) and not local_override
142
+
143
+ if use_sharepoint:
144
+ _emit({"phase": "auth", "source": "sharepoint"})
145
+ token = acquire_graph_token_silent(sp_tenant, sp_client, sp_cache)
146
+ if not token:
147
+ raise RuntimeError(
148
+ "SharePoint authentication unavailable: no valid token in the MSAL "
149
+ "cache. Run a one-time sign-in on the server: "
150
+ "python -m kryten_webqueue.jobs.fetchurls_auth"
151
+ )
152
+ _emit({"phase": "download", "source": "sharepoint"})
153
+ wb_bytes, _drive_id, _item_id = download_sharepoint_xlsx(token, sp_share)
154
+ source = "sharepoint"
155
+ else:
156
+ workbook_path = local_override or cfg_local
157
+ if not workbook_path:
158
+ raise RuntimeError(
159
+ "catalog_blackout needs a workbook source: configure "
160
+ "fetchurls.sharepoint_* (recommended) or fetchurls.workbook_path."
161
+ )
162
+ wb_file = Path(workbook_path)
163
+ if not wb_file.exists():
164
+ raise RuntimeError(f"Workbook not found: {wb_file}")
165
+ wb_bytes = wb_file.read_bytes()
166
+ source = "local"
167
+
168
+ _emit({"phase": "scanning"})
169
+ blackouts, sheets_scanned = scan_workbook_blackouts(wb_bytes, _dt.date.today())
170
+ _emit(
171
+ {
172
+ "phase": "done",
173
+ "sheets_scanned": sheets_scanned,
174
+ "blackouts": len(blackouts),
175
+ }
176
+ )
177
+ return {
178
+ "source": source,
179
+ "sheets_scanned": sheets_scanned,
180
+ "blackouts": blackouts,
181
+ }
@@ -668,6 +668,76 @@ async def motd_posters_job(params: dict, ctx):
668
668
  )
669
669
 
670
670
 
671
+ # ── Catalog blackout (weekend visibility) job ──────────────────────────────────
672
+
673
+
674
+ CATALOG_BLACKOUT_SCHEMA = [
675
+ {
676
+ "name": "dry_run",
677
+ "type": "bool",
678
+ "default": False,
679
+ "label": "Dry run (report only, no DB writes)",
680
+ },
681
+ {
682
+ "name": "workbook_path",
683
+ "type": "string",
684
+ "default": None,
685
+ "label": "Local workbook path (override SharePoint)",
686
+ },
687
+ ]
688
+
689
+
690
+ async def catalog_blackout_job(params: dict, ctx) -> dict:
691
+ """Hide upcoming-weekend dropsugar items from regular users (blackout window).
692
+
693
+ Scans the workbook's current/future weekend sheets (columns E & F) for
694
+ dropsugar URLs, resolves them to friendly_tokens, and blacks each out until
695
+ the end of the *following* weekend so replays stay hidden through that week.
696
+ Downloads nothing — a pure catalog-visibility maintenance chore. Admins
697
+ still see (dimmed) blacked-out items and can queue them manually.
698
+ """
699
+ result = await _run_vendored(
700
+ "kryten_webqueue.integrations.cmsutils.blackout_scan",
701
+ params,
702
+ ctx,
703
+ deps=["openpyxl", "requests"],
704
+ )
705
+ blackouts = result.get("blackouts") or []
706
+ dry_run = str(params.get("dry_run", "")).lower() in ("1", "true") or (
707
+ params.get("dry_run") is True
708
+ )
709
+
710
+ applied = 0
711
+ if not dry_run:
712
+ for b in blackouts:
713
+ await ctx.db.upsert_blackout(
714
+ b["token"], reason=b["reason"], expires_at=b["expires_at"]
715
+ )
716
+ applied += 1
717
+ pruned = 0 if dry_run else await ctx.db.prune_expired_blackouts()
718
+ active = await ctx.db.count_active_blackouts()
719
+
720
+ logger.info(
721
+ "catalog_blackout: scanned %d sheet(s), %d token(s) found, "
722
+ "%d applied, %d pruned, %d active",
723
+ result.get("sheets_scanned", 0),
724
+ len(blackouts),
725
+ applied,
726
+ pruned,
727
+ active,
728
+ )
729
+ return {
730
+ "source": result.get("source"),
731
+ "sheets_scanned": result.get("sheets_scanned", 0),
732
+ "found": len(blackouts),
733
+ "applied": applied,
734
+ "pruned": pruned,
735
+ "active": active,
736
+ "dry_run": dry_run,
737
+ "sample": blackouts[:25],
738
+ }
739
+
740
+
671
741
  # ── Fetch queue jobs ───────────────────────────────────────────────────────────
672
742
 
673
743