kryten-webqueue 0.36.2__tar.gz → 0.36.4__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.2 → kryten_webqueue-0.36.4}/CHANGELOG.md +28 -0
  2. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/config.example.json +1 -0
  4. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/tags.py +26 -19
  5. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/sync.py +69 -19
  6. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/config.py +3 -0
  7. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/pyproject.toml +1 -1
  8. kryten_webqueue-0.36.4/scripts/patch_mediacms_bulk_facets.py +105 -0
  9. kryten_webqueue-0.36.4/scripts/patch_mediacms_tags.py +90 -0
  10. kryten_webqueue-0.36.4/scripts/trigger_enrich.py +45 -0
  11. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/.github/workflows/gitleaks.yml +0 -0
  12. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/.github/workflows/python-publish.yml +0 -0
  13. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/.github/workflows/release.yml +0 -0
  14. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/.gitignore +0 -0
  15. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/.gitleaks.toml +0 -0
  16. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/.pre-commit-config.yaml +0 -0
  17. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/AGENT-WORKFLOW-GUIDE.md +0 -0
  18. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/README.md +0 -0
  19. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/deploy/kryten-webqueue.service +0 -0
  20. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/deploy/nginx-queue.conf +0 -0
  21. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/IMPLEMENTATION_SPEC.md +0 -0
  22. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/IMPL_API_GATE.md +0 -0
  23. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/IMPL_ECONOMY.md +0 -0
  24. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/IMPL_KRYTEN_PY.md +0 -0
  25. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/IMPL_ROBOT.md +0 -0
  26. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  27. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  28. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/Log for incorrect promo insertion problem.txt +0 -0
  29. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  30. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/PRE_PLAN_GAPS.md +0 -0
  31. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/PRODUCT_PLAN.md +0 -0
  32. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  33. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/SPEC_EMOTE_REHOST.md +0 -0
  34. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  35. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/docs/UX_POLISH_PLAN.md +0 -0
  36. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/__init__.py +0 -0
  37. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/__main__.py +0 -0
  38. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/api_gate/__init__.py +0 -0
  39. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/api_gate/client.py +0 -0
  40. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/app.py +0 -0
  41. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/auth/__init__.py +0 -0
  42. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/auth/otp.py +0 -0
  43. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/auth/rate_limit.py +0 -0
  44. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/auth/session.py +0 -0
  45. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/__init__.py +0 -0
  46. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/__init__.py +0 -0
  47. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  48. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_connection.py +0 -0
  49. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  50. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  51. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_people.py +0 -0
  52. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  53. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_queue.py +0 -0
  54. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  55. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  56. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  57. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  58. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  59. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  60. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  61. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  62. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  63. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  64. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  65. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  66. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  67. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/images.py +0 -0
  68. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/catalog/mediacms.py +0 -0
  69. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/__init__.py +0 -0
  70. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  71. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  72. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  73. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  74. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  75. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  76. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  77. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  78. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  79. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  80. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/jobs/__init__.py +0 -0
  81. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  82. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  83. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/jobs/manager.py +0 -0
  84. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  85. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/jobs/tasks.py +0 -0
  86. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/logging_config.py +0 -0
  87. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/playlists/__init__.py +0 -0
  88. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/playlists/bulk_add.py +0 -0
  89. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/playlists/fire.py +0 -0
  90. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/playlists/importer.py +0 -0
  91. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/playlists/ordering.py +0 -0
  92. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/playlists/scheduler.py +0 -0
  93. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/promos/__init__.py +0 -0
  94. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/promos/director.py +0 -0
  95. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/__init__.py +0 -0
  96. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/completion.py +0 -0
  97. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/ordering.py +0 -0
  98. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/poller.py +0 -0
  99. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/presence.py +0 -0
  100. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/race_poller.py +0 -0
  101. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/queue/shadow.py +0 -0
  102. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/__init__.py +0 -0
  103. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_catalog.py +0 -0
  104. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_feedback.py +0 -0
  105. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  106. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_jobs.py +0 -0
  107. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_moderation.py +0 -0
  108. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_playlists.py +0 -0
  109. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_promos.py +0 -0
  110. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_queue.py +0 -0
  111. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/admin_schedules.py +0 -0
  112. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/auth.py +0 -0
  113. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/catalog.py +0 -0
  114. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/feedback.py +0 -0
  115. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/pages.py +0 -0
  116. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/queue.py +0 -0
  117. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/routes/user.py +0 -0
  118. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/css/main.css +0 -0
  119. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  120. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/js/admin-promos.js +0 -0
  121. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  122. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  123. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/js/admin.js +0 -0
  124. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/static/js/main.js +0 -0
  125. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/admin/index.html +0 -0
  126. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/admin/playlists.html +0 -0
  127. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/admin/promos.html +0 -0
  128. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  129. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/admin/schedules.html +0 -0
  130. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/auth/login.html +0 -0
  131. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/base.html +0 -0
  132. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/catalog/browse.html +0 -0
  133. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  134. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  135. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/feedback/index.html +0 -0
  136. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/queue/index.html +0 -0
  137. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/race.html +0 -0
  138. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/user/dashboard.html +0 -0
  139. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/templates/user/my_list.html +0 -0
  140. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/ws/__init__.py +0 -0
  141. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/ws/handler.py +0 -0
  142. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/kryten_webqueue/ws/manager.py +0 -0
  143. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/__init__.py +0 -0
  144. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_classify.py +0 -0
  145. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_config_persistence.py +0 -0
  146. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_feedback.py +0 -0
  147. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_fetchurls_sharepoint.py +0 -0
  148. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_people_facets.py +0 -0
  149. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_phase1.py +0 -0
  150. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_phase2_jobs.py +0 -0
  151. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_phase3_jobs.py +0 -0
  152. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_phase4_live_fixes.py +0 -0
  153. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_playlist_import.py +0 -0
  154. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_presence_refund.py +0 -0
  155. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_promo_director.py +0 -0
  156. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_promo_pool_exclusion.py +0 -0
  157. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_providers.py +0 -0
  158. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_queue_announce.py +0 -0
  159. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_race_poller.py +0 -0
  160. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_recently_played_hide.py +0 -0
  161. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_save_results_to_playlist.py +0 -0
  162. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_schedule_lock.py +0 -0
  163. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_search_facets.py +0 -0
  164. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_title_normalization.py +0 -0
  165. {kryten_webqueue-0.36.2 → kryten_webqueue-0.36.4}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.36.4] - 2026-08-14
