kryten-webqueue 0.36.3__tar.gz → 0.36.5__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 (165) hide show
  1. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/CHANGELOG.md +23 -0
  2. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/tags.py +22 -37
  4. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/sync.py +69 -19
  5. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/pyproject.toml +1 -1
  6. kryten_webqueue-0.36.5/scripts/patch_mediacms_bulk_facets.py +105 -0
  7. kryten_webqueue-0.36.5/scripts/trigger_enrich.py +45 -0
  8. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/.github/workflows/gitleaks.yml +0 -0
  9. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/.gitignore +0 -0
  12. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/.gitleaks.toml +0 -0
  13. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/.pre-commit-config.yaml +0 -0
  14. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/AGENT-WORKFLOW-GUIDE.md +0 -0
  15. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/README.md +0 -0
  16. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/config.example.json +0 -0
  17. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  25. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  26. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/Log for incorrect promo insertion problem.txt +0 -0
  27. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  28. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/PRE_PLAN_GAPS.md +0 -0
  29. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/PRODUCT_PLAN.md +0 -0
  30. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  31. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/SPEC_EMOTE_REHOST.md +0 -0
  32. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  33. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/docs/UX_POLISH_PLAN.md +0 -0
  34. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/__init__.py +0 -0
  35. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/__main__.py +0 -0
  36. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/api_gate/__init__.py +0 -0
  37. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/api_gate/client.py +0 -0
  38. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/app.py +0 -0
  39. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/auth/__init__.py +0 -0
  40. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/auth/otp.py +0 -0
  41. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/auth/rate_limit.py +0 -0
  42. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/auth/session.py +0 -0
  43. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/__init__.py +0 -0
  44. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/__init__.py +0 -0
  45. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  46. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_connection.py +0 -0
  47. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  48. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  49. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_people.py +0 -0
  50. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  51. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_queue.py +0 -0
  52. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  53. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  54. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  55. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  56. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  57. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  58. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  59. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  60. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  61. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  62. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  63. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  64. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  65. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/images.py +0 -0
  66. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/catalog/mediacms.py +0 -0
  67. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/config.py +0 -0
  68. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/__init__.py +0 -0
  69. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  70. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  71. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  72. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  73. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  74. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  75. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  76. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  77. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  78. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  79. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/jobs/__init__.py +0 -0
  80. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  81. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  82. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/jobs/manager.py +0 -0
  83. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  84. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/jobs/tasks.py +0 -0
  85. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/logging_config.py +0 -0
  86. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/playlists/__init__.py +0 -0
  87. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/playlists/bulk_add.py +0 -0
  88. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/playlists/fire.py +0 -0
  89. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/playlists/importer.py +0 -0
  90. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/playlists/ordering.py +0 -0
  91. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/playlists/scheduler.py +0 -0
  92. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/promos/__init__.py +0 -0
  93. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/promos/director.py +0 -0
  94. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/__init__.py +0 -0
  95. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/completion.py +0 -0
  96. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/ordering.py +0 -0
  97. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/poller.py +0 -0
  98. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/presence.py +0 -0
  99. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/race_poller.py +0 -0
  100. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/queue/shadow.py +0 -0
  101. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/__init__.py +0 -0
  102. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_catalog.py +0 -0
  103. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_feedback.py +0 -0
  104. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  105. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_jobs.py +0 -0
  106. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_moderation.py +0 -0
  107. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_playlists.py +0 -0
  108. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_promos.py +0 -0
  109. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_queue.py +0 -0
  110. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/admin_schedules.py +0 -0
  111. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/auth.py +0 -0
  112. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/catalog.py +0 -0
  113. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/feedback.py +0 -0
  114. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/pages.py +0 -0
  115. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/queue.py +0 -0
  116. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/routes/user.py +0 -0
  117. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/css/main.css +0 -0
  118. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  119. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/js/admin-promos.js +0 -0
  120. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  121. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  122. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/js/admin.js +0 -0
  123. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/static/js/main.js +0 -0
  124. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/admin/index.html +0 -0
  125. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/admin/playlists.html +0 -0
  126. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/admin/promos.html +0 -0
  127. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  128. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/admin/schedules.html +0 -0
  129. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/auth/login.html +0 -0
  130. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/base.html +0 -0
  131. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/catalog/browse.html +0 -0
  132. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  133. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  134. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/feedback/index.html +0 -0
  135. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/queue/index.html +0 -0
  136. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/race.html +0 -0
  137. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/user/dashboard.html +0 -0
  138. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/templates/user/my_list.html +0 -0
  139. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/ws/__init__.py +0 -0
  140. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/ws/handler.py +0 -0
  141. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/kryten_webqueue/ws/manager.py +0 -0
  142. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/scripts/patch_mediacms_tags.py +0 -0
  143. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/__init__.py +0 -0
  144. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_classify.py +0 -0
  145. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_config_persistence.py +0 -0
  146. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_feedback.py +0 -0
  147. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_fetchurls_sharepoint.py +0 -0
  148. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_people_facets.py +0 -0
  149. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_phase1.py +0 -0
  150. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_phase2_jobs.py +0 -0
  151. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_phase3_jobs.py +0 -0
  152. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_phase4_live_fixes.py +0 -0
  153. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_playlist_import.py +0 -0
  154. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_presence_refund.py +0 -0
  155. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_promo_director.py +0 -0
  156. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_promo_pool_exclusion.py +0 -0
  157. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_providers.py +0 -0
  158. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_queue_announce.py +0 -0
  159. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_race_poller.py +0 -0
  160. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_recently_played_hide.py +0 -0
  161. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_save_results_to_playlist.py +0 -0
  162. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_schedule_lock.py +0 -0
  163. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_search_facets.py +0 -0
  164. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_title_normalization.py +0 -0
  165. {kryten_webqueue-0.36.3 → kryten_webqueue-0.36.5}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.36.5] - 2026-08-14
