kryten-webqueue 0.45.0__tar.gz → 0.46.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 (209) hide show
  1. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/CHANGELOG.md +37 -0
  2. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/config.example.json +6 -3
  4. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/config.py +13 -2
  5. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/motd_publish.py +2 -1
  6. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/motd/builder.py +72 -14
  7. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/motd/render.py +1 -0
  8. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_motd.py +4 -1
  9. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/motd/channel_z.html +1 -1
  10. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/pyproject.toml +1 -1
  11. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_motd_publish.py +110 -2
  12. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/.github/workflows/gitleaks.yml +0 -0
  13. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/.github/workflows/python-publish.yml +0 -0
  14. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/.github/workflows/release.yml +0 -0
  15. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/.gitignore +0 -0
  16. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/.gitleaks.toml +0 -0
  17. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/.pre-commit-config.yaml +0 -0
  18. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  19. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/AGENTS.md +0 -0
  20. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/README.md +0 -0
  21. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  29. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  30. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  31. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  32. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  33. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/PRE_PLAN_GAPS.md +0 -0
  34. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/PRODUCT_PLAN.md +0 -0
  35. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/PUBLIC_API.md +0 -0
  36. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  37. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  38. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  39. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  40. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  41. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  42. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/docs/UX_POLISH_PLAN.md +0 -0
  43. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/__init__.py +0 -0
  44. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/__main__.py +0 -0
  45. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  46. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/api_gate/client.py +0 -0
  47. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/app.py +0 -0
  48. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/auth/__init__.py +0 -0
  49. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/auth/device_keys.py +0 -0
  50. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/auth/otp.py +0 -0
  51. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  52. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/auth/session.py +0 -0
  53. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/__init__.py +0 -0
  54. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
  55. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  56. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  57. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
  58. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
  59. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  60. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  61. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
  62. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_motd.py +0 -0
  63. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  64. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  65. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  66. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  67. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  68. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  69. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  70. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  71. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  72. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  73. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  74. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  75. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  76. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  77. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  78. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  79. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  80. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  81. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/images.py +0 -0
  82. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  83. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/sync.py +0 -0
  84. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  85. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  86. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  87. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  88. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  89. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  90. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  91. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/__init__.py +0 -0
  92. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  93. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  94. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  95. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  96. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  97. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  98. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  99. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  100. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  101. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  102. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  103. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/__init__.py +0 -0
  104. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  105. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  106. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/log_capture.py +0 -0
  107. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/manager.py +0 -0
  108. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  109. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/jobs/tasks.py +0 -0
  110. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/logging_config.py +0 -0
  111. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/motd/__init__.py +0 -0
  112. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/playlists/__init__.py +0 -0
  113. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  114. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/playlists/fire.py +0 -0
  115. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/playlists/importer.py +0 -0
  116. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/playlists/ordering.py +0 -0
  117. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  118. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/promos/__init__.py +0 -0
  119. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/promos/director.py +0 -0
  120. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/__init__.py +0 -0
  121. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/completion.py +0 -0
  122. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/ordering.py +0 -0
  123. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/poller.py +0 -0
  124. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/presence.py +0 -0
  125. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/race_poller.py +0 -0
  126. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/queue/shadow.py +0 -0
  127. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/__init__.py +0 -0
  128. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  129. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  130. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  131. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  132. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  133. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  134. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  135. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  136. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  137. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/auth.py +0 -0
  138. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/catalog.py +0 -0
  139. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/devices.py +0 -0
  140. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/feedback.py +0 -0
  141. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/pages.py +0 -0
  142. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/public_api.py +0 -0
  143. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/queue.py +0 -0
  144. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/routes/user.py +0 -0
  145. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/css/main.css +0 -0
  146. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/admin-motd.js +0 -0
  147. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  148. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  149. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  150. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  151. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/admin.js +0 -0
  152. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/keybindings.js +0 -0
  153. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/static/js/main.js +0 -0
  154. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/admin/index.html +0 -0
  155. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/admin/motd.html +0 -0
  156. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  157. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  158. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  159. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  160. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/auth/login.html +0 -0
  161. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/base.html +0 -0
  162. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  163. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  164. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  165. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  166. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  167. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/queue/index.html +0 -0
  168. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/race.html +0 -0
  169. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  170. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/user/link_device.html +0 -0
  171. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  172. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/ws/__init__.py +0 -0
  173. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/ws/handler.py +0 -0
  174. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/kryten_webqueue/ws/manager.py +0 -0
  175. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/scripts/AGENTS.md +0 -0
  176. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  177. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/scripts/patch_mediacms_tags.py +0 -0
  178. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/scripts/trigger_enrich.py +0 -0
  179. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/__init__.py +0 -0
  180. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_catalog_blackout.py +0 -0
  181. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_catalog_delete.py +0 -0
  182. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_classify.py +0 -0
  183. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_config_persistence.py +0 -0
  184. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_device_linking.py +0 -0
  185. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_feedback.py +0 -0
  186. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_fetch_queue_pacing.py +0 -0
  187. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_fetchurls_sharepoint.py +0 -0
  188. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_identify_step.py +0 -0
  189. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_job_logs_and_queue_visibility.py +0 -0
  190. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_people_facets.py +0 -0
  191. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_phase1.py +0 -0
  192. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_phase2_jobs.py +0 -0
  193. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_phase3_jobs.py +0 -0
  194. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_phase4_live_fixes.py +0 -0
  195. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_playlist_import.py +0 -0
  196. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_presence_refund.py +0 -0
  197. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_promo_director.py +0 -0
  198. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_promo_pool_exclusion.py +0 -0
  199. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_providers.py +0 -0
  200. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_queue_announce.py +0 -0
  201. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_queue_preview_fallback.py +0 -0
  202. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_race_poller.py +0 -0
  203. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_recently_played_hide.py +0 -0
  204. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_save_results_to_playlist.py +0 -0
  205. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_schedule_lock.py +0 -0
  206. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_search_facets.py +0 -0
  207. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_title_normalization.py +0 -0
  208. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_tmdb_index.py +0 -0
  209. {kryten_webqueue-0.45.0 → kryten_webqueue-0.46.0}/tests/test_watchlist.py +0 -0
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.46.0] - 2026-09-10
4
+
5
+ ### Changed
6
+
7
+ - **MOTD template rebuilt from the live hand-built MOTD** (pulled from the
8
+ channel via api-gate) rather than the older copy in kryten-llm. Banner image,
9
+ headline, and both footer lines now match the real markup verbatim — including
10
+ the "Our Webqueue app is in open beta!" footer text that the earlier default
11
+ dropped. The generated snippet diffs clean against the live one apart from
12
+ properly closed `<a>` tags and `title`/`alt` attributes.
13
+ - **Grid shape follows the schedule.** A 5/7 weekend now renders 5/7 instead of
14
+ being forced into an even 6/6, matching how the MOTD is actually built. A
15
+ night is never truncated, so an over-full weekend shows every scheduled movie;
16
+ `motd.slots` is a target the grid is only padded *up* to when the weekend is
17
+ still thin.
18
+ - **Sunday is excluded.** New `motd.nights` (default `[1, 2]`) controls which
19
+ nights the grid covers; Sunday has no schedule yet, so its workbook titles are
20
+ parsed but ignored. Add `3` once the scheduled-playlists feature fills that
21
+ slot.
22
+ - **The next-event line stays hidden.** It is still carried in the render
23
+ context, but is only emitted when `motd.show_next_event` is true (default
24
+ false), so the published MOTD matches the hand-built one until the feature is
25
+ deliberately debuted.
26
+
27
+ ## [0.45.1] - 2026-09-10
28
+
29
+ ### Fixed
30
+
31
+ - **MOTD mystery boxes now use the branded placeholder art** the catalog browse
32
+ view already draws from (`placeholder_dir`, served under `/images`), picking a
33
+ different tape per slot the way browse varies it per tile. The 0.45.0 default
34
+ pointed at a `motd_boxes/mystery-box.jpg` that doesn't exist, so every
35
+ unresolved slot would have rendered a broken image. New
36
+ `motd.mystery_box_base_url` absolutizes those URLs for CyTube, which renders
37
+ the MOTD off-site; `motd.mystery_box_url` is now an empty-by-default fallback
38
+ used only when no branded placeholders are installed.
39
+
3
40
  ## [0.45.0] - 2026-09-10
