kryten-webqueue 0.42.0__tar.gz → 0.43.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 (196) hide show
  1. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/CHANGELOG.md +25 -0
  2. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/PKG-INFO +11 -1
  3. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/README.md +10 -0
  4. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/config.example.json +6 -0
  5. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PUBLIC_API.md +6 -4
  6. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/app.py +20 -0
  7. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_fetch_queue.py +30 -0
  8. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/config.py +38 -1
  9. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/tasks.py +57 -3
  10. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin.js +12 -0
  11. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/pyproject.toml +1 -1
  12. kryten_webqueue-0.43.0/tests/test_fetch_queue_pacing.py +159 -0
  13. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.github/workflows/gitleaks.yml +0 -0
  14. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.github/workflows/python-publish.yml +0 -0
  15. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.github/workflows/release.yml +0 -0
  16. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.gitignore +0 -0
  17. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.gitleaks.toml +0 -0
  18. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/.pre-commit-config.yaml +0 -0
  19. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  20. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/AGENTS.md +0 -0
  21. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  29. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  30. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  31. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  32. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  33. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PRE_PLAN_GAPS.md +0 -0
  34. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/PRODUCT_PLAN.md +0 -0
  35. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  36. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  37. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  38. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  39. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  40. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  41. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/docs/UX_POLISH_PLAN.md +0 -0
  42. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/__init__.py +0 -0
  43. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/__main__.py +0 -0
  44. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  45. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/api_gate/client.py +0 -0
  46. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/__init__.py +0 -0
  47. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/device_keys.py +0 -0
  48. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/otp.py +0 -0
  49. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  50. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/session.py +0 -0
  51. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/__init__.py +0 -0
  52. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
  53. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  54. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  55. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_connection.py +0 -0
  56. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_devices.py +0 -0
  57. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  58. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  59. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  60. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  61. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  62. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  63. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  64. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  65. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  66. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  67. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  68. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  69. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  70. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  71. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  72. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  73. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  74. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  75. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  76. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  77. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/images.py +0 -0
  78. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  79. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/sync.py +0 -0
  80. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  81. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  82. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  83. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  84. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  85. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  86. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  87. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/__init__.py +0 -0
  88. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  89. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  90. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  91. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  92. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  93. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  94. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  95. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  96. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  97. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  98. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  99. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/__init__.py +0 -0
  100. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  101. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  102. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/manager.py +0 -0
  103. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  104. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/logging_config.py +0 -0
  105. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/__init__.py +0 -0
  106. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  107. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/fire.py +0 -0
  108. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/importer.py +0 -0
  109. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/ordering.py +0 -0
  110. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  111. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/promos/__init__.py +0 -0
  112. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/promos/director.py +0 -0
  113. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/__init__.py +0 -0
  114. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/completion.py +0 -0
  115. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/ordering.py +0 -0
  116. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/poller.py +0 -0
  117. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/presence.py +0 -0
  118. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/race_poller.py +0 -0
  119. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/shadow.py +0 -0
  120. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/__init__.py +0 -0
  121. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  122. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  123. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  124. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  125. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  126. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  127. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  128. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  129. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  130. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/auth.py +0 -0
  131. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/catalog.py +0 -0
  132. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/devices.py +0 -0
  133. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/feedback.py +0 -0
  134. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/pages.py +0 -0
  135. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/public_api.py +0 -0
  136. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/queue.py +0 -0
  137. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/user.py +0 -0
  138. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/css/main.css +0 -0
  139. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  140. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  141. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  142. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  143. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/keybindings.js +0 -0
  144. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/main.js +0 -0
  145. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/index.html +0 -0
  146. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  147. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  148. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  149. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  150. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/auth/login.html +0 -0
  151. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/base.html +0 -0
  152. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  153. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  154. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  155. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  156. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  157. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/queue/index.html +0 -0
  158. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/race.html +0 -0
  159. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  160. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/link_device.html +0 -0
  161. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  162. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/__init__.py +0 -0
  163. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/handler.py +0 -0
  164. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/manager.py +0 -0
  165. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/AGENTS.md +0 -0
  166. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  167. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/patch_mediacms_tags.py +0 -0
  168. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/scripts/trigger_enrich.py +0 -0
  169. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/__init__.py +0 -0
  170. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_catalog_blackout.py +0 -0
  171. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_catalog_delete.py +0 -0
  172. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_classify.py +0 -0
  173. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_config_persistence.py +0 -0
  174. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_device_linking.py +0 -0
  175. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_feedback.py +0 -0
  176. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_fetchurls_sharepoint.py +0 -0
  177. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_identify_step.py +0 -0
  178. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_people_facets.py +0 -0
  179. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase1.py +0 -0
  180. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase2_jobs.py +0 -0
  181. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase3_jobs.py +0 -0
  182. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_phase4_live_fixes.py +0 -0
  183. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_playlist_import.py +0 -0
  184. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_presence_refund.py +0 -0
  185. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_promo_director.py +0 -0
  186. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_promo_pool_exclusion.py +0 -0
  187. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_providers.py +0 -0
  188. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_queue_announce.py +0 -0
  189. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_race_poller.py +0 -0
  190. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_recently_played_hide.py +0 -0
  191. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_save_results_to_playlist.py +0 -0
  192. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_schedule_lock.py +0 -0
  193. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_search_facets.py +0 -0
  194. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_title_normalization.py +0 -0
  195. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_tmdb_index.py +0 -0
  196. {kryten_webqueue-0.42.0 → kryten_webqueue-0.43.0}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.43.0] - 2026-08-24