6
+
7
+ ### Changed
8
+
9
+ - **Tags step no longer reads every item.** With `mediacms_manage_all_media` set,
10
+ the tags step now issues a single idempotent `add_tags` POST per item (the
11
+ server skips tags already present) instead of a per-item `GET` to read current
12
+ tags/owner. The redundant per-item reverse-sync `GET` was also removed — catalog
13
+ sync already mirrors CMS tags in bulk via `/api/v1/media_facets`. Stock-CMS
14
+ (owner-scoped) deployments still do the ownership `GET` on the fallback path.
15
+
16
+ ## [0.36.4] - 2026-08-14
17
+
18
+ ### Changed
19
+
20
+ - **Catalog sync pulls tags/categories in bulk.** Instead of one
21
+ `GET /api/v1/media/{token}` per item (10k+ requests, ~30+ min), sync now
22
+ prefetches every item's facets from a new `GET /api/v1/media_facets` endpoint
23
+ in a handful of paginated calls (~35s for the full catalog). Falls back to the
24
+ per-item detail endpoint automatically when the bulk endpoint is unavailable
25
+ (stock MediaCMS). See `scripts/patch_mediacms_bulk_facets.py` for the CMS-side
26
+ endpoint.
27
+
5
28
  ## [0.36.3] - 2026-08-14
6
29
 
7
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.36.3
3
+ Version: 0.36.5
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
@@ -117,8 +117,6 @@ class TagsStep:
117
117
  desired,
118
118
  api_user=api_user,
119
119
  )
120
- # Reverse sync: pull any CMS-only tags back to local DB
121
- await self._reverse_sync(client, cls.friendly_token)
122
120
  if changed:
123
121
  result.changed += 1
124
122
  else:
@@ -148,50 +146,37 @@ class TagsStep:
148
146
 
149
147
  The ``PUT /media/{token}`` endpoint only accepts title/description/media_file
150
148
  and silently ignores a ``tags`` field, so tag changes must go through
