kryten-webqueue 0.43.0__tar.gz → 0.44.1__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 (200) hide show
  1. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/CHANGELOG.md +62 -0
  2. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_catalog.py +30 -0
  4. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_connection.py +24 -0
  5. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_fetch_queue.py +71 -7
  6. kryten_webqueue-0.44.1/kryten_webqueue/integrations/ytpipe/__init__.py +11 -0
  7. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/ytpipe/downloader.py +42 -8
  8. kryten_webqueue-0.44.1/kryten_webqueue/jobs/log_capture.py +73 -0
  9. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/jobs/manager.py +14 -0
  10. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/jobs/tasks.py +42 -0
  11. kryten_webqueue-0.44.1/kryten_webqueue/routes/admin_jobs.py +169 -0
  12. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/css/main.css +100 -1
  13. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/admin.js +148 -99
  14. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/admin/index.html +1 -1
  15. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/admin/playlists.html +1 -1
  16. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/admin/promos.html +1 -1
  17. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/admin/queue_mgmt.html +1 -1
  18. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/admin/schedules.html +1 -1
  19. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/base.html +3 -3
  20. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/pyproject.toml +1 -1
  21. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_fetch_queue_pacing.py +97 -0
  22. kryten_webqueue-0.44.1/tests/test_job_logs_and_queue_visibility.py +132 -0
  23. kryten_webqueue-0.43.0/kryten_webqueue/integrations/ytpipe/__init__.py +0 -1
  24. kryten_webqueue-0.43.0/kryten_webqueue/routes/admin_jobs.py +0 -87
  25. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/.github/workflows/gitleaks.yml +0 -0
  26. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/.github/workflows/python-publish.yml +0 -0
  27. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/.github/workflows/release.yml +0 -0
  28. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/.gitignore +0 -0
  29. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/.gitleaks.toml +0 -0
  30. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/.pre-commit-config.yaml +0 -0
  31. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/AGENT-WORKFLOW-GUIDE.md +0 -0
  32. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/AGENTS.md +0 -0
  33. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/README.md +0 -0
  34. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/config.example.json +0 -0
  35. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/deploy/kryten-webqueue.service +0 -0
  36. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/deploy/nginx-queue.conf +0 -0
  37. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  38. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/IMPL_API_GATE.md +0 -0
  39. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/IMPL_ECONOMY.md +0 -0
  40. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  41. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/IMPL_ROBOT.md +0 -0
  42. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  43. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  44. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
  45. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  46. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  47. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/PRE_PLAN_GAPS.md +0 -0
  48. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/PRODUCT_PLAN.md +0 -0
  49. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/PUBLIC_API.md +0 -0
  50. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  51. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  52. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  53. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/SPEC_EMOTE_REHOST.md +0 -0
  54. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  55. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  56. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/docs/UX_POLISH_PLAN.md +0 -0
  57. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/__init__.py +0 -0
  58. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/__main__.py +0 -0
  59. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  60. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/api_gate/client.py +0 -0
  61. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/app.py +0 -0
  62. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/auth/__init__.py +0 -0
  63. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/auth/device_keys.py +0 -0
  64. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/auth/otp.py +0 -0
  65. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  66. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/auth/session.py +0 -0
  67. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/__init__.py +0 -0
  68. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/__init__.py +0 -0
  69. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  70. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_devices.py +0 -0
  71. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  72. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  73. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_people.py +0 -0
  74. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  75. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_queue.py +0 -0
  76. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  77. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  78. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  79. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  80. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  81. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  82. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  83. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  84. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  85. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  86. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  87. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  88. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  89. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  90. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  91. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/images.py +0 -0
  92. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  93. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/sync.py +0 -0
  94. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  95. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  96. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  97. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  98. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  99. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  100. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  101. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/config.py +0 -0
  102. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/__init__.py +0 -0
  103. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  104. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  105. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  106. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  107. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  108. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  109. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  110. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  111. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  112. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/jobs/__init__.py +0 -0
  113. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  114. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  115. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  116. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/logging_config.py +0 -0
  117. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/playlists/__init__.py +0 -0
  118. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  119. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/playlists/fire.py +0 -0
  120. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/playlists/importer.py +0 -0
  121. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/playlists/ordering.py +0 -0
  122. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  123. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/promos/__init__.py +0 -0
  124. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/promos/director.py +0 -0
  125. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/__init__.py +0 -0
  126. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/completion.py +0 -0
  127. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/ordering.py +0 -0
  128. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/poller.py +0 -0
  129. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/presence.py +0 -0
  130. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/race_poller.py +0 -0
  131. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/queue/shadow.py +0 -0
  132. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/__init__.py +0 -0
  133. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  134. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_feedback.py +0 -0
  135. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  136. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_moderation.py +0 -0
  137. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  138. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  139. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  140. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  141. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/auth.py +0 -0
  142. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/catalog.py +0 -0
  143. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/devices.py +0 -0
  144. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/feedback.py +0 -0
  145. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/pages.py +0 -0
  146. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/public_api.py +0 -0
  147. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/queue.py +0 -0
  148. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/routes/user.py +0 -0
  149. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  150. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
  151. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  152. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  153. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/keybindings.js +0 -0
  154. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/static/js/main.js +0 -0
  155. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/auth/login.html +0 -0
  156. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  157. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  158. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  159. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  160. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/feedback/index.html +0 -0
  161. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/queue/index.html +0 -0
  162. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/race.html +0 -0
  163. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  164. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/user/link_device.html +0 -0
  165. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/templates/user/my_list.html +0 -0
  166. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/ws/__init__.py +0 -0
  167. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/ws/handler.py +0 -0
  168. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/kryten_webqueue/ws/manager.py +0 -0
  169. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/scripts/AGENTS.md +0 -0
  170. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/scripts/patch_mediacms_bulk_facets.py +0 -0
  171. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/scripts/patch_mediacms_tags.py +0 -0
  172. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/scripts/trigger_enrich.py +0 -0
  173. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/__init__.py +0 -0
  174. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_catalog_blackout.py +0 -0
  175. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_catalog_delete.py +0 -0
  176. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_classify.py +0 -0
  177. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_config_persistence.py +0 -0
  178. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_device_linking.py +0 -0
  179. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_feedback.py +0 -0
  180. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_fetchurls_sharepoint.py +0 -0
  181. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_identify_step.py +0 -0
  182. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_people_facets.py +0 -0
  183. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_phase1.py +0 -0
  184. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_phase2_jobs.py +0 -0
  185. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_phase3_jobs.py +0 -0
  186. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_phase4_live_fixes.py +0 -0
  187. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_playlist_import.py +0 -0
  188. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_presence_refund.py +0 -0
  189. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_promo_director.py +0 -0
  190. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_promo_pool_exclusion.py +0 -0
  191. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_providers.py +0 -0
  192. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_queue_announce.py +0 -0
  193. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_race_poller.py +0 -0
  194. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_recently_played_hide.py +0 -0
  195. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_save_results_to_playlist.py +0 -0
  196. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_schedule_lock.py +0 -0
  197. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_search_facets.py +0 -0
  198. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_title_normalization.py +0 -0
  199. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_tmdb_index.py +0 -0
  200. {kryten_webqueue-0.43.0 → kryten_webqueue-0.44.1}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,68 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ### Fixed
