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