151
- ``POST /media/user/bulk_actions`` with ``action: add_tags``. On stock
152
- MediaCMS that endpoint is owner-scoped, so unless ``manage_all`` is set we
153
- skip the push for media the API user does not own rather than flooding
154
- CMS with 400s.
149
+ ``POST /media/user/bulk_actions`` with ``action: add_tags``.
150
+
151
+ When ``manage_all`` is set the server-side ``add_tags`` is idempotent
152
+ (it skips tags already present), so we POST directly with no per-item GET.
153
+ On stock MediaCMS the endpoint is owner-scoped, so we must first GET the
154
+ item to check ownership and skip the push for media we do not own.
155
155
  """
156
- detail_url = f"{self._base}/api/v1/media/{token}"
157
156
  try:
158
- resp = await client.get(detail_url)
159
- if resp.status_code != 200:
160
- return False
161
- data = resp.json()
162
- owner = data.get("user")
163
- if not self._manage_all and (api_user is None or owner != api_user):
164
- return False
165
- current = {
166
- (t.get("title") if isinstance(t, dict) else t)
167
- for t in (data.get("tags_info") or [])
168
- }
169
- to_add = [t for t in new_tags if t not in current]
170
- if not to_add:
171
- return False
157
+ if not self._manage_all:
158
+ resp = await client.get(f"{self._base}/api/v1/media/{token}")
159
+ if resp.status_code != 200:
160
+ return False
161
+ data = resp.json()
162
+ owner = data.get("user")
163
+ if api_user is None or owner != api_user:
164
+ return False
165
+ current = {
166
+ (t.get("title") if isinstance(t, dict) else t)
167
+ for t in (data.get("tags_info") or [])
168
+ }
169
+ new_tags = [t for t in new_tags if t not in current]
170
+ if not new_tags:
171
+ return False
172
172
  put = await client.post(
173
173
  f"{self._base}/api/v1/media/user/bulk_actions",
174
174
  json={
175
175
  "action": "add_tags",
176
176
  "media_ids": [token],
177
- "tag_titles": to_add,
177
+ "tag_titles": new_tags,
178
178
  },
179
179
  )
180
180
  return put.status_code in (200, 201)
181
181
  except (httpx.HTTPError, json.JSONDecodeError, ValueError):
182
182
  return False
183
-
184
- async def _reverse_sync(self, client: httpx.AsyncClient, token: str) -> None:
185
- """Insert any CMS-only tags into the local catalog_tags table."""
186
- url = f"{self._base}/api/v1/media/{token}"
187
- try:
188
- resp = await client.get(url)
189
- if resp.status_code != 200:
190
- return
191
- data = resp.json()
192
- for t in data.get("tags_info") or []:
193
- name = t.get("title") if isinstance(t, dict) else t
194
- if name:
195
- await self._db.add_catalog_tag(token, str(name))
196
- except (httpx.HTTPError, json.JSONDecodeError, ValueError) as exc:
197
- logger.warning("[tags] Failed reverse-sync for %s: %s", token, exc)
@@ -46,6 +46,9 @@ class CatalogSync:
46
46
  self._token = mediacms_token
47
47
  self._db = db
48
48
  self._cover_art = cover_art
49
+ # Populated once per sync by _prefetch_all_facets; None means the bulk
50
+ # endpoint is unavailable and we fall back to per-item detail fetches.
51
+ self._facets_map: dict[str, dict] | None = None
49
52
  self._client = httpx.AsyncClient(
50
53
  headers={"Authorization": f"Token {mediacms_token}"},
51
54
  timeout=30.0,
@@ -60,6 +63,10 @@ class CatalogSync:
60
63
  stats = {"seen": 0, "new": 0, "updated": 0, "errors": 0, "deleted": 0}
61
64
  sync_started_at = datetime.now(UTC).isoformat()
62
65
 
66
+ # Pull every item's tags/categories in a few paginated calls up front so
67
+ # per-item facet sync needs no HTTP (falls back to detail fetch on miss).
68
+ await self._prefetch_all_facets()
69
+
63
70
  try:
64
71
  # /manage_media returns all items (9k+); /media is capped at 1000
65
72
  next_url: str | None = f"{self._url}/api/v1/manage_media"
@@ -191,33 +198,76 @@ class CatalogSync:
191
198
  await asyncio.sleep(0.25)
192
199
 
193
200
  async def _sync_item_facets(self, token: str):
194
- """Populate category/tag memberships from the media detail endpoint.
201
+ """Populate category/tag memberships for an item.
195
202
 
196
- The list (manage_media) serializer omits categories/tags, so we fetch
197
- the per-item detail (``/api/v1/media/{token}``) which exposes
198
- ``categories_info`` and ``tags_info``.
203
+ Prefers the bulk facets pulled by :meth:`_prefetch_all_facets`; falls
204
+ back to the per-item detail endpoint (``/api/v1/media/{token}``) which
205
+ exposes ``categories_info`` and ``tags_info`` when the bulk map is
206
+ unavailable or missing this token.
199
207
  """