6
+
7
+ ### Added
8
+
9
+ - **Fetch-queue drain cooldown (anti-bot pacing).** The **Fetch Queue — Process**
10
+ job now waits a randomized cooldown *between* items instead of downloading
11
+ back-to-back, so it stops tripping the source's bot detection (the MediaCMS
12
+ encoder is the real bottleneck anyway). The wait is drawn uniformly from
13
+ `cooldown_mean_minutes ± cooldown_jitter_minutes` (default **42 ± 8 min**).
14
+ New optional `fetch_queue` config block: `cooldown_mean_minutes`,
15
+ `cooldown_jitter_minutes`, `cooldown_enabled`. Settings are **re-read from the
16
+ config file before every wait**, so editing the config retunes a *running*
17
+ drain — no restart needed. A `cooldown` progress heartbeat exposes the
18
+ remaining wait to the admin UI.
19
+
20
+ ### Changed
21
+
22
+ - **Interrupted fetch-queue downloads now resume.** On startup any item left
23
+ `running` by a crash/restart is reset to `pending`, and the drain auto-starts
24
+ if anything is pending — so a download cut off mid-flight is re-attempted
25
+ without an admin re-triggering it. A drain cancelled by shutdown re-queues its
26
+ in-flight item (rather than marking it `failed`) for the same reason. Retries
27
+ re-download the whole file; a leftover yt-dlp `.part` file may let yt-dlp
28
+ resume where it left off.
29
+
5
30
  ## [0.42.0] - 2026-08-24
6
31
 
7
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.42.0
3
+ Version: 0.43.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
@@ -57,6 +57,36 @@ class _FetchQueueMixin:
57
57
  )
58
58
  await self._db.commit()
59
59
 
60
+ async def requeue_fetch_item(self, item_id: int) -> None:
61
+ """Return a single item to 'pending' (e.g. interrupted by shutdown).
62
+
63
+ Clears the run timestamps and any prior error so the drain re-attempts it
64
+ cleanly on the next pass.
65
+ """
66
+ await self._db.execute(
67
+ "UPDATE fetch_queue"
68
+ " SET status = 'pending', started_at = NULL, finished_at = NULL, error = NULL"
69
+ " WHERE id = ?",
70
+ [item_id],
71
+ )
72
+ await self._db.commit()
73
+
74
+ async def reset_running_fetch_items(self) -> int:
75
+ """Reset any 'running' items to 'pending'; return how many were reset.
76
+
77
+ Called at startup to recover from a crash or hard kill that left the
78
+ in-flight item marked 'running' forever (the running flag is process
79
+ state, not durable). Flipping it back to 'pending' lets the drain
80
+ re-attempt it — a fresh download, though a yt-dlp ``.part`` file left in
81
+ the work dir may let yt-dlp resume where it left off.
82
+ """
83
+ cursor = await self._db.execute(
84
+ "UPDATE fetch_queue SET status = 'pending', started_at = NULL"
85
+ " WHERE status = 'running'"
86
+ )
87
+ await self._db.commit()
88
+ return cursor.rowcount or 0
89
+
60
90
  async def get_fetch_queue(
61
91
  self, *, limit: int = 100, status: str | None = None
62
92
  ) -> list[dict]:
@@ -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
 
@@ -15,6 +15,8 @@ import functools
15
15
  import importlib
16
16
  import json
17
17
  import logging
18
+ import random
19
+ import time
18
20
 
19
21
  from .manager import JobError
20
22
 
