kryten-webqueue 0.41.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.41.0 → kryten_webqueue-0.43.0}/CHANGELOG.md +61 -0
  2. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/PKG-INFO +11 -1
  3. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/README.md +10 -0
  4. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/config.example.json +6 -0
  5. kryten_webqueue-0.43.0/docs/PUBLIC_API.md +291 -0
  6. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/app.py +50 -0
  7. kryten_webqueue-0.43.0/kryten_webqueue/auth/device_keys.py +99 -0
  8. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/__init__.py +2 -0
  9. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_connection.py +35 -0
  10. kryten_webqueue-0.43.0/kryten_webqueue/catalog/db/_devices.py +119 -0
  11. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_fetch_queue.py +30 -0
  12. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/config.py +38 -1
  13. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/tasks.py +123 -3
  14. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_moderation.py +24 -1
  15. kryten_webqueue-0.43.0/kryten_webqueue/routes/devices.py +111 -0
  16. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/pages.py +8 -0
  17. kryten_webqueue-0.43.0/kryten_webqueue/routes/public_api.py +214 -0
  18. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/css/main.css +98 -0
  19. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin.js +12 -0
  20. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/dashboard.html +4 -0
  21. kryten_webqueue-0.43.0/kryten_webqueue/templates/user/link_device.html +152 -0
  22. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/pyproject.toml +1 -1
  23. kryten_webqueue-0.43.0/tests/test_device_linking.py +295 -0
  24. kryten_webqueue-0.43.0/tests/test_fetch_queue_pacing.py +159 -0
  25. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/.github/workflows/gitleaks.yml +0 -0
  26. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/.github/workflows/python-publish.yml +0 -0
  27. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/.github/workflows/release.yml +0 -0
  28. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/.gitignore +0 -0
  29. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/.gitleaks.toml +0 -0
  30. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/.pre-commit-config.yaml +0 -0
  31. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  32. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/AGENTS.md +0 -0
  33. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/deploy/kryten-webqueue.service +0 -0
  34. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/deploy/nginx-queue.conf +0 -0
  35. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  36. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/IMPL_API_GATE.md +0 -0
  37. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/IMPL_ECONOMY.md +0 -0
  38. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  39. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/IMPL_ROBOT.md +0 -0
  40. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  41. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  42. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  43. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  44. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  45. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/PRE_PLAN_GAPS.md +0 -0
  46. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/PRODUCT_PLAN.md +0 -0
  47. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  48. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  49. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  50. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  51. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  52. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  53. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/docs/UX_POLISH_PLAN.md +0 -0
  54. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/__init__.py +0 -0
  55. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/__main__.py +0 -0
  56. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  57. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/api_gate/client.py +0 -0
  58. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/__init__.py +0 -0
  59. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/otp.py +0 -0
  60. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  61. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/auth/session.py +0 -0
  62. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/__init__.py +0 -0
  63. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  64. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  65. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  66. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  67. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  68. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  69. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  70. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  71. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  72. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  73. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  74. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  75. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  76. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  77. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  78. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  79. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  80. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  81. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  82. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  83. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  84. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  85. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/images.py +0 -0
  86. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  87. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/sync.py +0 -0
  88. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  89. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  90. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  91. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  92. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  93. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  94. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  95. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/__init__.py +0 -0
  96. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  97. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  98. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  99. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  100. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  101. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  102. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  103. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  104. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  105. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  106. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  107. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/__init__.py +0 -0
  108. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  109. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  110. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/manager.py +0 -0
  111. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  112. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/logging_config.py +0 -0
  113. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/__init__.py +0 -0
  114. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  115. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/fire.py +0 -0
  116. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/importer.py +0 -0
  117. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/ordering.py +0 -0
  118. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  119. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/promos/__init__.py +0 -0
  120. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/promos/director.py +0 -0
  121. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/__init__.py +0 -0
  122. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/completion.py +0 -0
  123. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/ordering.py +0 -0
  124. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/poller.py +0 -0
  125. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/presence.py +0 -0
  126. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/race_poller.py +0 -0
  127. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/queue/shadow.py +0 -0
  128. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/__init__.py +0 -0
  129. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  130. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  131. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  132. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  133. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  134. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  135. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  136. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  137. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/auth.py +0 -0
  138. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/catalog.py +0 -0
  139. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/feedback.py +0 -0
  140. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/queue.py +0 -0
  141. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/routes/user.py +0 -0
  142. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  143. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  144. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  145. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  146. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/keybindings.js +0 -0
  147. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/static/js/main.js +0 -0
  148. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/index.html +0 -0
  149. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  150. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  151. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  152. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  153. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/auth/login.html +0 -0
  154. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/base.html +0 -0
  155. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  156. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  157. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  158. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  159. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  160. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/queue/index.html +0 -0
  161. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/race.html +0 -0
  162. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  163. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/__init__.py +0 -0
  164. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/handler.py +0 -0
  165. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/kryten_webqueue/ws/manager.py +0 -0
  166. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/scripts/AGENTS.md +0 -0
  167. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  168. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/scripts/patch_mediacms_tags.py +0 -0
  169. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/scripts/trigger_enrich.py +0 -0
  170. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/__init__.py +0 -0
  171. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_catalog_blackout.py +0 -0
  172. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_catalog_delete.py +0 -0
  173. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_classify.py +0 -0
  174. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_config_persistence.py +0 -0
  175. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_feedback.py +0 -0
  176. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_fetchurls_sharepoint.py +0 -0
  177. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_identify_step.py +0 -0
  178. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_people_facets.py +0 -0
  179. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_phase1.py +0 -0
  180. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_phase2_jobs.py +0 -0
  181. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_phase3_jobs.py +0 -0
  182. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_phase4_live_fixes.py +0 -0
  183. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_playlist_import.py +0 -0
  184. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_presence_refund.py +0 -0
  185. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_promo_director.py +0 -0
  186. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_promo_pool_exclusion.py +0 -0
  187. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_providers.py +0 -0
  188. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_queue_announce.py +0 -0
  189. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_race_poller.py +0 -0
  190. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_recently_played_hide.py +0 -0
  191. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_save_results_to_playlist.py +0 -0
  192. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_schedule_lock.py +0 -0
  193. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_search_facets.py +0 -0
  194. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_title_normalization.py +0 -0
  195. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_tmdb_index.py +0 -0
  196. {kryten_webqueue-0.41.0 → kryten_webqueue-0.43.0}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,67 @@
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
+
30
+ ## [0.42.0] - 2026-08-24
31
+
32
+ ### Added
33
+
34
+ - **Public API for third-party client apps (Smart TV / tablet device linking).**
35
+ A new key-authenticated API under `/api/public/v1` exposes the live channel
36
+ state so an external application can render the equivalent of the `/queue`
37
+ page. See [docs/PUBLIC_API.md](docs/PUBLIC_API.md) for the full contract and a
38
+ developer's guide.
39
+ - **Device linking flow.** A logged-in user visits **`/link`** ("Link a
40
+ Device", also reachable from the account/Z-coin dashboard), names the
41
+ device, and receives a **5-character, single-use, uppercase-alphanumeric
42
+ one-time pad** (10-minute TTL, unambiguous alphabet). The device redeems it
43
+ at **`POST /api/public/v1/link`** and receives a long-lived API key
44
+ (`Authorization: Bearer <key>`). The pad is destroyed on redemption (one key
45
+ per pad); a user may link multiple devices, each with its own named key.
46
+ - **Data endpoints (Bearer key required):** `GET /api/public/v1/current`
47
+ (now playing — title, synopsis, total/elapsed/remaining time),
48
+ `GET /api/public/v1/queue` (ordered queue with predicted start times), and
49
+ `GET /api/public/v1/events` (upcoming enabled scheduled playlists).
50
+ - **Device management.** `GET/POST/DELETE /user/devices*` (session-cookie) back
51
+ a dashboard list where users name, view, and **revoke** linked devices. Keys
52
+ are stored only as a SHA-256 hash plus a non-secret display prefix; the
53
+ plaintext key is returned exactly once at redemption and never again.
54
+ - **Revoke-on-ban.** Issuing a `ban` through the webqueue admin moderation UI
55
+ now also revokes that user's device keys. A scheduled
56
+ **`device_key_ban_reconcile`** job additionally reconciles key holders
57
+ against kryten-moderator's authoritative ban list (via api-gate,
58
+ case-insensitive) to cover bans issued directly in CyTube or by the
59
+ moderator service. It is seeded to run every 15 minutes by default
60
+ (adjustable/disable-able via the admin job scheduler; deactivate rather than
61
+ delete, as a deleted schedule is re-seeded on startup).
62
+ - **Abuse protection.** Per-user throttling on pad generation and per-IP
63
+ throttling on the redemption endpoint; strict code-format validation.
64
+ - New DB migration **v27** (`device_link_codes`, `device_api_keys`).
65
+
5
66
  ## [0.41.0] - 2026-08-24