6
+
7
+ - **Static assets are now cache-busted by version.** JS/CSS includes gained a
8
+ `?v={app_version}` query string, so browsers fetch fresh assets after every
9
+ release instead of serving a stale cached copy. This fixes the admin Download
10
+ Queue rendering as empty immediately after the 0.44.0 upgrade: the new endpoint
11
+ returns `{items, …}` but a cached pre-0.44.0 `admin.js` read the response as a
12
+ bare array, so `.length` was `undefined` and it showed "Queue is empty."
13
+
14
+ ## [0.44.0]
15
+
16
+ ### Added
17
+
18
+ - **Admin Jobs tab: full-text run logs + humanized summaries.** Every background
19
+ job run now captures the Python log records it emits (scoped to that run via a
20
+ `contextvars` filter, so concurrent jobs and web requests don't bleed in) into
21
+ a new uncapped `job_run_logs` table (schema **v29**, forward-only migration —
22
+ kept as a separate table rather than a `job_runs` column so it ports cleanly to
23
+ Postgres). The run-detail modal now renders the JSON summary as **humanized,
24
+ labelled rows** (friendly `Snake Case → Title Case` labels, `Yes/No` for
25
+ booleans, thousands separators) instead of a raw blob, and shows the **full log**
26
+ beneath it. Both the JSON summary and the full-text log are **downloadable** per
27
+ run (`GET /admin/jobs/runs/{id}/detail/download` and `.../log/download`) for
28
+ offline study.
29
+ - **Per-job run selector.** Each job row's "Last Run" text is replaced by a
30
+ dropdown of that job's recent runs (by start time) and a clickable status cell
31
+ (`completed` / `failed` / …) sitting just before the action buttons; picking a
32
+ run and clicking its status opens that run's summary + log.
33
+ - **Download queue pagination + 24h success expiry.** The download-queue list is
34
+ paginated at **20 items/page**, and successful (`done`) downloads finished more
35
+ than **24h ago are hidden** to keep the list manageable. Failures and
36
+ pending/running items are always shown regardless of age. This is a
37
+ **visibility filter only** — no rows are ever deleted, so the audit trail is
38
+ preserved (`GET /admin/jobs/fetch-queue` now returns
39
+ `{items, total, page, pages, limit}`).
40
+
41
+ - **Failed MediaCMS uploads are re-queued instead of lost.** When a fetch-queue
42
+ item's upload fails with a broken connection (`IncompleteRead`), the drain now
43
+ re-queues it to the **back** of the queue for another attempt (up to
44
+ **3 attempts** total) rather than marking it `failed` on the first hit. The
45
+ retry runs *after* everything else currently pending, and the existing
46
+ inter-item cooldown naturally spaces it out. Only the **upload stage** is
47
+ retried — download/validation failures still fail immediately (they need
48
+ case-by-case handling). A new `fetch_queue.attempts` column (schema **v28**,
49
+ forward-only migration) tracks attempts; the last failure is recorded as
50
+ `failed` with an "after N attempts" note.
51
+
52
+ ### Fixed
53
+
54
+ - **MediaCMS upload no longer fails hard on a broken response.** The final upload
55
+ POST previously had **no timeout** and no handling for a dropped connection, so
56
+ a truncated response (`Connection broken: IncompleteRead(...)` — MediaCMS
57
+ serializes a large media object into the response *after* accepting the file)
58
+ surfaced as a permanent `Failed to upload to MediaCMS` even though the item had
59
+ usually already been created server-side. The POST now uses a `(connect, read)`
60
+ timeout of `(30, 600)`, and on a connection-broken/`IncompleteRead`/timeout it
61
+ attempts to **recover the just-created item** by its "Original URL" description
62
+ marker (applying tags to it) rather than blindly re-POSTing and creating a
63
+ duplicate. If it can't be recovered yet (e.g. search-index lag) it raises a
64
+ distinct, retryable error — the next attempt's up-front duplicate check skips it
65
+ if it did land.
66
+
5
67
  ## [0.43.0] - 2026-08-24
6
68
 
7
69
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.43.0
3
+ Version: 0.44.1
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
@@ -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
 
@@ -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, *, limit: int = 100, status: str | None = None
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
- sql += " WHERE status = ?"
157
+ clauses.append("status = ?")
97
158
  params.append(status)
98
- sql += " ORDER BY added_at DESC LIMIT ?"
99
- params.append(limit)
100
- return await self._fetch_all(sql, params)
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
+ """
@@ -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)
@@ -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