@@ -840,6 +842,41 @@ FETCH_QUEUE_ADD_SCHEMA = [
840
842
  FETCH_QUEUE_DRAIN_SCHEMA: list[dict] = []
841
843
 
842
844
 
845
+ async def _fetch_queue_cooldown(ctx, *, processed: int, failed: int) -> None:
846
+ """Wait a randomized cooldown between fetch-queue downloads.
847
+
848
+ The wait is drawn uniformly from ``mean ± jitter`` minutes (clamped at >= 0)
849
+ so the drain doesn't hammer the source and trip bot detection. Settings are
850
+ re-read from the config file each call, so editing the config file (and
851
+ reloading) retunes a running drain without a restart. ``asyncio.sleep``
852
+ stays cancellable, so shutdown interrupts the wait cleanly.
853
+ """
854
+ fq = ctx.config.reload_fetch_queue()
855
+ if not fq.cooldown_enabled:
856
+ return
857
+ low = max(0.0, fq.cooldown_mean_minutes - fq.cooldown_jitter_minutes)
858
+ high = max(low, fq.cooldown_mean_minutes + fq.cooldown_jitter_minutes)
859
+ seconds = max(0.0, random.uniform(low, high) * 60.0)
860
+ if seconds <= 0:
861
+ return
862
+ await ctx.progress(
863
+ {
864
+ "status": "cooldown",
865
+ "processed": processed,
866
+ "failed": failed,
867
+ "cooldown_seconds": round(seconds),
868
+ "resume_epoch": round(time.time() + seconds),
869
+ }
870
+ )
871
+ logger.info(
872
+ "fetch_queue_drain: cooldown %.1f min before next item (processed=%d failed=%d)",
873
+ seconds / 60.0,
874
+ processed,
875
+ failed,
876
+ )
877
+ await asyncio.sleep(seconds)
878
+
879
+
843
880
  async def fetch_queue_add_job(params: dict, ctx) -> dict:
844
881
  """Add one or more URLs to the fetch queue and auto-start the drain if idle."""
845
882
  raw = (params.get("urls") or "").strip()
@@ -883,7 +920,17 @@ async def fetch_queue_add_job(params: dict, ctx) -> dict:
883
920
 
884
921
 
885
922
  async def fetch_queue_drain_job(params: dict, ctx) -> dict:
886
- """Process all pending fetch-queue items one at a time."""
923
+ """Process all pending fetch-queue items one at a time.
924
+
925
+ Between items the drain waits a randomized cooldown (configurable via
926
+ ``fetch_queue`` in the config, centered on ~42 min with jitter) so downloads
927
+ don't hammer the source and trip bot detection — the MediaCMS encoder is the
928
+ real bottleneck anyway. Cooldown settings are re-read live before each wait.
929
+
930
+ An item interrupted by shutdown is re-queued (not failed) so it retries on
931
+ the next start; combined with the startup reset of orphaned 'running' items,
932
+ an interrupted download is always re-attempted from the top.
933
+ """
887
934
  processed = 0
888
935
  failed = 0
889
936
 
@@ -918,8 +965,11 @@ async def fetch_queue_drain_job(params: dict, ctx) -> dict:
918
965
  processed += 1
919
966
  logger.info("fetch_queue_drain: id=%d done", item["id"])
920
967
  except asyncio.CancelledError:
921
- await ctx.db.finish_fetch_item(
922
- item["id"], status="failed", error="cancelled"
968
+ # Interrupted by shutdown/cancel: re-queue so it retries on the next
969
+ # start rather than being lost as a permanent failure.
970
+ await ctx.db.requeue_fetch_item(item["id"])
971
+ logger.info(
972
+ "fetch_queue_drain: id=%d interrupted — re-queued for retry", item["id"]
923
973
  )
924
974
  raise
925
975
  except Exception as exc: # noqa: BLE001
@@ -929,5 +979,9 @@ async def fetch_queue_drain_job(params: dict, ctx) -> dict:
929
979
  await ctx.db.finish_fetch_item(item["id"], status="failed", error=str(exc))
930
980
  failed += 1
931
981
 
982
+ # Space out downloads: cooldown before the next item, unless none remain.
983
+ if await ctx.db.count_fetch_queue_pending() > 0:
984
+ await _fetch_queue_cooldown(ctx, processed=processed, failed=failed)
985
+
932
986
  logger.info("fetch_queue_drain: done — processed=%d failed=%d", processed, failed)
933
987
  return {"processed": processed, "failed": failed}
@@ -438,6 +438,13 @@ async function showRunDetail(runId) {
438
438
  bodyHtml = `<p style="color:var(--warning)">\u23f3 Download in progress</p>
439
439
  <p>Current URL: <code style="word-break:break-all">${escapeHtml(d.url || '')}</code></p>
440
440
  <p>Done: <strong>${d.processed || 0}</strong> &nbsp; Failed: <strong>${d.failed || 0}</strong></p>`;
441
+ } else if (d && d.status === 'cooldown') {
442
+ // fetch_queue_drain anti-bot cooldown between items
443
+ const mins = d.cooldown_seconds ? Math.round(d.cooldown_seconds / 60) : 0;
444
+ const resume = d.resume_epoch ? new Date(d.resume_epoch * 1000).toLocaleTimeString() : '';
445
+ bodyHtml = `<p style="color:var(--warning)">\u23f8\ufe0f Cooling down before the next download</p>
446
+ <p>Waiting <strong>~${mins} min</strong>${resume ? ` (resumes ~${escapeHtml(resume)})` : ''}.</p>
447
+ <p>Done: <strong>${d.processed || 0}</strong> &nbsp; Failed: <strong>${d.failed || 0}</strong></p>`;
441
448
  } else {
442
449
  // Generic / legacy format
443
450
  const raw = typeof run.detail === 'string' ? run.detail : JSON.stringify(run.detail, null, 2);
@@ -597,6 +604,11 @@ function summarizeRunDetail(detail) {
597
604
  const host = (() => { try { return new URL(d.url).hostname; } catch (e) { return d.url.slice(0, 40); } })();
598
605
  return `\u23f3 ${host} (${d.processed || 0} done)`;
599
606
  }
607
+ // fetch_queue_drain cooldown between items
608
+ if (d.status === 'cooldown') {
609
+ const mins = d.cooldown_seconds ? Math.round(d.cooldown_seconds / 60) : 0;
610
+ return `\u23f8\ufe0f cooldown ~${mins} min (${d.processed || 0} done)`;
611
+ }
600
612
  if (parts.length) return parts.join(' · ');
601
613
  }
602
614
  return '';
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.42.0"
3
+ version = "0.43.0"
4
4
  description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,159 @@
1
+ """Fetch-queue drain pacing + interrupted-download recovery.
2
+
3
+ Covers the anti-bot cooldown between drain items (config-driven, jittered,
4
+ live-reloadable) and the crash/shutdown recovery paths that re-queue an
5
+ interrupted download so it retries instead of being lost.
6
+ """
7
+
8
+ import json
9
+ from types import SimpleNamespace
10
+
11
+ import pytest
12
+
13
+ from kryten_webqueue.catalog.db import Database
14
+ from kryten_webqueue.config import Config, FetchQueueConfig
15
+ from kryten_webqueue.jobs import tasks
16
+ from kryten_webqueue.jobs.manager import JobContext
17
+
18
+
19
+ @pytest.fixture
20
+ async def db(tmp_path):
21
+ database = Database(str(tmp_path / "fetch_queue.db"))
22
+ await database.connect()
23
+ await database.run_migrations()
24
+ yield database
25
+ await database.close()
26
+
27
+
28
+ def _ctx(db, config):
29
+ return JobContext(
30
+ db=db, api_gate=None, config=config, run_id=1, triggered_by="admin"
31
+ )
32
+
33
+
34
+ # --- DB recovery helpers -------------------------------------------------------
35
+
36
+
37
+ async def test_reset_running_fetch_items_requeues_orphans(db):
38
+ a = await db.enqueue_fetch(url="https://x/1")
39
+ b = await db.enqueue_fetch(url="https://x/2")
40
+ # Simulate a crash mid-download: one item stuck at 'running'.
41
+ await db.claim_next_fetch_item() # claims oldest (a) → running
42
+
43
+ reset = await db.reset_running_fetch_items()
44
+ assert reset == 1
45
+ assert await db.count_fetch_queue_pending() == 2 # both pending again
46
+
47
+ # The reset item can be claimed again (fresh re-attempt).
48
+ claimed = await db.claim_next_fetch_item()
49
+ assert claimed["id"] == a
50
+ _ = b # second item remains pending
51
+
52
+
53
+ async def test_requeue_fetch_item_clears_error_and_timestamps(db):
54
+ item_id = await db.enqueue_fetch(url="https://x/1")
55
+ await db.claim_next_fetch_item()
56
+ await db.finish_fetch_item(item_id, status="failed", error="boom")
57
+
58
+ await db.requeue_fetch_item(item_id)
59
+ rows = await db.get_fetch_queue(limit=10)
60
+ row = next(r for r in rows if r["id"] == item_id)
61
+ assert row["status"] == "pending"
62
+ assert row["started_at"] is None
63
+ assert row["finished_at"] is None
64
+ assert row["error"] is None
65
+
66
+
67
+ # --- live config reload --------------------------------------------------------
68
+
69
+
70
+ def test_reload_fetch_queue_reads_current_file(tmp_path):
71
+ path = tmp_path / "config.json"
72
+ base = {
73
+ "secret_key": "x" * 16,
74
+ "api_gate_token": "t",
75
+ "mediacms_token": "t",
76
+ "fetch_queue": {"cooldown_mean_minutes": 10.0},
77
+ }
78
+ path.write_text(json.dumps(base), encoding="utf-8")
79
+ config = Config.from_file(path)
80
+ assert config.reload_fetch_queue().cooldown_mean_minutes == 10.0
81
+
82
+ # Edit the file on disk: a running drain must pick up the new value.
83
+ base["fetch_queue"]["cooldown_mean_minutes"] = 55.0
84
+ path.write_text(json.dumps(base), encoding="utf-8")
85
+ assert config.reload_fetch_queue().cooldown_mean_minutes == 55.0
86
+
87
+
88
+ def test_reload_fetch_queue_falls_back_on_bad_file(tmp_path):
89
+ path = tmp_path / "config.json"
90
+ path.write_text(
91
+ json.dumps(
92
+ {"secret_key": "x" * 16, "api_gate_token": "t", "mediacms_token": "t"}
93
+ ),
94
+ encoding="utf-8",
95
+ )
96
+ config = Config.from_file(path)
97
+ # Corrupt the file; reload must fall back to the in-memory section, not raise.
98
+ path.write_text("{ not valid json", encoding="utf-8")
99
+ fq = config.reload_fetch_queue()
100
+ assert fq.cooldown_mean_minutes == config.fetch_queue.cooldown_mean_minutes
101
+
102
+
103
+ # --- cooldown behaviour --------------------------------------------------------
104
+
105
+
106
+ async def test_cooldown_disabled_does_not_sleep(db, monkeypatch):
107
+ slept: list[float] = []
108
+
109
+ async def _fake_sleep(seconds):
110
+ slept.append(seconds)
111
+
112
+ monkeypatch.setattr(tasks.asyncio, "sleep", _fake_sleep)
113
+ config = SimpleNamespace(
114
+ reload_fetch_queue=lambda: FetchQueueConfig(cooldown_enabled=False)
115
+ )
116
+ ctx = _ctx(db, config)
117
+ await tasks._fetch_queue_cooldown(ctx, processed=1, failed=0)
118
+ assert slept == []
119
+
120
+
121
+ async def test_cooldown_sleeps_within_jitter_band(db, monkeypatch):
122
+ slept: list[float] = []
123
+
124
+ async def _fake_sleep(seconds):
125
+ slept.append(seconds)
126
+
127
+ monkeypatch.setattr(tasks.asyncio, "sleep", _fake_sleep)
128
+ fq = FetchQueueConfig(cooldown_mean_minutes=42.0, cooldown_jitter_minutes=8.0)
129
+ config = SimpleNamespace(reload_fetch_queue=lambda: fq)
130
+ ctx = _ctx(db, config)
131
+ await tasks._fetch_queue_cooldown(ctx, processed=2, failed=1)
132
+ assert len(slept) == 1
133
+ # Within [34, 50] minutes, expressed in seconds.
134
+ assert 34 * 60 <= slept[0] <= 50 * 60
135
+
136
+
137
+ async def test_drain_cooldown_runs_between_items_only(db, monkeypatch):
138
+ await db.enqueue_fetch(url="https://x/1")
139
+ await db.enqueue_fetch(url="https://x/2")
140
+
141
+ cooldowns: list[int] = []
142
+
143
+ async def _fake_cooldown(ctx, *, processed, failed):
144
+ cooldowns.append(processed)
145
+
146
+ async def _fake_single_fetch(params, ctx):
147
+ return {"ok": True}
148
+
149
+ monkeypatch.setattr(tasks, "_fetch_queue_cooldown", _fake_cooldown)
150
+ monkeypatch.setattr(tasks, "_run_single_fetch", _fake_single_fetch)
151
+
152
+ fq = FetchQueueConfig()
153
+ ctx = _ctx(db, SimpleNamespace(reload_fetch_queue=lambda: fq))
154
+ result = await tasks.fetch_queue_drain_job({}, ctx)
155
+
156
+ assert result == {"processed": 2, "failed": 0}
157
+ # Two items → exactly one inter-item cooldown (after the first, before the
158
+ # second); none after the last item.
159
+ assert cooldowns == [1]