kryten-webqueue 0.44.7__tar.gz → 0.45.1__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.44.7 → kryten_webqueue-0.45.1}/CHANGELOG.md +56 -0
  2. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/PKG-INFO +2 -1
  3. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/config.example.json +22 -0
  4. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/api_gate/client.py +3 -0
  5. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/app.py +10 -0
  6. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/__init__.py +2 -0
  7. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_connection.py +29 -4
  8. kryten_webqueue-0.45.1/kryten_webqueue/catalog/db/_motd.py +55 -0
  9. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/config.py +40 -2
  10. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/motdposters.py +61 -0
  11. kryten_webqueue-0.45.1/kryten_webqueue/jobs/motd_publish.py +199 -0
  12. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/tasks.py +19 -0
  13. kryten_webqueue-0.45.1/kryten_webqueue/motd/__init__.py +13 -0
  14. kryten_webqueue-0.45.1/kryten_webqueue/motd/builder.py +306 -0
  15. kryten_webqueue-0.45.1/kryten_webqueue/motd/render.py +80 -0
  16. kryten_webqueue-0.45.1/kryten_webqueue/routes/admin_motd.py +198 -0
  17. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/pages.py +8 -0
  18. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/css/main.css +46 -0
  19. kryten_webqueue-0.45.1/kryten_webqueue/static/js/admin-motd.js +214 -0
  20. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/admin/index.html +1 -0
  21. kryten_webqueue-0.45.1/kryten_webqueue/templates/admin/motd.html +53 -0
  22. kryten_webqueue-0.45.1/kryten_webqueue/templates/motd/channel_z.html +32 -0
  23. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/pyproject.toml +3 -1
  24. kryten_webqueue-0.45.1/tests/test_motd_publish.py +295 -0
  25. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/.github/workflows/gitleaks.yml +0 -0
  26. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/.github/workflows/python-publish.yml +0 -0
  27. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/.github/workflows/release.yml +0 -0
  28. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/.gitignore +0 -0
  29. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/.gitleaks.toml +0 -0
  30. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/.pre-commit-config.yaml +0 -0
  31. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/AGENT-WORKFLOW-GUIDE.md +0 -0
  32. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/AGENTS.md +0 -0
  33. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/README.md +0 -0
  34. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/deploy/kryten-webqueue.service +0 -0
  35. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/deploy/nginx-queue.conf +0 -0
  36. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  37. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/IMPL_API_GATE.md +0 -0
  38. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/IMPL_ECONOMY.md +0 -0
  39. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  40. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/IMPL_ROBOT.md +0 -0
  41. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  42. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  43. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
  44. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  45. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  46. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/PRE_PLAN_GAPS.md +0 -0
  47. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/PRODUCT_PLAN.md +0 -0
  48. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/PUBLIC_API.md +0 -0
  49. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  50. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  51. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  52. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/SPEC_EMOTE_REHOST.md +0 -0
  53. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  54. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  55. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/docs/UX_POLISH_PLAN.md +0 -0
  56. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/__init__.py +0 -0
  57. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/__main__.py +0 -0
  58. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  59. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/auth/__init__.py +0 -0
  60. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/auth/device_keys.py +0 -0
  61. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/auth/otp.py +0 -0
  62. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  63. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/auth/session.py +0 -0
  64. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/__init__.py +0 -0
  65. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  66. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  67. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_devices.py +0 -0
  68. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  69. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  70. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
  71. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_people.py +0 -0
  72. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  73. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_queue.py +0 -0
  74. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  75. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  76. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  77. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  78. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  79. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  80. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  81. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  82. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  83. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  84. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  85. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  86. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  87. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  88. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  89. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/images.py +0 -0
  90. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  91. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/sync.py +0 -0
  92. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  93. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  94. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  95. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  96. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  97. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  98. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  99. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/__init__.py +0 -0
  100. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  101. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  102. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  103. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  104. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  105. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  106. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  107. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  108. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  109. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  110. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/__init__.py +0 -0
  111. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  112. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  113. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/log_capture.py +0 -0
  114. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/manager.py +0 -0
  115. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  116. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/logging_config.py +0 -0
  117. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/playlists/__init__.py +0 -0
  118. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  119. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/playlists/fire.py +0 -0
  120. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/playlists/importer.py +0 -0
  121. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/playlists/ordering.py +0 -0
  122. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  123. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/promos/__init__.py +0 -0
  124. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/promos/director.py +0 -0
  125. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/__init__.py +0 -0
  126. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/completion.py +0 -0
  127. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/ordering.py +0 -0
  128. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/poller.py +0 -0
  129. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/presence.py +0 -0
  130. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/race_poller.py +0 -0
  131. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/queue/shadow.py +0 -0
  132. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/__init__.py +0 -0
  133. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  134. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_feedback.py +0 -0
  135. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  136. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  137. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_moderation.py +0 -0
  138. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  139. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  140. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  141. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  142. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/auth.py +0 -0
  143. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/catalog.py +0 -0
  144. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/devices.py +0 -0
  145. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/feedback.py +0 -0
  146. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/public_api.py +0 -0
  147. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/queue.py +0 -0
  148. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/routes/user.py +0 -0
  149. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  150. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
  151. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  152. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  153. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/admin.js +0 -0
  154. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/keybindings.js +0 -0
  155. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/static/js/main.js +0 -0
  156. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  157. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  158. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  159. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  160. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/auth/login.html +0 -0
  161. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/base.html +0 -0
  162. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  163. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  164. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  165. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  166. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/feedback/index.html +0 -0
  167. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/queue/index.html +0 -0
  168. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/race.html +0 -0
  169. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  170. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/user/link_device.html +0 -0
  171. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/templates/user/my_list.html +0 -0
  172. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/ws/__init__.py +0 -0
  173. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/ws/handler.py +0 -0
  174. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/kryten_webqueue/ws/manager.py +0 -0
  175. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/scripts/AGENTS.md +0 -0
  176. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/scripts/patch_mediacms_bulk_facets.py +0 -0
  177. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/scripts/patch_mediacms_tags.py +0 -0
  178. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/scripts/trigger_enrich.py +0 -0
  179. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/__init__.py +0 -0
  180. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_catalog_blackout.py +0 -0
  181. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_catalog_delete.py +0 -0
  182. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_classify.py +0 -0
  183. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_config_persistence.py +0 -0
  184. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_device_linking.py +0 -0
  185. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_feedback.py +0 -0
  186. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_fetch_queue_pacing.py +0 -0
  187. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_fetchurls_sharepoint.py +0 -0
  188. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_identify_step.py +0 -0
  189. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_job_logs_and_queue_visibility.py +0 -0
  190. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_people_facets.py +0 -0
  191. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_phase1.py +0 -0
  192. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_phase2_jobs.py +0 -0
  193. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_phase3_jobs.py +0 -0
  194. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_phase4_live_fixes.py +0 -0
  195. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_playlist_import.py +0 -0
  196. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_presence_refund.py +0 -0
  197. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_promo_director.py +0 -0
  198. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_promo_pool_exclusion.py +0 -0
  199. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_providers.py +0 -0
  200. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_queue_announce.py +0 -0
  201. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_queue_preview_fallback.py +0 -0
  202. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_race_poller.py +0 -0
  203. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_recently_played_hide.py +0 -0
  204. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_save_results_to_playlist.py +0 -0
  205. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_schedule_lock.py +0 -0
  206. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_search_facets.py +0 -0
  207. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_title_normalization.py +0 -0
  208. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_tmdb_index.py +0 -0
  209. {kryten_webqueue-0.44.7 → kryten_webqueue-0.45.1}/tests/test_watchlist.py +0 -0
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.45.1] - 2026-09-10
4
+
5
+ ### Fixed
6
+
7
+ - **MOTD mystery boxes now use the branded placeholder art** the catalog browse
8
+ view already draws from (`placeholder_dir`, served under `/images`), picking a
9
+ different tape per slot the way browse varies it per tile. The 0.45.0 default
10
+ pointed at a `motd_boxes/mystery-box.jpg` that doesn't exist, so every
11
+ unresolved slot would have rendered a broken image. New
12
+ `motd.mystery_box_base_url` absolutizes those URLs for CyTube, which renders
13
+ the MOTD off-site; `motd.mystery_box_url` is now an empty-by-default fallback
14
+ used only when no branded placeholders are installed.
15
+
16
+ ## [0.45.0] - 2026-09-10
17
+
18
+ ### Added
19
+
20
+ - **`motd_publish` job** — builds the weekend poster grid from the schedule
21
+ workbook and sets the channel Message of the Day through api-gate's
22
+ `PUT /admin/motd`. Each curator title (`Title (YYYY)`) is resolved to an IMDb
23
+ tt# and poster art; positions with no title yet, or a title that can't be
24
+ verified, render as **mystery boxes** so the grid is always complete and
25
+ self-heals on the next run once the title is fixed. A missing or unreadable
26
+ workbook degrades to an all-mystery grid rather than failing the run.
27
+ - **Per-week MOTD slot overrides** (migration **v30**, `motd_overrides`). An
28
+ admin can pin a hand-picked title, curator-supplied art, or a non-IMDb link
29
+ for any grid position; overrides always beat the lookup and retire on their
30
+ own when the workbook week rolls over. New admin API under `/admin/motd`:
31
+ `GET` (preview the resolved grid + live MOTD), `GET /render`,
32
+ `PUT`/`DELETE /overrides/{slot_key}`, `POST /overrides/{slot_key}/art`
33
+ (alternate-art upload), and `POST /publish`.
34
+ - **Admin MOTD page** at `/admin/motd` (linked from the admin nav): shows the
35
+ resolved grid night by night with a resolved/override/mystery badge per slot,
36
+ an override editor (title, link, poster URL, or an art upload), an
37
+ HTML preview, the currently-live MOTD, and a one-click publish.
38
+ - **Early debut.** The workbook sheet rolls forward on Monday; `week=next`
39
+ (job param and admin query string) publishes the coming weekend's grid on
40
+ Sunday, while weekend traffic is still watching.
41
+ - **`fetchurls` now chains `motd_publish`** (new `publish_motd` param, on by
42
+ default) — a curator's corrected title re-publishes the MOTD in the same
43
+ pass. Best-effort: a MOTD failure never fails URL resolution.
44
+ - **Next-event context.** The rendered snippet carries the next armed playlist
45
+ schedule (`label`, `starts_in`), laying the groundwork for swapping the MOTD
46
+ on each scheduled playlist fire.
47
+ - `ApiGateClient.set_motd()`; `MOTDConfig` gained `template`, `slots`,
48
+ `banner_url`, `headline`, `showtime`, `mystery_box_url`, `mystery_box_href`,
49
+ `links`, and `upload_max_bytes` (see `config.example.json`).
50
+
51
+ ### Security
52
+
53
+ - The MOTD template renders with Jinja autoescape on and a `safe_url` filter
54
+ that rejects any non-`http(s)` URL, so untrusted curator titles and override
55
+ links can't inject markup or `javascript:` URLs into the channel MOTD. Slot
56
+ keys are regex-validated and art uploads are restricted by content type and
57
+ size.
58
+
3
59
  ## [0.44.7] - 2026-08-31
