kryten-webqueue 0.36.7__tar.gz → 0.37.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. kryten_webqueue-0.37.0/AGENTS.md +71 -0
  2. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/CHANGELOG.md +13 -0
  3. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/PKG-INFO +1 -1
  4. kryten_webqueue-0.37.0/docs/SPEC_ADMIN_ITEM_EDITOR.md +264 -0
  5. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_catalog.py +52 -15
  6. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_connection.py +13 -0
  7. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_enrichment.py +31 -1
  8. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/providers.py +3 -1
  9. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/art.py +3 -4
  10. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/meta.py +3 -1
  11. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/mediacms.py +41 -0
  12. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_catalog.py +101 -0
  13. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/catalog/browse.html +4 -0
  14. kryten_webqueue-0.37.0/kryten_webqueue/templates/catalog/edit_item_modal.html +332 -0
  15. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/catalog/item_detail.html +4 -0
  16. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/pyproject.toml +1 -1
  17. kryten_webqueue-0.37.0/scripts/AGENTS.md +98 -0
  18. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.github/workflows/gitleaks.yml +0 -0
  19. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.github/workflows/python-publish.yml +0 -0
  20. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.github/workflows/release.yml +0 -0
  21. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.gitignore +0 -0
  22. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.gitleaks.toml +0 -0
  23. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/.pre-commit-config.yaml +0 -0
  24. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  25. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/README.md +0 -0
  26. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/config.example.json +0 -0
  27. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/deploy/kryten-webqueue.service +0 -0
  28. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/deploy/nginx-queue.conf +0 -0
  29. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  30. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_API_GATE.md +0 -0
  31. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_ECONOMY.md +0 -0
  32. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  33. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/IMPL_ROBOT.md +0 -0
  34. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  35. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  36. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  37. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  38. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/PRE_PLAN_GAPS.md +0 -0
  39. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/PRODUCT_PLAN.md +0 -0
  40. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  41. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  42. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  43. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/docs/UX_POLISH_PLAN.md +0 -0
  44. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/__init__.py +0 -0
  45. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/__main__.py +0 -0
  46. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  47. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/api_gate/client.py +0 -0
  48. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/app.py +0 -0
  49. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/__init__.py +0 -0
  50. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/otp.py +0 -0
  51. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  52. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/auth/session.py +0 -0
  53. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/__init__.py +0 -0
  54. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/__init__.py +0 -0
  55. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  56. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  57. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  58. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  59. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  60. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  61. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  62. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  63. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  64. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  65. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  66. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  67. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  68. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  69. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  70. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/images.py +0 -0
  71. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/catalog/sync.py +0 -0
  72. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/config.py +0 -0
  73. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/__init__.py +0 -0
  74. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  75. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  76. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  77. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  78. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  79. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  80. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  81. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  82. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  83. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  84. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/__init__.py +0 -0
  85. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  86. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  87. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/manager.py +0 -0
  88. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  89. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/jobs/tasks.py +0 -0
  90. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/logging_config.py +0 -0
  91. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/__init__.py +0 -0
  92. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  93. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/fire.py +0 -0
  94. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/importer.py +0 -0
  95. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/ordering.py +0 -0
  96. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  97. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/promos/__init__.py +0 -0
  98. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/promos/director.py +0 -0
  99. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/__init__.py +0 -0
  100. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/completion.py +0 -0
  101. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/ordering.py +0 -0
  102. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/poller.py +0 -0
  103. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/presence.py +0 -0
  104. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/race_poller.py +0 -0
  105. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/queue/shadow.py +0 -0
  106. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/__init__.py +0 -0
  107. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  108. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  109. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  110. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  111. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  112. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  113. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  114. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  115. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/auth.py +0 -0
  116. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/catalog.py +0 -0
  117. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/feedback.py +0 -0
  118. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/pages.py +0 -0
  119. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/queue.py +0 -0
  120. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/routes/user.py +0 -0
  121. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/css/main.css +0 -0
  122. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  123. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  124. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  125. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  126. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/admin.js +0 -0
  127. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/static/js/main.js +0 -0
  128. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/index.html +0 -0
  129. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  130. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  131. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  132. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  133. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/auth/login.html +0 -0
  134. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/base.html +0 -0
  135. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  136. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  137. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/queue/index.html +0 -0
  138. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/race.html +0 -0
  139. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  140. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  141. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/ws/__init__.py +0 -0
  142. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/ws/handler.py +0 -0
  143. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/kryten_webqueue/ws/manager.py +0 -0
  144. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/scripts/patch_mediacms_bulk_facets.py +0 -0
  145. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/scripts/patch_mediacms_tags.py +0 -0
  146. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/scripts/trigger_enrich.py +0 -0
  147. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/__init__.py +0 -0
  148. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_classify.py +0 -0
  149. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_config_persistence.py +0 -0
  150. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_feedback.py +0 -0
  151. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_fetchurls_sharepoint.py +0 -0
  152. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_people_facets.py +0 -0
  153. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase1.py +0 -0
  154. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase2_jobs.py +0 -0
  155. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase3_jobs.py +0 -0
  156. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_phase4_live_fixes.py +0 -0
  157. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_playlist_import.py +0 -0
  158. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_presence_refund.py +0 -0
  159. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_promo_director.py +0 -0
  160. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_promo_pool_exclusion.py +0 -0
  161. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_providers.py +0 -0
  162. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_queue_announce.py +0 -0
  163. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_race_poller.py +0 -0
  164. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_recently_played_hide.py +0 -0
  165. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_save_results_to_playlist.py +0 -0
  166. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_schedule_lock.py +0 -0
  167. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_search_facets.py +0 -0
  168. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_title_normalization.py +0 -0
  169. {kryten_webqueue-0.36.7 → kryten_webqueue-0.37.0}/tests/test_watchlist.py +0 -0