200
- resp = await self._client.get(f"{self._url}/api/v1/media/{token}")
201
- if resp.status_code != 200:
202
- return
203
- data = resp.json()
204
-
205
- cat_names = [
206
- c.get("title")
207
- for c in (data.get("categories_info") or [])
208
- if isinstance(c, dict) and c.get("title")
209
- ]
210
- tag_names = []
211
- for t in data.get("tags_info") or []:
212
- name = t.get("title") if isinstance(t, dict) else t
213
- if name:
214
- tag_names.append(name)
208
+ cached = self._facets_map.get(token) if self._facets_map is not None else None
209
+ if cached is not None:
210
+ cat_names = cached.get("categories") or []
211
+ tag_names = cached.get("tags") or []
212
+ else:
213
+ resp = await self._client.get(f"{self._url}/api/v1/media/{token}")
214
+ if resp.status_code != 200:
215
+ return
216
+ data = resp.json()
217
+ cat_names = [
218
+ c.get("title")
219
+ for c in (data.get("categories_info") or [])
220
+ if isinstance(c, dict) and c.get("title")
221
+ ]
222
+ tag_names = []
223
+ for t in data.get("tags_info") or []:
224
+ name = t.get("title") if isinstance(t, dict) else t
225
+ if name:
226
+ tag_names.append(name)
215
227
 
216
228
  cat_ids = [await self._db.upsert_category(n) for n in cat_names]
217
229
  tag_ids = [await self._db.upsert_tag(n) for n in tag_names]
218
230
  await self._db.set_catalog_categories(token, cat_ids)
219
231
  await self._db.set_catalog_tags(token, tag_ids)
220
232
 
233
+ async def _prefetch_all_facets(self) -> None:
234
+ """Bulk-load every item's tags/categories from ``/api/v1/media_facets``.
235
+
236
+ One call per page (page_size=500) instead of one GET per item. Sets
237
+ ``self._facets_map`` to None on any failure so callers fall back to the
238
+ per-item detail endpoint.
239
+ """
240
+ facets: dict[str, dict] = {}
241
+ page = 1
242
+ try:
243
+ while True:
244
+ resp = await self._client.get(
245
+ f"{self._url}/api/v1/media_facets",
246
+ params={"page": page, "page_size": 500},
247
+ )
248
+ if resp.status_code != 200:
249
+ # Endpoint absent (stock CMS) — fall back to per-item.
250
+ self._facets_map = None if page == 1 else facets
251
+ return
252
+ data = resp.json()
253
+ for row in data.get("results") or []:
254
+ tok = row.get("friendly_token")
255
+ if tok:
256
+ facets[tok] = {
257
+ "tags": row.get("tags") or [],
258
+ "categories": row.get("categories") or [],
259
+ }
260
+ if not data.get("has_next"):
261
+ break
262
+ page += 1
263
+ self._facets_map = facets
264
+ logger.info(
265
+ "Prefetched facets for %d items in %d page(s)", len(facets), page
266
+ )
267
+ except (httpx.HTTPError, ValueError) as exc:
268
+ logger.debug("Facet prefetch failed (%s); using per-item fallback", exc)
269
+ self._facets_map = None
270
+
221
271
  def _build_manifest_url(self, media: dict) -> str:
222
272
  # CyTube custom media ("cm") requires the manifest JSON URL, NOT the