4
60
 
5
61
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.44.7
3
+ Version: 0.45.1
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
@@ -17,6 +17,7 @@ Requires-Dist: pycryptodomex>=3.20
17
17
  Requires-Dist: pydantic>=2.0
18
18
  Requires-Dist: pyjwt>=2.8
19
19
  Requires-Dist: python-dateutil>=2.8
20
+ Requires-Dist: python-multipart>=0.0.9
20
21
  Requires-Dist: python-slugify>=8.0
21
22
  Requires-Dist: pyyaml>=6.0
22
23
  Requires-Dist: requests>=2.31
@@ -86,5 +86,27 @@
86
86
  "inter_emote_delay_sec": 2.0
87
87
  },
88
88
 
89
+ "motd": {
90
+ "poster_dir": "/home/mediacms.io/mediacms/static/motd_boxes",
91
+ "poster_base_url": "https://www.dropsugar.co/static/motd_boxes",
92
+ "output_dir": "~/kryten",
93
+ "template": "channel_z.html",
94
+ "slots": 12,
95
+ "banner_url": "https://i.postimg.cc/jdr2mR8Y/1562479186-8-channel-Ztitlenew.png",
96
+ "headline": "CHANNEL Z WEEKEND MOVIE PLAYLIST ({dates})",
97
+ "showtime": "6pm ET",
98
+ "mystery_box_base_url": "https://queue.dropsugar.co",
99
+ "mystery_box_url": "",
100
+ "mystery_box_href": "https://queue.dropsugar.co/",
101
+ "upload_max_bytes": 5242880,
102
+ "links": [
103
+ { "label": "Join us on Reddit!", "url": "https://www.reddit.com/r/Channel_Z/" },
104
+ {
105
+ "label": "See the queue and decide what's next!",
106
+ "url": "https://queue.dropsugar.co/"
107
+ }
108
+ ]
109
+ },
110
+
89
111
  "prometheus_port": 28292