@@ -0,0 +1,71 @@
1
+ # Kryten-WebQueue — Project Guidelines
2
+
3
+ Kryten-WebQueue is the **web/HTTP layer** of the Kryten ecosystem: a Netflix/Tubi-style
4
+ catalog browser and pay-to-play queue manager for a CyTube channel. It is a FastAPI +
5
+ uvicorn app that serves the public site, enriches a local catalog mirrored from **MediaCMS**,
6
+ and drives playback by issuing commands through **kryten-api-gate**. Unlike the NATS-only
7
+ microservices, this is a user-facing HTTP application with its own SQLite database.
8
+
9
+ ## Architecture
10
+ - **FastAPI app** (`kryten_webqueue/app.py`) — routers under `routes/`, Jinja2 `templates/`,
11
+ `static/` assets, WebSockets under `ws/` (queue + race views). Auth is a JWT **session
12
+ cookie** (`auth/session.py`); admin routes require rank ≥ 3 (`require_admin`).
13
+ - **Local catalog in SQLite** via `aiosqlite` (`catalog/db/`). Schema + **forward-only
14
+ migrations** live in `catalog/db/_connection.py` (bump the version and add a migration;
15
+ never rewrite an applied one). DB path comes from config (`db_path`).
16
+ - **Catalog sync** (`catalog/sync.py`) mirrors items + facets from MediaCMS. It bulk-pulls
17
+ tags/categories via `GET /api/v1/media_facets` (falls back to per-item detail on stock
18
+ CMS). `set_catalog_tags` is a **REPLACE** — **CMS is the source of truth for tags**.
19
+ - **Enrichment pipeline** (`catalog/enrichment/`): `sync → classify → title → meta → art →
20
+ tags → categories`. TMDB/OMDB lookups in `providers.py`; all title normalization in
21
+ `normalise.py` (`normalize_movie_title`). Classification is **cached** in enrichment state;
22
+ classify-logic changes only reach existing items on a `force` re-run.
23
+ - **Jobs** (`jobs/`): `JobManager` runs single-flight background jobs with **schema-validated
24
+ params** (`catalog_enrich`, `catalog_sync`, …) and APScheduler cron (`job_scheduler.py`).
25
+ The `steps` param is a **validated enum** — only fixed combos are accepted (e.g.
26
+ `all`, `classify,meta,art,tags`, `classify,meta`, `art`, `tags`, `title`, `sync`,
27
+ `classify`); arbitrary combos return HTTP 400.
28
+ - **External surfaces**: MediaCMS over HTTP (catalog source, art, tag/title writes) and
29
+ kryten-api-gate (playback commands, via `api_gate_url`/`api_gate_token`). **Working on the
30
+ MediaCMS instance and our local patches: see [scripts/AGENTS.md](scripts/AGENTS.md).**
31
+
32
+ ## Build and Test
33
+ Run from the repo root (uv-managed):
34
+ - Install deps: `uv sync`
35
+ - Format: `uv run black .`
36
+ - Lint (autofix): `uv run ruff check --fix .`
37
+ - Types: `uv run mypy kryten_webqueue`
38
+ - Tests: `uv run pytest` (add `--cov=kryten_webqueue --cov-report=term-missing` for coverage)
39
+
40
+ Run all four before committing. Do not bypass checks (`--no-verify`).
41
+
42
+ ## Conventions
43
+ - Python 3.12+, flat `kryten_webqueue/` package, 100% `async`/`await`, Pydantic v2 config,
44
+ FastAPI + uvicorn. black/ruff use their **default** line length (no override configured).
45
+ pytest `asyncio_mode = "auto"`.
46
+ - Config is JSON with auto-discovery: `--config` flag → `/etc/kryten-webqueue/config.json` →
47
+ `./config.json`. Keep `config.example.json` in sync; never hardcode secrets, keys, or
48
+ MediaCMS/api-gate URLs. Notable keys: `secret_key` (JWT), `port` (default 2010),
49
+ `api_gate_url`/`api_gate_token`, `mediacms_url`/`mediacms_token`,
50
+ `mediacms_manage_all_media`, `tmdb_api_key`/`omdb_api_key`, `db_path`.
51
+ - **High-stakes surfaces**: DB migrations, enrichment classify/title logic (affects the whole
52
+ catalog; needs a `force` re-run to re-apply), MediaCMS write paths, and catalog sync
53
+ (REPLACE semantics). Flag and version changes to these.
54
+ - Version lives only in `pyproject.toml [project] version`. Update `CHANGELOG.md`
55
+ (Keep-a-Changelog + SemVer, ISO dates) for every versioned change.
56
+ - Commit prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `ci:`.
57
+ Branches: `feature/…`, `fix/…`.
58
+
59
+ ## Deployment
60
+ Deployed via **pipx** on `grindhouse.local` as the `kryten-webqueue` systemd service
61
+ (uvicorn on `port`, behind nginx; `deploy/` ships the unit + nginx config). Release flow:
62
+ 1. Bump `version` in `pyproject.toml`, update `CHANGELOG.md`.
63
+ 2. Commit, `git tag vX.Y.Z`, `git push && git push origin vX.Y.Z` (also published to PyPI).
64
+ 3. Install the tag and cycle the service:
65
+ ```
66
+ ssh kryten@grindhouse.local "sudo systemctl stop kryten-webqueue; \
67
+ pipx runpip kryten-webqueue install 'git+https://github.com/grobertson/kryten-webqueue.git@vX.Y.Z'; \
68
+ sudo systemctl start kryten-webqueue; systemctl is-active kryten-webqueue"
69
+ ```
70
+ Trigger enrichment/sync jobs via `POST /admin/jobs/catalog_enrich/run` (admin session
71
+ required; jobs are single-flight — restart the service to clear a stuck run).
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.37.0] - 2026-08-14
6
+
7
+ ### Added
8
+
9
+ - **Admin manual item editor.** Admins can now directly edit catalog item metadata
10
+ via an "Edit" button on catalog tiles and detail pages. The modal editor allows
11
+ editing both core metadata (title, description, duration) and advanced enrichment
12
+ state (content_type, lookup fields, hosted show, TV episode details). Edits can
13
+ optionally sync back to MediaCMS and trigger re-enrichment. All changes are logged
14
+ to an audit trail (`item_edit_log` table) tracking who edited what and when. This
15
+ complements the existing re-enrich button for cases where auto-enrichment cannot
16
+ fix problematic metadata.
17
+
5
18
  ## [0.36.7] - 2026-08-14
