kryten-webqueue 0.36.4__tar.gz → 0.36.6__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.4 → kryten_webqueue-0.36.6}/CHANGELOG.md +26 -0
  2. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/classify.py +4 -28
  4. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/normalise.py +54 -0
  5. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/tags.py +22 -37
  6. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/title.py +4 -3
  7. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/.github/workflows/gitleaks.yml +0 -0
  9. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/.gitignore +0 -0
  12. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/.gitleaks.toml +0 -0
  13. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/.pre-commit-config.yaml +0 -0
  14. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/AGENT-WORKFLOW-GUIDE.md +0 -0
  15. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/README.md +0 -0
  16. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/config.example.json +0 -0
  17. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  25. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  26. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/Log for incorrect promo insertion problem.txt +0 -0
  27. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  28. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/PRE_PLAN_GAPS.md +0 -0
  29. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/PRODUCT_PLAN.md +0 -0
  30. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  31. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/SPEC_EMOTE_REHOST.md +0 -0
  32. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  33. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/docs/UX_POLISH_PLAN.md +0 -0
  34. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/__init__.py +0 -0
  35. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/__main__.py +0 -0
  36. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/api_gate/__init__.py +0 -0
  37. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/api_gate/client.py +0 -0
  38. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/app.py +0 -0
  39. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/auth/__init__.py +0 -0
  40. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/auth/otp.py +0 -0
  41. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/auth/rate_limit.py +0 -0
  42. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/auth/session.py +0 -0
  43. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/__init__.py +0 -0
  44. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/__init__.py +0 -0
  45. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  46. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_connection.py +0 -0
  47. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  48. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  49. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_people.py +0 -0
  50. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  51. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_queue.py +0 -0
  52. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  53. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  54. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  55. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  56. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  57. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  58. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  59. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  60. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  61. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  62. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/images.py +0 -0
  63. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/mediacms.py +0 -0
  64. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/catalog/sync.py +0 -0
  65. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/config.py +0 -0
  66. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/__init__.py +0 -0
  67. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  68. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  69. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  70. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  71. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  72. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  73. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  74. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  75. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  76. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  77. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/jobs/__init__.py +0 -0
  78. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  79. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  80. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/jobs/manager.py +0 -0
  81. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  82. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/jobs/tasks.py +0 -0
  83. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/logging_config.py +0 -0
  84. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/playlists/__init__.py +0 -0
  85. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/playlists/bulk_add.py +0 -0
  86. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/playlists/fire.py +0 -0
  87. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/playlists/importer.py +0 -0
  88. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/playlists/ordering.py +0 -0
  89. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/playlists/scheduler.py +0 -0
  90. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/promos/__init__.py +0 -0
  91. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/promos/director.py +0 -0
  92. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/__init__.py +0 -0
  93. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/completion.py +0 -0
  94. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/ordering.py +0 -0
  95. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/poller.py +0 -0
  96. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/presence.py +0 -0
  97. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/race_poller.py +0 -0
  98. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/queue/shadow.py +0 -0
  99. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/__init__.py +0 -0
  100. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_catalog.py +0 -0
  101. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_feedback.py +0 -0
  102. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  103. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_jobs.py +0 -0
  104. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_moderation.py +0 -0
  105. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_playlists.py +0 -0
  106. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_promos.py +0 -0
  107. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_queue.py +0 -0
  108. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/admin_schedules.py +0 -0
  109. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/auth.py +0 -0
  110. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/catalog.py +0 -0
  111. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/feedback.py +0 -0
  112. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/pages.py +0 -0
  113. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/queue.py +0 -0
  114. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/routes/user.py +0 -0
  115. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/css/main.css +0 -0
  116. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  117. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/js/admin-promos.js +0 -0
  118. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  119. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  120. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/js/admin.js +0 -0
  121. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/static/js/main.js +0 -0
  122. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/admin/index.html +0 -0
  123. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/admin/playlists.html +0 -0
  124. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/admin/promos.html +0 -0
  125. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  126. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/admin/schedules.html +0 -0
  127. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/auth/login.html +0 -0
  128. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/base.html +0 -0
  129. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/catalog/browse.html +0 -0
  130. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  131. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  132. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/feedback/index.html +0 -0
  133. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/queue/index.html +0 -0
  134. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/race.html +0 -0
  135. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/user/dashboard.html +0 -0
  136. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/templates/user/my_list.html +0 -0
  137. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/ws/__init__.py +0 -0
  138. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/ws/handler.py +0 -0
  139. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/kryten_webqueue/ws/manager.py +0 -0
  140. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/scripts/patch_mediacms_bulk_facets.py +0 -0
  141. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/scripts/patch_mediacms_tags.py +0 -0
  142. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/scripts/trigger_enrich.py +0 -0
  143. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/__init__.py +0 -0
  144. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_classify.py +0 -0
  145. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_config_persistence.py +0 -0
  146. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_feedback.py +0 -0
  147. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_fetchurls_sharepoint.py +0 -0
  148. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_people_facets.py +0 -0
  149. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_phase1.py +0 -0
  150. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_phase2_jobs.py +0 -0
  151. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_phase3_jobs.py +0 -0
  152. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_phase4_live_fixes.py +0 -0
  153. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_playlist_import.py +0 -0
  154. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_presence_refund.py +0 -0
  155. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_promo_director.py +0 -0
  156. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_promo_pool_exclusion.py +0 -0
  157. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_providers.py +0 -0
  158. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_queue_announce.py +0 -0
  159. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_race_poller.py +0 -0
  160. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_recently_played_hide.py +0 -0
  161. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_save_results_to_playlist.py +0 -0
  162. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_schedule_lock.py +0 -0
  163. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_search_facets.py +0 -0
  164. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_title_normalization.py +0 -0
  165. {kryten_webqueue-0.36.4 → kryten_webqueue-0.36.6}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.36.6] - 2026-08-14
