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.
Files changed (201) hide show
  1. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/CHANGELOG.md +76 -0
  2. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/PKG-INFO +11 -1
  3. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/README.md +10 -0
  4. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/config.example.json +6 -0
  5. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PUBLIC_API.md +6 -4
  6. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/app.py +20 -0
  7. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_catalog.py +30 -0
  8. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_connection.py +24 -0
  9. kryten_webqueue-0.44.0/kryten_webqueue/catalog/db/_fetch_queue.py +179 -0
  10. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/config.py +38 -1
  11. kryten_webqueue-0.44.0/kryten_webqueue/integrations/ytpipe/__init__.py +11 -0
  12. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/ytpipe/downloader.py +42 -8
  13. kryten_webqueue-0.44.0/kryten_webqueue/jobs/log_capture.py +73 -0
  14. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/manager.py +14 -0
  15. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/tasks.py +99 -3
  16. kryten_webqueue-0.44.0/kryten_webqueue/routes/admin_jobs.py +169 -0
  17. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/css/main.css +100 -1
  18. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin.js +153 -92
  19. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/pyproject.toml +1 -1
  20. kryten_webqueue-0.44.0/tests/test_fetch_queue_pacing.py +256 -0
  21. kryten_webqueue-0.44.0/tests/test_job_logs_and_queue_visibility.py +132 -0
  22. kryten_webqueue-0.42.0/kryten_webqueue/catalog/db/_fetch_queue.py +0 -85
  23. kryten_webqueue-0.42.0/kryten_webqueue/integrations/ytpipe/__init__.py +0 -1
  24. kryten_webqueue-0.42.0/kryten_webqueue/routes/admin_jobs.py +0 -87
  25. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.github/workflows/gitleaks.yml +0 -0
  26. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.github/workflows/python-publish.yml +0 -0
  27. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.github/workflows/release.yml +0 -0
  28. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.gitignore +0 -0
  29. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.gitleaks.toml +0 -0
  30. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/.pre-commit-config.yaml +0 -0
  31. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  32. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/AGENTS.md +0 -0
  33. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/deploy/kryten-webqueue.service +0 -0
  34. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/deploy/nginx-queue.conf +0 -0
  35. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  36. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_API_GATE.md +0 -0
  37. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_ECONOMY.md +0 -0
  38. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  39. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/IMPL_ROBOT.md +0 -0
  40. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  41. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  42. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  43. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  44. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  45. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PRE_PLAN_GAPS.md +0 -0
  46. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/PRODUCT_PLAN.md +0 -0
  47. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  48. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  49. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  50. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  51. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  52. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  53. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/docs/UX_POLISH_PLAN.md +0 -0
  54. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/__init__.py +0 -0
  55. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/__main__.py +0 -0
  56. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  57. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/api_gate/client.py +0 -0
  58. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/__init__.py +0 -0
  59. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/device_keys.py +0 -0
  60. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/otp.py +0 -0
  61. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  62. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/auth/session.py +0 -0
  63. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/__init__.py +0 -0
  64. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
  65. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  66. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
  67. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  68. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  69. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  70. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  71. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  72. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  73. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  74. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  75. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  76. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  77. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  78. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  79. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  80. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  81. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  82. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  83. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  84. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  85. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  86. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  87. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/images.py +0 -0
  88. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  89. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/sync.py +0 -0
  90. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  91. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  92. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  93. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  94. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  95. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  96. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  97. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/__init__.py +0 -0
  98. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  99. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  100. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  101. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  102. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  103. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  104. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  105. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  106. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  107. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/__init__.py +0 -0
  108. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  109. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  110. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  111. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/logging_config.py +0 -0
  112. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/__init__.py +0 -0
  113. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  114. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/fire.py +0 -0
  115. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/importer.py +0 -0
  116. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/ordering.py +0 -0
  117. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  118. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/promos/__init__.py +0 -0
  119. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/promos/director.py +0 -0
  120. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/__init__.py +0 -0
  121. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/completion.py +0 -0
  122. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/ordering.py +0 -0
  123. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/poller.py +0 -0
  124. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/presence.py +0 -0
  125. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/race_poller.py +0 -0
  126. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/queue/shadow.py +0 -0
  127. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/__init__.py +0 -0
  128. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  129. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  130. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  131. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  132. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  133. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  134. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  135. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  136. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/auth.py +0 -0
  137. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/catalog.py +0 -0
  138. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/devices.py +0 -0
  139. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/feedback.py +0 -0
  140. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/pages.py +0 -0
  141. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/public_api.py +0 -0
  142. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/queue.py +0 -0
  143. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/routes/user.py +0 -0
  144. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  145. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  146. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  147. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  148. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/keybindings.js +0 -0
  149. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/static/js/main.js +0 -0
  150. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/index.html +0 -0
  151. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  152. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  153. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  154. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  155. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/auth/login.html +0 -0
  156. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/base.html +0 -0
  157. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  158. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  159. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  160. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  161. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  162. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/queue/index.html +0 -0
  163. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/race.html +0 -0
  164. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  165. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/link_device.html +0 -0
  166. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  167. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/__init__.py +0 -0
  168. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/handler.py +0 -0
  169. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/kryten_webqueue/ws/manager.py +0 -0
  170. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/AGENTS.md +0 -0
  171. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  172. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/patch_mediacms_tags.py +0 -0
  173. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/scripts/trigger_enrich.py +0 -0
  174. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/__init__.py +0 -0
  175. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_catalog_blackout.py +0 -0
  176. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_catalog_delete.py +0 -0
  177. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_classify.py +0 -0
  178. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_config_persistence.py +0 -0
  179. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_device_linking.py +0 -0
  180. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_feedback.py +0 -0
  181. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_fetchurls_sharepoint.py +0 -0
  182. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_identify_step.py +0 -0
  183. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_people_facets.py +0 -0
  184. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase1.py +0 -0
  185. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase2_jobs.py +0 -0
  186. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase3_jobs.py +0 -0
  187. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_phase4_live_fixes.py +0 -0
  188. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_playlist_import.py +0 -0
  189. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_presence_refund.py +0 -0
  190. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_promo_director.py +0 -0
  191. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_promo_pool_exclusion.py +0 -0
  192. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_providers.py +0 -0
  193. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_queue_announce.py +0 -0
  194. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_race_poller.py +0 -0
  195. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_recently_played_hide.py +0 -0
  196. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_save_results_to_playlist.py +0 -0
  197. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_schedule_lock.py +0 -0
  198. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_search_facets.py +0 -0
  199. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_title_normalization.py +0 -0
  200. {kryten_webqueue-0.42.0 → kryten_webqueue-0.44.0}/tests/test_tmdb_index.py +0 -0
  201. {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.42.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` every ~10–15s; `/events` changes rarely (every few minutes is
279
- plenty).
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 second.
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
+ """
@@ -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
- # Remove Content-Type header for multipart upload
2884
- upload_session = requests.Session()
2885
- upload_session.headers.update(
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/", files=files, data=data
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)