kryten-webqueue 0.42.0__tar.gz → 0.44.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.44.0}/CHANGELOG.md +76 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/PKG-INFO +11 -1
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/README.md +10 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/config.example.json +6 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PUBLIC_API.md +6 -4
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/app.py +20 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_catalog.py +30 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_connection.py +24 -0
- kryten_webqueue-0.44.0/kryten_webqueue/catalog/db/_fetch_queue.py +179 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/config.py +38 -1
- kryten_webqueue-0.44.0/kryten_webqueue/integrations/ytpipe/__init__.py +11 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/ytpipe/downloader.py +42 -8
- kryten_webqueue-0.44.0/kryten_webqueue/jobs/log_capture.py +73 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/manager.py +14 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/tasks.py +99 -3
- kryten_webqueue-0.44.0/kryten_webqueue/routes/admin_jobs.py +169 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/css/main.css +100 -1
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin.js +153 -92
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/pyproject.toml +1 -1
- kryten_webqueue-0.44.0/tests/test_fetch_queue_pacing.py +256 -0
- kryten_webqueue-0.44.0/tests/test_job_logs_and_queue_visibility.py +132 -0
- kryten_webqueue-0.42.0/kryten_webqueue/catalog/db/_fetch_queue.py +0 -85
- kryten_webqueue-0.42.0/kryten_webqueue/integrations/ytpipe/__init__.py +0 -1
- kryten_webqueue-0.42.0/kryten_webqueue/routes/admin_jobs.py +0 -87
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.gitignore +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/AGENTS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/device_keys.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/devices.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/public_api.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/keybindings.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/link_device.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/AGENTS.md +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/patch_mediacms_tags.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/trigger_enrich.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_catalog_blackout.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_catalog_delete.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_classify.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_device_linking.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_identify_step.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_providers.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_tmdb_index.py +0 -0
- {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_watchlist.py +0 -0
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Admin Jobs tab: full-text run logs + humanized summaries.** Every background
|
|
8
|
+
job run now captures the Python log records it emits (scoped to that run via a
|
|
9
|
+
`contextvars` filter, so concurrent jobs and web requests don't bleed in) into
|
|
10
|
+
a new uncapped `job_run_logs` table (schema **v29**, forward-only migration —
|
|
11
|
+
kept as a separate table rather than a `job_runs` column so it ports cleanly to
|
|
12
|
+
Postgres). The run-detail modal now renders the JSON summary as **humanized,
|
|
13
|
+
labelled rows** (friendly `Snake Case → Title Case` labels, `Yes/No` for
|
|
14
|
+
booleans, thousands separators) instead of a raw blob, and shows the **full log**
|
|
15
|
+
beneath it. Both the JSON summary and the full-text log are **downloadable** per
|
|
16
|
+
run (`GET /admin/jobs/runs/{id}/detail/download` and `.../log/download`) for
|
|
17
|
+
offline study.
|
|
18
|
+
- **Per-job run selector.** Each job row's "Last Run" text is replaced by a
|
|
19
|
+
dropdown of that job's recent runs (by start time) and a clickable status cell
|
|
20
|
+
(`completed` / `failed` / …) sitting just before the action buttons; picking a
|
|
21
|
+
run and clicking its status opens that run's summary + log.
|
|
22
|
+
- **Download queue pagination + 24h success expiry.** The download-queue list is
|
|
23
|
+
paginated at **20 items/page**, and successful (`done`) downloads finished more
|
|
24
|
+
than **24h ago are hidden** to keep the list manageable. Failures and
|
|
25
|
+
pending/running items are always shown regardless of age. This is a
|
|
26
|
+
**visibility filter only** — no rows are ever deleted, so the audit trail is
|
|
27
|
+
preserved (`GET /admin/jobs/fetch-queue` now returns
|
|
28
|
+
`{items, total, page, pages, limit}`).
|
|
29
|
+
|
|
30
|
+
- **Failed MediaCMS uploads are re-queued instead of lost.** When a fetch-queue
|
|
31
|
+
item's upload fails with a broken connection (`IncompleteRead`), the drain now
|
|
32
|
+
re-queues it to the **back** of the queue for another attempt (up to
|
|
33
|
+
**3 attempts** total) rather than marking it `failed` on the first hit. The
|
|
34
|
+
retry runs *after* everything else currently pending, and the existing
|
|
35
|
+
inter-item cooldown naturally spaces it out. Only the **upload stage** is
|
|
36
|
+
retried — download/validation failures still fail immediately (they need
|
|
37
|
+
case-by-case handling). A new `fetch_queue.attempts` column (schema **v28**,
|
|
38
|
+
forward-only migration) tracks attempts; the last failure is recorded as
|
|
39
|
+
`failed` with an "after N attempts" note.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **MediaCMS upload no longer fails hard on a broken response.** The final upload
|
|
44
|
+
POST previously had **no timeout** and no handling for a dropped connection, so
|
|
45
|
+
a truncated response (`Connection broken: IncompleteRead(...)` — MediaCMS
|
|
46
|
+
serializes a large media object into the response *after* accepting the file)
|
|
47
|
+
surfaced as a permanent `Failed to upload to MediaCMS` even though the item had
|
|
48
|
+
usually already been created server-side. The POST now uses a `(connect, read)`
|
|
49
|
+
timeout of `(30, 600)`, and on a connection-broken/`IncompleteRead`/timeout it
|
|
50
|
+
attempts to **recover the just-created item** by its "Original URL" description
|
|
51
|
+
marker (applying tags to it) rather than blindly re-POSTing and creating a
|
|
52
|
+
duplicate. If it can't be recovered yet (e.g. search-index lag) it raises a
|
|
53
|
+
distinct, retryable error — the next attempt's up-front duplicate check skips it
|
|
54
|
+
if it did land.
|
|
55
|
+
|
|
56
|
+
## [0.43.0] - 2026-08-24
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **Fetch-queue drain cooldown (anti-bot pacing).** The **Fetch Queue — Process**
|
|
61
|
+
job now waits a randomized cooldown *between* items instead of downloading
|
|
62
|
+
back-to-back, so it stops tripping the source's bot detection (the MediaCMS
|
|
63
|
+
encoder is the real bottleneck anyway). The wait is drawn uniformly from
|
|
64
|
+
`cooldown_mean_minutes ± cooldown_jitter_minutes` (default **42 ± 8 min**).
|
|
65
|
+
New optional `fetch_queue` config block: `cooldown_mean_minutes`,
|
|
66
|
+
`cooldown_jitter_minutes`, `cooldown_enabled`. Settings are **re-read from the
|
|
67
|
+
config file before every wait**, so editing the config retunes a *running*
|
|
68
|
+
drain — no restart needed. A `cooldown` progress heartbeat exposes the
|
|
69
|
+
remaining wait to the admin UI.
|
|
70
|
+
|
|
71
|
+
### Changed
|
|
72
|
+
|
|
73
|
+
- **Interrupted fetch-queue downloads now resume.** On startup any item left
|
|
74
|
+
`running` by a crash/restart is reset to `pending`, and the drain auto-starts
|
|
75
|
+
if anything is pending — so a download cut off mid-flight is re-attempted
|
|
76
|
+
without an admin re-triggering it. A drain cancelled by shutdown re-queues its
|
|
77
|
+
in-flight item (rather than marking it `failed`) for the same reason. Retries
|
|
78
|
+
re-download the whole file; a leftover yt-dlp `.part` file may let yt-dlp
|
|
79
|
+
resume where it left off.
|
|
80
|
+
|
|
5
81
|
## [0.42.0] - 2026-08-24
|
|
6
82
|
|
|
7
83
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.44.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
|
|
@@ -1146,6 +1146,36 @@ class _CatalogMixin:
|
|
|
1146
1146
|
"""Update only a running job's detail column (used for live progress)."""
|
|
1147
1147
|
await self._execute("UPDATE job_runs SET detail=? WHERE id=?", [detail, run_id])
|
|
1148
1148
|
|
|
1149
|
+
async def add_job_run_logs(
|
|
1150
|
+
self, run_id: int, records: list[tuple[str, str, str, str]]
|
|
1151
|
+
) -> None:
|
|
1152
|
+
"""Bulk-append captured log lines for a run.
|
|
1153
|
+
|
|
1154
|
+
``records`` is a list of ``(logged_at_iso, level, logger, message)``
|
|
1155
|
+
tuples in emission order; ``seq`` is assigned from the list index so the
|
|
1156
|
+
original ordering survives even when timestamps collide.
|
|
1157
|
+
"""
|
|
1158
|
+
if not records:
|
|
1159
|
+
return
|
|
1160
|
+
rows = [
|
|
1161
|
+
(run_id, seq, logged_at, level, logger, message)
|
|
1162
|
+
for seq, (logged_at, level, logger, message) in enumerate(records)
|
|
1163
|
+
]
|
|
1164
|
+
await self._db.executemany(
|
|
1165
|
+
"INSERT INTO job_run_logs (run_id, seq, logged_at, level, logger, message) "
|
|
1166
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
1167
|
+
rows,
|
|
1168
|
+
)
|
|
1169
|
+
await self._db.commit()
|
|
1170
|
+
|
|
1171
|
+
async def get_job_run_logs(self, run_id: int) -> list[dict]:
|
|
1172
|
+
return await self._fetch_all(
|
|
1173
|
+
"SELECT * FROM job_run_logs WHERE run_id=? ORDER BY seq ASC", [run_id]
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
async def get_job_run(self, run_id: int) -> dict | None:
|
|
1177
|
+
return await self._fetch_one("SELECT * FROM job_runs WHERE id=?", [run_id])
|
|
1178
|
+
|
|
1149
1179
|
async def get_job_runs(
|
|
1150
1180
|
self, job_name: str | None = None, limit: int = 10
|
|
1151
1181
|
) -> list[dict]:
|
|
@@ -509,6 +509,30 @@ MIGRATIONS = [
|
|
|
509
509
|
CREATE INDEX IF NOT EXISTS idx_device_api_keys_user ON device_api_keys(username);
|
|
510
510
|
CREATE INDEX IF NOT EXISTS idx_device_api_keys_hash ON device_api_keys(key_hash);
|
|
511
511
|
""",
|
|
512
|
+
# v28: Retry counter for the fetch queue. A MediaCMS upload that fails with a
|
|
513
|
+
# broken connection (IncompleteRead) is re-queued to the back of the queue up
|
|
514
|
+
# to a bounded number of attempts instead of failing permanently; this tracks
|
|
515
|
+
# how many upload attempts an item has consumed.
|
|
516
|
+
"""
|
|
517
|
+
ALTER TABLE fetch_queue ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0;
|
|
518
|
+
""",
|
|
519
|
+
# v29: Full-text run logs. Each background job run captures the Python log
|
|
520
|
+
# records it emits (scoped to the run via a contextvars filter) as one row
|
|
521
|
+
# per line, so the admin UI can show and download a complete log alongside
|
|
522
|
+
# the JSON summary. Kept in a separate, uncapped table (Postgres-friendly)
|
|
523
|
+
# rather than a column on job_runs.
|
|
524
|
+
"""
|
|
525
|
+
CREATE TABLE IF NOT EXISTS job_run_logs (
|
|
526
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
527
|
+
run_id INTEGER NOT NULL,
|
|
528
|
+
seq INTEGER NOT NULL,
|
|
529
|
+
logged_at TIMESTAMP NOT NULL,
|
|
530
|
+
level TEXT,
|
|
531
|
+
logger TEXT,
|
|
532
|
+
message TEXT NOT NULL
|
|
533
|
+
);
|
|
534
|
+
CREATE INDEX IF NOT EXISTS idx_job_run_logs_run ON job_run_logs(run_id, seq);
|
|
535
|
+
""",
|
|
512
536
|
]
|
|
513
537
|
|
|
514
538
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
class _FetchQueueMixin:
|
|
2
|
+
"""Persistent download queue for the fetch_queue_add / fetch_queue_drain jobs."""
|
|
3
|
+
|
|
4
|
+
async def enqueue_fetch(
|
|
5
|
+
self,
|
|
6
|
+
*,
|
|
7
|
+
url: str,
|
|
8
|
+
quality: str = "medium",
|
|
9
|
+
max_videos: int = 50,
|
|
10
|
+
add_to_playlist: int | None = None,
|
|
11
|
+
added_by: str | None = None,
|
|
12
|
+
) -> int:
|
|
13
|
+
cursor = await self._db.execute(
|
|
14
|
+
"""
|
|
15
|
+
INSERT INTO fetch_queue (url, quality, max_videos, add_to_playlist, added_by)
|
|
16
|
+
VALUES (?, ?, ?, ?, ?)
|
|
17
|
+
""",
|
|
18
|
+
[url, quality, max_videos, add_to_playlist, added_by],
|
|
19
|
+
)
|
|
20
|
+
await self._db.commit()
|
|
21
|
+
return cursor.lastrowid
|
|
22
|
+
|
|
23
|
+
async def claim_next_fetch_item(self) -> dict | None:
|
|
24
|
+
"""Atomically claim the oldest pending item (sets status to running)."""
|
|
25
|
+
row = await self._fetch_one(
|
|
26
|
+
"SELECT * FROM fetch_queue WHERE status = 'pending' ORDER BY added_at ASC LIMIT 1"
|
|
27
|
+
)
|
|
28
|
+
if not row:
|
|
29
|
+
return None
|
|
30
|
+
cursor = await self._db.execute(
|
|
31
|
+
"UPDATE fetch_queue SET status = 'running', started_at = datetime('now')"
|
|
32
|
+
" WHERE id = ? AND status = 'pending'",
|
|
33
|
+
[row["id"]],
|
|
34
|
+
)
|
|
35
|
+
await self._db.commit()
|
|
36
|
+
if (cursor.rowcount or 0) == 0:
|
|
37
|
+
return None # lost the claim (shouldn't happen; drain is single-flight)
|
|
38
|
+
row = dict(row)
|
|
39
|
+
row["status"] = "running"
|
|
40
|
+
return row
|
|
41
|
+
|
|
42
|
+
async def finish_fetch_item(
|
|
43
|
+
self,
|
|
44
|
+
item_id: int,
|
|
45
|
+
*,
|
|
46
|
+
status: str,
|
|
47
|
+
result_json: str | None = None,
|
|
48
|
+
error: str | None = None,
|
|
49
|
+
bump_attempts: bool = False,
|
|
50
|
+
) -> None:
|
|
51
|
+
attempts_sql = ", attempts = attempts + 1" if bump_attempts else ""
|
|
52
|
+
await self._db.execute(
|
|
53
|
+
f"""
|
|
54
|
+
UPDATE fetch_queue
|
|
55
|
+
SET status = ?, finished_at = datetime('now'), result_json = ?, error = ?{attempts_sql}
|
|
56
|
+
WHERE id = ?
|
|
57
|
+
""",
|
|
58
|
+
[status, result_json, error, item_id],
|
|
59
|
+
)
|
|
60
|
+
await self._db.commit()
|
|
61
|
+
|
|
62
|
+
async def requeue_fetch_item(self, item_id: int) -> None:
|
|
63
|
+
"""Return a single item to 'pending' (e.g. interrupted by shutdown).
|
|
64
|
+
|
|
65
|
+
Clears the run timestamps and any prior error so the drain re-attempts it
|
|
66
|
+
cleanly on the next pass.
|
|
67
|
+
"""
|
|
68
|
+
await self._db.execute(
|
|
69
|
+
"UPDATE fetch_queue"
|
|
70
|
+
" SET status = 'pending', started_at = NULL, finished_at = NULL, error = NULL"
|
|
71
|
+
" WHERE id = ?",
|
|
72
|
+
[item_id],
|
|
73
|
+
)
|
|
74
|
+
await self._db.commit()
|
|
75
|
+
|
|
76
|
+
async def requeue_fetch_item_for_retry(
|
|
77
|
+
self, item_id: int, *, error: str | None = None
|
|
78
|
+
) -> int:
|
|
79
|
+
"""Re-queue an item to the *back* of the queue for another upload attempt.
|
|
80
|
+
|
|
81
|
+
Increments ``attempts``, sets ``added_at`` strictly after every existing
|
|
82
|
+
row so it sorts last (retry after everything else currently pending), and
|
|
83
|
+
clears the run timestamps. Keeps ``error`` as a breadcrumb of why it was
|
|
84
|
+
re-queued. Returns the new ``attempts`` count.
|
|
85
|
+
"""
|
|
86
|
+
await self._db.execute(
|
|
87
|
+
"UPDATE fetch_queue"
|
|
88
|
+
" SET status = 'pending', attempts = attempts + 1,"
|
|
89
|
+
" added_at = (SELECT datetime(MAX(added_at), '+1 second') FROM fetch_queue),"
|
|
90
|
+
" started_at = NULL, finished_at = NULL, error = ?"
|
|
91
|
+
" WHERE id = ?",
|
|
92
|
+
[error, item_id],
|
|
93
|
+
)
|
|
94
|
+
await self._db.commit()
|
|
95
|
+
row = await self._fetch_one(
|
|
96
|
+
"SELECT attempts FROM fetch_queue WHERE id = ?", [item_id]
|
|
97
|
+
)
|
|
98
|
+
return int(row["attempts"]) if row else 0
|
|
99
|
+
|
|
100
|
+
async def reset_running_fetch_items(self) -> int:
|
|
101
|
+
"""Reset any 'running' items to 'pending'; return how many were reset.
|
|
102
|
+
|
|
103
|
+
Called at startup to recover from a crash or hard kill that left the
|
|
104
|
+
in-flight item marked 'running' forever (the running flag is process
|
|
105
|
+
state, not durable). Flipping it back to 'pending' lets the drain
|
|
106
|
+
re-attempt it — a fresh download, though a yt-dlp ``.part`` file left in
|
|
107
|
+
the work dir may let yt-dlp resume where it left off.
|
|
108
|
+
"""
|
|
109
|
+
cursor = await self._db.execute(
|
|
110
|
+
"UPDATE fetch_queue SET status = 'pending', started_at = NULL"
|
|
111
|
+
" WHERE status = 'running'"
|
|
112
|
+
)
|
|
113
|
+
await self._db.commit()
|
|
114
|
+
return cursor.rowcount or 0
|
|
115
|
+
|
|
116
|
+
async def get_fetch_queue(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
limit: int = 100,
|
|
120
|
+
offset: int = 0,
|
|
121
|
+
status: str | None = None,
|
|
122
|
+
hide_expired_done: bool = False,
|
|
123
|
+
) -> list[dict]:
|
|
124
|
+
sql = "SELECT * FROM fetch_queue"
|
|
125
|
+
where, params = self._fetch_queue_filter(status, hide_expired_done)
|
|
126
|
+
if where:
|
|
127
|
+
sql += " WHERE " + where
|
|
128
|
+
sql += " ORDER BY added_at DESC LIMIT ? OFFSET ?"
|
|
129
|
+
params.extend([limit, offset])
|
|
130
|
+
return await self._fetch_all(sql, params)
|
|
131
|
+
|
|
132
|
+
async def count_fetch_queue(
|
|
133
|
+
self, *, status: str | None = None, hide_expired_done: bool = False
|
|
134
|
+
) -> int:
|
|
135
|
+
"""Count rows matching the same visibility filter as ``get_fetch_queue``."""
|
|
136
|
+
sql = "SELECT COUNT(*) AS c FROM fetch_queue"
|
|
137
|
+
where, params = self._fetch_queue_filter(status, hide_expired_done)
|
|
138
|
+
if where:
|
|
139
|
+
sql += " WHERE " + where
|
|
140
|
+
row = await self._fetch_one(sql, params)
|
|
141
|
+
return int(row["c"]) if row else 0
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _fetch_queue_filter(
|
|
145
|
+
status: str | None, hide_expired_done: bool
|
|
146
|
+
) -> tuple[str, list]:
|
|
147
|
+
"""Build the shared WHERE clause for fetch-queue list/count queries.
|
|
148
|
+
|
|
149
|
+
When ``hide_expired_done`` is set, successful downloads finished more than
|
|
150
|
+
24h ago are excluded from view (rows are retained for audit — this is a
|
|
151
|
+
visibility filter only). Failed / pending / running items are never
|
|
152
|
+
hidden regardless of age.
|
|
153
|
+
"""
|
|
154
|
+
clauses: list[str] = []
|
|
155
|
+
params: list = []
|
|
156
|
+
if status:
|
|
157
|
+
clauses.append("status = ?")
|
|
158
|
+
params.append(status)
|
|
159
|
+
if hide_expired_done:
|
|
160
|
+
clauses.append(
|
|
161
|
+
"NOT (status = 'done' AND finished_at IS NOT NULL "
|
|
162
|
+
"AND finished_at < datetime('now', '-24 hours'))"
|
|
163
|
+
)
|
|
164
|
+
return " AND ".join(clauses), params
|
|
165
|
+
|
|
166
|
+
async def count_fetch_queue_pending(self) -> int:
|
|
167
|
+
row = await self._fetch_one(
|
|
168
|
+
"SELECT COUNT(*) AS c FROM fetch_queue WHERE status = 'pending'"
|
|
169
|
+
)
|
|
170
|
+
return int(row["c"]) if row else 0
|
|
171
|
+
|
|
172
|
+
async def delete_fetch_queue_item(self, item_id: int) -> bool:
|
|
173
|
+
"""Remove a non-running item. Returns False if not found or currently running."""
|
|
174
|
+
cursor = await self._db.execute(
|
|
175
|
+
"DELETE FROM fetch_queue WHERE id = ? AND status != 'running'",
|
|
176
|
+
[item_id],
|
|
177
|
+
)
|
|
178
|
+
await self._db.commit()
|
|
179
|
+
return (cursor.rowcount or 0) > 0
|
|
@@ -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
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Vendored from d:\\Devel\\yt-pipe (youtube_to_mediacms.py), vendored 2026-06-09."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RetryableUploadError(Exception):
|
|
5
|
+
"""A MediaCMS upload failed transiently (connection broken / IncompleteRead).
|
|
6
|
+
|
|
7
|
+
Raised only from the upload stage after an on-the-spot recovery attempt
|
|
8
|
+
fails, so the fetch-queue drain can re-queue the item rather than marking it
|
|
9
|
+
permanently failed. Lives here (not in ``downloader``) so importers can catch
|
|
10
|
+
it without pulling in the heavy ``yt_dlp`` optional dependency.
|
|
11
|
+
"""
|
{kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
@@ -32,6 +32,8 @@ import requests
|
|
|
32
32
|
import yt_dlp
|
|
33
33
|
from slugify import slugify
|
|
34
34
|
|
|
35
|
+
from . import RetryableUploadError
|
|
36
|
+
|
|
35
37
|
|
|
36
38
|
# yt-dlp needs an external JavaScript runtime to solve YouTube's JS challenges.
|
|
37
39
|
# Only "deno" is enabled by default; many hosts only have Node installed, which
|
|
@@ -2865,8 +2867,11 @@ class MediaDownloaderToMediaCMS:
|
|
|
2865
2867
|
# Prepare file for upload
|
|
2866
2868
|
file_name = os.path.basename(file_path)
|
|
2867
2869
|
|
|
2870
|
+
# Remove Content-Type header for multipart upload
|
|
2871
|
+
upload_session = requests.Session()
|
|
2872
|
+
upload_session.headers.update({"Authorization": f"Token {self.api_token}"})
|
|
2873
|
+
|
|
2868
2874
|
try:
|
|
2869
|
-
# First, upload the file
|
|
2870
2875
|
with open(file_path, "rb") as video_file:
|
|
2871
2876
|
files = {"media_file": (file_name, video_file, "video/mp4")}
|
|
2872
2877
|
data = {
|
|
@@ -2880,16 +2885,45 @@ class MediaDownloaderToMediaCMS:
|
|
|
2880
2885
|
f"[DEBUG] Tags: {len(existing_tags)} via API, {len(missing_tags)} in description"
|
|
2881
2886
|
)
|
|
2882
2887
|
|
|
2883
|
-
#
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
{"Authorization": f"Token {self.api_token}"}
|
|
2887
|
-
)
|
|
2888
|
-
|
|
2888
|
+
# (connect, read) timeout. The read leg is generous because
|
|
2889
|
+
# MediaCMS serializes a large media object (encodings, thumbnails)
|
|
2890
|
+
# into the response after accepting the file.
|
|
2889
2891
|
response = upload_session.post(
|
|
2890
|
-
f"{self.api_url}/media/",
|
|
2892
|
+
f"{self.api_url}/media/",
|
|
2893
|
+
files=files,
|
|
2894
|
+
data=data,
|
|
2895
|
+
timeout=(30, 600),
|
|
2891
2896
|
)
|
|
2897
|
+
except (
|
|
2898
|
+
requests.exceptions.ConnectionError,
|
|
2899
|
+
requests.exceptions.ChunkedEncodingError,
|
|
2900
|
+
requests.exceptions.Timeout,
|
|
2901
|
+
) as e:
|
|
2902
|
+
# The connection dropped mid-transfer — most often an IncompleteRead
|
|
2903
|
+
# while reading MediaCMS's (large) JSON response, meaning the item was
|
|
2904
|
+
# already created server-side before the socket broke. Blindly
|
|
2905
|
+
# re-POSTing here would duplicate it, so try to recover the created
|
|
2906
|
+
# item by its "Original URL" description marker instead.
|
|
2907
|
+
print(f"[DEBUG] Upload connection error (item may already exist): {e}")
|
|
2908
|
+
recovered = self.check_media_already_imported(original_url, title)
|
|
2909
|
+
if recovered:
|
|
2910
|
+
print(
|
|
2911
|
+
"✅ Recovered upload after broken response — already in MediaCMS: "
|
|
2912
|
+
f"{recovered.get('friendly_token', 'Unknown')}"
|
|
2913
|
+
)
|
|
2914
|
+
if existing_tags:
|
|
2915
|
+
media_id = recovered.get("friendly_token")
|
|
2916
|
+
if media_id:
|
|
2917
|
+
self.add_tags_to_media(media_id, existing_tags)
|
|
2918
|
+
return recovered
|
|
2919
|
+
# Not recoverable yet (e.g. search index lag). Surface a distinct,
|
|
2920
|
+
# retryable error so the caller/queue can re-attempt later; the next
|
|
2921
|
+
# attempt's up-front duplicate check will skip it if it did land.
|
|
2922
|
+
raise RetryableUploadError(
|
|
2923
|
+
f"Failed to upload to MediaCMS (connection broken, item not recovered): {e}"
|
|
2924
|
+
) from e
|
|
2892
2925
|
|
|
2926
|
+
try:
|
|
2893
2927
|
if response.status_code == 201:
|
|
2894
2928
|
result = response.json()
|
|
2895
2929
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Per-run capture of Python log records emitted during a background job.
|
|
2
|
+
|
|
3
|
+
A single :class:`logging.Handler` is installed on the root logger. It is gated
|
|
4
|
+
by a :class:`contextvars.ContextVar`, so a record is captured only when it is
|
|
5
|
+
emitted from within a job run's async context — the buffer is set on the run's
|
|
6
|
+
task and inherited by any child tasks / ``asyncio.to_thread`` calls it spawns.
|
|
7
|
+
Concurrent web requests (and other jobs) run in their own contexts with their
|
|
8
|
+
own buffer, so their logs never bleed into a run's log.
|
|
9
|
+
|
|
10
|
+
The captured records are drained by :class:`~kryten_webqueue.jobs.manager.JobManager`
|
|
11
|
+
when a run finishes and persisted one row per line to ``job_run_logs``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextvars
|
|
15
|
+
import logging
|
|
16
|
+
from datetime import datetime, UTC
|
|
17
|
+
|
|
18
|
+
# One in-flight log buffer per run context. Each entry is
|
|
19
|
+
# ``(logged_at_iso, level, logger_name, message)``.
|
|
20
|
+
_current_buffer: contextvars.ContextVar[list[tuple[str, str, str, str]] | None] = (
|
|
21
|
+
contextvars.ContextVar("kryten_job_run_log_buffer", default=None)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_handler: "logging.Handler | None" = None
|
|
25
|
+
_formatter = logging.Formatter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _RunLogHandler(logging.Handler):
|
|
29
|
+
"""Append formatted records to the current context's run buffer, if any."""
|
|
30
|
+
|
|
31
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
32
|
+
buffer = _current_buffer.get()
|
|
33
|
+
if buffer is None:
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
logged_at = datetime.fromtimestamp(record.created, UTC).isoformat()
|
|
37
|
+
buffer.append(
|
|
38
|
+
(logged_at, record.levelname, record.name, record.getMessage())
|
|
39
|
+
)
|
|
40
|
+
if record.exc_info:
|
|
41
|
+
buffer.append(
|
|
42
|
+
(
|
|
43
|
+
logged_at,
|
|
44
|
+
record.levelname,
|
|
45
|
+
record.name,
|
|
46
|
+
_formatter.formatException(record.exc_info),
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
except Exception: # noqa: BLE001 - logging must never raise
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install() -> None:
|
|
54
|
+
"""Attach the capture handler to the root logger (idempotent)."""
|
|
55
|
+
global _handler
|
|
56
|
+
if _handler is not None:
|
|
57
|
+
return
|
|
58
|
+
handler = _RunLogHandler()
|
|
59
|
+
handler.setLevel(logging.DEBUG)
|
|
60
|
+
logging.getLogger().addHandler(handler)
|
|
61
|
+
_handler = handler
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def start_capture() -> list[tuple[str, str, str, str]]:
|
|
65
|
+
"""Begin capturing in the current context; returns the run's log buffer."""
|
|
66
|
+
buffer: list[tuple[str, str, str, str]] = []
|
|
67
|
+
_current_buffer.set(buffer)
|
|
68
|
+
return buffer
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def stop_capture() -> None:
|
|
72
|
+
"""Stop capturing in the current context."""
|
|
73
|
+
_current_buffer.set(None)
|