6
+
7
+ ### Fixed
8
+
9
+ - **`(Dubbed)` / `(Subbed)` markers no longer break art lookup.** `(dubbed)` is
10
+ stripped from both the display title and the search query (dubbed is the
11
+ default). `(subbed)`/`(subs)`/`(subtitles)` stay in the display title but are
12
+ omitted from the metadata/art search.
13
+ - **YouTube “full movie” pipe titles are cleaned properly.** Detection now matches
14
+ non-adjacent variants like “Full **Horror** Movie”, the title is taken from the
15
+ first segment before the pipe, and the year is extracted from the whole string
16
+ (it often trails after the last pipe, e.g. “… | Larry Bucha (1961)”). Both the
17
+ search query and the display title use this cleanup consistently (previously the
18
+ display title was never tidied for pipe uploads).
19
+
20
+ ## [0.36.5] - 2026-08-14
21
+
22
+ ### Changed
23
+
24
+ - **Tags step no longer reads every item.** With `mediacms_manage_all_media` set,
25
+ the tags step now issues a single idempotent `add_tags` POST per item (the
26
+ server skips tags already present) instead of a per-item `GET` to read current
27
+ tags/owner. The redundant per-item reverse-sync `GET` was also removed — catalog
28
+ sync already mirrors CMS tags in bulk via `/api/v1/media_facets`. Stock-CMS
29
+ (owner-scoped) deployments still do the ownership `GET` on the fallback path.
30
+
5
31
  ## [0.36.4] - 2026-08-14
6
32
 
7
33
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.36.4
3
+ Version: 0.36.6
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
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import re
10
10
  from dataclasses import dataclass, field
11
11
 
12
- from .normalise import normalize_and_clean, strip_extension
12
+ from .normalise import normalize_movie_title
13
13
 
14
14
  # ---------------------------------------------------------------------------
15
15
  # Hosted show registry
@@ -250,13 +250,6 @@ class ItemClassification:
250
250
  has_real_art: bool = False
251
251
 
252
252
 