4
41
 
5
42
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.45.0
3
+ Version: 0.46.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
@@ -92,19 +92,22 @@
92
92
  "output_dir": "~/kryten",
93
93
  "template": "channel_z.html",
94
94
  "slots": 12,
95
+ "nights": [1, 2],
95
96
  "banner_url": "https://i.postimg.cc/jdr2mR8Y/1562479186-8-channel-Ztitlenew.png",
96
97
  "headline": "CHANNEL Z WEEKEND MOVIE PLAYLIST ({dates})",
97
98
  "showtime": "6pm ET",
98
- "mystery_box_url": "https://www.dropsugar.co/static/motd_boxes/mystery-box.jpg",
99
+ "mystery_box_base_url": "https://queue.dropsugar.co",
100
+ "mystery_box_url": "",
99
101
  "mystery_box_href": "https://queue.dropsugar.co/",
100
102
  "upload_max_bytes": 5242880,
101
103
  "links": [
102
104
  { "label": "Join us on Reddit!", "url": "https://www.reddit.com/r/Channel_Z/" },
103
105
  {
104
- "label": "See the queue and decide what's next!",
106
+ "label": "See the queue and decide what's next! Our Webqueue app is in open beta!",
105
107
  "url": "https://queue.dropsugar.co/"
106
108
  }
107
- ]
109
+ ],
110
+ "show_next_event": false
108
111
  },