6
19
 
7
20
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.36.7
3
+ Version: 0.37.0
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -0,0 +1,264 @@
1
+ # Admin Manual Item Editor — Implementation Spec
2
+
3
+ **Version**: 1.0
4
+ **Status**: Implementing
5
+ **Date**: 2026-08-14
6
+
7
+ ---
8
+
9
+ ## Overview
10
+
11
+ Add an admin-only manual edit capability for catalog items, appearing on both list view tiles and item detail pages. This complements the existing re-enrich button by allowing admins to directly fix metadata that can't be automatically corrected.
12
+
13
+ ## User Requirements
14
+
15
+ Based on user feedback:
16
+ 1. **Include enrichment state editing in V1** — allow editing classification fields
17
+ 2. **Include edit audit logging in V1** — track who edited what and when
18
+ 3. **"Re-enrich on save"** — optional checkbox that triggers the standard enrichment job for the edited item
19
+ 4. **Skip tag/category editing** — these are better managed on the MediaCMS side
20
+
21
+ ---
22
+
23
+ ## Architecture
24
+
25
+ ### What Fields Are Editable?
26
+
27
+ **Core Metadata** (always editable):
28
+ - `title` — display title
29
+ - `description` — synopsis/overview
30
+ - `duration_sec` — runtime in seconds
31
+
32
+ **Enrichment State** (advanced):
33
+ - `content_type` — classification (movie, tv_episode, hosted_movie, riffed_movie, archive, unknown)
34
+ - `lookup_title` — normalized title for TMDB/OMDB search
35
+ - `lookup_year` — year for metadata lookup
36
+ - `hosted_show` — for hosted movies (Svengoolie, MST3K, JBB, TLDI, etc.)
37
+ - `tv_show`, `tv_season`, `tv_episode_num` — for TV episodes
38
+
39
+ ### Sync Strategy
40
+
41
+ - **Title/Description → MediaCMS**: Optional via "Sync to MediaCMS" checkbox (default: checked)
42
+ - **Enrichment State**: Local catalog only (not a MediaCMS concern)
43
+ - **Re-enrich trigger**: Optional via "Re-enrich after save" checkbox — runs the standard `catalog_enrich` job for this single item
44
+
45
+ ### Audit Trail
46
+
47
+ New `item_edit_log` table:
48
+ ```sql
49
+ CREATE TABLE item_edit_log (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ friendly_token TEXT NOT NULL,
52
+ username TEXT NOT NULL,
53
+ field_name TEXT NOT NULL,
54
+ old_value TEXT,
55
+ new_value TEXT,
56
+ edited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
57
+ );
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Implementation Plan
63
+
64
+ ### Files to Create
65
+
66
+ 1. **`kryten_webqueue/templates/catalog/edit_item_modal.html`**
67
+ - Modal dialog with form for editing
68
+ - Pre-populated with current data
69
+ - Two sections: Core Metadata + Advanced (enrichment state)
70
+
71
+ ### Files to Modify
72
+
73
+ 1. **Backend**
74
+ - `kryten_webqueue/routes/admin_catalog.py`
75
+ - Add `PATCH /admin/catalog/{friendly_token}/edit`
76
+ - Validate, update catalog + enrichment state
77
+ - Log changes to audit trail
78
+ - Optionally sync to MediaCMS
79
+ - Optionally trigger re-enrichment job
80
+
81
+ - `kryten_webqueue/catalog/db/_connection.py`
82
+ - Add migration v21: `item_edit_log` table
83
+
84
+ - `kryten_webqueue/catalog/db/_enrichment.py`
85
+ - Add `update_enrichment_state(token, fields)` method
86
+
87
+ - `kryten_webqueue/catalog/db/_catalog.py`
88
+ - Add `log_item_edit(token, username, field, old, new)` method
89
+ - Add `get_item_edit_history(token, limit)` method
90
+
91
+ - `kryten_webqueue/catalog/mediacms.py`
92
+ - Add `update_item(token, title, description)` method
93
+
94
+ 2. **Frontend**
95
+ - `kryten_webqueue/templates/catalog/browse.html`
96
+ - Add "Edit" button in admin actions
97
+ - Include modal template
98
+
99
+ - `kryten_webqueue/templates/catalog/item_detail.html`
100
+ - Add "Edit" button in admin actions
101
+ - Include modal template
102
+
103
+ ---
104
+
105
+ ## API Specification
106
+
107
+ ### `PATCH /admin/catalog/{friendly_token}/edit`
108
+
109
+ **Auth**: Admin only (rank >= 3)
110
+
111
+ **Request Body**:
112
+ ```json
113
+ {
114
+ "title": "New Title (1980)",
115
+ "description": "Updated description...",
116
+ "duration_sec": 5400,
117
+ "content_type": "movie",
118
+ "lookup_title": "New Title",
119
+ "lookup_year": "1980",
120
+ "hosted_show": null,
121
+ "tv_show": null,
122
+ "tv_season": null,
123
+ "tv_episode_num": null,
124
+ "sync_to_cms": true,
125
+ "re_enrich": false
126
+ }
127
+ ```
128
+
129
+ **Response** (success):
130
+ ```json
131
+ {
132
+ "success": true,
133
+ "changes_logged": 3,
134
+ "cms_synced": true,
135
+ "enrich_job_id": null
136
+ }
137
+ ```
138
+
139
+ **Response** (with re-enrich):
140
+ ```json
141
+ {
142
+ "success": true,
143
+ "changes_logged": 2,
144
+ "cms_synced": false,
145
+ "enrich_job_id": 42
146
+ }
147
+ ```
148
+
149
+ ---
150
+
151
+ ## UI Design
152
+
153
+ ### Modal Layout
154
+
155
+ ```
156
+ ┌─────────────────────────────────────────────────┐
157
+ │ Edit Item ✕ │
158
+ ├─────────────────────────────────────────────────┤
159
+ │ │
160
+ │ Core Metadata │
161
+ │ ┌───────────────────────────────────────────┐ │
162
+ │ │ Title: [New Title (1980) ]│ │
163
+ │ └───────────────────────────────────────────┘ │
164
+ │ ┌───────────────────────────────────────────┐ │
165
+ │ │ Description: │ │
166
+ │ │ [Multi-line textarea... ]│ │
167
+ │ │ [ ]│ │
168
+ │ └───────────────────────────────────────────┘ │
169
+ │ Duration (sec): [5400 ] │
170
+ │ │
171
+ │ ────────────────────────────────────────── │
172
+ │ │
173
+ │ Advanced (Enrichment State) │
174
+ │ Content Type: [movie ▼] │
175
+ │ Lookup Title: [New Title ] │
176
+ │ Lookup Year: [1980 ] │
177
+ │ Hosted Show: [ ] (e.g., Svengoolie) │
178
+ │ │
179
+ │ TV Episode (if applicable): │
180
+ │ Show: [ ] Season: [ ] Episode: [ ]│
181
+ │ │
182
+ │ ────────────────────────────────────────── │
183
+ │ │
184
+ │ ☑ Sync title/description to MediaCMS │
185
+ │ ☐ Re-enrich after save (runs classify, title, │
186
+ │ meta, art, tags for this item) │
187
+ │ │
188
+ │ [ Save ] [ Cancel ] │
189
+ │ │
190
+ └─────────────────────────────────────────────────┘
191
+ ```
192
+
193
+ ---
194
+
195
+ ## Security
196
+
197
+ - **Authorization**: All edits require rank >= 3 (admin)
198
+ - **Input Validation**:
199
+ - Title: 1-500 chars, required
200
+ - Description: max 5000 chars
201
+ - Duration: positive integer or null
202
+ - Content type: enum validation
203
+ - Years: 4-digit integers or null
204
+ - **SQL Injection**: Parameterized queries throughout
205
+ - **Audit Trail**: Every field change logged with username
206
+ - **CSRF**: Session cookie + same-origin credentials
207
+
208
+ ---
209
+
210
+ ## Testing Strategy
211
+
212
+ ### Unit Tests
213
+
214
+ - `test_edit_item_core_metadata` — edit title/description/duration
215
+ - `test_edit_item_enrichment_state` — edit content_type/lookup fields
216
+ - `test_edit_item_audit_log` — verify changes are logged
217
+ - `test_edit_item_cms_sync` — verify MediaCMS update
218
+ - `test_edit_item_re_enrich` — verify job triggered
219
+ - `test_edit_item_unauthorized` — 403 for non-admin
220
+ - `test_edit_item_validation` — 400 for invalid data
221
+
222
+ ### Manual Testing
223
+
224
+ - [ ] Edit button visible to admins only
225
+ - [ ] Modal opens with pre-filled data
226
+ - [ ] Core metadata edits save and reflect
227
+ - [ ] Enrichment state edits save and reflect
228
+ - [ ] CMS sync checkbox works
229
+ - [ ] Re-enrich checkbox triggers job
230
+ - [ ] Audit log records all changes
231
+ - [ ] Toast notifications work
232
+ - [ ] Form validation prevents bad data
233
+
234
+ ---
235
+
236
+ ## Rollout
237
+
238
+ 1. Implement all changes on feature branch
239
+ 2. Run full test suite (black, ruff, mypy, pytest)
240
+ 3. Manual QA on local dev instance
241
+ 4. Update version in `pyproject.toml` → `0.37.0`
242
+ 5. Update `CHANGELOG.md`
243
+ 6. Commit, tag `v0.37.0`, push to GitHub
244
+ 7. Publish to PyPI (automated via GitHub)
245
+ 8. **DO NOT DEPLOY** — long-running job in progress
246
+
247
+ ---
248
+
249
+ ## Future Enhancements (V2)
250
+
251
+ - Edit history viewer (show all past edits for an item)
252
+ - Bulk edit mode (edit multiple items at once)
253
+ - Tag/category editing (requires CMS bulk API improvements)
254
+ - Revert to previous version (undo an edit)
255
+ - Export edit log as CSV
256
+
257
+ ---
258
+
259
+ ## Open Questions (Resolved)
260
+
261
+ 1. ✅ Include enrichment state in V1? → **Yes**
262
+ 2. ✅ Include audit logging in V1? → **Yes**
263
+ 3. ✅ How should re-enrich work? → **Simple: trigger standard job for single item**
264
+ 4. ✅ Include tag editing? → **No, defer to CMS side**
@@ -793,21 +793,26 @@ class _CatalogMixin:
793
793
 
794
794
  async def update_catalog(self, friendly_token: str, row: dict):
795
795
  """Update catalog item with partial or full field set.
796
-
796
+
797
797
  Only fields present in *row* are updated. Always adds friendly_token
798
798
  and updates updated_at to current time when any field changes.
799
799
  """
800
800
  if not row:
801
801
  return
802
-
802
+
803
803
  # Build SET clause from provided fields
804
804
  allowed = {
805
- "title", "description", "duration_sec", "manifest_url",
806
- "thumbnail_url", "added_at", "synced_at"
805
+ "title",
806
+ "description",
807
+ "duration_sec",
808
+ "manifest_url",
809
+ "thumbnail_url",
810
+ "added_at",
811
+ "synced_at",
807
812
  }
808
813
  updates = []
809
814
  params = {"friendly_token": friendly_token}
810
-
815
+
811
816
  for key in row:
812
817
  if key in allowed:
813
818
  # added_at uses COALESCE to preserve existing if None
@@ -816,20 +821,21 @@ class _CatalogMixin:
816
821
  else:
817
822
  updates.append(f"{key}=:{key}")
818
823
  params[key] = row[key]
819
-
824
+
820
825
  # Always update updated_at when modifying
821
826
  updates.append("updated_at=:updated_at")
822
827
  params["updated_at"] = row.get("synced_at") or params.get("synced_at")
823
828
  if not params["updated_at"]:
824
829
  from datetime import datetime, UTC
830
+
825
831
  params["updated_at"] = datetime.now(UTC).isoformat()
826
-
832
+
827
833
  if not updates:
828
834
  return
829
-
835
+
830
836
  sql = f"UPDATE catalog SET {', '.join(updates)} WHERE friendly_token=:friendly_token"
831
837
  await self._db.execute(sql, params)
832
-
838
+
833
839
  # Rebuild FTS for this row if title or description changed
834
840
  if "title" in row or "description" in row:
835
841
  await self._db.execute(
@@ -850,10 +856,10 @@ class _CatalogMixin:
850
856
 
851
857
  async def delete_stale_catalog_items(self, sync_started_at: str) -> int:
852
858
  """Delete catalog items that weren't updated during the sync.
853
-
859
+
854
860
  Items with synced_at older than sync_started_at are no longer present
855
861
  in MediaCMS and should be removed. Returns count of deleted items.
856
-
862
+
857
863
  Foreign key CASCADE will automatically remove related entries from:
858
864
  - catalog_fts
859
865
  - catalog_categories
@@ -866,23 +872,23 @@ class _CatalogMixin:
866
872
  [sync_started_at],
867
873
  )
868
874
  tokens = [row[0] for row in await cursor.fetchall()]
869
-
875
+
870
876
  if not tokens:
871
877
  return 0
872
-
878
+
873
879
  # Delete from FTS first (not automatically cascaded)
874
880
  placeholders = ",".join("?" * len(tokens))
875
881
  await self._db.execute(
876
882
  f"DELETE FROM catalog_fts WHERE friendly_token IN ({placeholders})",
877
883
  tokens,
878
884
  )
879
-
885
+
880
886
  # Delete from catalog (cascades to join tables via ON DELETE CASCADE)
881
887
  await self._db.execute(
882
888
  f"DELETE FROM catalog WHERE friendly_token IN ({placeholders})",
883
889
  tokens,
884
890
  )
885
-
891
+
886
892
  await self._db.commit()
887
893
  return len(tokens)
888
894
 
@@ -1049,6 +1055,37 @@ class _CatalogMixin:
1049
1055
  return True
1050
1056
  return False
1051
1057
 
1058
+ # --- Item edit audit log -----------------------------------------------
1059
+
1060
+ async def log_item_edit(
1061
+ self,
1062
+ friendly_token: str,
1063
+ username: str,
1064
+ field_name: str,
1065
+ old_value: str | int | None,
1066
+ new_value: str | int | None,
1067
+ ) -> None:
1068
+ """Log a manual admin edit to the audit trail."""
1069
+ # Convert to strings for storage
1070
+ old_str = str(old_value) if old_value is not None else None
1071
+ new_str = str(new_value) if new_value is not None else None
1072
+
1073
+ await self._execute(
1074
+ "INSERT INTO item_edit_log (friendly_token, username, field_name, old_value, new_value) "
1075
+ "VALUES (?, ?, ?, ?, ?)",
1076
+ [friendly_token, username, field_name, old_str, new_str],
1077
+ )
1078
+
1079
+ async def get_item_edit_history(
1080
+ self, friendly_token: str, limit: int = 50
1081
+ ) -> list[dict]:
1082
+ """Retrieve edit history for an item, most recent first."""
1083
+ return await self._fetch_all(
1084
+ "SELECT * FROM item_edit_log WHERE friendly_token = ? "
1085
+ "ORDER BY edited_at DESC LIMIT ?",
1086
+ [friendly_token, limit],
1087
+ )
1088
+
1052
1089
  async def cleanup_expired_otps(self):
1053
1090
  await self._execute(
1054
1091
  "DELETE FROM otps WHERE expires_at < datetime('now') OR used=1"
@@ -399,6 +399,19 @@ MIGRATIONS = [
399
399
  """
400
400
  ALTER TABLE item_enrichment_state ADD COLUMN last_categories_at TEXT;
401
401
  """,
402
+ # v21: Item edit audit log (track manual admin edits)
403
+ """
404
+ CREATE TABLE IF NOT EXISTS item_edit_log (
405
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
406
+ friendly_token TEXT NOT NULL,
407
+ username TEXT NOT NULL,
408
+ field_name TEXT NOT NULL,
409
+ old_value TEXT,
410
+ new_value TEXT,
411
+ edited_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
412
+ );
413
+ CREATE INDEX IF NOT EXISTS idx_item_edit_log_token ON item_edit_log(friendly_token, edited_at DESC);
414
+ """,
402
415
  ]
403
416
 
404
417
 
@@ -55,6 +55,33 @@ class _EnrichmentMixin:
55
55
  [*safe.values(), token],
56
56
  )
57
57
 
58
+ async def update_enrichment_state(self, token: str, fields: dict) -> None:
59
+ """Update specific enrichment state fields for an item.
60
+
61
+ Unlike save_enrichment_state (which is for the enrichment pipeline),
62
+ this is for manual admin edits and only accepts a subset of fields.
63
+ """
64
+ if not fields:
65
+ return
66
+ allowed = {
67
+ "content_type",
68
+ "hosted_show",
69
+ "lookup_title",
70
+ "lookup_year",
71
+ "tv_show",
72
+ "tv_season",
73
+ "tv_episode_num",
74
+ }
75
+ safe = {k: v for k, v in fields.items() if k in allowed}
76
+ if not safe:
77
+ return
78
+ await self.ensure_enrichment_state(token)
79
+ sets = ", ".join(f"{k} = ?" for k in safe)
80
+ await self._execute(
81
+ f"UPDATE item_enrichment_state SET {sets} WHERE friendly_token = ?",
82
+ [*safe.values(), token],
83
+ )
84
+
58
85
  async def get_catalog_for_enrichment(
59
86
  self,
60
87
  *,
@@ -113,10 +140,13 @@ class _EnrichmentMixin:
113
140
  return json.loads(raw)
114
141
  except (ValueError, TypeError) as exc:
115
142
  import logging
143
+
116
144
  logger = logging.getLogger(__name__)
117
145
  token = (row or {}).get("friendly_token", "?")
118
146
  logger.warning(
119
147
  "Failed to parse meta_json for %s: %s. Raw value (first 200 chars): %r",
120
- token, exc, raw[:200] if isinstance(raw, str) else raw
148
+ token,
149
+ exc,
150
+ raw[:200] if isinstance(raw, str) else raw,
121
151
  )
122
152
  return None
@@ -173,7 +173,9 @@ class TMDBProvider:
173
173
  first_air = tv_show.get("first_air_date", "")
174
174
  meta.year = first_air[:4] if first_air else None
175
175
  if tv_show.get("poster_path"):
176
- meta.poster_url = f"https://image.tmdb.org/t/p/w780{tv_show['poster_path']}"
176
+ meta.poster_url = (
177
+ f"https://image.tmdb.org/t/p/w780{tv_show['poster_path']}"
178
+ )
177
179
  return meta
178
180
  except (httpx.HTTPError, json.JSONDecodeError, ValueError) as exc:
179
181
  logger.debug("TMDB TV show search error for %r: %s", show, exc)
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import asyncio
6
5
  import hashlib
7
6
  import io
8
7
  import logging
@@ -115,12 +114,12 @@ class ArtStep:
115
114
 
116
115
  async def _resolve_poster(self, cls: ItemClassification) -> str | None:
117
116
  """Query TMDB then OMDB using lookup_title (the key fix for hosted movies).
118
-
117
+
119
118
  For TV episodes, search for the show poster (not episode-specific art).
120
119
  """
121
120
  if not cls.lookup_title:
122
121
  return None
123
-
122
+
124
123
  # TV episodes: get the show poster
125
124
  if cls.content_type == "tv_episode":
126
125
  meta = await self._tmdb.search_tv_show(cls.lookup_title)
@@ -128,7 +127,7 @@ class ArtStep:
128
127
  return meta.poster_url
129
128
  meta = await self._omdb.search_tv_show(cls.lookup_title)
130
129
  return meta.poster_url
131
-
130
+
132
131
  # Movies and other content: search as movie
133
132
  meta = await self._tmdb.search_movie(cls.lookup_title, cls.lookup_year)
134
133
  if meta.poster_url:
@@ -164,7 +164,9 @@ class MetaStep:
164
164
  e,
165
165
  meta_json[:300],
166
166
  )
167
- result.record_error(f"{cls.friendly_token}: malformed meta_json ({e})")
167
+ result.record_error(
168
+ f"{cls.friendly_token}: malformed meta_json ({e})"
169
+ )
168
170
  continue
169
171
 
170
172
  logger.info(
@@ -122,3 +122,44 @@ class MediaCMSClient:
122
122
  )
123
123
  await self._restore_owner(client, friendly_token, owner)
124
124
  return ok
125
+
126
+ async def update_item(
127
+ self,
128
+ friendly_token: str,
129
+ *,
130
+ title: str | None = None,
131
+ description: str | None = None,
132
+ ) -> bool:
133
+ """Update item title and/or description in MediaCMS.
134
+
135
+ Returns True if the remote write succeeded.
136
+ """
137
+ if not title and not description:
138
+ return True
139
+
140
+ async with httpx.AsyncClient(headers=self._headers, timeout=_TIMEOUT) as client:
141
+ media = await self._get_media(client, friendly_token)
142
+ if media is None:
143
+ return False
144
+
145
+ owner = media.get("user")
146
+ payload = {}
147
+ if title is not None:
148
+ payload["title"] = title
149
+ if description is not None:
150
+ payload["description"] = description
151
+
152
+ resp = await client.put(
153
+ f"{self._base}/api/v1/media/{friendly_token}",
154
+ data=payload,
155
+ )
156
+ ok = resp.status_code in (200, 201)
157
+ if not ok:
158
+ logger.warning(
159
+ "MediaCMS update for %s failed: HTTP %s — %s",
160
+ friendly_token,
161
+ resp.status_code,
162
+ resp.text[:200],
163
+ )
164
+ await self._restore_owner(client, friendly_token, owner)
165
+ return ok