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