223
273
  # human watch page (/view?m=TOKEN). MediaCMS exposes a CyTube manifest at
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.36.3"
3
+ version = "0.36.5"
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,105 @@
1
+ #!/usr/bin/env python3
2
+ """Add a read-only bulk facets endpoint to MediaCMS so integrations can pull
3
+ every item's friendly_token + tags + categories in a few paginated calls
4
+ instead of one GET per media. Editor/superuser only. Idempotent."""
5
+
6
+ import sys
7
+
8
+ BASE = "/home/mediacms.io/mediacms/files"
9
+ MEDIA = f"{BASE}/views/media.py"
10
+ INIT = f"{BASE}/views/__init__.py"
11
+ URLS = f"{BASE}/urls.py"
12
+
13
+ VIEW_MARKER = "class MediaFacetsList(APIView):"
14
+ VIEW_CODE = '''
15
+
16
+ class MediaFacetsList(APIView):
17
+ """Bulk read-only listing of media friendly_token + tags + categories.
18
+
19
+ Lets trusted integrations (MediaCMS editors) pull every item's facets in a
20
+ few paginated calls instead of one GET per media. Slice pagination by id.
21
+ """
22
+
23
+ permission_classes = (permissions.IsAuthenticated,)
24
+
25
+ def get(self, request, format=None):
26
+ if not is_mediacms_editor(request.user):
27
+ return Response({"detail": "not allowed"}, status=status.HTTP_403_FORBIDDEN)
28
+ try:
29
+ page = max(1, int(request.query_params.get("page", 1)))
30
+ except (TypeError, ValueError):
31
+ page = 1
32
+ try:
33
+ page_size = int(request.query_params.get("page_size", 500))
34
+ except (TypeError, ValueError):
35
+ page_size = 500
36
+ page_size = max(1, min(2000, page_size))
37
+ start = (page - 1) * page_size
38
+ rows = Media.objects.order_by("id").prefetch_related("tags", "category")[
39
+ start : start + page_size
40
+ ]
41
+ results = [
42
+ {
43
+ "friendly_token": m.friendly_token,
44
+ "tags": [t.title for t in m.tags.all()],
45
+ "categories": [c.title for c in m.category.all()],
46
+ }
47
+ for m in rows
48
+ ]
49
+ return Response(
50
+ {
51
+ "page": page,
52
+ "page_size": page_size,
53
+ "has_next": len(results) == page_size,
54
+ "results": results,
55
+ }
56
+ )
57
+ '''
58
+
59
+ INIT_ANCHOR = "from .media import MediaBulkUserActions # noqa: F401\n"
60
+ INIT_ADD = "from .media import MediaFacetsList # noqa: F401\n"
61
+
62
+ URL_ANCHOR = (
63
+ ' re_path(r"^api/v1/media/user/bulk_actions/$", views.MediaBulkUserActions.as_view()),\n'
64
+ )
65
+ URL_ADD = ' re_path(r"^api/v1/media_facets$", views.MediaFacetsList.as_view()),\n'
66
+
67
+
68
+ def patch(path, check, apply_fn):
69
+ with open(path, encoding="utf-8") as f:
70
+ src = f.read()
71
+ if check in src:
72
+ print(f"{path}: already patched")
73
+ return True
74
+ new = apply_fn(src)
75
+ if new is None:
76
+ print(f"ERROR: anchor not found in {path}")
77
+ return False
78
+ with open(path, "w", encoding="utf-8") as f:
79
+ f.write(new)
80
+ print(f"{path}: patched")
81
+ return True
82
+
83
+
84
+ def main() -> int:
85
+ ok = True
86
+ ok &= patch(MEDIA, VIEW_MARKER, lambda s: s.rstrip("\n") + "\n" + VIEW_CODE)
87
+ ok &= patch(
88
+ INIT,
89
+ INIT_ADD.strip(),
90
+ lambda s: s.replace(INIT_ANCHOR, INIT_ANCHOR + INIT_ADD, 1)
91
+ if INIT_ANCHOR in s
92
+ else None,
93
+ )
94
+ ok &= patch(
95
+ URLS,
96
+ "media_facets",
97
+ lambda s: s.replace(URL_ANCHOR, URL_ANCHOR + URL_ADD, 1)
98
+ if URL_ANCHOR in s
99
+ else None,
100
+ )
101
+ return 0 if ok else 1
102
+
103
+
104
+ if __name__ == "__main__":
105
+ sys.exit(main())
@@ -0,0 +1,45 @@
1
+ """One-off: mint an admin session and trigger the catalog_enrich job locally.
2
+
3
+ Usage (on the server, with the service venv python):
4
+ python trigger_enrich.py steps=tags force=1
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import urllib.request
10
+ from datetime import datetime, timedelta, UTC
11
+
12
+ import jwt
13
+
14
+ CONFIG_PATH = "/etc/kryten-webqueue/config.json"
15
+
16
+
17
+ def main() -> int:
18
+ params = dict(a.split("=", 1) for a in sys.argv[1:] if "=" in a)
19
+ cfg = json.load(open(CONFIG_PATH))
20
+ secret = cfg["secret_key"]
21
+ port = cfg.get("port", 2010)
22
+ token = jwt.encode(
23
+ {
24
+ "sub": "backfill-admin",
25
+ "rank": 3,
26
+ "iat": datetime.now(UTC),
27
+ "exp": datetime.now(UTC) + timedelta(hours=1),
28
+ },
29
+ secret,
30
+ algorithm="HS256",
31
+ )
32
+ body = json.dumps({"params": params}).encode()
33
+ req = urllib.request.Request(
34
+ f"http://127.0.0.1:{port}/admin/jobs/catalog_enrich/run",
35
+ data=body,
36
+ headers={"Content-Type": "application/json", "Cookie": f"session={token}"},
37
+ method="POST",
38
+ )
39
+ with urllib.request.urlopen(req, timeout=30) as resp:
40
+ print(resp.status, resp.read().decode())
41
+ return 0
42
+
43
+
44
+ if __name__ == "__main__":
45
+ raise SystemExit(main())