6
67
 
7
68
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.41.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,
@@ -0,0 +1,291 @@
1
+ # Public API — Developer's Guide
2
+
3
+ The kryten-webqueue **Public API** lets approved third-party applications (Smart
4
+ TV and tablet clients) read the live channel state — what's playing now, the
5
+ upcoming pay-to-play queue, and scheduled events — using a long-lived API key.
6
+
7
+ - **Base URL:** `https://queue.dropsugar.co`
8
+ - **API root:** `/api/public/v1`
9
+ - **Auth:** `Authorization: Bearer <api_key>` on every data request.
10
+ - **Format:** JSON, UTF-8. All timestamps are ISO-8601 (UTC, `+00:00`).
11
+
12
+ Every externally callable endpoint lives under `/api/public/v1`. The only
13
+ endpoint that does **not** require the `Authorization` header is the bootstrap
14
+ exchange (`POST /api/public/v1/link`), which trades a one-time code for a key.
15
+
16
+ ---
17
+
18
+ ## 1. Linking flow (how a device gets a key)
19
+
20
+ Keys are minted through a short, user-driven pairing flow so a device never
21
+ handles the user's credentials.
22
+
23
+ ```
24
+ ┌────────────┐ 1. names device, gets code ┌──────────────────┐
25
+ │ Browser │ ───────────────────────────────▶│ queue.dropsugar │
26
+ │ (logged in)│ GET /link → "AB2CD" │ .co │
27
+ └────────────┘ └──────────────────┘
28
+ │ 2. user reads the 5-char code aloud / types it
29
+ ▼
30
+ ┌────────────┐ 3. POST /api/public/v1/link ┌──────────────────┐
31
+ │ TV / app │ ───────────────────────────────▶│ queue.dropsugar │
32
+ │ │ { "code": "AB2CD" } │ .co │
33
+ │ │ ◀───────────────────────────────│ │
34
+ └────────────┘ 4. { "api_key": "kqd_…" } └──────────────────┘
35
+ │ 5. store the key; send it as a Bearer token thereafter
36
+ ▼
37
+ GET /api/public/v1/current, /queue, /events …
38
+ ```
39
+
40
+ **Rules the client must respect**
41
+
42
+ 1. The user first names the device in the browser (`/link`) and receives a
43
+ **5-character, uppercase alphanumeric** code.
44
+ 2. The code is a **one-time pad**: valid for **10 minutes** and usable **once**.
45
+ On a successful exchange it is destroyed and cannot be reused.
46
+ 3. Codes use an unambiguous alphabet (no `O`, `0`, `I`, `1`). Uppercase the
47
+ user's input before sending; the server also normalizes.
48
+ 4. The API key is returned **exactly once**, in the exchange response. Store it
49
+ securely on the device — it is never retrievable again. If lost, the user
50
+ revokes the device and links again.
51
+ 5. A user may link multiple devices; each has its own key and name.
52
+ 6. A key is valid indefinitely until the user revokes it (or the user is
53
+ banned, which revokes all of their keys).
54
+
55
+ ---
56
+
57
+ ## 2. Endpoints
58
+
59
+ ### 2.1 `POST /api/public/v1/link` — exchange a code for a key
60
+
61
+ Bootstrap only. **No** `Authorization` header.
62
+
63
+ **Request body**
64
+
65
+ ```json
66
+ {
67
+ "code": "AB2CD"
68
+ }
69
+ ```
70
+
71
+ | Field | Type | Notes |
72
+ |-------|--------|--------------------------------------------------|
73
+ | code | string | The 5-char pad from `/link`. Case-insensitive. |
74
+
75
+ **200 response**
76
+
77
+ ```json
78
+ {
79
+ "api_key": "kqd_9f3a1c7b2e5d4a6f8091b2c3d4e5f60718293a4b5c6d7e8f",
80
+ "token_type": "Bearer",
81
+ "device_id": 12,
82
+ "device_name": "Living Room TV",
83
+ "username": "alice"
84
+ }
85
+ ```
86
+
87
+ | Field | Type | Notes |
88
+ |-------------|---------|------------------------------------------------|
89
+ | api_key | string | Full secret. Send as `Authorization: Bearer …`. Shown once. |
90
+ | token_type | string | Always `"Bearer"`. |
91
+ | device_id | integer | Server-side id for this linked device. |
92
+ | device_name | string | The name the user chose. |
93
+ | username | string | The account the key acts on behalf of. |
94
+
95
+ **Errors**
96
+
97
+ | Status | Meaning |
98
+ |--------|-----------------------------------------------------|
99
+ | 400 | Code is malformed (wrong length / illegal chars). |
100
+ | 404 | Code is unknown, already used, or expired. |
101
+ | 429 | Too many attempts from this client; back off. |
102
+
103
+ ---
104
+
105
+ ### 2.2 `GET /api/public/v1/current` — now playing
106
+
107
+ Requires `Authorization: Bearer <key>`.
108
+
109
+ **200 response (something playing)**
110
+
111
+ ```json
112
+ {
113
+ "playing": true,
114
+ "item": {
115
+ "title": "The Blob",
116
+ "friendly_token": "aB3xY",
117
+ "synopsis": "A gelatinous alien terrorises a small town.",
118
+ "duration_sec": 5400,
119
+ "current_time_sec": 1234.5,
120
+ "remaining_sec": 4165.5,
121
+ "cover_art_url": "https://queue.dropsugar.co/images/aB3xY/500.webp",
122
+ "categories": ["Horror", "Sci-Fi"],
123
+ "tags": ["1958", "cult"]
124
+ },
125
+ "updated_at": "2026-08-24T21:40:00+00:00"
126
+ }
127
+ ```
128
+
129
+ **200 response (nothing playing)**
130
+
131
+ ```json
132
+ { "playing": false, "item": null, "updated_at": "2026-08-24T21:40:00+00:00" }
133
+ ```
134
+
135
+ | Field | Type | Notes |
136
+ |------------------------|-----------------|------------------------------------------|
137
+ | playing | boolean | `false` when the channel is idle. |
138
+ | item.title | string | Display title. |
139
+ | item.friendly_token | string \| null | Catalog id (stable per item). |
140
+ | item.synopsis | string \| null | Plot / description. |
141
+ | item.duration_sec | integer \| null | Total runtime in seconds. |
142
+ | item.current_time_sec | number | Elapsed playback position, seconds. |
143
+ | item.remaining_sec | number \| null | Time left, seconds. |
144
+ | item.cover_art_url | string \| null | Absolute poster URL. |
145
+ | item.categories | string[] | Category names. |
146
+ | item.tags | string[] | Tag names. |
147
+ | updated_at | string | When the snapshot was produced. |
148
+
149
+ ---
150
+
151
+ ### 2.3 `GET /api/public/v1/queue` — the upcoming queue
152
+
153
+ Requires `Authorization: Bearer <key>`.
154
+
155
+ **200 response**
156
+
157
+ ```json
158
+ {
159
+ "items": [
160
+ {
161
+ "position": 0,
162
+ "uid": 88213,
163
+ "title": "The Blob",
164
+ "friendly_token": "aB3xY",
165
+ "duration_sec": 5400,
166
+ "estimated_start_at": "2026-08-24T21:40:00+00:00",
167
+ "estimated_start_in_sec": 0,
168
+ "cover_art_url": "https://queue.dropsugar.co/images/aB3xY/500.webp",
169
+ "queued_by": "alice",
170
+ "tier": "queue",
171
+ "is_now_playing": true
172
+ },
173
+ {
174
+ "position": 1,
175
+ "uid": 88214,
176
+ "title": "Plan 9 from Outer Space",
177
+ "friendly_token": "cD4wZ",
178
+ "duration_sec": 4740,
179
+ "estimated_start_at": "2026-08-24T23:10:00+00:00",
180
+ "estimated_start_in_sec": 4165,
181
+ "cover_art_url": "https://queue.dropsugar.co/images/cD4wZ/500.webp",
182
+ "queued_by": "bob",
183
+ "tier": "playnext",
184
+ "is_now_playing": false
185
+ }
186
+ ],
187
+ "count": 2,
188
+ "updated_at": "2026-08-24T21:40:00+00:00"
189
+ }
190
+ ```
191
+
192
+ | Field | Type | Notes |
193
+ |------------------------------|-----------------|----------------------------------------------|
194
+ | items[].position | integer | 0-based order in the queue. |
195
+ | items[].uid | integer \| null | Playlist uid (for correlating updates). |
196
+ | items[].title | string | Display title. |
197
+ | items[].friendly_token | string \| null | Catalog id. |
198
+ | items[].duration_sec | integer \| null | Runtime in seconds. |
199
+ | items[].estimated_start_at | string \| null | Predicted start (ISO-8601). |
200
+ | items[].estimated_start_in_sec | integer \| null | Predicted seconds until start. |
201
+ | items[].cover_art_url | string \| null | Absolute poster URL. |
202
+ | items[].queued_by | string \| null | Username who queued it (may be absent). |
203
+ | items[].tier | string \| null | `queue` or `playnext`. |
204
+ | items[].is_now_playing | boolean | `true` for the item currently on screen. |
205
+ | count | integer | `items.length`. |
206
+ | updated_at | string | Snapshot time. |
207
+
208
+ > The now-playing item is included here **and** in `/current`. Filter on
209
+ > `is_now_playing` if you want strictly upcoming items.
210
+
211
+ ---
212
+
213
+ ### 2.4 `GET /api/public/v1/events` — scheduled events
214
+
215
+ Requires `Authorization: Bearer <key>`. Returns upcoming **enabled** scheduled
216
+ playlists (what operators call "events"), soonest first.
217
+
218
+ **200 response**
219
+
220
+ ```json
221
+ {
222
+ "events": [
223
+ { "label": "Friday Night Grindhouse", "fire_at": "2026-08-28T02:00:00+00:00", "is_recurring": true },
224
+ { "label": "Halloween Marathon", "fire_at": "2026-10-31T20:00:00+00:00", "is_recurring": false }
225
+ ],
226
+ "count": 2,
227
+ "updated_at": "2026-08-24T21:40:00+00:00"
228
+ }
229
+ ```
230
+
231
+ | Field | Type | Notes |
232
+ |-----------------------|---------|-----------------------------------------------|
233
+ | events[].label | string | Event name. |
234
+ | events[].fire_at | string | Scheduled start (ISO-8601, future only). |
235
+ | events[].is_recurring | boolean | Whether the event repeats. |
236
+ | count | integer | `events.length`. |
237
+ | updated_at | string | Snapshot time. |
238
+
239
+ ---
240
+
241
+ ## 3. Authentication details
242
+
243
+ Send the key on every data request:
244
+
245
+ ```
246
+ Authorization: Bearer kqd_9f3a1c7b2e5d4a6f8091b2c3d4e5f60718293a4b5c6d7e8f
247
+ ```
248
+
249
+ - Missing header → **401** `{"detail": "Missing API key"}`.
250
+ - Unknown/revoked key → **401** `{"detail": "Invalid API key"}`.
251
+ - Both 401s include `WWW-Authenticate: Bearer`.
252
+
253
+ Keys are stored server-side only as an irreversible SHA-256 hash; the plaintext
254
+ lives solely on the device.
255
+
256
+ ---
257
+
258
+ ## 4. Errors (all endpoints)
259
+
260
+ Errors use FastAPI's standard shape:
261
+
262
+ ```json
263
+ { "detail": "Invalid or expired code." }
264
+ ```
265
+
266
+ | Status | When |
267
+ |--------|-------------------------------------------------------------|
268
+ | 400 | Malformed request (bad code format, bad JSON). |
269
+ | 401 | Missing or invalid API key. |
270
+ | 404 | Unknown/expired link code. |
271
+ | 429 | Rate limited (link generation or code exchange). |
272
+
273
+ ---
274
+
275
+ ## 5. Client implementation notes
276
+
277
+ - **Polling:** these endpoints return point-in-time snapshots. Poll `/current`
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).
282
+ - **Countdowns:** compute live countdowns on-device from `remaining_sec` /
283
+ `estimated_start_in_sec` rather than re-polling every minute.
284
+ - **Cover art:** `cover_art_url` is absolute and ready to load. It may be
285
+ `null`; show a placeholder.
286
+ - **Key loss / revocation:** if requests start returning 401, the key was
287
+ revoked (by the user, or automatically when the account is banned — bans are
288
+ reconciled against the moderator service on a schedule). Prompt the user to
289
+ re-link.
290
+ - **Time zones:** all times are UTC with an explicit offset; convert for
291
+ display.
@@ -38,6 +38,8 @@ from .routes.admin_promos import router as admin_promos_router
38
38
  from .routes.admin_moderation import router as admin_moderation_router
