kryten-webqueue 0.43.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.43.0 → kryten_webqueue-0.44.0}/CHANGELOG.md +51 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/PKG-INFO +1 -1
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_catalog.py +30 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_connection.py +24 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_fetch_queue.py +71 -7
- kryten_webqueue-0.44.0/kryten_webqueue/integrations/ytpipe/__init__.py +11 -0
- {kryten_webqueue-0.43.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.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/manager.py +14 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/tasks.py +42 -0
- kryten_webqueue-0.44.0/kryten_webqueue/routes/admin_jobs.py +169 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/css/main.css +100 -1
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin.js +148 -99
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/pyproject.toml +1 -1
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_fetch_queue_pacing.py +97 -0
- kryten_webqueue-0.44.0/tests/test_job_logs_and_queue_visibility.py +132 -0
- kryten_webqueue-0.43.0/kryten_webqueue/integrations/ytpipe/__init__.py +0 -1
- kryten_webqueue-0.43.0/kryten_webqueue/routes/admin_jobs.py +0 -87
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/.github/workflows/gitleaks.yml +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/.gitignore +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/.gitleaks.toml +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/.pre-commit-config.yaml +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/AGENTS.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/README.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/config.example.json +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/PUBLIC_API.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/device_keys.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_people.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/logging_config.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/promos/director.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/completion.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/presence.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/race_poller.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/devices.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/feedback.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/public_api.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/keybindings.js +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/link_device.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/my_list.html +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/scripts/AGENTS.md +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/scripts/patch_mediacms_tags.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/scripts/trigger_enrich.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/__init__.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_catalog_blackout.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_catalog_delete.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_classify.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_config_persistence.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_device_linking.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_feedback.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_fetchurls_sharepoint.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_identify_step.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_people_facets.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_playlist_import.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_presence_refund.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_promo_director.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_promo_pool_exclusion.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_providers.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_queue_announce.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_race_poller.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_recently_played_hide.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_save_results_to_playlist.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_schedule_lock.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_search_facets.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_title_normalization.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_tmdb_index.py +0 -0
- {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/tests/test_watchlist.py +0 -0
|
@@ -2,6 +2,57 @@
|
|
|
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
|
+
|
|
5
56
|
## [0.43.0] - 2026-08-24
|
|
6
57
|
|
|
7
58
|
### Added
|
|
@@ -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
|
|
{kryten_webqueue-0.43.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_fetch_queue.py
RENAMED
|
@@ -46,11 +46,13 @@ class _FetchQueueMixin:
|
|
|
46
46
|
status: str,
|
|
47
47
|
result_json: str | None = None,
|
|
48
48
|
error: str | None = None,
|
|
49
|
+
bump_attempts: bool = False,
|
|
49
50
|
) -> None:
|
|
51
|
+
attempts_sql = ", attempts = attempts + 1" if bump_attempts else ""
|
|
50
52
|
await self._db.execute(
|
|
51
|
-
"""
|
|
53
|
+
f"""
|
|
52
54
|
UPDATE fetch_queue
|
|
53
|
-
SET status = ?, finished_at = datetime('now'), result_json = ?, error = ?
|
|
55
|
+
SET status = ?, finished_at = datetime('now'), result_json = ?, error = ?{attempts_sql}
|
|
54
56
|
WHERE id = ?
|
|
55
57
|
""",
|
|
56
58
|
[status, result_json, error, item_id],
|
|
@@ -71,6 +73,30 @@ class _FetchQueueMixin:
|
|
|
71
73
|
)
|
|
72
74
|
await self._db.commit()
|
|
73
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
|
+
|
|
74
100
|
async def reset_running_fetch_items(self) -> int:
|
|
75
101
|
"""Reset any 'running' items to 'pending'; return how many were reset.
|
|
76
102
|
|
|
@@ -88,16 +114,54 @@ class _FetchQueueMixin:
|
|
|
88
114
|
return cursor.rowcount or 0
|
|
89
115
|
|
|
90
116
|
async def get_fetch_queue(
|
|
91
|
-
self,
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
limit: int = 100,
|
|
120
|
+
offset: int = 0,
|
|
121
|
+
status: str | None = None,
|
|
122
|
+
hide_expired_done: bool = False,
|
|
92
123
|
) -> list[dict]:
|
|
93
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] = []
|
|
94
155
|
params: list = []
|
|
95
156
|
if status:
|
|
96
|
-
|
|
157
|
+
clauses.append("status = ?")
|
|
97
158
|
params.append(status)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
101
165
|
|
|
102
166
|
async def count_fetch_queue_pending(self) -> int:
|
|
103
167
|
row = await self._fetch_one(
|
|
@@ -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.43.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)
|
|
@@ -17,6 +17,8 @@ import json
|
|
|
17
17
|
import logging
|
|
18
18
|
from typing import Any, Awaitable, Callable
|
|
19
19
|
|
|
20
|
+
from . import log_capture
|
|
21
|
+
|
|
20
22
|
logger = logging.getLogger(__name__)
|
|
21
23
|
|
|
22
24
|
# A job is an async callable taking validated params + a context, returning an
|
|
@@ -150,6 +152,7 @@ class JobManager:
|
|
|
150
152
|
self._cover_art = cover_art
|
|
151
153
|
self._jobs: dict[str, dict] = {}
|
|
152
154
|
self._running: dict[str, asyncio.Task] = {}
|
|
155
|
+
log_capture.install()
|
|
153
156
|
|
|
154
157
|
def register(
|
|
155
158
|
self,
|
|
@@ -237,6 +240,7 @@ class JobManager:
|
|
|
237
240
|
cover_art=getattr(self, "_cover_art", None),
|
|
238
241
|
job_manager=self,
|
|
239
242
|
)
|
|
243
|
+
log_buffer = log_capture.start_capture()
|
|
240
244
|
try:
|
|
241
245
|
result = await func(params, ctx)
|
|
242
246
|
detail = json.dumps(result) if result is not None else None
|
|
@@ -278,3 +282,13 @@ class JobManager:
|
|
|
278
282
|
)
|
|
279
283
|
finally:
|
|
280
284
|
self._running.pop(name, None)
|
|
285
|
+
log_capture.stop_capture()
|
|
286
|
+
if log_buffer:
|
|
287
|
+
try:
|
|
288
|
+
await self._db.add_job_run_logs(run_id, log_buffer)
|
|
289
|
+
except Exception: # noqa: BLE001 - log persistence is best-effort
|
|
290
|
+
logger.debug(
|
|
291
|
+
"Could not persist %d captured log line(s) for job '%s'",
|
|
292
|
+
len(log_buffer),
|
|
293
|
+
name,
|
|
294
|
+
)
|
|
@@ -18,10 +18,16 @@ import logging
|
|
|
18
18
|
import random
|
|
19
19
|
import time
|
|
20
20
|
|
|
21
|
+
from ..integrations.ytpipe import RetryableUploadError
|
|
21
22
|
from .manager import JobError
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
26
|
+
# Max MediaCMS upload attempts per fetch-queue item before it is failed for good.
|
|
27
|
+
# A broken-connection upload (IncompleteRead) re-queues to the back of the queue;
|
|
28
|
+
# after this many total attempts it is recorded as ``failed``.
|
|
29
|
+
MAX_FETCH_UPLOAD_ATTEMPTS = 3
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
def _require(module_name: str, *, extra: str = "jobs"):
|
|
27
33
|
"""Import a job dependency, raising a clear error if it's not installed."""
|
|
@@ -972,6 +978,42 @@ async def fetch_queue_drain_job(params: dict, ctx) -> dict:
|
|
|
972
978
|
"fetch_queue_drain: id=%d interrupted — re-queued for retry", item["id"]
|
|
973
979
|
)
|
|
974
980
|
raise
|
|
981
|
+
except RetryableUploadError as exc:
|
|
982
|
+
# The MediaCMS upload dropped its connection (IncompleteRead). Re-queue
|
|
983
|
+
# to the back of the queue for another attempt, bounded by
|
|
984
|
+
# MAX_FETCH_UPLOAD_ATTEMPTS; the next attempt's up-front duplicate
|
|
985
|
+
# check skips it cleanly if the item actually landed server-side.
|
|
986
|
+
attempts = (item.get("attempts") or 0) + 1
|
|
987
|
+
if attempts < MAX_FETCH_UPLOAD_ATTEMPTS:
|
|
988
|
+
await ctx.db.requeue_fetch_item_for_retry(
|
|
989
|
+
item["id"],
|
|
990
|
+
error=f"upload retry {attempts}/{MAX_FETCH_UPLOAD_ATTEMPTS}: {exc}",
|
|
991
|
+
)
|
|
992
|
+
logger.warning(
|
|
993
|
+
"fetch_queue_drain: id=%d (%s) upload failed (attempt %d/%d) — "
|
|
994
|
+
"re-queued: %s",
|
|
995
|
+
item["id"],
|
|
996
|
+
item["url"],
|
|
997
|
+
attempts,
|
|
998
|
+
MAX_FETCH_UPLOAD_ATTEMPTS,
|
|
999
|
+
exc,
|
|
1000
|
+
)
|
|
1001
|
+
else:
|
|
1002
|
+
await ctx.db.finish_fetch_item(
|
|
1003
|
+
item["id"],
|
|
1004
|
+
status="failed",
|
|
1005
|
+
error=f"upload failed after {attempts} attempts: {exc}",
|
|
1006
|
+
bump_attempts=True,
|
|
1007
|
+
)
|
|
1008
|
+
failed += 1
|
|
1009
|
+
logger.warning(
|
|
1010
|
+
"fetch_queue_drain: id=%d (%s) upload failed after %d attempts — "
|
|
1011
|
+
"giving up: %s",
|
|
1012
|
+
item["id"],
|
|
1013
|
+
item["url"],
|
|
1014
|
+
attempts,
|
|
1015
|
+
exc,
|
|
1016
|
+
)
|
|
975
1017
|
except Exception as exc: # noqa: BLE001
|
|
976
1018
|
logger.warning(
|
|
977
1019
|
"fetch_queue_drain: id=%d (%s) failed: %s", item["id"], item["url"], exc
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter, Request, Depends, HTTPException
|
|
4
|
+
from fastapi.responses import Response
|
|
5
|
+
|
|
6
|
+
from ..auth.session import require_admin
|
|
7
|
+
|
|
8
|
+
router = APIRouter(prefix="/admin/jobs", tags=["admin"])
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@router.get("")
|
|
12
|
+
async def list_jobs(request: Request, user: dict = Depends(require_admin)):
|
|
13
|
+
"""List registered jobs with their parameter schema and last-run summary."""
|
|
14
|
+
job_manager = request.app.state.job_manager
|
|
15
|
+
db = request.app.state.db
|
|
16
|
+
jobs = job_manager.list_jobs()
|
|
17
|
+
for job in jobs:
|
|
18
|
+
runs = await db.get_job_runs(job_name=job["name"], limit=1)
|
|
19
|
+
job["last_run"] = runs[0] if runs else None
|
|
20
|
+
return jobs
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@router.get("/runs")
|
|
24
|
+
async def job_runs(
|
|
25
|
+
request: Request,
|
|
26
|
+
user: dict = Depends(require_admin),
|
|
27
|
+
job: str | None = None,
|
|
28
|
+
limit: int = 10,
|
|
29
|
+
):
|
|
30
|
+
"""Recent run history, optionally filtered by job name."""
|
|
31
|
+
db = request.app.state.db
|
|
32
|
+
return await db.get_job_runs(job_name=job, limit=limit)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@router.get("/runs/{run_id}/log")
|
|
36
|
+
async def job_run_log(
|
|
37
|
+
request: Request, run_id: int, user: dict = Depends(require_admin)
|
|
38
|
+
):
|
|
39
|
+
"""Return the captured full-text log lines for a single run."""
|
|
40
|
+
db = request.app.state.db
|
|
41
|
+
run = await db.get_job_run(run_id)
|
|
42
|
+
if not run:
|
|
43
|
+
raise HTTPException(404, "Unknown run")
|
|
44
|
+
return await db.get_job_run_logs(run_id)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@router.get("/runs/{run_id}/log/download")
|
|
48
|
+
async def download_job_run_log(
|
|
49
|
+
request: Request, run_id: int, user: dict = Depends(require_admin)
|
|
50
|
+
):
|
|
51
|
+
"""Download the run's full-text log as a plain-text file."""
|
|
52
|
+
db = request.app.state.db
|
|
53
|
+
run = await db.get_job_run(run_id)
|
|
54
|
+
if not run:
|
|
55
|
+
raise HTTPException(404, "Unknown run")
|
|
56
|
+
lines = await db.get_job_run_logs(run_id)
|
|
57
|
+
body = "\n".join(
|
|
58
|
+
f"{ln['logged_at']} {ln.get('level', ''):<8} {ln.get('logger', '')}: {ln['message']}"
|
|
59
|
+
for ln in lines
|
|
60
|
+
)
|
|
61
|
+
filename = f"{run['job_name']}-run{run_id}.log"
|
|
62
|
+
return Response(
|
|
63
|
+
content=body,
|
|
64
|
+
media_type="text/plain; charset=utf-8",
|
|
65
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@router.get("/runs/{run_id}/detail/download")
|
|
70
|
+
async def download_job_run_detail(
|
|
71
|
+
request: Request, run_id: int, user: dict = Depends(require_admin)
|
|
72
|
+
):
|
|
73
|
+
"""Download the run's JSON summary (the ``detail`` blob) as a file."""
|
|
74
|
+
db = request.app.state.db
|
|
75
|
+
run = await db.get_job_run(run_id)
|
|
76
|
+
if not run:
|
|
77
|
+
raise HTTPException(404, "Unknown run")
|
|
78
|
+
detail = run.get("detail")
|
|
79
|
+
try:
|
|
80
|
+
body = json.dumps(json.loads(detail), indent=2) if detail else "{}"
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
body = detail or "{}"
|
|
83
|
+
filename = f"{run['job_name']}-run{run_id}.json"
|
|
84
|
+
return Response(
|
|
85
|
+
content=body,
|
|
86
|
+
media_type="application/json; charset=utf-8",
|
|
87
|
+
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get("/{name}/schema")
|
|
92
|
+
async def job_schema(request: Request, name: str, user: dict = Depends(require_admin)):
|
|
93
|
+
"""Return a job's parameter schema for rendering the Run form."""
|
|
94
|
+
job_manager = request.app.state.job_manager
|
|
95
|
+
try:
|
|
96
|
+
return {"name": name, "schema": job_manager.get_schema(name)}
|
|
97
|
+
except KeyError:
|
|
98
|
+
raise HTTPException(404, f"Unknown job: {name}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@router.post("/{name}/run")
|
|
102
|
+
async def run_job(request: Request, name: str, user: dict = Depends(require_admin)):
|
|
103
|
+
"""Trigger a registered job to run in the background.
|
|
104
|
+
|
|
105
|
+
Accepts an optional JSON body ``{"params": {...}}`` validated against the
|
|
106
|
+
job's schema. Returns 400 on invalid params, 404 for an unknown job, and
|
|
107
|
+
409 if the job is already running.
|
|
108
|
+
"""
|
|
109
|
+
job_manager = request.app.state.job_manager
|
|
110
|
+
params = None
|
|
111
|
+
try:
|
|
112
|
+
body = await request.json()
|
|
113
|
+
if isinstance(body, dict):
|
|
114
|
+
params = body.get("params") if "params" in body else (body or None)
|
|
115
|
+
except Exception:
|
|
116
|
+
params = None # empty/non-JSON body → run with no params
|
|
117
|
+
try:
|
|
118
|
+
result = await job_manager.run(
|
|
119
|
+
name, triggered_by=user["username"], params=params
|
|
120
|
+
)
|
|
121
|
+
except KeyError:
|
|
122
|
+
raise HTTPException(404, f"Unknown job: {name}")
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise HTTPException(400, str(exc))
|
|
125
|
+
if not result.get("started"):
|
|
126
|
+
raise HTTPException(409, result.get("reason", "Job already running"))
|
|
127
|
+
return {"success": True, **result}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@router.get("/fetch-queue")
|
|
131
|
+
async def get_fetch_queue(
|
|
132
|
+
request: Request,
|
|
133
|
+
user: dict = Depends(require_admin),
|
|
134
|
+
page: int = 1,
|
|
135
|
+
limit: int = 20,
|
|
136
|
+
):
|
|
137
|
+
"""Return a page of the download queue (newest first).
|
|
138
|
+
|
|
139
|
+
Successful downloads finished more than 24h ago are hidden from the list to
|
|
140
|
+
keep it manageable; failed / pending / running items always remain visible.
|
|
141
|
+
Rows are never deleted — this is a visibility filter only, so the full audit
|
|
142
|
+
trail is preserved. Paginated at ``limit`` items per page (default 20).
|
|
143
|
+
"""
|
|
144
|
+
db = request.app.state.db
|
|
145
|
+
page = max(1, page)
|
|
146
|
+
limit = max(1, min(limit, 100))
|
|
147
|
+
offset = (page - 1) * limit
|
|
148
|
+
total = await db.count_fetch_queue(hide_expired_done=True)
|
|
149
|
+
items = await db.get_fetch_queue(limit=limit, offset=offset, hide_expired_done=True)
|
|
150
|
+
pages = max(1, (total + limit - 1) // limit)
|
|
151
|
+
return {
|
|
152
|
+
"items": items,
|
|
153
|
+
"total": total,
|
|
154
|
+
"page": page,
|
|
155
|
+
"pages": pages,
|
|
156
|
+
"limit": limit,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.delete("/fetch-queue/{item_id}")
|
|
161
|
+
async def delete_fetch_queue_item(
|
|
162
|
+
request: Request, item_id: int, user: dict = Depends(require_admin)
|
|
163
|
+
):
|
|
164
|
+
"""Remove a pending or finished fetch queue item (running items are protected)."""
|
|
165
|
+
db = request.app.state.db
|
|
166
|
+
ok = await db.delete_fetch_queue_item(item_id)
|
|
167
|
+
if not ok:
|
|
168
|
+
raise HTTPException(404, "Item not found or currently running")
|
|
169
|
+
return {"ok": True}
|