6
+
7
+ ### Changed
8
+
9
+ - **Catalog sync pulls tags/categories in bulk.** Instead of one
10
+ `GET /api/v1/media/{token}` per item (10k+ requests, ~30+ min), sync now
11
+ prefetches every item's facets from a new `GET /api/v1/media_facets` endpoint
12
+ in a handful of paginated calls (~35s for the full catalog). Falls back to the
13
+ per-item detail endpoint automatically when the bulk endpoint is unavailable
14
+ (stock MediaCMS). See `scripts/patch_mediacms_bulk_facets.py` for the CMS-side
15
+ endpoint.
16
+
17
+ ## [0.36.3] - 2026-08-14
18
+
19
+ ### Added
20
+
21
+ - **`mediacms_manage_all_media` config flag.** When set (paired with a CMS whose
22
+ `bulk_actions` is patched to let managers/superusers tag any media), the tags
23
+ step pushes derived genre/MPAA/hosted tags to CMS for every item, not just
24
+ media the API user owns. Defaults to `false` (owner-only, safe).
25
+
26
+ ### Changed
27
+
28
+ - **MPAA rating tags are now alphanumeric** (`mpaar`, `mpaapg13`, …) to match
29
+ MediaCMS's `Tag.save()` normalization (which strips non-alphanumerics), so tags
30
+ round-trip cleanly between the local catalog and CMS without creating hyphenated
31
+ duplicates on reverse-sync.
32
+
5
33
  ## [0.36.2] - 2026-08-14
6
34
 
7
35
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.36.2
3
+ Version: 0.36.4
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
@@ -13,6 +13,7 @@
13
13
 
14
14
  "mediacms_url": "https://www.dropsugar.com",
15
15
  "mediacms_token": "CHANGE_ME",
16
+ "mediacms_manage_all_media": false,
16
17
 
17
18
  "tmdb_api_key": "",
18
19
  "omdb_api_key": "",
@@ -18,15 +18,15 @@ _TIMEOUT = 20.0
18
18
  _MPAA = frozenset({"G", "PG", "PG-13", "R", "NC-17", "NR", "TV-MA", "TV-14", "TV-PG"})