109
112
 
110
113
  "prometheus_port": 28292
@@ -43,11 +43,19 @@ class MOTDConfig(BaseModel):
43
43
  template: str = "channel_z.html"
44
44
  # Grid size; unresolved positions are filled with mystery boxes.
45
45
  slots: int = 12
46
+ # Nights the grid covers, in display order (1=Fri, 2=Sat, 3=Sun). Sunday has
47
+ # no schedule yet, so its workbook titles are ignored until it's added here.
48
+ nights: list[int] = Field(default_factory=lambda: [1, 2])
46
49
  banner_url: str = "https://i.postimg.cc/jdr2mR8Y/1562479186-8-channel-Ztitlenew.png"
47
50
  # {dates} is substituted with the weekend showtimes (e.g. "8/14 @ 6pm ET & ...").
48
51
  headline: str = "CHANNEL Z WEEKEND MOVIE PLAYLIST ({dates})"
49
52
  showtime: str = "6pm ET"
50
- mystery_box_url: str = "https://www.dropsugar.co/static/motd_boxes/mystery-box.jpg"
53
+ # Mystery boxes are drawn from the same branded placeholder pool the browse
54
+ # view uses (served from the webqueue /images mount); this absolutizes them
55
+ # for CyTube, which renders the MOTD off-site.
56
+ mystery_box_base_url: str = "https://queue.dropsugar.co"
57
+ # Single fallback used only when no branded placeholders are installed.
58
+ mystery_box_url: str = ""
51
59
  mystery_box_href: str = "https://queue.dropsugar.co/"
52
60
  links: list[MOTDLink] = Field(
53
61
  default_factory=lambda: [
@@ -56,11 +64,14 @@ class MOTDConfig(BaseModel):
56
64
  url="https://www.reddit.com/r/Channel_Z/",
57
65
  ),
58
66
  MOTDLink(
59
- label="See the queue and decide what's next!",
67
+ label="See the queue and decide what's next! Our Webqueue app is in open beta!",
60
68
  url="https://queue.dropsugar.co/",
61
69
  ),
62
70
  ]
63
71
  )
72
+ # The next-event line is carried in the render context but stays hidden until
73
+ # it's deliberately debuted; the hand-built MOTD has no such line.
74
+ show_next_event: bool = False
64
75
  # Max size accepted by the admin alternate-art upload endpoint.
65
76
  upload_max_bytes: int = 5 * 1024 * 1024
66
77
 
@@ -17,7 +17,7 @@ import datetime
17
17
  import logging
18
18
  from pathlib import Path
19
19
 
20
- from ..motd.builder import build_slots
20
+ from ..motd.builder import build_slots, mystery_pool
21
21
  from ..motd.render import render_motd
22
22
  from .manager import JobError
23
23
 
@@ -140,6 +140,7 @@ async def motd_publish_job(params: dict, ctx) -> dict:
140
140
  refresh_art=refresh_art,
141
141
  dry_run=dry_run,
142
142
  week_offset=week_offset,
143
+ mystery_urls=mystery_pool(ctx.config, getattr(ctx, "cover_art", None)),
143
144
  emit=_emit,
144
145
  )
145
146
  except RuntimeError as exc:
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  import datetime
15
15
  import logging
16
+ import random
16
17
  import re
17
18
  from dataclasses import dataclass, field
18
19
  from pathlib import Path