90
112
  }
@@ -108,6 +108,9 @@ class ApiGateClient:
108
108
  result = await self.get("/admin/motd")
109
109
  return result.get("motd", "")
110
110
 
111
+ async def set_motd(self, motd: str) -> dict:
112
+ return await self.put("/admin/motd", json={"motd": motd})
113
+
111
114
  # --- Economy proxy ---
112
115
 
113
116
  async def get_balance(self, username: str) -> dict:
@@ -32,6 +32,7 @@ from .routes.admin_playlists import router as admin_playlists_router
32
32
  from .routes.admin_schedules import router as admin_schedules_router
33
33
  from .routes.admin_queue import router as admin_queue_router
34
34
  from .routes.admin_jobs import router as admin_jobs_router
35
+ from .routes.admin_motd import router as admin_motd_router
35
36
  from .routes.admin_job_schedules import router as admin_job_schedules_router
36
37
  from .routes.admin_catalog import router as admin_catalog_router
37
38
  from .routes.admin_promos import router as admin_promos_router
@@ -167,6 +168,14 @@ async def lifespan(app: FastAPI):
167
168
  label="MOTD Posters (weekend poster grid)",
168
169
  schema=MOTD_POSTERS_SCHEMA,
169
170
  )
171
+ from .jobs.motd_publish import motd_publish_job, MOTD_PUBLISH_SCHEMA
172
+
173
+ job_manager.register(
174
+ "motd_publish",
175
+ motd_publish_job,
176
+ label="MOTD Publish (build grid & set channel MOTD)",
177
+ schema=MOTD_PUBLISH_SCHEMA,
178
+ )
170
179
  job_manager.register(
171
180
  "tmdb_index_refresh",
172
181
  tmdb_index_refresh_job,
@@ -418,6 +427,7 @@ def create_app(config: Config) -> FastAPI:
418
427
  app.include_router(admin_schedules_router)
419
428
  app.include_router(admin_queue_router)
420
429
  app.include_router(admin_jobs_router)
430
+ app.include_router(admin_motd_router)
421
431
  app.include_router(admin_job_schedules_router)
422
432
  app.include_router(admin_catalog_router)
423
433
  app.include_router(admin_promos_router)
@@ -14,6 +14,7 @@ from ._enrichment import _EnrichmentMixin
14
14
  from ._fetch_queue import _FetchQueueMixin
15
15
  from ._blackouts import _BlackoutMixin
16
16
  from ._devices import _DevicesMixin
17
+ from ._motd import _MOTDMixin
17
18
 
18
19
 
19
20
  class Database(
@@ -27,6 +28,7 @@ class Database(
27
28
  _FetchQueueMixin,
28
29
  _BlackoutMixin,
29
30
  _DevicesMixin,
31
+ _MOTDMixin,
30
32
  _DBBase,
31
33
  ):
32
34
  """Async SQLite database wrapper."""
@@ -533,6 +533,25 @@ MIGRATIONS = [
533
533
  );
534
534
  CREATE INDEX IF NOT EXISTS idx_job_run_logs_run ON job_run_logs(run_id, seq);
535
535
  """,
536
+ # v30: Per-week MOTD slot overrides. The motd_publish job resolves the
537
+ # weekend line-up from the workbook automatically; a row here pins one grid
538
+ # position for one week (curator-supplied art, a hand-picked title, or a
539
+ # non-IMDb link) and always wins over the auto-resolved value. Keyed on the
540
+ # workbook sheet name (e.g. '8.14-8.15') plus the stable slot key
541
+ # ('night1-slot3'), so re-running the job is idempotent and the overrides
542
+ # retire naturally once that week rolls off.
543
+ """
544
+ CREATE TABLE IF NOT EXISTS motd_overrides (
545
+ week_key TEXT NOT NULL,
546
+ slot_key TEXT NOT NULL,
547
+ title TEXT,
548
+ poster_url TEXT,
549
+ href TEXT,
550
+ created_by TEXT,
551
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
552
+ PRIMARY KEY (week_key, slot_key)
553
+ );
554
+ """,
536
555
  ]
537
556
 
538
557
 
@@ -549,10 +568,16 @@ class _DBBase:
549
568
  self._db.row_factory = aiosqlite.Row
550
569
  await self._db.execute("PRAGMA journal_mode=WAL")
551
570
  await self._db.execute("PRAGMA foreign_keys=ON")
552
- await self._db.execute("PRAGMA synchronous=NORMAL") # safe in WAL; faster than FULL
553
- await self._db.execute("PRAGMA busy_timeout=5000") # retry up to 5 s on lock
554
- await self._db.execute("PRAGMA wal_autocheckpoint=100") # checkpoint every 100 pages
555
- await self._db.execute("PRAGMA wal_checkpoint(PASSIVE)") # drain any stale WAL on open
571
+ await self._db.execute(
572
+ "PRAGMA synchronous=NORMAL"
573
+ ) # safe in WAL; faster than FULL
574
+ await self._db.execute("PRAGMA busy_timeout=5000") # retry up to 5 s on lock
575
+ await self._db.execute(
576
+ "PRAGMA wal_autocheckpoint=100"
577
+ ) # checkpoint every 100 pages
578
+ await self._db.execute(
579
+ "PRAGMA wal_checkpoint(PASSIVE)"
580
+ ) # drain any stale WAL on open
556
581
 
557
582
  async def close(self):
558
583
  if self._db:
@@ -0,0 +1,55 @@
1
+ class _MOTDMixin:
2
+ """Per-week MOTD slot override CRUD (see migration v30).
3
+
4
+ An override pins one position of the weekend poster grid for one workbook
5
+ week. Any of ``title`` / ``poster_url`` / ``href`` may be NULL, in which
6
+ case the auto-resolved value for that field is kept.
7
+ """
8
+
9
+ async def upsert_motd_override(
10
+ self,
11
+ week_key: str,
12
+ slot_key: str,
13
+ *,
14
+ title: str | None = None,
15
+ poster_url: str | None = None,
16
+ href: str | None = None,
17
+ created_by: str | None = None,
18
+ ) -> None:
19
+ """Insert or update one slot override, merging with any existing row."""
20
+ await self._db.execute(
21
+ """
22
+ INSERT INTO motd_overrides
23
+ (week_key, slot_key, title, poster_url, href, created_by, updated_at)
24
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
25
+ ON CONFLICT(week_key, slot_key) DO UPDATE SET
26
+ title = COALESCE(excluded.title, motd_overrides.title),
27
+ poster_url = COALESCE(excluded.poster_url, motd_overrides.poster_url),
28
+ href = COALESCE(excluded.href, motd_overrides.href),
29
+ created_by = excluded.created_by,
30
+ updated_at = CURRENT_TIMESTAMP
31
+ """,
32
+ [week_key, slot_key, title, poster_url, href, created_by],
33
+ )
34
+ await self._db.commit()
35
+
36
+ async def list_motd_overrides(self, week_key: str) -> list[dict]:
37
+ return await self._fetch_all(
38
+ "SELECT * FROM motd_overrides WHERE week_key = ? ORDER BY slot_key",
39
+ [week_key],
40
+ )
41
+
42
+ async def delete_motd_override(self, week_key: str, slot_key: str) -> int:
43
+ cursor = await self._db.execute(
44
+ "DELETE FROM motd_overrides WHERE week_key = ? AND slot_key = ?",
45
+ [week_key, slot_key],
46
+ )
47
+ await self._db.commit()
48
+ return cursor.rowcount or 0
49
+
50
+ async def clear_motd_overrides(self, week_key: str) -> int:
51
+ cursor = await self._db.execute(
52
+ "DELETE FROM motd_overrides WHERE week_key = ?", [week_key]
53
+ )
54
+ await self._db.commit()
55
+ return cursor.rowcount or 0
@@ -1,5 +1,5 @@
1
1
  from pathlib import Path
2
- from pydantic import BaseModel, PrivateAttr
2
+ from pydantic import BaseModel, Field, PrivateAttr
3
3
  import json
4
4
 
5
5
 
@@ -21,8 +21,15 @@ class EmoteRehostConfig(BaseModel):
21
21
  inter_emote_delay_sec: float = 2.0
22
22
 
23
23
 
24
+ class MOTDLink(BaseModel):
25
+ """A footer link rendered under the MOTD poster grid."""
26
+
27
+ label: str
28
+ url: str
29
+
30
+
24
31
  class MOTDConfig(BaseModel):
25
- """Settings for the MOTD poster generator job."""
32
+ """Settings for the MOTD poster generator and publisher jobs."""
26
33
 
27
34
  # Filesystem path where poster images are written.
28
35
  poster_dir: str = "/home/mediacms.io/mediacms/static/motd_boxes"
@@ -31,6 +38,37 @@ class MOTDConfig(BaseModel):
31
38
  # Directory where the generated HTML snippet is written.
32
39
  output_dir: str = "~/kryten"
33
40
 
41
+ # --- motd_publish ---
42
+ # Jinja template under kryten_webqueue/templates/motd/.
43
+ template: str = "channel_z.html"
44
+ # Grid size; unresolved positions are filled with mystery boxes.
45
+ slots: int = 12
46
+ banner_url: str = "https://i.postimg.cc/jdr2mR8Y/1562479186-8-channel-Ztitlenew.png"
47
+ # {dates} is substituted with the weekend showtimes (e.g. "8/14 @ 6pm ET & ...").
48
+ headline: str = "CHANNEL Z WEEKEND MOVIE PLAYLIST ({dates})"
49
+ showtime: str = "6pm ET"
50
+ # Mystery boxes are drawn from the same branded placeholder pool the browse
51
+ # view uses (served from the webqueue /images mount); this absolutizes them
52
+ # for CyTube, which renders the MOTD off-site.
53
+ mystery_box_base_url: str = "https://queue.dropsugar.co"
54
+ # Single fallback used only when no branded placeholders are installed.
55
+ mystery_box_url: str = ""
56
+ mystery_box_href: str = "https://queue.dropsugar.co/"
57
+ links: list[MOTDLink] = Field(
58
+ default_factory=lambda: [
59
+ MOTDLink(
60
+ label="Join us on Reddit!",
61
+ url="https://www.reddit.com/r/Channel_Z/",
62
+ ),
63
+ MOTDLink(
64
+ label="See the queue and decide what's next!",
65
+ url="https://queue.dropsugar.co/",
66
+ ),
67
+ ]
68
+ )
69
+ # Max size accepted by the admin alternate-art upload endpoint.
70
+ upload_max_bytes: int = 5 * 1024 * 1024
71
+
34
72
 
35
73
  class FetchUrlsConfig(BaseModel):
36
74
  """Settings for the fetchurls job.
@@ -225,6 +225,67 @@ def _poster_filename(date: datetime.date, movie_idx: int, night: int) -> str:
225
225
  return f"art-{date:%Y-%m-%d}-movie{movie_idx}-night{night}.jpg"
226
226
 
227
227
 
228
+ def load_workbook_bytes(config, *, local_override: str = "", emit=None) -> bytes:
229
+ """Read the Channel Z workbook from SharePoint, or a local .xlsx fallback.
230
+
231
+ Mirrors ``fetchurls.run()``'s source resolution so every workbook-backed job
232
+ honours the same credentials and local-override precedence.
233
+ """
234
+
235
+ def _say(detail: dict) -> None:
236
+ if emit:
237
+ emit(detail)
238
+
239
+ fu = getattr(config, "fetchurls", None)
240
+ sp_tenant: str = getattr(fu, "sharepoint_tenant_id", "") if fu else ""
241
+ sp_client: str = getattr(fu, "sharepoint_client_id", "") if fu else ""
242
+ sp_share: str = getattr(fu, "sharepoint_sharing_url", "") if fu else ""
243
+ sp_cache: str = getattr(fu, "token_cache_path", "") if fu else ""
244
+ cfg_local: str = getattr(fu, "workbook_path", "") if fu else ""
245
+
246
+ if bool(sp_tenant and sp_client and sp_share) and not local_override:
247
+ _say({"phase": "auth"})
248
+ graph_token = acquire_graph_token_silent(sp_tenant, sp_client, sp_cache)
249
+ if not graph_token:
250
+ raise RuntimeError(
251
+ "SharePoint token unavailable — run: "
252
+ "python -m kryten_webqueue.jobs.fetchurls_auth"
253
+ )
254
+ _say({"phase": "download"})
255
+ wb_bytes, _, _ = download_sharepoint_xlsx(graph_token, sp_share)
256
+ return wb_bytes
257
+
258
+ workbook_path = local_override or cfg_local
259
+ if not workbook_path:
260
+ raise RuntimeError(
261
+ "No workbook source: configure fetchurls.sharepoint_* or "
262
+ "fetchurls.workbook_path"
263
+ )
264
+ wb_file = Path(workbook_path)
265
+ if not wb_file.exists():
266
+ raise RuntimeError(f"Workbook not found: {wb_file}")
267
+ return wb_file.read_bytes()
268
+
269
+
270
+ def resolve_weekend_sheet(wb_bytes: bytes, sheet_name: str) -> None:
271
+ """Raise a helpful RuntimeError when the expected weekend sheet is absent."""
272
+ try:
273
+ import openpyxl
274
+ except ImportError:
275
+ raise RuntimeError("openpyxl is required to read the weekend workbook")
276
+
277
+ wb_peek = openpyxl.load_workbook(io.BytesIO(wb_bytes), read_only=True)
278
+ all_sheets = wb_peek.sheetnames
279
+ wb_peek.close()
280
+ if sheet_name in all_sheets:
281
+ return
282
+ weekend_sheets = [s for s in all_sheets if _SHEET_DATE_RE.match(s.strip())]
283
+ raise RuntimeError(
284
+ f"Weekend sheet '{sheet_name}' not found. "
285
+ f"Available: {', '.join(weekend_sheets or all_sheets)}"
286
+ )
287
+
288
+
228
289
  def _generate_html(night_entries: list[dict], poster_base_url: str) -> str:
229
290
  """Build the <div class="poster-grid"> snippet from processed entries."""
230
291
  base = poster_base_url.rstrip("/")
@@ -0,0 +1,199 @@
1
+ """MOTD publish job — build the weekend poster grid and set the channel MOTD.
2
+
3
+ Resolves the current weekend's line-up from the schedule workbook, downloads
4
+ poster art, renders the Jinja snippet, and pushes it to CyTube through
5
+ api-gate's ``PUT /admin/motd``. Unresolvable or not-yet-scheduled positions
6
+ become mystery boxes, so a run always produces a complete grid and self-heals
7
+ on the next run once a curator fixes a title.
8
+
9
+ Admin-supplied per-slot overrides (custom art, hand-picked title, non-IMDb
10
+ link) are stored per workbook week and always win over the lookup.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import datetime
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ from ..motd.builder import build_slots, mystery_pool
21
+ from ..motd.render import render_motd
22
+ from .manager import JobError
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ MOTD_PUBLISH_SCHEMA = [
27
+ {
28
+ "name": "publish",
29
+ "label": "Publish to channel",
30
+ "type": "bool",
31
+ "default": True,
32
+ "required": False,
33
+ "help": "Push the rendered snippet to CyTube's MOTD. Turn off to only write the HTML file.",
34
+ },
35
+ {
36
+ "name": "dry_run",
37
+ "label": "Dry run (render only)",
38
+ "type": "bool",
39
+ "default": False,
40
+ "required": False,
41
+ "help": "Resolve titles and render the snippet without downloading art, writing files, or touching the channel.",
42
+ },
43
+ {
44
+ "name": "refresh_art",
45
+ "label": "Re-download poster art",
46
+ "type": "bool",
47
+ "default": False,
48
+ "required": False,
49
+ "help": "Fetch poster art again even when the file already exists on disk.",
50
+ },
51
+ {
52
+ "name": "week",
53
+ "label": "Which weekend",
54
+ "type": "enum",
55
+ "default": "current",
56
+ "required": False,
57
+ "options": [
58
+ {"value": "current", "label": "This weekend (rolls over Monday)"},
59
+ {
60
+ "value": "next",
61
+ "label": "Next weekend — debut early, while weekend traffic is still here",
62
+ },
63
+ ],
64
+ "help": "The sheet rolls forward on Monday; pick 'next' to publish the coming weekend's grid on Sunday.",
65
+ },
66
+ {
67
+ "name": "workbook_path",
68
+ "label": "Local workbook path (override SharePoint)",
69
+ "type": "string",
70
+ "default": None,
71
+ "required": False,
72
+ },
73
+ ]
74
+
75
+
76
+ def _humanize_delta(seconds: float) -> str:
77
+ if seconds < 90:
78
+ return "starting now"
79
+ minutes = int(seconds // 60)
80
+ if minutes < 60:
81
+ return f"in {minutes} min"
82
+ hours, minutes = divmod(minutes, 60)
83
+ if hours < 24:
84
+ return f"in {hours}h {minutes:02d}m" if minutes else f"in {hours}h"
85
+ days = hours // 24
86
+ return f"in {days} day{'s' if days != 1 else ''}"
87
+
88
+
89
+ async def _next_event(db) -> dict | None:
90
+ """Summarise the next armed playlist schedule for the MOTD context."""
91
+ row = await db.get_next_schedule()
92
+ if not row:
93
+ return None
94
+ fire_at = row.get("fire_at")
95
+ starts_in = ""
96
+ try:
97
+ when = datetime.datetime.fromisoformat(str(fire_at).replace("Z", "+00:00"))
98
+ if when.tzinfo is None:
99
+ when = when.replace(tzinfo=datetime.timezone.utc)
100
+ delta = (when - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
101
+ if delta > 0:
102
+ starts_in = _humanize_delta(delta)
103
+ except (TypeError, ValueError):
104
+ logger.debug("motd_publish: unparseable fire_at %r", fire_at)
105
+ return {
106
+ "schedule_id": row.get("id"),
107
+ "playlist_id": row.get("playlist_id"),
108
+ "label": row.get("label") or "Scheduled event",
109
+ "fire_at": fire_at,
110
+ "starts_in": starts_in,
111
+ }
112
+
113
+
114
+ async def motd_publish_job(params: dict, ctx) -> dict:
115
+ """Build, render, and publish the weekend MOTD."""
116
+ dry_run = bool(params.get("dry_run", False))
117
+ publish = bool(params.get("publish", True)) and not dry_run
118
+ refresh_art = bool(params.get("refresh_art", False))
119
+ workbook_path = params.get("workbook_path") or ""
120
+ week_offset = 1 if params.get("week") == "next" else 0
121
+
122
+ from ..motd.builder import week_context
123
+
124
+ week_key, _ = week_context(week_offset=week_offset)
125
+ overrides = {
126
+ row["slot_key"]: row for row in await ctx.db.list_motd_overrides(week_key)
127
+ }
128
+
129
+ loop = asyncio.get_running_loop()
130
+
131
+ def _emit(detail: dict) -> None:
132
+ asyncio.run_coroutine_threadsafe(ctx.progress(detail), loop)
133
+
134
+ try:
135
+ week = await asyncio.to_thread(
136
+ build_slots,
137
+ ctx.config,
138
+ overrides=overrides,
139
+ workbook_path=workbook_path,
140
+ refresh_art=refresh_art,
141
+ dry_run=dry_run,
142
+ week_offset=week_offset,
143
+ mystery_urls=mystery_pool(ctx.config, getattr(ctx, "cover_art", None)),
144
+ emit=_emit,
145
+ )
146
+ except RuntimeError as exc:
147
+ raise JobError(str(exc)) from exc
148
+
149
+ next_event = await _next_event(ctx.db)
150
+ html = render_motd(ctx.config, week, next_event=next_event)
151
+
152
+ output_path: str | None = None
153
+ if not dry_run:
154
+ out_dir = Path(ctx.config.motd.output_dir).expanduser()
155
+ out_dir.mkdir(parents=True, exist_ok=True)
156
+ target = out_dir / f"motd-{week.week_key}.html"
157
+ await asyncio.to_thread(target.write_text, html, encoding="utf-8")
158
+ output_path = str(target)
159
+ logger.info("motd_publish: wrote snippet → %s", target)
160
+
161
+ published = False
162
+ if publish:
163
+ try:
164
+ await ctx.api_gate.set_motd(html)
165
+ published = True
166
+ logger.info("motd_publish: MOTD updated for %s", week.week_key)
167
+ except Exception as exc: # noqa: BLE001 - surfaced as a job failure message
168
+ raise JobError(f"Failed to set channel MOTD: {exc}") from exc
169
+
170
+ resolved = sum(1 for s in week.slots if s.resolved)
171
+ overridden = sum(1 for s in week.slots if s.source == "override")
172
+ mystery = [s.slot_key for s in week.slots if not s.resolved]
173
+
174
+ logger.info(
175
+ "motd_publish: %s — %d/%d slots resolved (%d overridden, %d mystery)",
176
+ week.week_key,
177
+ resolved,
178
+ len(week.slots),
179
+ overridden,
180
+ len(mystery),
181
+ )
182
+
183
+ result = {
184
+ "week_key": week.week_key,
185
+ "slots": len(week.slots),
186
+ "resolved": resolved,
187
+ "overridden": overridden,
188
+ "mystery": len(mystery),
189
+ "mystery_slots": mystery,
190
+ "workbook_found": week.workbook_found,
191
+ "warnings": week.warnings,
192
+ "next_event": next_event,
193
+ "published": published,
194
+ "output_path": output_path,
195
+ "dry_run": dry_run,
196
+ "html_bytes": len(html),
197
+ }
198
+ await ctx.progress({"phase": "complete", **result})
199
+ return result
@@ -521,6 +521,19 @@ async def fetchurls_job(params: dict, ctx):
521
521
  pm.get("failed", 0),
522
522
  )
523
523
 
524
+ # Curators fix titles in the workbook, so a fetchurls pass is exactly when a
525
+ # previously-unresolvable MOTD slot becomes resolvable. Best-effort: a MOTD
526
+ # failure must not fail the URL resolution that already succeeded.
527
+ if params.get("publish_motd", True) and ctx.job_manager:
528
+ try:
529
+ await ctx.job_manager.run(
530
+ "motd_publish", triggered_by=ctx.triggered_by or "fetchurls"
531
+ )
532
+ result["motd_publish_started"] = True
533
+ except Exception as exc: # noqa: BLE001
534
+ logger.warning("fetchurls: could not start motd_publish: %s", exc)
535
+ result["motd_publish_started"] = False
536
+
524
537
  return result
525
538
 
526
539
 
@@ -649,6 +662,12 @@ FETCHURLS_SCHEMA = [
649
662
  "default": None,
650
663
  "label": "Local workbook path (override SharePoint)",
651
664
  },
665
+ {
666
+ "name": "publish_motd",
667
+ "type": "bool",
668
+ "default": True,
669
+ "label": "Re-publish the weekend MOTD afterwards",
670
+ },
652
671
  ]
653
672
 
654
673
  MOTD_POSTERS_SCHEMA = [
@@ -0,0 +1,13 @@
1
+ """Weekend MOTD generation — resolve the line-up, render it, publish it.
2
+
3
+ The builder turns the current weekend's workbook sheet into a fixed-size grid
4
+ of poster slots, resolving each curator-supplied ``Title (YYYY)`` to an IMDb
5
+ tt# and poster art. Slots that cannot be resolved (or that have no title yet)
6
+ become mystery boxes, so the grid is always complete and self-heals on the next
7
+ run once a curator fixes their title.
8
+ """
9
+
10
+ from .builder import MOTDSlot, build_slots, week_context
11
+ from .render import render_motd
12
+
13
+ __all__ = ["MOTDSlot", "build_slots", "week_context", "render_motd"]