19
19
 
20
20
  _MPAA_SLUG = {
21
- "G": "mpaa-g",
22
- "PG": "mpaa-pg",
23
- "PG-13": "mpaa-pg13",
24
- "R": "mpaa-r",
25
- "NC-17": "mpaa-nc17",
26
- "NR": "mpaa-nr",
27
- "TV-MA": "mpaa-tvma",
28
- "TV-14": "mpaa-tv14",
29
- "TV-PG": "mpaa-tvpg",
21
+ "G": "mpaag",
22
+ "PG": "mpaapg",
23
+ "PG-13": "mpaapg13",
24
+ "R": "mpaar",
25
+ "NC-17": "mpaanc17",
26
+ "NR": "mpaanr",
27
+ "TV-MA": "mpaatvma",
28
+ "TV-14": "mpaatv14",
29
+ "TV-PG": "mpaatvpg",
30
30
  }
31
31
 
32
32
 
@@ -41,14 +41,17 @@ class TagsStep:
41
41
  config.mediacms_url.rstrip("/").removesuffix("/api/v1").removesuffix("/api")
42
42
  )
43
43
  self._headers = {"Authorization": f"Token {config.mediacms_token}"}
44
+ # When the token can manage all media (e.g. a manager/superuser on a
45
+ # patched CMS), push tags for every item instead of only owned media.
46
+ self._manage_all = bool(getattr(config, "mediacms_manage_all_media", False))
44
47
 
45
48
  async def _get_api_username(self, client: httpx.AsyncClient) -> str | None:
46
49
  """Return the API token's username, or None.
47
50
 
48
- MediaCMS ``/media/user/bulk_actions`` filters ``Media.objects.filter(
49
- user=request.user, ...)`` with no manager/superuser bypass, so tag
50
- pushes only succeed on media this user owns. We use this to skip futile
51
- pushes against media owned by others instead of flooding CMS with 400s.
51
+ Stock MediaCMS ``/media/user/bulk_actions`` filters ``Media.objects.filter(
52
+ user=request.user, ...)``, so tag pushes only succeed on media this user
53
+ owns. When ``mediacms_manage_all_media`` is not set we use this to skip
54
+ futile pushes against media owned by others instead of flooding CMS.
52
55
  """
53
56
  try:
54
57
  resp = await client.get(f"{self._base}/api/v1/whoami")
@@ -70,7 +73,11 @@ class TagsStep:
70
73
  now = datetime.now(UTC).isoformat()
71
74
 
72
75
  async with httpx.AsyncClient(headers=self._headers, timeout=_TIMEOUT) as client:
73
- api_user = None if dry_run else await self._get_api_username(client)
76
+ api_user = (
77
+ None
78
+ if dry_run or self._manage_all
79
+ else await self._get_api_username(client)
80
+ )
74
81
  for cls in classifications:
75
82
  result.processed += 1
76
83
  try:
@@ -141,10 +148,10 @@ class TagsStep:
141
148
 
142
149
  The ``PUT /media/{token}`` endpoint only accepts title/description/media_file
143
150
  and silently ignores a ``tags`` field, so tag changes must go through