@@ -34,7 +35,9 @@ logger = logging.getLogger(__name__)
34
35
 
35
36
  SLOT_KEY_RE = re.compile(r"^night[1-3]-slot([1-9]\d{0,2})$")
36
37
 
37
- # Nights the grid always reserves space for, in display order.
38
+ # Every night the workbook can describe. The grid itself covers only the nights
39
+ # in ``motd.nights`` — Sunday has no schedule yet, so its titles are parsed but
40
+ # never placed.
38
41
  _NIGHTS = (1, 2, 3)
39
42
 
40
43
 
@@ -117,17 +120,43 @@ def _imdb_href(imdb_id: str | None) -> str:
117
120
  return f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else ""
118
121
 
119
122
 
123
+ def _grid_nights(motd_cfg) -> tuple[int, ...]:
124
+ """Nights the grid reserves space for, in display order."""
125
+ configured = getattr(motd_cfg, "nights", None) or _NIGHTS
126
+ nights = tuple(int(n) for n in configured if int(n) in _NIGHTS)
127
+ return nights or _NIGHTS
128
+
129
+
130
+ def _slot_counts(
131
+ titles_by_night: dict[int, list[str]], nights: tuple[int, ...], slots: int
132
+ ) -> dict[int, int]:
133
+ """Decide how many positions each night gets.
134
+
135
+ The schedule drives the shape — a 5/7 weekend renders 5/7, not an even 6/6 —
136
+ and a night is never truncated, so a scheduled movie can't be hidden. Only
137
+ when the weekend is still thin is the grid padded up to ``slots``, handing
138
+ each extra to whichever night is furthest below its even share.
139
+ """
140
+ counts = {n: len(titles_by_night.get(n, [])) for n in nights}
141
+ share = slots / len(nights)
142
+ while sum(counts.values()) < slots:
143
+ night = min(nights, key=lambda n: (counts[n] - share, nights.index(n)))
144
+ counts[night] += 1
145
+ return counts
146
+
147
+
120
148
  def _blank_grid(
121
- friday: datetime.date, saturday: datetime.date, sunday: datetime.date, slots: int
149
+ friday: datetime.date,
150
+ saturday: datetime.date,
151
+ sunday: datetime.date,
152
+ counts: dict[int, int],
153
+ nights: tuple[int, ...],
122
154
  ) -> list[MOTDSlot]:
123
- """Lay out ``slots`` positions spread evenly across the three nights."""
124
- per_night = max(1, slots // len(_NIGHTS))
125
- remainder = slots - per_night * len(_NIGHTS)
155
+ """Lay out the per-night positions in display order."""
126
156
  dates = {1: friday, 2: saturday, 3: sunday}
127
157
  grid: list[MOTDSlot] = []
128
- for night in _NIGHTS:
129
- count = per_night + (1 if night <= remainder else 0)
130
- for position in range(1, count + 1):
158
+ for night in nights:
159
+ for position in range(1, counts[night] + 1):
131
160
  grid.append(
132
161
  MOTDSlot(
133
162
  slot_key=f"night{night}-slot{position}",
@@ -149,9 +178,28 @@ def _titles_by_night(movies_by_section: dict[str, list[str]]) -> dict[int, list[
149
178
  return by_night
150
179
 
151
180
 
152
- def _apply_mystery(slot: MOTDSlot, motd_cfg) -> None:
181
+ def mystery_pool(config, cover_art) -> list[str]:
182
+ """Absolute URLs for the branded placeholder art the browse view uses.
183
+
184
+ ``list_placeholder_urls`` returns site-relative ``/images/...`` paths; CyTube
185
+ renders the MOTD off-site, so they have to be absolutized.
186
+ """
187
+ if cover_art is None:
188
+ return []
189
+ base = str(getattr(config.motd, "mystery_box_base_url", "") or "").rstrip("/")
190
+ if not base:
191
+ return []
192
+ return [base + url for url in cover_art.list_placeholder_urls()]
193
+
194
+
195
+ def _apply_mystery(slot: MOTDSlot, motd_cfg, pool: list[str]) -> None:
153
196
  slot.source = "mystery"
154
- slot.poster_url = getattr(motd_cfg, "mystery_box_url", "") or ""
197
+ # Vary the art across the grid the way browse varies it across tiles.
198
+ slot.poster_url = (
199
+ random.choice(pool)
200
+ if pool
201
+ else (getattr(motd_cfg, "mystery_box_url", "") or "")
202
+ )
155
203
  slot.href = getattr(motd_cfg, "mystery_box_href", "") or ""
156
204
 
157
205
 
@@ -164,6 +212,7 @@ def build_slots(
164
212
  dry_run: bool = False,
165
213
  today: datetime.date | None = None,
166
214
  week_offset: int = 0,
215
+ mystery_urls: list[str] | None = None,
167
216
  emit=None,
168
217
  ) -> MOTDWeek:
169
218
  """Resolve the upcoming weekend into a complete grid of poster slots.
@@ -178,6 +227,7 @@ def build_slots(
178
227
  emit(detail)
179
228
 
180
229
  overrides = overrides or {}
230
+ pool = mystery_urls or []
181
231
  motd_cfg = getattr(config, "motd", None)
182
232
  slot_count = int(getattr(motd_cfg, "slots", 12) or 12)
183
233
  poster_dir = Path(
@@ -190,7 +240,6 @@ def build_slots(
190
240
  week_key, friday, saturday = upcoming_weekend_sheet(base_day)
191
241
  sunday = friday + datetime.timedelta(days=2)
192
242
  week = MOTDWeek(week_key=week_key, friday=friday, saturday=saturday, sunday=sunday)
193
- week.slots = _blank_grid(friday, saturday, sunday, slot_count)
194
243
 
195
244
  # The workbook is best-effort: a missing sheet yields an all-mystery grid
196
245
  # rather than a failed run, so the MOTD still publishes on schedule.
@@ -208,6 +257,15 @@ def build_slots(
208
257
  week.warnings.append(f"workbook unavailable: {exc}")
209
258
  logger.warning("motd: workbook unavailable for %s: %s", week_key, exc)
210
259
 
260
+ nights = _grid_nights(motd_cfg)
261
+ week.slots = _blank_grid(
262
+ friday,
263
+ saturday,
264
+ sunday,
265
+ _slot_counts(titles_by_night, nights, slot_count),
266
+ nights,
267
+ )
268
+
211
269
  if not dry_run:
212
270
  poster_dir.mkdir(parents=True, exist_ok=True)
213
271
 
@@ -234,13 +292,13 @@ def build_slots(
234
292
  continue
235
293
 
236
294
  if not slot.title:
237
- _apply_mystery(slot, motd_cfg)
295
+ _apply_mystery(slot, motd_cfg, pool)
238
296
  slot.note = "no title on the schedule yet"
239
297
  continue
240
298
 
241
299
  info = _resolve_omdb(slot.title, api_key=omdb_key) if omdb_key else None
242
300
  if not info:
243
- _apply_mystery(slot, motd_cfg)
301
+ _apply_mystery(slot, motd_cfg, pool)
244
302
  slot.note = "title could not be verified"
245
303
  logger.info("motd: no OMDB match for %r (%s)", slot.title, slot.slot_key)
246
304
  if override.get("href"):
@@ -266,7 +324,7 @@ def build_slots(
266
324
  slot.poster_url = f"{poster_base_url}/{filename}"
267
325
  slot.source = "omdb"
268
326
  else:
269
- _apply_mystery(slot, motd_cfg)
327
+ _apply_mystery(slot, motd_cfg, pool)
270
328
  slot.note = "poster download failed"
271
329
 
272
330
  slot.href = override.get("href") or _imdb_href(slot.imdb_id)
@@ -73,6 +73,7 @@ def render_motd(config, week: MOTDWeek, *, next_event: dict | None = None) -> st
73
73
  for link in getattr(motd_cfg, "links", [])
74
74
  ],
75
75
  next_event=next_event,
76
+ show_next_event=bool(getattr(motd_cfg, "show_next_event", False)),
76
77
  week_key=week.week_key,
77
78
  generated_at=datetime.datetime.now(datetime.timezone.utc).isoformat(
78
79
  timespec="seconds"
@@ -18,7 +18,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Uplo
18
18
  from pydantic import BaseModel
19
19
 
20
20
  from ..auth.session import require_admin
21
- from ..motd.builder import SLOT_KEY_RE, build_slots, week_context
21
+ from ..motd.builder import SLOT_KEY_RE, build_slots, mystery_pool, week_context
22
22
  from ..motd.render import render_motd
23
23
 
24
24
  router = APIRouter(prefix="/admin/motd", tags=["admin"])
@@ -72,6 +72,9 @@ async def _build_current(request: Request, week: str = "current"):
72
72
  overrides=overrides,
73
73
  dry_run=True,
74
74
  week_offset=_week_offset(week),
75
+ mystery_urls=mystery_pool(
76
+ config, getattr(request.app.state, "cover_art", None)
77
+ ),
75
78
  )
76
79
 
77
80
 
@@ -22,7 +22,7 @@
22
22
  {% endfor %}
23
23
  {% endfor %}
24
24
  </div>
25
- {% if next_event %}
25
+ {% if show_next_event and next_event %}
26
26
  <div class="weekend-title">🟥🟧🟨🟩Up next: {{ next_event.label }}{% if next_event.starts_in %} — {{ next_event.starts_in }}{% endif %}🟩🟨🟧🟥</div>
27
27
  {% endif %}
28
28
  {% for link in links %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.45.0"
3
+ version = "0.46.0"
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"
@@ -30,6 +30,7 @@ def _config(tmp_path, **motd_kw):
30
30
  poster_dir=str(tmp_path / "boxes"),
31
31
  poster_base_url="https://cdn.example/boxes",
32
32
  output_dir=str(tmp_path / "out"),
33
+ mystery_box_base_url="https://queue.example",
33
34
  mystery_box_url="https://cdn.example/boxes/mystery.jpg",
34
35
  mystery_box_href="https://queue.example/",
35
36
  **motd_kw,
@@ -71,12 +72,70 @@ def _stub_workbook(monkeypatch, titles_by_night):
71
72
  # --- grid layout ---
72
73
 
73
74
 
74
- def test_grid_is_always_full_size(tmp_path, monkeypatch, stub_lookup):
75
+ def test_grid_pads_a_thin_weekend_to_the_target_size(
76
+ tmp_path, monkeypatch, stub_lookup
77
+ ):
75
78
  _stub_workbook(monkeypatch, {})
76
79
  week = builder.build_slots(_config(tmp_path), today=datetime.date(2026, 3, 4))
77
80
  assert len(week.slots) == 12
78
81
  assert [s.slot_key for s in week.slots[:2]] == ["night1-slot1", "night1-slot2"]
82
+ # Sunday has no schedule yet, so the grid is Friday + Saturday.
83
+ assert {s.night for s in week.slots} == {1, 2}
84
+ assert len([s for s in week.slots if s.night == 1]) == 6
85
+ assert len([s for s in week.slots if s.night == 2]) == 6
86
+
87
+
88
+ def test_grid_shape_follows_the_schedule(tmp_path, monkeypatch, stub_lookup):
89
+ # A real 5/7 weekend renders 5/7, not an even 6/6.
90
+ _stub_workbook(
91
+ monkeypatch,
92
+ {
93
+ 1: [f"Friday {n} (1980)" for n in range(5)],
94
+ 2: [f"Saturday {n} (1980)" for n in range(7)],
95
+ },
96
+ )
97
+ week = builder.build_slots(_config(tmp_path), today=datetime.date(2026, 3, 4))
98
+ assert len([s for s in week.slots if s.night == 1]) == 5
99
+ assert len([s for s in week.slots if s.night == 2]) == 7
100
+ assert all(s.resolved for s in week.slots)
101
+
102
+
103
+ def test_grid_never_truncates_an_overfull_weekend(tmp_path, monkeypatch, stub_lookup):
104
+ _stub_workbook(
105
+ monkeypatch,
106
+ {
107
+ 1: [f"Friday {n} (1980)" for n in range(8)],
108
+ 2: [f"Saturday {n} (1980)" for n in range(7)],
109
+ },
110
+ )
111
+ week = builder.build_slots(_config(tmp_path), today=datetime.date(2026, 3, 4))
112
+ assert len(week.slots) == 15
113
+ assert "Friday 7 (1980)" in [s.title for s in week.slots]
114
+
115
+
116
+ def test_sunday_titles_are_ignored(tmp_path, monkeypatch, stub_lookup):
117
+ _stub_workbook(
118
+ monkeypatch,
119
+ {
120
+ 1: ["Friday One (1976)"],
121
+ 2: ["Saturday One (1980)"],
122
+ 3: ["Sunday One (1990)"],
123
+ },
124
+ )
125
+ week = builder.build_slots(_config(tmp_path), today=datetime.date(2026, 3, 4))
126
+ titles = [s.title for s in week.slots if s.title]
127
+ assert "Friday One (1976)" in titles
128
+ assert "Saturday One (1980)" in titles
129
+ assert "Sunday One (1990)" not in titles
130
+
131
+
132
+ def test_nights_config_can_re_enable_sunday(tmp_path, monkeypatch, stub_lookup):
133
+ _stub_workbook(monkeypatch, {3: ["Sunday One (1990)"]})
134
+ week = builder.build_slots(
135
+ _config(tmp_path, nights=[1, 2, 3]), today=datetime.date(2026, 3, 4)
136
+ )
79
137
  assert {s.night for s in week.slots} == {1, 2, 3}
138
+ assert "Sunday One (1990)" in [s.title for s in week.slots if s.title]
80
139
 
81
140
 
82
141
  def test_missing_workbook_yields_all_mystery(tmp_path, monkeypatch, stub_lookup):
@@ -126,6 +185,43 @@ def test_unverifiable_title_falls_back_to_mystery(tmp_path, monkeypatch, stub_lo
126
185
  assert "could not be verified" in slot.note
127
186
 
128
187
 
188
+ # --- mystery art comes from the browse view's branded placeholder pool ---
189
+
190
+
191
+ def test_mystery_pool_absolutizes_browse_placeholders(tmp_path):
192
+ config = _config(tmp_path)
193
+ cover_art = SimpleNamespace(
194
+ list_placeholder_urls=lambda: ["/images/placeholders/a.webp"]
195
+ )
196
+ assert builder.mystery_pool(config, cover_art) == [
197
+ "https://queue.example/images/placeholders/a.webp"
198
+ ]
199
+ assert builder.mystery_pool(config, None) == []
200
+
201
+
202
+ def test_mystery_slots_use_the_placeholder_pool(tmp_path, monkeypatch, stub_lookup):
203
+ _stub_workbook(monkeypatch, {})
204
+ week = builder.build_slots(
205
+ _config(tmp_path),
206
+ today=datetime.date(2026, 3, 4),
207
+ mystery_urls=["https://queue.example/images/placeholders/a.webp"],
208
+ )
209
+ assert all(
210
+ s.poster_url == "https://queue.example/images/placeholders/a.webp"
211
+ for s in week.slots
212
+ )
213
+
214
+
215
+ def test_mystery_falls_back_when_no_placeholders_installed(
216
+ tmp_path, monkeypatch, stub_lookup
217
+ ):
218
+ _stub_workbook(monkeypatch, {})
219
+ week = builder.build_slots(
220
+ _config(tmp_path), today=datetime.date(2026, 3, 4), mystery_urls=[]
221
+ )
222
+ assert all(s.poster_url.endswith("mystery.jpg") for s in week.slots)
223
+
224
+
129
225
  def test_dry_run_writes_nothing(tmp_path, monkeypatch, stub_lookup):
130
226
  _stub_workbook(monkeypatch, {1: ["The Big Bus (1976)"]})
131
227
  builder.build_slots(
@@ -203,13 +299,25 @@ def test_render_groups_by_night_and_includes_links(tmp_path, monkeypatch, stub_l
203
299
  assert "3/6 @ 6pm ET &amp; 3/7 @ 6pm ET" in html
204
300
 
205
301
 
206
- def test_render_includes_next_event_when_present(tmp_path, monkeypatch, stub_lookup):
302
+ def test_next_event_is_carried_but_hidden_by_default(
303
+ tmp_path, monkeypatch, stub_lookup
304
+ ):
207
305
  _stub_workbook(monkeypatch, {})
208
306
  config = _config(tmp_path)
209
307
  week = builder.build_slots(config, today=datetime.date(2026, 3, 4))
210
308
  html = render.render_motd(
211
309
  config, week, next_event={"label": "Saturday Night", "starts_in": "in 2h"}
212
310
  )
311
+ assert "Up next" not in html
312
+
313
+
314
+ def test_render_includes_next_event_when_enabled(tmp_path, monkeypatch, stub_lookup):
315
+ _stub_workbook(monkeypatch, {})
316
+ config = _config(tmp_path, show_next_event=True)
317
+ week = builder.build_slots(config, today=datetime.date(2026, 3, 4))
318
+ html = render.render_motd(
319
+ config, week, next_event={"label": "Saturday Night", "starts_in": "in 2h"}
320
+ )
213
321
  assert "Up next: Saturday Night — in 2h" in html
214
322
 
215
323