kryten-webqueue 0.44.6__tar.gz → 0.45.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.
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/CHANGELOG.md +59 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/PKG-INFO +2 -1
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/config.example.json +21 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/api_gate/client.py +3 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/app.py +10 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/__init__.py +2 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_catalog.py +10 -18
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_connection.py +29 -0
- kryten_webqueue-0.45.0/kryten_webqueue/catalog/db/_motd.py +55 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/config.py +35 -2
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +61 -0
- kryten_webqueue-0.45.0/kryten_webqueue/jobs/motd_publish.py +198 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/tasks.py +19 -0
- kryten_webqueue-0.45.0/kryten_webqueue/motd/__init__.py +13 -0
- kryten_webqueue-0.45.0/kryten_webqueue/motd/builder.py +284 -0
- kryten_webqueue-0.45.0/kryten_webqueue/motd/render.py +80 -0
- kryten_webqueue-0.45.0/kryten_webqueue/routes/admin_motd.py +195 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/pages.py +8 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/css/main.css +46 -0
- kryten_webqueue-0.45.0/kryten_webqueue/static/js/admin-motd.js +214 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/admin/index.html +1 -0
- kryten_webqueue-0.45.0/kryten_webqueue/templates/admin/motd.html +53 -0
- kryten_webqueue-0.45.0/kryten_webqueue/templates/motd/channel_z.html +32 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/pyproject.toml +3 -1
- kryten_webqueue-0.45.0/tests/test_motd_publish.py +257 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/.gitignore +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/AGENTS.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/README.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/PUBLIC_API.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/auth/device_keys.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/log_capture.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/devices.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/public_api.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/keybindings.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/user/link_device.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/scripts/AGENTS.md +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/scripts/patch_mediacms_tags.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/scripts/trigger_enrich.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_catalog_blackout.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_catalog_delete.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_classify.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_device_linking.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_fetch_queue_pacing.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_identify_step.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_job_logs_and_queue_visibility.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_providers.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_queue_preview_fallback.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_tmdb_index.py +0 -0
- {kryten_webqueue-0.44.6 → kryten_webqueue-0.45.0}/tests/test_watchlist.py +0 -0
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.45.0] - 2026-09-10
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`motd_publish` job** — builds the weekend poster grid from the schedule
|
|
8
|
+
workbook and sets the channel Message of the Day through api-gate's
|
|
9
|
+
`PUT /admin/motd`. Each curator title (`Title (YYYY)`) is resolved to an IMDb
|
|
10
|
+
tt# and poster art; positions with no title yet, or a title that can't be
|
|
11
|
+
verified, render as **mystery boxes** so the grid is always complete and
|
|
12
|
+
self-heals on the next run once the title is fixed. A missing or unreadable
|
|
13
|
+
workbook degrades to an all-mystery grid rather than failing the run.
|
|
14
|
+
- **Per-week MOTD slot overrides** (migration **v30**, `motd_overrides`). An
|
|
15
|
+
admin can pin a hand-picked title, curator-supplied art, or a non-IMDb link
|
|
16
|
+
for any grid position; overrides always beat the lookup and retire on their
|
|
17
|
+
own when the workbook week rolls over. New admin API under `/admin/motd`:
|
|
18
|
+
`GET` (preview the resolved grid + live MOTD), `GET /render`,
|
|
19
|
+
`PUT`/`DELETE /overrides/{slot_key}`, `POST /overrides/{slot_key}/art`
|
|
20
|
+
(alternate-art upload), and `POST /publish`.
|
|
21
|
+
- **Admin MOTD page** at `/admin/motd` (linked from the admin nav): shows the
|
|
22
|
+
resolved grid night by night with a resolved/override/mystery badge per slot,
|
|
23
|
+
an override editor (title, link, poster URL, or an art upload), an
|
|
24
|
+
HTML preview, the currently-live MOTD, and a one-click publish.
|
|
25
|
+
- **Early debut.** The workbook sheet rolls forward on Monday; `week=next`
|
|
26
|
+
(job param and admin query string) publishes the coming weekend's grid on
|
|
27
|
+
Sunday, while weekend traffic is still watching.
|
|
28
|
+
- **`fetchurls` now chains `motd_publish`** (new `publish_motd` param, on by
|
|
29
|
+
default) — a curator's corrected title re-publishes the MOTD in the same
|
|
30
|
+
pass. Best-effort: a MOTD failure never fails URL resolution.
|
|
31
|
+
- **Next-event context.** The rendered snippet carries the next armed playlist
|
|
32
|
+
schedule (`label`, `starts_in`), laying the groundwork for swapping the MOTD
|
|
33
|
+
on each scheduled playlist fire.
|
|
34
|
+
- `ApiGateClient.set_motd()`; `MOTDConfig` gained `template`, `slots`,
|
|
35
|
+
`banner_url`, `headline`, `showtime`, `mystery_box_url`, `mystery_box_href`,
|
|
36
|
+
`links`, and `upload_max_bytes` (see `config.example.json`).
|
|
37
|
+
|
|
38
|
+
### Security
|
|
39
|
+
|
|
40
|
+
- The MOTD template renders with Jinja autoescape on and a `safe_url` filter
|
|
41
|
+
that rejects any non-`http(s)` URL, so untrusted curator titles and override
|
|
42
|
+
links can't inject markup or `javascript:` URLs into the channel MOTD. Slot
|
|
43
|
+
keys are regex-validated and art uploads are restricted by content type and
|
|
44
|
+
size.
|
|
45
|
+
|
|
46
|
+
## [0.44.7] - 2026-08-31
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- **SQLite WAL corruption mitigation.** The catalog DB connection now sets
|
|
51
|
+
`synchronous=NORMAL` (safe in WAL mode), `busy_timeout=5000` (retry on
|
|
52
|
+
lock contention instead of failing immediately), `wal_autocheckpoint=100`
|
|
53
|
+
(checkpoint every 100 pages instead of the 1000-page default, keeping the
|
|
54
|
+
WAL small), and runs a passive checkpoint on every connect to drain any
|
|
55
|
+
stale frames left by an unclean previous run.
|
|
56
|
+
- **`delete_stale_catalog_items` rewritten to use subqueries.** Previously
|
|
57
|
+
built a potentially huge `IN (?, ?, …)` parameter list (one bind for every
|
|
58
|
+
stale token) before issuing two separate bulk DELETEs. Now uses correlated
|
|
59
|
+
subqueries instead, eliminating the SELECT round-trip and the large
|
|
60
|
+
parameter list, reducing per-sync WAL pressure.
|
|
61
|
+
|
|
3
62
|
## [0.44.6] - 2026-08-30
|
|
4
63
|
|
|
5
64
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.45.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
|
|
@@ -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,26 @@
|
|
|
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_url": "https://www.dropsugar.co/static/motd_boxes/mystery-box.jpg",
|
|
99
|
+
"mystery_box_href": "https://queue.dropsugar.co/",
|
|
100
|
+
"upload_max_bytes": 5242880,
|
|
101
|
+
"links": [
|
|
102
|
+
{ "label": "Join us on Reddit!", "url": "https://www.reddit.com/r/Channel_Z/" },
|
|
103
|
+
{
|
|
104
|
+
"label": "See the queue and decide what's next!",
|
|
105
|
+
"url": "https://queue.dropsugar.co/"
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
},
|
|
109
|
+
|
|
89
110
|
"prometheus_port": 28292
|
|
90
111
|
}
|
|
@@ -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."""
|
|
@@ -1042,30 +1042,22 @@ class _CatalogMixin:
|
|
|
1042
1042
|
- catalog_people
|
|
1043
1043
|
- catalog_studios
|
|
1044
1044
|
"""
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
[sync_started_at],
|
|
1048
|
-
)
|
|
1049
|
-
tokens = [row[0] for row in await cursor.fetchall()]
|
|
1050
|
-
|
|
1051
|
-
if not tokens:
|
|
1052
|
-
return 0
|
|
1053
|
-
|
|
1054
|
-
# Delete from FTS first (not automatically cascaded)
|
|
1055
|
-
placeholders = ",".join("?" * len(tokens))
|
|
1045
|
+
# Delete from FTS first (not automatically cascaded); use subquery to avoid
|
|
1046
|
+
# building a potentially huge IN-list parameter list for large syncs.
|
|
1056
1047
|
await self._db.execute(
|
|
1057
|
-
|
|
1058
|
-
|
|
1048
|
+
"DELETE FROM catalog_fts WHERE friendly_token IN "
|
|
1049
|
+
"(SELECT friendly_token FROM catalog WHERE synced_at < ? OR synced_at IS NULL)",
|
|
1050
|
+
[sync_started_at],
|
|
1059
1051
|
)
|
|
1060
1052
|
|
|
1061
1053
|
# Delete from catalog (cascades to join tables via ON DELETE CASCADE)
|
|
1062
|
-
await self._db.execute(
|
|
1063
|
-
|
|
1064
|
-
|
|
1054
|
+
cursor = await self._db.execute(
|
|
1055
|
+
"DELETE FROM catalog WHERE synced_at < ? OR synced_at IS NULL",
|
|
1056
|
+
[sync_started_at],
|
|
1065
1057
|
)
|
|
1066
|
-
|
|
1058
|
+
count = cursor.rowcount
|
|
1067
1059
|
await self._db.commit()
|
|
1068
|
-
return
|
|
1060
|
+
return count or 0
|
|
1069
1061
|
|
|
1070
1062
|
async def find_catalog_by_title(self, title: str) -> dict | None:
|
|
1071
1063
|
"""Best-effort lookup of a catalog item whose title matches ``title``.
|
|
@@ -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,6 +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")
|
|
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
|
|
552
581
|
|
|
553
582
|
async def close(self):
|
|
554
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
|
|
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,32 @@ 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_box_url: str = "https://www.dropsugar.co/static/motd_boxes/mystery-box.jpg"
|
|
51
|
+
mystery_box_href: str = "https://queue.dropsugar.co/"
|
|
52
|
+
links: list[MOTDLink] = Field(
|
|
53
|
+
default_factory=lambda: [
|
|
54
|
+
MOTDLink(
|
|
55
|
+
label="Join us on Reddit!",
|
|
56
|
+
url="https://www.reddit.com/r/Channel_Z/",
|
|
57
|
+
),
|
|
58
|
+
MOTDLink(
|
|
59
|
+
label="See the queue and decide what's next!",
|
|
60
|
+
url="https://queue.dropsugar.co/",
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
# Max size accepted by the admin alternate-art upload endpoint.
|
|
65
|
+
upload_max_bytes: int = 5 * 1024 * 1024
|
|
66
|
+
|
|
34
67
|
|
|
35
68
|
class FetchUrlsConfig(BaseModel):
|
|
36
69
|
"""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,198 @@
|
|
|
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
|
|
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
|
+
emit=_emit,
|
|
144
|
+
)
|
|
145
|
+
except RuntimeError as exc:
|
|
146
|
+
raise JobError(str(exc)) from exc
|
|
147
|
+
|
|
148
|
+
next_event = await _next_event(ctx.db)
|
|
149
|
+
html = render_motd(ctx.config, week, next_event=next_event)
|
|
150
|
+
|
|
151
|
+
output_path: str | None = None
|
|
152
|
+
if not dry_run:
|
|
153
|
+
out_dir = Path(ctx.config.motd.output_dir).expanduser()
|
|
154
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
155
|
+
target = out_dir / f"motd-{week.week_key}.html"
|
|
156
|
+
await asyncio.to_thread(target.write_text, html, encoding="utf-8")
|
|
157
|
+
output_path = str(target)
|
|
158
|
+
logger.info("motd_publish: wrote snippet → %s", target)
|
|
159
|
+
|
|
160
|
+
published = False
|
|
161
|
+
if publish:
|
|
162
|
+
try:
|
|
163
|
+
await ctx.api_gate.set_motd(html)
|
|
164
|
+
published = True
|
|
165
|
+
logger.info("motd_publish: MOTD updated for %s", week.week_key)
|
|
166
|
+
except Exception as exc: # noqa: BLE001 - surfaced as a job failure message
|
|
167
|
+
raise JobError(f"Failed to set channel MOTD: {exc}") from exc
|
|
168
|
+
|
|
169
|
+
resolved = sum(1 for s in week.slots if s.resolved)
|
|
170
|
+
overridden = sum(1 for s in week.slots if s.source == "override")
|
|
171
|
+
mystery = [s.slot_key for s in week.slots if not s.resolved]
|
|
172
|
+
|
|
173
|
+
logger.info(
|
|
174
|
+
"motd_publish: %s — %d/%d slots resolved (%d overridden, %d mystery)",
|
|
175
|
+
week.week_key,
|
|
176
|
+
resolved,
|
|
177
|
+
len(week.slots),
|
|
178
|
+
overridden,
|
|
179
|
+
len(mystery),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
result = {
|
|
183
|
+
"week_key": week.week_key,
|
|
184
|
+
"slots": len(week.slots),
|
|
185
|
+
"resolved": resolved,
|
|
186
|
+
"overridden": overridden,
|
|
187
|
+
"mystery": len(mystery),
|
|
188
|
+
"mystery_slots": mystery,
|
|
189
|
+
"workbook_found": week.workbook_found,
|
|
190
|
+
"warnings": week.warnings,
|
|
191
|
+
"next_event": next_event,
|
|
192
|
+
"published": published,
|
|
193
|
+
"output_path": output_path,
|
|
194
|
+
"dry_run": dry_run,
|
|
195
|
+
"html_bytes": len(html),
|
|
196
|
+
}
|
|
197
|
+
await ctx.progress({"phase": "complete", **result})
|
|
198
|
+
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 = [
|