39
39
  from .routes.feedback import router as feedback_router
40
40
  from .routes.admin_feedback import router as admin_feedback_router
41
+ from .routes.devices import router as devices_router
42
+ from .routes.public_api import router as public_api_router
41
43
  from .routes.pages import router as pages_router
42
44
  from .ws.handler import router as ws_router
43
45
 
@@ -100,6 +102,7 @@ async def lifespan(app: FastAPI):
100
102
  from .jobs.tasks import (
101
103
  catalog_enrich_job,
102
104
  catalog_blackout_job,
105
+ device_key_ban_reconcile_job,
103
106
  fetch_job,
104
107
  fetch_queue_add_job,
105
108
  fetch_queue_drain_job,
@@ -109,6 +112,7 @@ async def lifespan(app: FastAPI):
109
112
  tmdb_coverage_report_job,
110
113
  CATALOG_ENRICH_SCHEMA,
111
114
  CATALOG_BLACKOUT_SCHEMA,
115
+ DEVICE_KEY_BAN_RECONCILE_SCHEMA,
112
116
  FETCH_SCHEMA,
113
117
  FETCH_QUEUE_ADD_SCHEMA,
114
118
  FETCH_QUEUE_DRAIN_SCHEMA,
@@ -151,6 +155,12 @@ async def lifespan(app: FastAPI):
151
155
  label="Catalog Blackout (hide upcoming-weekend items)",
152
156
  schema=CATALOG_BLACKOUT_SCHEMA,
153
157
  )
