kryten-webqueue 0.42.0__tar.gz → 0.43.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.42.0 → kryten_webqueue-0.43.0}/CHANGELOG.md +25 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/PKG-INFO +11 -1
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/README.md +10 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/config.example.json +6 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PUBLIC_API.md +6 -4
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/app.py +20 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_fetch_queue.py +30 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/config.py +38 -1
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/tasks.py +57 -3
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin.js +12 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/pyproject.toml +1 -1
- kryten_webqueue-0.43.0/tests/test_fetch_queue_pacing.py +159 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.gitignore +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/AGENTS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/device_keys.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/devices.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/public_api.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/keybindings.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/link_device.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/AGENTS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/patch_mediacms_tags.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/trigger_enrich.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_catalog_blackout.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_catalog_delete.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_classify.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_device_linking.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_identify_step.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_providers.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_tmdb_index.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_watchlist.py +0 -0
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.43.0] - 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Fetch-queue drain cooldown (anti-bot pacing).** The **Fetch Queue — Process**
|
|
10
|
+
job now waits a randomized cooldown *between* items instead of downloading
|
|
11
|
+
back-to-back, so it stops tripping the source's bot detection (the MediaCMS
|
|
12
|
+
encoder is the real bottleneck anyway). The wait is drawn uniformly from
|
|
13
|
+
`cooldown_mean_minutes ± cooldown_jitter_minutes` (default **42 ± 8 min**).
|
|
14
|
+
New optional `fetch_queue` config block: `cooldown_mean_minutes`,
|
|
15
|
+
`cooldown_jitter_minutes`, `cooldown_enabled`. Settings are **re-read from the
|
|
16
|
+
config file before every wait**, so editing the config retunes a *running*
|
|
17
|
+
drain — no restart needed. A `cooldown` progress heartbeat exposes the
|
|
18
|
+
remaining wait to the admin UI.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **Interrupted fetch-queue downloads now resume.** On startup any item left
|
|
23
|
+
`running` by a crash/restart is reset to `pending`, and the drain auto-starts
|
|
24
|
+
if anything is pending — so a download cut off mid-flight is re-attempted
|
|
25
|
+
without an admin re-triggering it. A drain cancelled by shutdown re-queues its
|
|
26
|
+
in-flight item (rather than marking it `failed`) for the same reason. Retries
|
|
27
|
+
re-download the whole file; a leftover yt-dlp `.part` file may let yt-dlp
|
|
28
|
+
resume where it left off.
|
|
29
|
+
|
|
5
30
|
## [0.42.0] - 2026-08-24
|
|
6
31
|
|
|
7
32
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.43.0
|
|
4
4
|
Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
|
|
5
5
|
Author: grobertson
|
|
6
6
|
License-Expression: MIT
|
|
@@ -122,6 +122,16 @@ sudo nginx -t && sudo systemctl reload nginx
|
|
|
122
122
|
### WebSocket
|
|
123
123
|
- `ws://host/ws` — Real-time queue updates (auth via session cookie)
|
|
124
124
|
|
|
125
|
+
### Public API (third-party clients)
|
|
126
|
+
- `POST /api/public/v1/link` — Exchange a one-time device code for an API key
|
|
127
|
+
- `GET /api/public/v1/current` — Now playing
|
|
128
|
+
- `GET /api/public/v1/queue` — Live queue with predicted start times
|
|
129
|
+
- `GET /api/public/v1/events` — Upcoming scheduled events
|
|
130
|
+
|
|
131
|
+
Key-authenticated (`Authorization: Bearer <key>`) endpoints for Smart TV /
|
|
132
|
+
tablet apps. Users link a device at `/link`. Full contract, JSON schemas, and a
|
|
133
|
+
developer's guide: [docs/PUBLIC_API.md](docs/PUBLIC_API.md).
|
|
134
|
+
|
|
125
135
|
## Dependencies
|
|
126
136
|
|
|
127
137
|
- Python ≥ 3.12
|
|
@@ -89,6 +89,16 @@ sudo nginx -t && sudo systemctl reload nginx
|
|
|
89
89
|
### WebSocket
|
|
90
90
|
- `ws://host/ws` — Real-time queue updates (auth via session cookie)
|
|
91
91
|
|
|
92
|
+
### Public API (third-party clients)
|
|
93
|
+
- `POST /api/public/v1/link` — Exchange a one-time device code for an API key
|
|
94
|
+
- `GET /api/public/v1/current` — Now playing
|
|
95
|
+
- `GET /api/public/v1/queue` — Live queue with predicted start times
|
|
96
|
+
- `GET /api/public/v1/events` — Upcoming scheduled events
|
|
97
|
+
|
|
98
|
+
Key-authenticated (`Authorization: Bearer <key>`) endpoints for Smart TV /
|
|
99
|
+
tablet apps. Users link a device at `/link`. Full contract, JSON schemas, and a
|
|
100
|
+
developer's guide: [docs/PUBLIC_API.md](docs/PUBLIC_API.md).
|
|
101
|
+
|
|
92
102
|
## Dependencies
|
|
93
103
|
|
|
94
104
|
- Python ≥ 3.12
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
"token_cache_path": "/var/lib/kryten-webqueue/.fetchurls_tokens.bin"
|
|
31
31
|
},
|
|
32
32
|
|
|
33
|
+
"fetch_queue": {
|
|
34
|
+
"cooldown_mean_minutes": 42.0,
|
|
35
|
+
"cooldown_jitter_minutes": 8.0,
|
|
36
|
+
"cooldown_enabled": true
|
|
37
|
+
},
|
|
38
|
+
|
|
33
39
|
"presence_refund": {
|
|
34
40
|
"enabled": true,
|
|
35
41
|
"on_leave": true,
|
|
@@ -21,7 +21,7 @@ Keys are minted through a short, user-driven pairing flow so a device never
|
|
|
21
21
|
handles the user's credentials.
|
|
22
22
|
|
|
23
23
|
```
|
|
24
|
-
┌────────────┐ 1. names device, gets code
|
|
24
|
+
┌────────────┐ 1. names device, gets code ┌──────────────────┐
|
|
25
25
|
│ Browser │ ───────────────────────────────▶│ queue.dropsugar │
|
|
26
26
|
│ (logged in)│ GET /link → "AB2CD" │ .co │
|
|
27
27
|
└────────────┘ └──────────────────┘
|
|
@@ -275,10 +275,12 @@ Errors use FastAPI's standard shape:
|
|
|
275
275
|
## 5. Client implementation notes
|
|
276
276
|
|
|
277
277
|
- **Polling:** these endpoints return point-in-time snapshots. Poll `/current`
|
|
278
|
-
and `/queue`
|
|
279
|
-
|
|
278
|
+
and `/queue` no more often than once per minute, it is advised that you
|
|
279
|
+
locally calculate time remaining and poll even less, if an item is in progress
|
|
280
|
+
the only thing that will change about it's API data is the time elapsed and
|
|
281
|
+
time remaining values; `/events` changes rarely (every 15m-1h is plenty).
|
|
280
282
|
- **Countdowns:** compute live countdowns on-device from `remaining_sec` /
|
|
281
|
-
`estimated_start_in_sec` rather than re-polling every
|
|
283
|
+
`estimated_start_in_sec` rather than re-polling every minute.
|
|
282
284
|
- **Cover art:** `cover_art_url` is absolute and ready to load. It may be
|
|
283
285
|
`null`; show a placeholder.
|
|
284
286
|
- **Key loss / revocation:** if requests start returning 401, the key was
|
|
@@ -189,6 +189,26 @@ async def lifespan(app: FastAPI):
|
|
|
189
189
|
)
|
|
190
190
|
app.state.job_manager = job_manager
|
|
191
191
|
|
|
192
|
+
# Recover interrupted fetch-queue downloads. Any item left 'running' was cut
|
|
193
|
+
# off by a crash/restart (the running flag isn't durable); flip it back to
|
|
194
|
+
# 'pending' so the drain re-attempts it, then auto-start the drain if
|
|
195
|
+
# anything is pending so downloads resume without an admin re-triggering it.
|
|
196
|
+
requeued = await db.reset_running_fetch_items()
|
|
197
|
+
if requeued:
|
|
198
|
+
logger.warning(
|
|
199
|
+
"Re-queued %d interrupted fetch-queue item(s) after restart", requeued
|
|
200
|
+
)
|
|
201
|
+
if await db.count_fetch_queue_pending() > 0 and not job_manager.is_running(
|
|
202
|
+
"fetch_queue_drain"
|
|
203
|
+
):
|
|
204
|
+
try:
|
|
205
|
+
await job_manager.run("fetch_queue_drain", triggered_by="startup-recovery")
|
|
206
|
+
logger.info("Auto-started fetch_queue_drain to resume pending downloads")
|
|
207
|
+
except Exception: # noqa: BLE001 - best-effort; admin can re-trigger
|
|
208
|
+
logger.debug(
|
|
209
|
+
"Could not auto-start fetch_queue_drain at startup", exc_info=True
|
|
210
|
+
)
|
|
211
|
+
|
|
192
212
|
# Cron-based job scheduler (persists schedules to job_schedules table)
|
|
193
213
|
job_scheduler = JobScheduler(db, job_manager)
|
|
194
214
|
# Seed a default schedule for the security-sensitive ban-reconcile job so it
|
{kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_fetch_queue.py
RENAMED
|
@@ -57,6 +57,36 @@ class _FetchQueueMixin:
|
|
|
57
57
|
)
|
|
58
58
|
await self._db.commit()
|
|
59
59
|
|
|
60
|
+
async def requeue_fetch_item(self, item_id: int) -> None:
|
|
61
|
+
"""Return a single item to 'pending' (e.g. interrupted by shutdown).
|
|
62
|
+
|
|
63
|
+
Clears the run timestamps and any prior error so the drain re-attempts it
|
|
64
|
+
cleanly on the next pass.
|
|
65
|
+
"""
|
|
66
|
+
await self._db.execute(
|
|
67
|
+
"UPDATE fetch_queue"
|
|
68
|
+
" SET status = 'pending', started_at = NULL, finished_at = NULL, error = NULL"
|
|
69
|
+
" WHERE id = ?",
|
|
70
|
+
[item_id],
|
|
71
|
+
)
|
|
72
|
+
await self._db.commit()
|
|
73
|
+
|
|
74
|
+
async def reset_running_fetch_items(self) -> int:
|
|
75
|
+
"""Reset any 'running' items to 'pending'; return how many were reset.
|
|
76
|
+
|
|
77
|
+
Called at startup to recover from a crash or hard kill that left the
|
|
78
|
+
in-flight item marked 'running' forever (the running flag is process
|
|
79
|
+
state, not durable). Flipping it back to 'pending' lets the drain
|
|
80
|
+
re-attempt it — a fresh download, though a yt-dlp ``.part`` file left in
|
|
81
|
+
the work dir may let yt-dlp resume where it left off.
|
|
82
|
+
"""
|
|
83
|
+
cursor = await self._db.execute(
|
|
84
|
+
"UPDATE fetch_queue SET status = 'pending', started_at = NULL"
|
|
85
|
+
" WHERE status = 'running'"
|
|
86
|
+
)
|
|
87
|
+
await self._db.commit()
|
|
88
|
+
return cursor.rowcount or 0
|
|
89
|
+
|
|
60
90
|
async def get_fetch_queue(
|
|
61
91
|
self, *, limit: int = 100, status: str | None = None
|
|
62
92
|
) -> list[dict]:
|
|
@@ -51,6 +51,26 @@ class FetchUrlsConfig(BaseModel):
|
|
|
51
51
|
token_cache_path: str = "" # MSAL cache file, pre-seeded out-of-band
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
class FetchQueueConfig(BaseModel):
|
|
55
|
+
"""Settings for the persistent fetch-queue drain job.
|
|
56
|
+
|
|
57
|
+
The drain processes queued downloads one at a time and waits a randomized
|
|
58
|
+
cooldown *between* items so it doesn't hammer the source and trip bot
|
|
59
|
+
detection (the MediaCMS encoder is the real bottleneck anyway, so a long
|
|
60
|
+
gap costs little). Each wait is drawn uniformly from
|
|
61
|
+
``cooldown_mean_minutes ± cooldown_jitter_minutes`` (clamped at >= 0).
|
|
62
|
+
|
|
63
|
+
These are re-read from the config file before every wait, so editing the
|
|
64
|
+
config and reloading retunes a *running* drain without a restart.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# Randomized inter-item cooldown, centered on ~42 min with jitter.
|
|
68
|
+
cooldown_mean_minutes: float = 42.0
|
|
69
|
+
cooldown_jitter_minutes: float = 8.0
|
|
70
|
+
# Set false to drain back-to-back (e.g. for a one-off bulk import).
|
|
71
|
+
cooldown_enabled: bool = True
|
|
72
|
+
|
|
73
|
+
|
|
54
74
|
class PresenceRefundConfig(BaseModel):
|
|
55
75
|
"""Settings for presence-based cancel/refund of pending paid items.
|
|
56
76
|
|
|
@@ -168,9 +188,9 @@ class Config(BaseModel):
|
|
|
168
188
|
# Jobs (optional; jobs whose config/deps are absent fail fast at run time)
|
|
169
189
|
fetch_cookies_path: str = "" # optional yt-dlp cookies for gated sources
|
|
170
190
|
fetchurls: FetchUrlsConfig = FetchUrlsConfig()
|
|
191
|
+
fetch_queue: FetchQueueConfig = FetchQueueConfig()
|
|
171
192
|
emote_rehost: EmoteRehostConfig = EmoteRehostConfig()
|
|
172
193
|
motd: MOTDConfig = MOTDConfig()
|
|
173
|
-
|
|
174
194
|
# Presence-based cancel/refund of pending paid items
|
|
175
195
|
presence_refund: PresenceRefundConfig = PresenceRefundConfig()
|
|
176
196
|
|
|
@@ -213,6 +233,23 @@ class Config(BaseModel):
|
|
|
213
233
|
cfg._source_path = Path(path)
|
|
214
234
|
return cfg
|
|
215
235
|
|
|
236
|
+
def reload_fetch_queue(self) -> "FetchQueueConfig":
|
|
237
|
+
"""Re-read just the ``fetch_queue`` section from the source file.
|
|
238
|
+
|
|
239
|
+
Enables live cooldown tuning: edit the config file and the running drain
|
|
240
|
+
picks up new pacing before its next wait — no restart needed. On any
|
|
241
|
+
error (no known source path, unreadable or invalid file) falls back to
|
|
242
|
+
the section loaded at startup so the drain never breaks on a bad edit.
|
|
243
|
+
"""
|
|
244
|
+
if self._source_path is None:
|
|
245
|
+
return self.fetch_queue
|
|
246
|
+
try:
|
|
247
|
+
with open(self._source_path, encoding="utf-8") as f:
|
|
248
|
+
raw = json.load(f)
|
|
249
|
+
return FetchQueueConfig(**(raw.get("fetch_queue") or {}))
|
|
250
|
+
except Exception: # noqa: BLE001 - never let a bad edit break the drain
|
|
251
|
+
return self.fetch_queue
|
|
252
|
+
|
|
216
253
|
def save(self) -> None:
|
|
217
254
|
"""Persist the current config back to the file it was loaded from.
|
|
218
255
|
|
|
@@ -15,6 +15,8 @@ import functools
|
|
|
15
15
|
import importlib
|
|
16
16
|
import json
|
|
17
17
|
import logging
|
|
18
|
+
import random
|
|
19
|
+
import time
|
|
18
20
|
|
|
19
21
|
from .manager import JobError
|
|
20
22
|
|
|
@@ -840,6 +842,41 @@ FETCH_QUEUE_ADD_SCHEMA = [
|
|
|
840
842
|
FETCH_QUEUE_DRAIN_SCHEMA: list[dict] = []
|
|
841
843
|
|
|
842
844
|
|
|
845
|
+
async def _fetch_queue_cooldown(ctx, *, processed: int, failed: int) -> None:
|
|
846
|
+
"""Wait a randomized cooldown between fetch-queue downloads.
|
|
847
|
+
|
|
848
|
+
The wait is drawn uniformly from ``mean ± jitter`` minutes (clamped at >= 0)
|
|
849
|
+
so the drain doesn't hammer the source and trip bot detection. Settings are
|
|
850
|
+
re-read from the config file each call, so editing the config file (and
|
|
851
|
+
reloading) retunes a running drain without a restart. ``asyncio.sleep``
|
|
852
|
+
stays cancellable, so shutdown interrupts the wait cleanly.
|
|
853
|
+
"""
|
|
854
|
+
fq = ctx.config.reload_fetch_queue()
|
|
855
|
+
if not fq.cooldown_enabled:
|
|
856
|
+
return
|
|
857
|
+
low = max(0.0, fq.cooldown_mean_minutes - fq.cooldown_jitter_minutes)
|
|
858
|
+
high = max(low, fq.cooldown_mean_minutes + fq.cooldown_jitter_minutes)
|
|
859
|
+
seconds = max(0.0, random.uniform(low, high) * 60.0)
|
|
860
|
+
if seconds <= 0:
|
|
861
|
+
return
|
|
862
|
+
await ctx.progress(
|
|
863
|
+
{
|
|
864
|
+
"status": "cooldown",
|
|
865
|
+
"processed": processed,
|
|
866
|
+
"failed": failed,
|
|
867
|
+
"cooldown_seconds": round(seconds),
|
|
868
|
+
"resume_epoch": round(time.time() + seconds),
|
|
869
|
+
}
|
|
870
|
+
)
|
|
871
|
+
logger.info(
|
|
872
|
+
"fetch_queue_drain: cooldown %.1f min before next item (processed=%d failed=%d)",
|
|
873
|
+
seconds / 60.0,
|
|
874
|
+
processed,
|
|
875
|
+
failed,
|
|
876
|
+
)
|
|
877
|
+
await asyncio.sleep(seconds)
|
|
878
|
+
|
|
879
|
+
|
|
843
880
|
async def fetch_queue_add_job(params: dict, ctx) -> dict:
|
|
844
881
|
"""Add one or more URLs to the fetch queue and auto-start the drain if idle."""
|
|
845
882
|
raw = (params.get("urls") or "").strip()
|
|
@@ -883,7 +920,17 @@ async def fetch_queue_add_job(params: dict, ctx) -> dict:
|
|
|
883
920
|
|
|
884
921
|
|
|
885
922
|
async def fetch_queue_drain_job(params: dict, ctx) -> dict:
|
|
886
|
-
"""Process all pending fetch-queue items one at a time.
|
|
923
|
+
"""Process all pending fetch-queue items one at a time.
|
|
924
|
+
|
|
925
|
+
Between items the drain waits a randomized cooldown (configurable via
|
|
926
|
+
``fetch_queue`` in the config, centered on ~42 min with jitter) so downloads
|
|
927
|
+
don't hammer the source and trip bot detection — the MediaCMS encoder is the
|
|
928
|
+
real bottleneck anyway. Cooldown settings are re-read live before each wait.
|
|
929
|
+
|
|
930
|
+
An item interrupted by shutdown is re-queued (not failed) so it retries on
|
|
931
|
+
the next start; combined with the startup reset of orphaned 'running' items,
|
|
932
|
+
an interrupted download is always re-attempted from the top.
|
|
933
|
+
"""
|
|
887
934
|
processed = 0
|
|
888
935
|
failed = 0
|
|
889
936
|
|
|
@@ -918,8 +965,11 @@ async def fetch_queue_drain_job(params: dict, ctx) -> dict:
|
|
|
918
965
|
processed += 1
|
|
919
966
|
logger.info("fetch_queue_drain: id=%d done", item["id"])
|
|
920
967
|
except asyncio.CancelledError:
|
|
921
|
-
|
|
922
|
-
|
|
968
|
+
# Interrupted by shutdown/cancel: re-queue so it retries on the next
|
|
969
|
+
# start rather than being lost as a permanent failure.
|
|
970
|
+
await ctx.db.requeue_fetch_item(item["id"])
|
|
971
|
+
logger.info(
|
|
972
|
+
"fetch_queue_drain: id=%d interrupted — re-queued for retry", item["id"]
|
|
923
973
|
)
|
|
924
974
|
raise
|
|
925
975
|
except Exception as exc: # noqa: BLE001
|
|
@@ -929,5 +979,9 @@ async def fetch_queue_drain_job(params: dict, ctx) -> dict:
|
|
|
929
979
|
await ctx.db.finish_fetch_item(item["id"], status="failed", error=str(exc))
|
|
930
980
|
failed += 1
|
|
931
981
|
|
|
982
|
+
# Space out downloads: cooldown before the next item, unless none remain.
|
|
983
|
+
if await ctx.db.count_fetch_queue_pending() > 0:
|
|
984
|
+
await _fetch_queue_cooldown(ctx, processed=processed, failed=failed)
|
|
985
|
+
|
|
932
986
|
logger.info("fetch_queue_drain: done — processed=%d failed=%d", processed, failed)
|
|
933
987
|
return {"processed": processed, "failed": failed}
|
|
@@ -438,6 +438,13 @@ async function showRunDetail(runId) {
|
|
|
438
438
|
bodyHtml = `<p style="color:var(--warning)">\u23f3 Download in progress</p>
|
|
439
439
|
<p>Current URL: <code style="word-break:break-all">${escapeHtml(d.url || '')}</code></p>
|
|
440
440
|
<p>Done: <strong>${d.processed || 0}</strong> Failed: <strong>${d.failed || 0}</strong></p>`;
|
|
441
|
+
} else if (d && d.status === 'cooldown') {
|
|
442
|
+
// fetch_queue_drain anti-bot cooldown between items
|
|
443
|
+
const mins = d.cooldown_seconds ? Math.round(d.cooldown_seconds / 60) : 0;
|
|
444
|
+
const resume = d.resume_epoch ? new Date(d.resume_epoch * 1000).toLocaleTimeString() : '';
|
|
445
|
+
bodyHtml = `<p style="color:var(--warning)">\u23f8\ufe0f Cooling down before the next download</p>
|
|
446
|
+
<p>Waiting <strong>~${mins} min</strong>${resume ? ` (resumes ~${escapeHtml(resume)})` : ''}.</p>
|
|
447
|
+
<p>Done: <strong>${d.processed || 0}</strong> Failed: <strong>${d.failed || 0}</strong></p>`;
|
|
441
448
|
} else {
|
|
442
449
|
// Generic / legacy format
|
|
443
450
|
const raw = typeof run.detail === 'string' ? run.detail : JSON.stringify(run.detail, null, 2);
|
|
@@ -597,6 +604,11 @@ function summarizeRunDetail(detail) {
|
|
|
597
604
|
const host = (() => { try { return new URL(d.url).hostname; } catch (e) { return d.url.slice(0, 40); } })();
|
|
598
605
|
return `\u23f3 ${host} (${d.processed || 0} done)`;
|
|
599
606
|
}
|
|
607
|
+
// fetch_queue_drain cooldown between items
|
|
608
|
+
if (d.status === 'cooldown') {
|
|
609
|
+
const mins = d.cooldown_seconds ? Math.round(d.cooldown_seconds / 60) : 0;
|
|
610
|
+
return `\u23f8\ufe0f cooldown ~${mins} min (${d.processed || 0} done)`;
|
|
611
|
+
}
|
|
600
612
|
if (parts.length) return parts.join(' · ');
|
|
601
613
|
}
|
|
602
614
|
return '';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Fetch-queue drain pacing + interrupted-download recovery.
|
|
2
|
+
|
|
3
|
+
Covers the anti-bot cooldown between drain items (config-driven, jittered,
|
|
4
|
+
live-reloadable) and the crash/shutdown recovery paths that re-queue an
|
|
5
|
+
interrupted download so it retries instead of being lost.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from kryten_webqueue.catalog.db import Database
|
|
14
|
+
from kryten_webqueue.config import Config, FetchQueueConfig
|
|
15
|
+
from kryten_webqueue.jobs import tasks
|
|
16
|
+
from kryten_webqueue.jobs.manager import JobContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
async def db(tmp_path):
|
|
21
|
+
database = Database(str(tmp_path / "fetch_queue.db"))
|
|
22
|
+
await database.connect()
|
|
23
|
+
await database.run_migrations()
|
|
24
|
+
yield database
|
|
25
|
+
await database.close()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ctx(db, config):
|
|
29
|
+
return JobContext(
|
|
30
|
+
db=db, api_gate=None, config=config, run_id=1, triggered_by="admin"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- DB recovery helpers -------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def test_reset_running_fetch_items_requeues_orphans(db):
|
|
38
|
+
a = await db.enqueue_fetch(url="https://x/1")
|
|
39
|
+
b = await db.enqueue_fetch(url="https://x/2")
|
|
40
|
+
# Simulate a crash mid-download: one item stuck at 'running'.
|
|
41
|
+
await db.claim_next_fetch_item() # claims oldest (a) → running
|
|
42
|
+
|
|
43
|
+
reset = await db.reset_running_fetch_items()
|
|
44
|
+
assert reset == 1
|
|
45
|
+
assert await db.count_fetch_queue_pending() == 2 # both pending again
|
|
46
|
+
|
|
47
|
+
# The reset item can be claimed again (fresh re-attempt).
|
|
48
|
+
claimed = await db.claim_next_fetch_item()
|
|
49
|
+
assert claimed["id"] == a
|
|
50
|
+
_ = b # second item remains pending
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_requeue_fetch_item_clears_error_and_timestamps(db):
|
|
54
|
+
item_id = await db.enqueue_fetch(url="https://x/1")
|
|
55
|
+
await db.claim_next_fetch_item()
|
|
56
|
+
await db.finish_fetch_item(item_id, status="failed", error="boom")
|
|
57
|
+
|
|
58
|
+
await db.requeue_fetch_item(item_id)
|
|
59
|
+
rows = await db.get_fetch_queue(limit=10)
|
|
60
|
+
row = next(r for r in rows if r["id"] == item_id)
|
|
61
|
+
assert row["status"] == "pending"
|
|
62
|
+
assert row["started_at"] is None
|
|
63
|
+
assert row["finished_at"] is None
|
|
64
|
+
assert row["error"] is None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- live config reload --------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_reload_fetch_queue_reads_current_file(tmp_path):
|
|
71
|
+
path = tmp_path / "config.json"
|
|
72
|
+
base = {
|
|
73
|
+
"secret_key": "x" * 16,
|
|
74
|
+
"api_gate_token": "t",
|
|
75
|
+
"mediacms_token": "t",
|
|
76
|
+
"fetch_queue": {"cooldown_mean_minutes": 10.0},
|
|
77
|
+
}
|
|
78
|
+
path.write_text(json.dumps(base), encoding="utf-8")
|
|
79
|
+
config = Config.from_file(path)
|
|
80
|
+
assert config.reload_fetch_queue().cooldown_mean_minutes == 10.0
|
|
81
|
+
|
|
82
|
+
# Edit the file on disk: a running drain must pick up the new value.
|
|
83
|
+
base["fetch_queue"]["cooldown_mean_minutes"] = 55.0
|
|
84
|
+
path.write_text(json.dumps(base), encoding="utf-8")
|
|
85
|
+
assert config.reload_fetch_queue().cooldown_mean_minutes == 55.0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_reload_fetch_queue_falls_back_on_bad_file(tmp_path):
|
|
89
|
+
path = tmp_path / "config.json"
|
|
90
|
+
path.write_text(
|
|
91
|
+
json.dumps(
|
|
92
|
+
{"secret_key": "x" * 16, "api_gate_token": "t", "mediacms_token": "t"}
|
|
93
|
+
),
|
|
94
|
+
encoding="utf-8",
|
|
95
|
+
)
|
|
96
|
+
config = Config.from_file(path)
|
|
97
|
+
# Corrupt the file; reload must fall back to the in-memory section, not raise.
|
|
98
|
+
path.write_text("{ not valid json", encoding="utf-8")
|
|
99
|
+
fq = config.reload_fetch_queue()
|
|
100
|
+
assert fq.cooldown_mean_minutes == config.fetch_queue.cooldown_mean_minutes
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --- cooldown behaviour --------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
async def test_cooldown_disabled_does_not_sleep(db, monkeypatch):
|
|
107
|
+
slept: list[float] = []
|
|
108
|
+
|
|
109
|
+
async def _fake_sleep(seconds):
|
|
110
|
+
slept.append(seconds)
|
|
111
|
+
|
|
112
|
+
monkeypatch.setattr(tasks.asyncio, "sleep", _fake_sleep)
|
|
113
|
+
config = SimpleNamespace(
|
|
114
|
+
reload_fetch_queue=lambda: FetchQueueConfig(cooldown_enabled=False)
|
|
115
|
+
)
|
|
116
|
+
ctx = _ctx(db, config)
|
|
117
|
+
await tasks._fetch_queue_cooldown(ctx, processed=1, failed=0)
|
|
118
|
+
assert slept == []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_cooldown_sleeps_within_jitter_band(db, monkeypatch):
|
|
122
|
+
slept: list[float] = []
|
|
123
|
+
|
|
124
|
+
async def _fake_sleep(seconds):
|
|
125
|
+
slept.append(seconds)
|
|
126
|
+
|
|
127
|
+
monkeypatch.setattr(tasks.asyncio, "sleep", _fake_sleep)
|
|
128
|
+
fq = FetchQueueConfig(cooldown_mean_minutes=42.0, cooldown_jitter_minutes=8.0)
|
|
129
|
+
config = SimpleNamespace(reload_fetch_queue=lambda: fq)
|
|
130
|
+
ctx = _ctx(db, config)
|
|
131
|
+
await tasks._fetch_queue_cooldown(ctx, processed=2, failed=1)
|
|
132
|
+
assert len(slept) == 1
|
|
133
|
+
# Within [34, 50] minutes, expressed in seconds.
|
|
134
|
+
assert 34 * 60 <= slept[0] <= 50 * 60
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def test_drain_cooldown_runs_between_items_only(db, monkeypatch):
|
|
138
|
+
await db.enqueue_fetch(url="https://x/1")
|
|
139
|
+
await db.enqueue_fetch(url="https://x/2")
|
|
140
|
+
|
|
141
|
+
cooldowns: list[int] = []
|
|
142
|
+
|
|
143
|
+
async def _fake_cooldown(ctx, *, processed, failed):
|
|
144
|
+
cooldowns.append(processed)
|
|
145
|
+
|
|
146
|
+
async def _fake_single_fetch(params, ctx):
|
|
147
|
+
return {"ok": True}
|
|
148
|
+
|
|
149
|
+
monkeypatch.setattr(tasks, "_fetch_queue_cooldown", _fake_cooldown)
|
|
150
|
+
monkeypatch.setattr(tasks, "_run_single_fetch", _fake_single_fetch)
|
|
151
|
+
|
|
152
|
+
fq = FetchQueueConfig()
|
|
153
|
+
ctx = _ctx(db, SimpleNamespace(reload_fetch_queue=lambda: fq))
|
|
154
|
+
result = await tasks.fetch_queue_drain_job({}, ctx)
|
|
155
|
+
|
|
156
|
+
assert result == {"processed": 2, "failed": 0}
|
|
157
|
+
# Two items → exactly one inter-item cooldown (after the first, before the
|
|
158
|
+
# second); none after the last item.
|
|
159
|
+
assert cooldowns == [1]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Log for incorrect promo insertion problem.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|