253
- # YouTube "Title | English Full Movie | Genre1 Genre2" pattern
254
- # Also handles variants like "Title | Full Movie HD | Description"
255
- _YT_FULL_MOVIE_RE = re.compile(
256
- r"^(?P<title>.+?)\s*\|.*(Full\s+(?:Movie|Film|Length|Feature)).*$", re.I
257
- )
258
-
259
-
260
253
  def classify_item(
261
254
  friendly_token: str,
262
255
  raw_title: str,
@@ -326,26 +319,9 @@ def classify_item(
326
319
 
327
320
  # 4. Movie (>= 30 min)
328
321
  if duration_sec >= 1800:
329
- # YouTube title cleanup: extract title before first pipe if "full movie" appears
330
- yt = _YT_FULL_MOVIE_RE.match(raw_title.strip())
331
- if yt:
332
- clean_title = yt.group("title").strip()
333
- # Extract year from the clean title part
334
- clean_title, year = normalize_and_clean(clean_title)
335
- return ItemClassification(
336
- friendly_token=friendly_token,
337
- raw_title=raw_title,
338
- content_type="movie",
339
- hosted=None,
340
- lookup_title=clean_title,
341
- lookup_year=year,
342
- genre_hints=[],
343
- duration_sec=duration_sec,
344
- description_score=description_score,
345
- has_real_art=has_real_art,
346
- )
347
-
348
- clean_title, year = normalize_and_clean(raw_title)
322
+ # Handles YouTube pipe titles and dub/sub markers; year is pulled from
323
+ # the whole string so a trailing "(1961)" after pipes is still captured.
324
+ clean_title, year = normalize_movie_title(raw_title, for_search=True)
349
325
  return ItemClassification(
350
326
  friendly_token=friendly_token,
351
327
  raw_title=raw_title,
@@ -103,3 +103,57 @@ def normalize_and_clean(title: str) -> tuple[str, str | None]:
103
103
  t = strip_extension(t.strip(" .-"))
104
104
  t = re.sub(r"\s+", " ", t).strip()
105
105
  return t or title, year
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Dub / sub markers and YouTube "full movie" pipe titles
110
+ # ---------------------------------------------------------------------------
111
+
112
+ # "dubbed" is the default expectation → stripped from title and search.
113
+ _DUB_TERMS = ["dubbed"]
114
+ # sub markers stay in the display title but are omitted from art/metadata search.
115
+ _SUB_TERMS = ["subbed", "subtitled", "subtitles", "subs"]
116
+
117
+ # A pipe-delimited YouTube upload advertising a full movie, e.g.
118
+ # "Title | English Full Movie | ...", "Title | Full Horror Movie | ...".
119
+ _YT_FULL_MOVIE_RE = re.compile(
120
+ r"\bfull\s+(?:\w+\s+)?(?:movie|film|feature|length)\b", re.I
121
+ )
122
+
123
+
124
+ def _strip_terms(title: str, terms: list[str]) -> str:
125
+ """Remove bracketed and bare occurrences of ``terms`` from ``title``."""
126
+ pat = "|".join(terms)
127
+ title = re.sub(rf"\s*[\(\[\{{]\s*(?:{pat})\s*[\)\]\}}]", "", title, flags=re.I)
128
+ title = re.sub(rf"\s*\b(?:{pat})\b", "", title, flags=re.I)
129
+ return re.sub(r"\s+", " ", title).strip()
130
+
131
+
132
+ def _pre_pipe_title(title: str) -> str | None:
133
+ """First segment of a pipe-delimited YouTube 'full movie' title, else None."""
134
+ if "|" not in title or not _YT_FULL_MOVIE_RE.search(title):
135
+ return None
136
+ return title.split("|", 1)[0].strip()
137
+
138
+
139
+ def normalize_movie_title(raw: str, *, for_search: bool) -> tuple[str, str | None]:
140
+ """Return ``(clean_title, year_or_None)`` for a plain movie title.
141
+
142
+ Handles two cases the base cleaner misses:
143
+ - YouTube pipe titles ("Title | Full Movie | ..."): uses the first segment
144
+ before the pipe, but extracts the year from the *whole* string (it often
145
+ trails after the last pipe, e.g. "... | Larry Bucha (1961)").
146
+ - Dub/sub markers: ``(dubbed)`` is always removed; sub markers are removed
147
+ only when ``for_search`` (they stay in the display title).
148
+ """
149
+ # Year first, from the full string, so a trailing "(1961)" is captured even
150
+ # when the title before the pipe has none.
151
+ _, full_year = normalize_and_clean(raw)
152
+ base = _pre_pipe_title(raw) or raw
153
+ base = (
154
+ _strip_terms(base, _DUB_TERMS + _SUB_TERMS)
155
+ if for_search
156
+ else _strip_terms(base, _DUB_TERMS)
157
+ )
158
+ clean, base_year = normalize_and_clean(base)
159
+ return clean, base_year or full_year
@@ -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)
@@ -7,7 +7,7 @@ from datetime import datetime, UTC
7
7
  import httpx
8
8
 
9
9
  from ..classify import ItemClassification
10
- from ..normalise import normalize_and_clean
10
+ from ..normalise import normalize_movie_title
11
11
  from ..report import StepResult
12
12
 
13
13
  logger = logging.getLogger(__name__)
@@ -83,7 +83,9 @@ class TitleStep:
83
83
  """Return the desired CMS title, or None if no change needed."""
84
84
  ct = cls.content_type
85
85
  if ct == "movie":
86
- clean, year = normalize_and_clean(cls.raw_title)
86
+ # Display title keeps sub markers (dubbed is stripped); mirrors the
87
+ # search cleanup so pipe/YouTube titles are tidied consistently.
88
+ clean, year = normalize_movie_title(cls.raw_title, for_search=False)
87
89
  if year:
88
90
  candidate = f"{clean} ({year})"
89
91
  else:
@@ -100,7 +102,6 @@ class TitleStep:
100
102
  candidate = f"{t} - {show}"
101
103
  return candidate if candidate != cls.raw_title else None
102
104
  if ct == "riffed_movie" and cls.hosted:
103
- entry_template = cls.hosted # HostedInfo has show_name
104
105
  if "{title}" in (cls.hosted.show_name or ""):
105
106
  return None # template handled below
106
107
  # Only Rifftrax gets reformat; MST3K keeps original
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.36.4"
3
+ version = "0.36.6"
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"