kryten-webqueue 0.40.4__tar.gz → 0.42.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.42.0}/CHANGELOG.md +71 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/PKG-INFO +1 -1
- kryten_webqueue-0.42.0/docs/PUBLIC_API.md +289 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/app.py +38 -0
- kryten_webqueue-0.42.0/kryten_webqueue/auth/device_keys.py +99 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/__init__.py +4 -8
- kryten_webqueue-0.42.0/kryten_webqueue/catalog/db/_blackouts.py +55 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_catalog.py +105 -40
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_connection.py +52 -0
- kryten_webqueue-0.42.0/kryten_webqueue/catalog/db/_devices.py +119 -0
- kryten_webqueue-0.42.0/kryten_webqueue/integrations/cmsutils/blackout_scan.py +181 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/jobs/tasks.py +136 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_moderation.py +24 -1
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_queue.py +3 -1
- kryten_webqueue-0.42.0/kryten_webqueue/routes/devices.py +111 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/pages.py +17 -1
- kryten_webqueue-0.42.0/kryten_webqueue/routes/public_api.py +214 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/css/main.css +121 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/catalog/browse.html +3 -1
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/catalog/item_detail.html +6 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/user/dashboard.html +4 -0
- kryten_webqueue-0.42.0/kryten_webqueue/templates/user/link_device.html +152 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/pyproject.toml +1 -1
- kryten_webqueue-0.42.0/tests/test_catalog_blackout.py +188 -0
- kryten_webqueue-0.42.0/tests/test_device_linking.py +295 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/.gitignore +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/AGENTS.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/README.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/config.example.json +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/keybindings.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/scripts/AGENTS.md +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/scripts/patch_mediacms_tags.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/scripts/trigger_enrich.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_catalog_delete.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_classify.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_identify_step.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_providers.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_tmdb_index.py +0 -0
- {kryten_webqueue-0.40.4 → kryten_webqueue-0.42.0}/tests/test_watchlist.py +0 -0
|
@@ -2,6 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.42.0] - 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Public API for third-party client apps (Smart TV / tablet device linking).**
|
|
10
|
+
A new key-authenticated API under `/api/public/v1` exposes the live channel
|
|
11
|
+
state so an external application can render the equivalent of the `/queue`
|
|
12
|
+
page. See [docs/PUBLIC_API.md](docs/PUBLIC_API.md) for the full contract and a
|
|
13
|
+
developer's guide.
|
|
14
|
+
- **Device linking flow.** A logged-in user visits **`/link`** ("Link a
|
|
15
|
+
Device", also reachable from the account/Z-coin dashboard), names the
|
|
16
|
+
device, and receives a **5-character, single-use, uppercase-alphanumeric
|
|
17
|
+
one-time pad** (10-minute TTL, unambiguous alphabet). The device redeems it
|
|
18
|
+
at **`POST /api/public/v1/link`** and receives a long-lived API key
|
|
19
|
+
(`Authorization: Bearer <key>`). The pad is destroyed on redemption (one key
|
|
20
|
+
per pad); a user may link multiple devices, each with its own named key.
|
|
21
|
+
- **Data endpoints (Bearer key required):** `GET /api/public/v1/current`
|
|
22
|
+
(now playing — title, synopsis, total/elapsed/remaining time),
|
|
23
|
+
`GET /api/public/v1/queue` (ordered queue with predicted start times), and
|
|
24
|
+
`GET /api/public/v1/events` (upcoming enabled scheduled playlists).
|
|
25
|
+
- **Device management.** `GET/POST/DELETE /user/devices*` (session-cookie) back
|
|
26
|
+
a dashboard list where users name, view, and **revoke** linked devices. Keys
|
|
27
|
+
are stored only as a SHA-256 hash plus a non-secret display prefix; the
|
|
28
|
+
plaintext key is returned exactly once at redemption and never again.
|
|
29
|
+
- **Revoke-on-ban.** Issuing a `ban` through the webqueue admin moderation UI
|
|
30
|
+
now also revokes that user's device keys. A scheduled
|
|
31
|
+
**`device_key_ban_reconcile`** job additionally reconciles key holders
|
|
32
|
+
against kryten-moderator's authoritative ban list (via api-gate,
|
|
33
|
+
case-insensitive) to cover bans issued directly in CyTube or by the
|
|
34
|
+
moderator service. It is seeded to run every 15 minutes by default
|
|
35
|
+
(adjustable/disable-able via the admin job scheduler; deactivate rather than
|
|
36
|
+
delete, as a deleted schedule is re-seeded on startup).
|
|
37
|
+
- **Abuse protection.** Per-user throttling on pad generation and per-IP
|
|
38
|
+
throttling on the redemption endpoint; strict code-format validation.
|
|
39
|
+
- New DB migration **v27** (`device_link_codes`, `device_api_keys`).
|
|
40
|
+
|
|
41
|
+
## [0.41.0] - 2026-08-24
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **Reserved (immutable / promo-pool) items were leaking into the public
|
|
46
|
+
catalog.** The browse/search/`get_item`/`is_restricted`/tag filters excluded
|
|
47
|
+
reserved items by comparing `catalog.friendly_token` against
|
|
48
|
+
`saved_playlist_items.media_id` — but `media_id` stores the CyTube **manifest
|
|
49
|
+
URL** (e.g. `.../api/v1/media/cytube/<token>.json?format=json`), never the bare
|
|
50
|
+
token, so the `NOT IN` never matched and **every** item in Friday Night /
|
|
51
|
+
Saturday / promo pools stayed visible and pay-queueable. All six filter sites
|
|
52
|
+
now resolve `media_id` (manifest URL *or* legacy bare token) back to a
|
|
53
|
+
friendly_token via the catalog before excluding. This restores the intended
|
|
54
|
+
"reserved items are hidden from browse/search and rejected by pay-to-play"
|
|
55
|
+
behaviour.
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- **Weekend blackout window (hides upcoming-weekend items until they've aired).**
|
|
60
|
+
Items scheduled on an upcoming weekend sheet must not appear in the catalog —
|
|
61
|
+
but only the *next* weekend is ever materialised as an immutable playlist, so
|
|
62
|
+
films on later weekends (or added directly to column F) had nothing hiding
|
|
63
|
+
them. A new `catalog_blackouts` table (migration v26) hides a rehosted item
|
|
64
|
+
from regular users until the end of the **following** weekend, keeping replays
|
|
65
|
+
hidden through that week. Keyed on the bare `friendly_token`.
|
|
66
|
+
- New standalone **`catalog_blackout`** maintenance job scans the workbook's
|
|
67
|
+
current/future weekend sheets (columns **E** and **F**) for dropsugar URLs,
|
|
68
|
+
resolves them to friendly_tokens, and blacks each out. It downloads nothing;
|
|
69
|
+
it reuses the fetchurls SharePoint/local workbook source. Runnable from the
|
|
70
|
+
admin Jobs tab and schedulable via cron.
|
|
71
|
+
- Blacked-out items are hidden from browse, search, `get_item`, pay-to-play,
|
|
72
|
+
and the item detail page for regular users. **Admins** still see them (with
|
|
73
|
+
"Show hidden items" on), visually **dimmed** with a `BLACKOUT` badge, and can
|
|
74
|
+
queue them manually (`/admin/queue/add` bypasses the visibility filters).
|
|
75
|
+
|
|
5
76
|
## [0.40.4] - 2026-08-21
|
|
6
77
|
|
|
7
78
|
### Added
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# Public API — Developer's Guide
|
|
2
|
+
|
|
3
|
+
The kryten-webqueue **Public API** lets approved third-party applications (Smart
|
|
4
|
+
TV and tablet clients) read the live channel state — what's playing now, the
|
|
5
|
+
upcoming pay-to-play queue, and scheduled events — using a long-lived API key.
|
|
6
|
+
|
|
7
|
+
- **Base URL:** `https://queue.dropsugar.co`
|
|
8
|
+
- **API root:** `/api/public/v1`
|
|
9
|
+
- **Auth:** `Authorization: Bearer <api_key>` on every data request.
|
|
10
|
+
- **Format:** JSON, UTF-8. All timestamps are ISO-8601 (UTC, `+00:00`).
|
|
11
|
+
|
|
12
|
+
Every externally callable endpoint lives under `/api/public/v1`. The only
|
|
13
|
+
endpoint that does **not** require the `Authorization` header is the bootstrap
|
|
14
|
+
exchange (`POST /api/public/v1/link`), which trades a one-time code for a key.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. Linking flow (how a device gets a key)
|
|
19
|
+
|
|
20
|
+
Keys are minted through a short, user-driven pairing flow so a device never
|
|
21
|
+
handles the user's credentials.
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
┌────────────┐ 1. names device, gets code ┌──────────────────┐
|
|
25
|
+
│ Browser │ ───────────────────────────────▶│ queue.dropsugar │
|
|
26
|
+
│ (logged in)│ GET /link → "AB2CD" │ .co │
|
|
27
|
+
└────────────┘ └──────────────────┘
|
|
28
|
+
│ 2. user reads the 5-char code aloud / types it
|
|
29
|
+
▼
|
|
30
|
+
┌────────────┐ 3. POST /api/public/v1/link ┌──────────────────┐
|
|
31
|
+
│ TV / app │ ───────────────────────────────▶│ queue.dropsugar │
|
|
32
|
+
│ │ { "code": "AB2CD" } │ .co │
|
|
33
|
+
│ │ ◀───────────────────────────────│ │
|
|
34
|
+
└────────────┘ 4. { "api_key": "kqd_…" } └──────────────────┘
|
|
35
|
+
│ 5. store the key; send it as a Bearer token thereafter
|
|
36
|
+
▼
|
|
37
|
+
GET /api/public/v1/current, /queue, /events …
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
**Rules the client must respect**
|
|
41
|
+
|
|
42
|
+
1. The user first names the device in the browser (`/link`) and receives a
|
|
43
|
+
**5-character, uppercase alphanumeric** code.
|
|
44
|
+
2. The code is a **one-time pad**: valid for **10 minutes** and usable **once**.
|
|
45
|
+
On a successful exchange it is destroyed and cannot be reused.
|
|
46
|
+
3. Codes use an unambiguous alphabet (no `O`, `0`, `I`, `1`). Uppercase the
|
|
47
|
+
user's input before sending; the server also normalizes.
|
|
48
|
+
4. The API key is returned **exactly once**, in the exchange response. Store it
|
|
49
|
+
securely on the device — it is never retrievable again. If lost, the user
|
|
50
|
+
revokes the device and links again.
|
|
51
|
+
5. A user may link multiple devices; each has its own key and name.
|
|
52
|
+
6. A key is valid indefinitely until the user revokes it (or the user is
|
|
53
|
+
banned, which revokes all of their keys).
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 2. Endpoints
|
|
58
|
+
|
|
59
|
+
### 2.1 `POST /api/public/v1/link` — exchange a code for a key
|
|
60
|
+
|
|
61
|
+
Bootstrap only. **No** `Authorization` header.
|
|
62
|
+
|
|
63
|
+
**Request body**
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"code": "AB2CD"
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Field | Type | Notes |
|
|
72
|
+
|-------|--------|--------------------------------------------------|
|
|
73
|
+
| code | string | The 5-char pad from `/link`. Case-insensitive. |
|
|
74
|
+
|
|
75
|
+
**200 response**
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"api_key": "kqd_9f3a1c7b2e5d4a6f8091b2c3d4e5f60718293a4b5c6d7e8f",
|
|
80
|
+
"token_type": "Bearer",
|
|
81
|
+
"device_id": 12,
|
|
82
|
+
"device_name": "Living Room TV",
|
|
83
|
+
"username": "alice"
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Field | Type | Notes |
|
|
88
|
+
|-------------|---------|------------------------------------------------|
|
|
89
|
+
| api_key | string | Full secret. Send as `Authorization: Bearer …`. Shown once. |
|
|
90
|
+
| token_type | string | Always `"Bearer"`. |
|
|
91
|
+
| device_id | integer | Server-side id for this linked device. |
|
|
92
|
+
| device_name | string | The name the user chose. |
|
|
93
|
+
| username | string | The account the key acts on behalf of. |
|
|
94
|
+
|
|
95
|
+
**Errors**
|
|
96
|
+
|
|
97
|
+
| Status | Meaning |
|
|
98
|
+
|--------|-----------------------------------------------------|
|
|
99
|
+
| 400 | Code is malformed (wrong length / illegal chars). |
|
|
100
|
+
| 404 | Code is unknown, already used, or expired. |
|
|
101
|
+
| 429 | Too many attempts from this client; back off. |
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### 2.2 `GET /api/public/v1/current` — now playing
|
|
106
|
+
|
|
107
|
+
Requires `Authorization: Bearer <key>`.
|
|
108
|
+
|
|
109
|
+
**200 response (something playing)**
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"playing": true,
|
|
114
|
+
"item": {
|
|
115
|
+
"title": "The Blob",
|
|
116
|
+
"friendly_token": "aB3xY",
|
|
117
|
+
"synopsis": "A gelatinous alien terrorises a small town.",
|
|
118
|
+
"duration_sec": 5400,
|
|
119
|
+
"current_time_sec": 1234.5,
|
|
120
|
+
"remaining_sec": 4165.5,
|
|
121
|
+
"cover_art_url": "https://queue.dropsugar.co/images/aB3xY/500.webp",
|
|
122
|
+
"categories": ["Horror", "Sci-Fi"],
|
|
123
|
+
"tags": ["1958", "cult"]
|
|
124
|
+
},
|
|
125
|
+
"updated_at": "2026-08-24T21:40:00+00:00"
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**200 response (nothing playing)**
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{ "playing": false, "item": null, "updated_at": "2026-08-24T21:40:00+00:00" }
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
| Field | Type | Notes |
|
|
136
|
+
|------------------------|-----------------|------------------------------------------|
|
|
137
|
+
| playing | boolean | `false` when the channel is idle. |
|
|
138
|
+
| item.title | string | Display title. |
|
|
139
|
+
| item.friendly_token | string \| null | Catalog id (stable per item). |
|
|
140
|
+
| item.synopsis | string \| null | Plot / description. |
|
|
141
|
+
| item.duration_sec | integer \| null | Total runtime in seconds. |
|
|
142
|
+
| item.current_time_sec | number | Elapsed playback position, seconds. |
|
|
143
|
+
| item.remaining_sec | number \| null | Time left, seconds. |
|
|
144
|
+
| item.cover_art_url | string \| null | Absolute poster URL. |
|
|
145
|
+
| item.categories | string[] | Category names. |
|
|
146
|
+
| item.tags | string[] | Tag names. |
|
|
147
|
+
| updated_at | string | When the snapshot was produced. |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### 2.3 `GET /api/public/v1/queue` — the upcoming queue
|
|
152
|
+
|
|
153
|
+
Requires `Authorization: Bearer <key>`.
|
|
154
|
+
|
|
155
|
+
**200 response**
|
|
156
|
+
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"items": [
|
|
160
|
+
{
|
|
161
|
+
"position": 0,
|
|
162
|
+
"uid": 88213,
|
|
163
|
+
"title": "The Blob",
|
|
164
|
+
"friendly_token": "aB3xY",
|
|
165
|
+
"duration_sec": 5400,
|
|
166
|
+
"estimated_start_at": "2026-08-24T21:40:00+00:00",
|
|
167
|
+
"estimated_start_in_sec": 0,
|
|
168
|
+
"cover_art_url": "https://queue.dropsugar.co/images/aB3xY/500.webp",
|
|
169
|
+
"queued_by": "alice",
|
|
170
|
+
"tier": "queue",
|
|
171
|
+
"is_now_playing": true
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
"position": 1,
|
|
175
|
+
"uid": 88214,
|
|
176
|
+
"title": "Plan 9 from Outer Space",
|
|
177
|
+
"friendly_token": "cD4wZ",
|
|
178
|
+
"duration_sec": 4740,
|
|
179
|
+
"estimated_start_at": "2026-08-24T23:10:00+00:00",
|
|
180
|
+
"estimated_start_in_sec": 4165,
|
|
181
|
+
"cover_art_url": "https://queue.dropsugar.co/images/cD4wZ/500.webp",
|
|
182
|
+
"queued_by": "bob",
|
|
183
|
+
"tier": "playnext",
|
|
184
|
+
"is_now_playing": false
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
"count": 2,
|
|
188
|
+
"updated_at": "2026-08-24T21:40:00+00:00"
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
| Field | Type | Notes |
|
|
193
|
+
|------------------------------|-----------------|----------------------------------------------|
|
|
194
|
+
| items[].position | integer | 0-based order in the queue. |
|
|
195
|
+
| items[].uid | integer \| null | Playlist uid (for correlating updates). |
|
|
196
|
+
| items[].title | string | Display title. |
|
|
197
|
+
| items[].friendly_token | string \| null | Catalog id. |
|
|
198
|
+
| items[].duration_sec | integer \| null | Runtime in seconds. |
|
|
199
|
+
| items[].estimated_start_at | string \| null | Predicted start (ISO-8601). |
|
|
200
|
+
| items[].estimated_start_in_sec | integer \| null | Predicted seconds until start. |
|
|
201
|
+
| items[].cover_art_url | string \| null | Absolute poster URL. |
|
|
202
|
+
| items[].queued_by | string \| null | Username who queued it (may be absent). |
|
|
203
|
+
| items[].tier | string \| null | `queue` or `playnext`. |
|
|
204
|
+
| items[].is_now_playing | boolean | `true` for the item currently on screen. |
|
|
205
|
+
| count | integer | `items.length`. |
|
|
206
|
+
| updated_at | string | Snapshot time. |
|
|
207
|
+
|
|
208
|
+
> The now-playing item is included here **and** in `/current`. Filter on
|
|
209
|
+
> `is_now_playing` if you want strictly upcoming items.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
### 2.4 `GET /api/public/v1/events` — scheduled events
|
|
214
|
+
|
|
215
|
+
Requires `Authorization: Bearer <key>`. Returns upcoming **enabled** scheduled
|
|
216
|
+
playlists (what operators call "events"), soonest first.
|
|
217
|
+
|
|
218
|
+
**200 response**
|
|
219
|
+
|
|
220
|
+
```json
|
|
221
|
+
{
|
|
222
|
+
"events": [
|
|
223
|
+
{ "label": "Friday Night Grindhouse", "fire_at": "2026-08-28T02:00:00+00:00", "is_recurring": true },
|
|
224
|
+
{ "label": "Halloween Marathon", "fire_at": "2026-10-31T20:00:00+00:00", "is_recurring": false }
|
|
225
|
+
],
|
|
226
|
+
"count": 2,
|
|
227
|
+
"updated_at": "2026-08-24T21:40:00+00:00"
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
| Field | Type | Notes |
|
|
232
|
+
|-----------------------|---------|-----------------------------------------------|
|
|
233
|
+
| events[].label | string | Event name. |
|
|
234
|
+
| events[].fire_at | string | Scheduled start (ISO-8601, future only). |
|
|
235
|
+
| events[].is_recurring | boolean | Whether the event repeats. |
|
|
236
|
+
| count | integer | `events.length`. |
|
|
237
|
+
| updated_at | string | Snapshot time. |
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## 3. Authentication details
|
|
242
|
+
|
|
243
|
+
Send the key on every data request:
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
Authorization: Bearer kqd_9f3a1c7b2e5d4a6f8091b2c3d4e5f60718293a4b5c6d7e8f
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- Missing header → **401** `{"detail": "Missing API key"}`.
|
|
250
|
+
- Unknown/revoked key → **401** `{"detail": "Invalid API key"}`.
|
|
251
|
+
- Both 401s include `WWW-Authenticate: Bearer`.
|
|
252
|
+
|
|
253
|
+
Keys are stored server-side only as an irreversible SHA-256 hash; the plaintext
|
|
254
|
+
lives solely on the device.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## 4. Errors (all endpoints)
|
|
259
|
+
|
|
260
|
+
Errors use FastAPI's standard shape:
|
|
261
|
+
|
|
262
|
+
```json
|
|
263
|
+
{ "detail": "Invalid or expired code." }
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
| Status | When |
|
|
267
|
+
|--------|-------------------------------------------------------------|
|
|
268
|
+
| 400 | Malformed request (bad code format, bad JSON). |
|
|
269
|
+
| 401 | Missing or invalid API key. |
|
|
270
|
+
| 404 | Unknown/expired link code. |
|
|
271
|
+
| 429 | Rate limited (link generation or code exchange). |
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## 5. Client implementation notes
|
|
276
|
+
|
|
277
|
+
- **Polling:** these endpoints return point-in-time snapshots. Poll `/current`
|
|
278
|
+
and `/queue` every ~10–15s; `/events` changes rarely (every few minutes is
|
|
279
|
+
plenty).
|
|
280
|
+
- **Countdowns:** compute live countdowns on-device from `remaining_sec` /
|
|
281
|
+
`estimated_start_in_sec` rather than re-polling every second.
|
|
282
|
+
- **Cover art:** `cover_art_url` is absolute and ready to load. It may be
|
|
283
|
+
`null`; show a placeholder.
|
|
284
|
+
- **Key loss / revocation:** if requests start returning 401, the key was
|
|
285
|
+
revoked (by the user, or automatically when the account is banned — bans are
|
|
286
|
+
reconciled against the moderator service on a schedule). Prompt the user to
|
|
287
|
+
re-link.
|
|
288
|
+
- **Time zones:** all times are UTC with an explicit offset; convert for
|
|
289
|
+
display.
|
|
@@ -38,6 +38,8 @@ from .routes.admin_promos import router as admin_promos_router
|
|
|
38
38
|
from .routes.admin_moderation import router as admin_moderation_router
|
|
39
39
|
from .routes.feedback import router as feedback_router
|
|
40
40
|
from .routes.admin_feedback import router as admin_feedback_router
|
|
41
|
+
from .routes.devices import router as devices_router
|
|
42
|
+
from .routes.public_api import router as public_api_router
|
|
41
43
|
from .routes.pages import router as pages_router
|
|
42
44
|
from .ws.handler import router as ws_router
|
|
43
45
|
|
|
@@ -99,6 +101,8 @@ async def lifespan(app: FastAPI):
|
|
|
99
101
|
# with a clear message rather than crashing startup.
|
|
100
102
|
from .jobs.tasks import (
|
|
101
103
|
catalog_enrich_job,
|
|
104
|
+
catalog_blackout_job,
|
|
105
|
+
device_key_ban_reconcile_job,
|
|
102
106
|
fetch_job,
|
|
103
107
|
fetch_queue_add_job,
|
|
104
108
|
fetch_queue_drain_job,
|
|
@@ -107,6 +111,8 @@ async def lifespan(app: FastAPI):
|
|
|
107
111
|
tmdb_index_refresh_job,
|
|
108
112
|
tmdb_coverage_report_job,
|
|
109
113
|
CATALOG_ENRICH_SCHEMA,
|
|
114
|
+
CATALOG_BLACKOUT_SCHEMA,
|
|
115
|
+
DEVICE_KEY_BAN_RECONCILE_SCHEMA,
|
|
110
116
|
FETCH_SCHEMA,
|
|
111
117
|
FETCH_QUEUE_ADD_SCHEMA,
|
|
112
118
|
FETCH_QUEUE_DRAIN_SCHEMA,
|
|
@@ -143,6 +149,18 @@ async def lifespan(app: FastAPI):
|
|
|
143
149
|
label="Fetch URLs (weekend workbook)",
|
|
144
150
|
schema=FETCHURLS_SCHEMA,
|
|
145
151
|
)
|
|
152
|
+
job_manager.register(
|
|
153
|
+
"catalog_blackout",
|
|
154
|
+
catalog_blackout_job,
|
|
155
|
+
label="Catalog Blackout (hide upcoming-weekend items)",
|
|
156
|
+
schema=CATALOG_BLACKOUT_SCHEMA,
|
|
157
|
+
)
|
|
158
|
+
job_manager.register(
|
|
159
|
+
"device_key_ban_reconcile",
|
|
160
|
+
device_key_ban_reconcile_job,
|
|
161
|
+
label="Device Key Ban Reconcile (revoke banned users' API keys)",
|
|
162
|
+
schema=DEVICE_KEY_BAN_RECONCILE_SCHEMA,
|
|
163
|
+
)
|
|
146
164
|
job_manager.register(
|
|
147
165
|
"motd_posters",
|
|
148
166
|
motd_posters_job,
|
|
@@ -173,6 +191,17 @@ async def lifespan(app: FastAPI):
|
|
|
173
191
|
|
|
174
192
|
# Cron-based job scheduler (persists schedules to job_schedules table)
|
|
175
193
|
job_scheduler = JobScheduler(db, job_manager)
|
|
194
|
+
# Seed a default schedule for the security-sensitive ban-reconcile job so it
|
|
195
|
+
# runs periodically out-of-the-box. Only seeded when absent, so an admin's
|
|
196
|
+
# cron/active changes are preserved (disable it by deactivating, not
|
|
197
|
+
# deleting — a deleted row is re-seeded on next startup).
|
|
198
|
+
if await db.get_job_schedule("device_key_ban_reconcile") is None:
|
|
199
|
+
await db.upsert_job_schedule(
|
|
200
|
+
"device_key_ban_reconcile",
|
|
201
|
+
"*/15 * * * *",
|
|
202
|
+
label="Device Key Ban Reconcile (revoke banned users' API keys)",
|
|
203
|
+
created_by="system",
|
|
204
|
+
)
|
|
176
205
|
await job_scheduler.start()
|
|
177
206
|
app.state.job_scheduler = job_scheduler
|
|
178
207
|
|
|
@@ -225,6 +254,13 @@ async def lifespan(app: FastAPI):
|
|
|
225
254
|
# feedback/suggestion endpoints (per-user, namespaced keys).
|
|
226
255
|
app.state.rate_limiter = RateLimiter()
|
|
227
256
|
app.state.feedback_rate_limiter = RateLimiter(max_requests=12, window_seconds=300)
|
|
257
|
+
# Device linking: throttle pad generation (per user) and redemption (per IP).
|
|
258
|
+
app.state.device_link_rate_limiter = RateLimiter(
|
|
259
|
+
max_requests=10, window_seconds=600
|
|
260
|
+
)
|
|
261
|
+
app.state.device_exchange_rate_limiter = RateLimiter(
|
|
262
|
+
max_requests=10, window_seconds=600
|
|
263
|
+
)
|
|
228
264
|
# Hard per-user submission quota for feedback and suggestions: 2/day, 6/week
|
|
229
265
|
# (namespaced keys, checked in addition to the short-burst limiter above).
|
|
230
266
|
app.state.feedback_quota_limiter = QuotaLimiter(
|
|
@@ -368,6 +404,8 @@ def create_app(config: Config) -> FastAPI:
|
|
|
368
404
|
app.include_router(admin_moderation_router)
|
|
369
405
|
app.include_router(feedback_router)
|
|
370
406
|
app.include_router(admin_feedback_router)
|
|
407
|
+
app.include_router(devices_router)
|
|
408
|
+
app.include_router(public_api_router)
|
|
371
409
|
app.include_router(ws_router)
|
|
372
410
|
|
|
373
411
|
# Health check
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Device-linking primitives and the public-API auth dependency.
|
|
2
|
+
|
|
3
|
+
The smart-TV / tablet apps authenticate with a long-lived API key rather than a
|
|
4
|
+
session cookie. Keys are minted by exchanging a short-lived one-time pad (link
|
|
5
|
+
code) at ``POST /api/public/v1/link`` and are stored only as an irreversible
|
|
6
|
+
SHA-256 hash. This module owns the code/key formats, hashing, and the FastAPI
|
|
7
|
+
dependency that resolves ``Authorization: Bearer <key>`` to a user.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import secrets
|
|
12
|
+
|
|
13
|
+
from fastapi import Request, HTTPException
|
|
14
|
+
|
|
15
|
+
# Unambiguous uppercase alphanumeric alphabet (no 0/O/1/I) — friendly for
|
|
16
|
+
# on-screen TV entry while remaining strictly alphanumeric.
|
|
17
|
+
LINK_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
18
|
+
LINK_CODE_LENGTH = 5
|
|
19
|
+
LINK_CODE_TTL_MINUTES = 10
|
|
20
|
+
|
|
21
|
+
API_KEY_PREFIX = "kqd_" # kryten queue device
|
|
22
|
+
# Display prefix length: scheme prefix + 8 leading hex chars (non-secret).
|
|
23
|
+
_KEY_DISPLAY_LEN = len(API_KEY_PREFIX) + 8
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_link_code() -> str:
|
|
27
|
+
"""Return a fresh 5-char uppercase one-time pad."""
|
|
28
|
+
return "".join(secrets.choice(LINK_CODE_ALPHABET) for _ in range(LINK_CODE_LENGTH))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_link_code(raw: str) -> str:
|
|
32
|
+
"""Normalize user/device-supplied input: strip and uppercase."""
|
|
33
|
+
return (raw or "").strip().upper()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_valid_link_code_format(code: str) -> bool:
|
|
37
|
+
"""True when ``code`` is the right length and uses only the code alphabet."""
|
|
38
|
+
return len(code) == LINK_CODE_LENGTH and all(
|
|
39
|
+
ch in LINK_CODE_ALPHABET for ch in code
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_api_key() -> str:
|
|
44
|
+
"""Return a fresh opaque API key: ``kqd_`` + 48 hex chars."""
|
|
45
|
+
return f"{API_KEY_PREFIX}{secrets.token_hex(24)}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def api_key_display_prefix(full_key: str) -> str:
|
|
49
|
+
"""Non-secret prefix stored for display (e.g. ``kqd_a1b2c3d4``)."""
|
|
50
|
+
return full_key[:_KEY_DISPLAY_LEN]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def hash_api_key(full_key: str) -> str:
|
|
54
|
+
"""Irreversible SHA-256 hex digest of a full API key (what we persist)."""
|
|
55
|
+
return hashlib.sha256(full_key.encode("utf-8")).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_bearer_key(request: Request) -> str | None:
|
|
59
|
+
"""Pull the key from ``Authorization: Bearer <key>`` (or a bare key)."""
|
|
60
|
+
header = request.headers.get("authorization")
|
|
61
|
+
if not header:
|
|
62
|
+
return None
|
|
63
|
+
header = header.strip()
|
|
64
|
+
if header.lower().startswith("bearer "):
|
|
65
|
+
return header[7:].strip() or None
|
|
66
|
+
return header or None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def get_api_key_user(request: Request) -> dict:
|
|
70
|
+
"""FastAPI dependency: authenticate a public-API request by API key.
|
|
71
|
+
|
|
72
|
+
Resolves ``Authorization: Bearer <key>`` to the owning user, updates the
|
|
73
|
+
key's last-used timestamp, and returns ``{username, device_id, device_name}``.
|
|
74
|
+
Raises 401 when the header is missing or the key is unknown.
|
|
75
|
+
"""
|
|
76
|
+
key = _extract_bearer_key(request)
|
|
77
|
+
if not key:
|
|
78
|
+
raise HTTPException(
|
|
79
|
+
status_code=401,
|
|
80
|
+
detail="Missing API key",
|
|
81
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
82
|
+
)
|
|
83
|
+
db = request.app.state.db
|
|
84
|
+
row = await db.get_device_key_by_hash(hash_api_key(key))
|
|
85
|
+
if not row:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=401,
|
|
88
|
+
detail="Invalid API key",
|
|
89
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
await db.touch_device_key(row["id"])
|
|
93
|
+
except Exception: # noqa: BLE001 — last-used tracking must never block a request
|
|
94
|
+
pass
|
|
95
|
+
return {
|
|
96
|
+
"username": row["username"],
|
|
97
|
+
"device_id": row["id"],
|
|
98
|
+
"device_name": row["device_name"],
|
|
99
|
+
}
|
|
@@ -12,6 +12,8 @@ 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
|
|
16
|
+
from ._devices import _DevicesMixin
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
class Database(
|
|
@@ -23,6 +25,8 @@ class Database(
|
|
|
23
25
|
_PeopleMixin,
|
|
24
26
|
_EnrichmentMixin,
|
|
25
27
|
_FetchQueueMixin,
|
|
28
|
+
_BlackoutMixin,
|
|
29
|
+
_DevicesMixin,
|
|
26
30
|
_DBBase,
|
|
27
31
|
):
|
|
28
32
|
"""Async SQLite database wrapper."""
|
|
@@ -34,11 +38,3 @@ __all__ = [
|
|
|
34
38
|
"HIDDEN_CATEGORY_NAMES",
|
|
35
39
|
"HIDDEN_TAG_NAMES",
|
|
36
40
|
]
|
|
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
|
+
)
|