158
+ job_manager.register(
159
+ "device_key_ban_reconcile",
160
+ device_key_ban_reconcile_job,
161
+ label="Device Key Ban Reconcile (revoke banned users' API keys)",
162
+ schema=DEVICE_KEY_BAN_RECONCILE_SCHEMA,
163
+ )
154
164
  job_manager.register(
155
165
  "motd_posters",
156
166
  motd_posters_job,
@@ -179,8 +189,39 @@ async def lifespan(app: FastAPI):
179
189
  )
180
190
  app.state.job_manager = job_manager
181
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
+
182
212
  # Cron-based job scheduler (persists schedules to job_schedules table)
183
213
  job_scheduler = JobScheduler(db, job_manager)
214
+ # Seed a default schedule for the security-sensitive ban-reconcile job so it
215
+ # runs periodically out-of-the-box. Only seeded when absent, so an admin's
216
+ # cron/active changes are preserved (disable it by deactivating, not
217
+ # deleting — a deleted row is re-seeded on next startup).
218
+ if await db.get_job_schedule("device_key_ban_reconcile") is None:
219
+ await db.upsert_job_schedule(
220
+ "device_key_ban_reconcile",
221
+ "*/15 * * * *",
222
+ label="Device Key Ban Reconcile (revoke banned users' API keys)",
223
+ created_by="system",
224
+ )
184
225
  await job_scheduler.start()