144
- ``POST /media/user/bulk_actions`` with ``action: add_tags``. That endpoint
145
- filters ``Media.objects.filter(user=request.user, ...)`` with no manager or
146
- superuser bypass, so we skip the push unless the API user owns the media
147
- rather than flooding CMS with 400s.
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.
148
155
  """
149
156
  detail_url = f"{self._base}/api/v1/media/{token}"
150
157
  try:
@@ -153,7 +160,7 @@ class TagsStep:
153
160
  return False
154
161
  data = resp.json()
155
162
  owner = data.get("user")
156
- if api_user is None or owner != api_user:
163
+ if not self._manage_all and (api_user is None or owner != api_user):
157
164
  return False
158
165
  current = {
159
166
  (t.get("title") if isinstance(t, dict) else t)
@@ -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
@@ -151,6 +151,9 @@ class Config(BaseModel):
151
151
  # MediaCMS
152
152
  mediacms_url: str = "https://www.dropsugar.com"
153
153
  mediacms_token: str
154
+ # Set true when the token can tag any media (manager/superuser on a CMS whose
155
+ # bulk_actions is patched to allow it); pushes derived tags for all items.
156
+ mediacms_manage_all_media: bool = False
154
157
 
155
158
  # Cover art APIs
156
159
  tmdb_api_key: str = ""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.36.2"
3
+ version = "0.36.4"
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,90 @@
1
+ #!/usr/bin/env python3
2
+ """Patch MediaCMS MediaBulkUserActions so editors/superusers can tag any media,
3
+ and add_tags creates missing tags (normalized like Tag.save)."""
4
+
5
+ import sys
6
+
7
+ PATH = "/home/mediacms.io/mediacms/files/views/media.py"
8
+
9
+ QS_OLD = (
10
+ " media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)\n"
11
+ )
12
+ QS_NEW = (
13
+ " if is_mediacms_editor(request.user):\n"
14
+ " media = Media.objects.filter(friendly_token__in=media_ids)\n"
15
+ " else:\n"
16
+ " media = Media.objects.filter(user=request.user, friendly_token__in=media_ids)\n"
17
+ )
18
+
19
+ TAGS_OLD = (
20
+ ' elif action == "add_tags":\n'
21
+ " tag_titles = request.data.get('tag_titles', [])\n"
22
+ " if not tag_titles:\n"
23
+ ' return Response({"detail": "tag_titles is required for add_tags action"}, status=status.HTTP_400_BAD_REQUEST)\n'
24
+ "\n"
25
+ " tags = Tag.objects.filter(title__in=tag_titles)\n"
26
+ " if not tags:\n"
27
+ ' return Response({"detail": "No matching tags found"}, status=status.HTTP_400_BAD_REQUEST)\n'
28
+ "\n"
29
+ " added_count = 0\n"
30
+ " for tag in tags:\n"
31
+ " for m in media:\n"
32
+ " if not m.tags.filter(title=tag.title).exists():\n"
33
+ " m.tags.add(tag)\n"
34
+ " added_count += 1\n"
35
+ "\n"
36
+ ' return Response({"detail": f"Added {added_count} media items to {tags.count()} tags"})\n'
37
+ )
38
+ TAGS_NEW = (
39
+ ' elif action == "add_tags":\n'
40
+ " tag_titles = request.data.get('tag_titles', [])\n"
41
+ " if not tag_titles:\n"
42
+ ' return Response({"detail": "tag_titles is required for add_tags action"}, status=status.HTTP_400_BAD_REQUEST)\n'
43
+ "\n"
44
+ " tags = []\n"
45
+ " for raw in tag_titles:\n"
46
+ " title = helpers.get_alphanumeric_only(raw)[:100]\n"
47
+ " if not title:\n"
48
+ " continue\n"
49
+ " tag, _ = Tag.objects.get_or_create(title=title, defaults={\"user\": request.user})\n"
50
+ " tags.append(tag)\n"
51
+ " if not tags:\n"
52
+ ' return Response({"detail": "No matching tags found"}, status=status.HTTP_400_BAD_REQUEST)\n'
53
+ "\n"
54
+ " added_count = 0\n"
55
+ " for tag in tags:\n"
56
+ " for m in media:\n"
57
+ " if not m.tags.filter(title=tag.title).exists():\n"
58
+ " m.tags.add(tag)\n"
59
+ " added_count += 1\n"
60
+ "\n"
61
+ ' return Response({"detail": f"Added {added_count} media items to {len(tags)} tags"})\n'
62
+ )
63
+
64
+
65
+ def main() -> int:
66
+ with open(PATH, encoding="utf-8") as f:
67
+ src = f.read()
68
+
69
+ if QS_NEW in src and TAGS_NEW in src:
70
+ print("Already patched; nothing to do.")
71
+ return 0
72
+
73
+ if src.count(QS_OLD) != 1:
74
+ print(f"ERROR: queryset anchor found {src.count(QS_OLD)} times (expected 1)")
75
+ return 2
76
+ if src.count(TAGS_OLD) != 1:
77
+ print(f"ERROR: add_tags anchor found {src.count(TAGS_OLD)} times (expected 1)")
78
+ return 3
79
+
80
+ src = src.replace(QS_OLD, QS_NEW, 1)
81
+ src = src.replace(TAGS_OLD, TAGS_NEW, 1)
82
+
83
+ with open(PATH, "w", encoding="utf-8") as f:
84
+ f.write(src)
85
+ print("Patched OK.")
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ 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())