185
226
  app.state.job_scheduler = job_scheduler
186
227
 
@@ -233,6 +274,13 @@ async def lifespan(app: FastAPI):
233
274
  # feedback/suggestion endpoints (per-user, namespaced keys).
234
275
  app.state.rate_limiter = RateLimiter()
235
276
  app.state.feedback_rate_limiter = RateLimiter(max_requests=12, window_seconds=300)
277
+ # Device linking: throttle pad generation (per user) and redemption (per IP).
278
+ app.state.device_link_rate_limiter = RateLimiter(
279
+ max_requests=10, window_seconds=600
280
+ )
281
+ app.state.device_exchange_rate_limiter = RateLimiter(
282
+ max_requests=10, window_seconds=600
283
+ )
236
284
  # Hard per-user submission quota for feedback and suggestions: 2/day, 6/week
237
285
  # (namespaced keys, checked in addition to the short-burst limiter above).
238
286
  app.state.feedback_quota_limiter = QuotaLimiter(
@@ -376,6 +424,8 @@ def create_app(config: Config) -> FastAPI:
376
424
  app.include_router(admin_moderation_router)
377
425
  app.include_router(feedback_router)
378
426
  app.include_router(admin_feedback_router)
427
+ app.include_router(devices_router)
428
+ app.include_router(public_api_router)
379
429
  app.include_router(ws_router)
380
430
 
381
431
  # Health check
@@ -0,0 +1,99 @@
1
+ """Device-linking primitives and the public-API auth dependency.
2
+
3
+ The smart-TV / tablet apps authenticate with a long-lived API key rather than a
4
+ session cookie. Keys are minted by exchanging a short-lived one-time pad (link
5
+ code) at ``POST /api/public/v1/link`` and are stored only as an irreversible
6
+ SHA-256 hash. This module owns the code/key formats, hashing, and the FastAPI
7
+ dependency that resolves ``Authorization: Bearer <key>`` to a user.
8
+ """
9
+
10
+ import hashlib
11
+ import secrets
12
+
13
+ from fastapi import Request, HTTPException
14
+
15
+ # Unambiguous uppercase alphanumeric alphabet (no 0/O/1/I) — friendly for
16
+ # on-screen TV entry while remaining strictly alphanumeric.
17
+ LINK_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
18
+ LINK_CODE_LENGTH = 5
19
+ LINK_CODE_TTL_MINUTES = 10
20
+
21
+ API_KEY_PREFIX = "kqd_" # kryten queue device
22
+ # Display prefix length: scheme prefix + 8 leading hex chars (non-secret).
23
+ _KEY_DISPLAY_LEN = len(API_KEY_PREFIX) + 8
24
+
25
+
26
+ def generate_link_code() -> str:
27
+ """Return a fresh 5-char uppercase one-time pad."""
28
+ return "".join(secrets.choice(LINK_CODE_ALPHABET) for _ in range(LINK_CODE_LENGTH))
29
+
30
+
31
+ def normalize_link_code(raw: str) -> str:
32
+ """Normalize user/device-supplied input: strip and uppercase."""
33
+ return (raw or "").strip().upper()
34
+
35
+
36
+ def is_valid_link_code_format(code: str) -> bool:
37
+ """True when ``code`` is the right length and uses only the code alphabet."""
38
+ return len(code) == LINK_CODE_LENGTH and all(
39
+ ch in LINK_CODE_ALPHABET for ch in code
40
+ )
41
+
42
+
43
+ def generate_api_key() -> str:
44
+ """Return a fresh opaque API key: ``kqd_`` + 48 hex chars."""
45
+ return f"{API_KEY_PREFIX}{secrets.token_hex(24)}"
46
+
47
+
48
+ def api_key_display_prefix(full_key: str) -> str:
49
+ """Non-secret prefix stored for display (e.g. ``kqd_a1b2c3d4``)."""
50
+ return full_key[:_KEY_DISPLAY_LEN]
51
+
52
+
53
+ def hash_api_key(full_key: str) -> str:
54
+ """Irreversible SHA-256 hex digest of a full API key (what we persist)."""
55
+ return hashlib.sha256(full_key.encode("utf-8")).hexdigest()
56
+
57
+
58
+ def _extract_bearer_key(request: Request) -> str | None:
59
+ """Pull the key from ``Authorization: Bearer <key>`` (or a bare key)."""
60
+ header = request.headers.get("authorization")
61
+ if not header:
62
+ return None
63
+ header = header.strip()
64
+ if header.lower().startswith("bearer "):
65
+ return header[7:].strip() or None
66
+ return header or None
67
+
68
+
69
+ async def get_api_key_user(request: Request) -> dict:
70
+ """FastAPI dependency: authenticate a public-API request by API key.
71
+
72
+ Resolves ``Authorization: Bearer <key>`` to the owning user, updates the
73
+ key's last-used timestamp, and returns ``{username, device_id, device_name}``.
74
+ Raises 401 when the header is missing or the key is unknown.
75
+ """
76
+ key = _extract_bearer_key(request)
77
+ if not key:
78
+ raise HTTPException(
79
+ status_code=401,
80
+ detail="Missing API key",
81
+ headers={"WWW-Authenticate": "Bearer"},
82
+ )
83
+ db = request.app.state.db
84
+ row = await db.get_device_key_by_hash(hash_api_key(key))
85
+ if not row:
86
+ raise HTTPException(
87
+ status_code=401,
88
+ detail="Invalid API key",
89
+ headers={"WWW-Authenticate": "Bearer"},
90
+ )
91
+ try:
92
+ await db.touch_device_key(row["id"])
93
+ except Exception: # noqa: BLE001 — last-used tracking must never block a request
94
+ pass
95
+ return {
96
+ "username": row["username"],
97
+ "device_id": row["id"],
98
+ "device_name": row["device_name"],
99
+ }
@@ -13,6 +13,7 @@ from ._people import _PeopleMixin
13
13
  from ._enrichment import _EnrichmentMixin
14
14
  from ._fetch_queue import _FetchQueueMixin
15
15
  from ._blackouts import _BlackoutMixin
16
+ from ._devices import _DevicesMixin
16
17
 
17
18
 
18
19
  class Database(
@@ -25,6 +26,7 @@ class Database(
25
26
  _EnrichmentMixin,
26
27
  _FetchQueueMixin,
27
28
  _BlackoutMixin,
29
+ _DevicesMixin,
28
30
  _DBBase,
29
31
  ):
30
32
  """Async SQLite database wrapper."""
@@ -474,6 +474,41 @@ MIGRATIONS = [
474
474
  );
475
475
  CREATE INDEX IF NOT EXISTS idx_catalog_blackouts_expires ON catalog_blackouts(expires_at);
476
476
  """,
477
+ # v27: Device linking for the public API (Smart-TV / tablet apps).
478
+ #
479
+ # device_link_codes are short-lived, single-use one-time pads: a logged-in
480
+ # user names a device and receives a 5-char code (10-min TTL). The device
481
+ # POSTs the code to /api/public/v1/link to exchange it for an API key. The
482
+ # code is deleted the moment it is successfully redeemed (one key per pad).
483
+ #
484
+ # device_api_keys stores issued keys as an irreversible SHA-256 hash plus a
485
+ # non-secret display prefix and the user-chosen device name. The full key is
486
+ # returned exactly once (at exchange) and never persisted in plaintext. A
487
+ # user may hold many keys (one per linked device); revoking a row (via the
488
+ # dashboard, or automatically on ban) permanently disables that device.
489
+ """
490
+ CREATE TABLE IF NOT EXISTS device_link_codes (
491
+ code TEXT PRIMARY KEY,
492
+ username TEXT NOT NULL,
493
+ device_name TEXT NOT NULL,
494
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
495
+ expires_at TIMESTAMP NOT NULL
496
+ );
497
+ CREATE INDEX IF NOT EXISTS idx_device_link_codes_user ON device_link_codes(username);
498
+ CREATE INDEX IF NOT EXISTS idx_device_link_codes_expires ON device_link_codes(expires_at);
499
+
500
+ CREATE TABLE IF NOT EXISTS device_api_keys (
501
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
502
+ username TEXT NOT NULL,
503
+ device_name TEXT NOT NULL,
504
+ key_prefix TEXT NOT NULL,
505
+ key_hash TEXT NOT NULL UNIQUE,
506
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
507
+ last_used_at TIMESTAMP
508
+ );
509
+ CREATE INDEX IF NOT EXISTS idx_device_api_keys_user ON device_api_keys(username);
510
+ CREATE INDEX IF NOT EXISTS idx_device_api_keys_hash ON device_api_keys(key_hash);
511
+ """,
477
512
  ]
478
513
 
479
514