kryten-webqueue 0.34.16__tar.gz → 0.35.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 (162) hide show
  1. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/CHANGELOG.md +36 -0
  2. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/app.py +9 -1
  4. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/__init__.py +10 -0
  5. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_connection.py +22 -0
  6. kryten_webqueue-0.35.0/kryten_webqueue/catalog/db/_enrichment.py +114 -0
  7. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/__init__.py +30 -0
  8. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/classify.py +343 -0
  9. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/normalise.py +105 -0
  10. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/pipeline.py +235 -0
  11. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/providers.py +401 -0
  12. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/report.py +44 -0
  13. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/__init__.py +8 -0
  14. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/art.py +172 -0
  15. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/categories.py +13 -0
  16. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/meta.py +262 -0
  17. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/sync.py +35 -0
  18. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/tags.py +142 -0
  19. kryten_webqueue-0.35.0/kryten_webqueue/catalog/enrichment/steps/title.py +126 -0
  20. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/manager.py +12 -2
  21. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/tasks.py +91 -17
  22. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_catalog.py +25 -0
  23. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/catalog/item_detail.html +18 -0
  24. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/pyproject.toml +1 -1
  25. kryten_webqueue-0.35.0/tests/test_classify.py +129 -0
  26. kryten_webqueue-0.35.0/tests/test_providers.py +120 -0
  27. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.github/workflows/gitleaks.yml +0 -0
  28. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.github/workflows/python-publish.yml +0 -0
  29. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.github/workflows/release.yml +0 -0
  30. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.gitignore +0 -0
  31. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.gitleaks.toml +0 -0
  32. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/.pre-commit-config.yaml +0 -0
  33. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/AGENT-WORKFLOW-GUIDE.md +0 -0
  34. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/README.md +0 -0
  35. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/config.example.json +0 -0
  36. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/deploy/kryten-webqueue.service +0 -0
  37. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/deploy/nginx-queue.conf +0 -0
  38. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  39. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_API_GATE.md +0 -0
  40. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_ECONOMY.md +0 -0
  41. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  42. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/IMPL_ROBOT.md +0 -0
  43. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  44. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  45. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/Log for incorrect promo insertion problem.txt +0 -0
  46. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  47. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/PRE_PLAN_GAPS.md +0 -0
  48. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/PRODUCT_PLAN.md +0 -0
  49. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  50. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/SPEC_EMOTE_REHOST.md +0 -0
  51. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  52. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/docs/UX_POLISH_PLAN.md +0 -0
  53. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/__init__.py +0 -0
  54. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/__main__.py +0 -0
  55. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  56. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/api_gate/client.py +0 -0
  57. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/__init__.py +0 -0
  58. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/otp.py +0 -0
  59. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  60. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/auth/session.py +0 -0
  61. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/__init__.py +0 -0
  62. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_catalog.py +0 -0
  63. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  64. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_people.py +0 -0
  65. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  66. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_queue.py +0 -0
  67. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  68. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/images.py +0 -0
  69. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  70. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/catalog/sync.py +0 -0
  71. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/config.py +0 -0
  72. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/__init__.py +0 -0
  73. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  74. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  75. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  76. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  77. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  78. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  79. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  80. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  81. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  82. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  83. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/__init__.py +0 -0
  84. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  85. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  86. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  87. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/logging_config.py +0 -0
  88. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/__init__.py +0 -0
  89. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  90. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/fire.py +0 -0
  91. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/importer.py +0 -0
  92. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/ordering.py +0 -0
  93. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  94. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/promos/__init__.py +0 -0
  95. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/promos/director.py +0 -0
  96. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/__init__.py +0 -0
  97. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/completion.py +0 -0
  98. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/ordering.py +0 -0
  99. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/poller.py +0 -0
  100. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/presence.py +0 -0
  101. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/race_poller.py +0 -0
  102. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/queue/shadow.py +0 -0
  103. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/__init__.py +0 -0
  104. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_feedback.py +0 -0
  105. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  106. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  107. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_moderation.py +0 -0
  108. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  109. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  110. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  111. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  112. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/auth.py +0 -0
  113. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/catalog.py +0 -0
  114. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/feedback.py +0 -0
  115. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/pages.py +0 -0
  116. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/queue.py +0 -0
  117. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/routes/user.py +0 -0
  118. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/css/main.css +0 -0
  119. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  120. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-promos.js +0 -0
  121. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  122. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  123. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/admin.js +0 -0
  124. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/static/js/main.js +0 -0
  125. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/index.html +0 -0
  126. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  127. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  128. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  129. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  130. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/auth/login.html +0 -0
  131. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/base.html +0 -0
  132. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  133. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  134. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/feedback/index.html +0 -0
  135. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/queue/index.html +0 -0
  136. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/race.html +0 -0
  137. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  138. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/templates/user/my_list.html +0 -0
  139. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/ws/__init__.py +0 -0
  140. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/ws/handler.py +0 -0
  141. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/kryten_webqueue/ws/manager.py +0 -0
  142. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/__init__.py +0 -0
  143. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_config_persistence.py +0 -0
  144. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_feedback.py +0 -0
  145. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_fetchurls_sharepoint.py +0 -0
  146. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_people_facets.py +0 -0
  147. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase1.py +0 -0
  148. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase2_jobs.py +0 -0
  149. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase3_jobs.py +0 -0
  150. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_phase4_live_fixes.py +0 -0
  151. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_playlist_import.py +0 -0
  152. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_presence_refund.py +0 -0
  153. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_promo_director.py +0 -0
  154. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_promo_pool_exclusion.py +0 -0
  155. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_queue_announce.py +0 -0
  156. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_race_poller.py +0 -0
  157. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_recently_played_hide.py +0 -0
  158. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_save_results_to_playlist.py +0 -0
  159. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_schedule_lock.py +0 -0
  160. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_search_facets.py +0 -0
  161. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_title_normalization.py +0 -0
  162. {kryten_webqueue-0.34.16 → kryten_webqueue-0.35.0}/tests/test_watchlist.py +0 -0
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.35.0] - 2026-08-13
6
+
7
+ ### Added
8
+
9
+ - **Unified Catalog Enrichment Pipeline** (`kryten_webqueue/catalog/enrichment/`).
10
+ Consolidates `enrichtitles`, `enrichmeta`, `enrichtv`, and the sync-time art
11
+ resolver into a single `CatalogEnrichmentPipeline` with composable, named steps.
12
+
13
+ **Steps:** `sync` → `classify` → `title` → `meta` → `art` → `tags` →
14
+ `categories` (stub, deferred).
15
+
16
+ **Key fixes:**
17
+ - Art step uses `classification.lookup_title` (the extracted film title, e.g.
18
+ `"Phantom of the Mall (1989)"`) instead of the raw CMS title (`"Phantom of
19
+ the Mall Svengoolie"`). Svengoolie, MonsterVision, Last Drive-In, Rifftrax,
20
+ and MST3K items can now resolve TMDB posters correctly.
21
+ - Single `HOSTED_SHOW_REGISTRY` replaces three scattered `_HOSTED_PATTERNS`
22
+ lists; hosted-show detection is now authoritative in `classify.py`.
23
+ - Single `normalise.py` replaces five separate title-cleaning implementations.
24
+ - `meta_json` cached in `item_enrichment_state` carries `poster_url` so art
25
+ and meta share one TMDB call (no double lookup).
26
+ - Meta step populates `catalog_people` / `catalog_studios` tables from TMDB
27
+ credits automatically.
28
+
29
+ **New job:** `catalog_enrich` — full or partial pipeline; params: `steps`,
30
+ `tokens`, `force`, `dry_run`, `limit`, `min_score`.
31
+
32
+ **Backward-compat wrappers:** `enrichtitles`, `enrichmeta`, `enrichtv` now
33
+ delegate to the new pipeline (`steps=classify,title` / `classify,meta`).
34
+
35
+ **Re-enrich button** on the item detail admin panel — force-runs all
36
+ enrichment steps for a single item without touching the cron schedule.
37
+
38
+ **DB migration v19:** `item_enrichment_state` table — per-item cache of
39
+ classification, TMDB/OMDB result, and per-step timestamps.
40
+
5
41
  ## [0.34.16] - 2026-08-13
6
42
 
7
43
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.34.16
3
+ Version: 0.35.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
@@ -92,7 +92,7 @@ async def lifespan(app: FastAPI):
92
92
  app.state.catalog_sync = catalog_sync
93
93
 
94
94
  # Generic background job runner (records run history to job_runs)
95
- job_manager = JobManager(db, api_gate=api_gate, config=config)
95
+ job_manager = JobManager(db, api_gate=api_gate, config=config, cover_art=cover_art)
96
96
 
97
97
  async def _catalog_sync_job(params, ctx):
98
98
  # catalog_sync registers with no schema; params/ctx are ignored. The
@@ -112,9 +112,11 @@ async def lifespan(app: FastAPI):
112
112
  enrichtitles_job,
113
113
  enrichmeta_job,
114
114
  enrichtv_job,
115
+ catalog_enrich_job,
115
116
  fetch_job,
116
117
  fetchurls_job,
117
118
  motd_posters_job,
119
+ CATALOG_ENRICH_SCHEMA,
118
120
  ENRICHTITLES_SCHEMA,
119
121
  ENRICHMETA_SCHEMA,
120
122
  ENRICHTV_SCHEMA,
@@ -123,6 +125,12 @@ async def lifespan(app: FastAPI):
123
125
  MOTD_POSTERS_SCHEMA,
124
126
  )
125
127
 
128
+ job_manager.register(
129
+ "catalog_enrich",
130
+ catalog_enrich_job,
131
+ label="Catalog Enrichment Pipeline",
132
+ schema=CATALOG_ENRICH_SCHEMA,
133
+ )
126
134
  job_manager.register(
127
135
  "enrichtitles",
128
136
  enrichtitles_job,
@@ -10,6 +10,7 @@ from ._queue import _QueueMixin
10
10
  from ._feedback import _FeedbackMixin
11
11
  from ._watchlist import _WatchlistMixin
12
12
  from ._people import _PeopleMixin
13
+ from ._enrichment import _EnrichmentMixin
13
14
 
14
15
 
15
16
  class Database(
@@ -19,6 +20,7 @@ class Database(
19
20
  _FeedbackMixin,
20
21
  _WatchlistMixin,
21
22
  _PeopleMixin,
23
+ _EnrichmentMixin,
22
24
  _DBBase,
23
25
  ):
24
26
  """Async SQLite database wrapper."""
@@ -30,3 +32,11 @@ __all__ = [
30
32
  "HIDDEN_CATEGORY_NAMES",
31
33
  "HIDDEN_TAG_NAMES",
32
34
  ]
35
+
36
+
37
+ __all__ = [
38
+ "Database",
39
+ "HIDDEN_ITEM_TAG",
40
+ "HIDDEN_CATEGORY_NAMES",
41
+ "HIDDEN_TAG_NAMES",
42
+ ]
@@ -373,6 +373,28 @@ MIGRATIONS = [
373
373
  );
374
374
  CREATE INDEX IF NOT EXISTS idx_catalog_studios_token ON catalog_studios(friendly_token);
375
375
  """,
376
+ # v19: Per-item enrichment pipeline state cache.
377
+ """
378
+ CREATE TABLE IF NOT EXISTS item_enrichment_state (
379
+ friendly_token TEXT PRIMARY KEY,
380
+ content_type TEXT,
381
+ hosted_show TEXT,
382
+ lookup_title TEXT,
383
+ lookup_year TEXT,
384
+ tv_show TEXT,
385
+ tv_season INTEGER,
386
+ tv_episode_num INTEGER,
387
+ description_score INTEGER,
388
+ tmdb_id TEXT,
389
+ imdb_id TEXT,
390
+ meta_json TEXT,
391
+ last_classify_at TEXT,
392
+ last_title_at TEXT,
393
+ last_meta_at TEXT,
394
+ last_art_at TEXT,
395
+ last_tags_at TEXT
396
+ );
397
+ """,
376
398
  ]
377
399
 
378
400
 
@@ -0,0 +1,114 @@
1
+ """Enrichment pipeline state — per-item caching of classification + metadata."""
2
+
3
+ import json
4
+
5
+
6
+ class _EnrichmentMixin:
7
+ """item_enrichment_state CRUD methods."""
8
+
9
+ async def ensure_enrichment_state(self, token: str) -> None:
10
+ """Create a skeleton enrichment-state row if none exists."""
11
+ await self._db.execute(
12
+ "INSERT OR IGNORE INTO item_enrichment_state (friendly_token) VALUES (?)",
13
+ [token],
14
+ )
15
+ await self._db.commit()
16
+
17
+ async def get_enrichment_state(self, token: str) -> dict | None:
18
+ return await self._fetch_one(
19
+ "SELECT * FROM item_enrichment_state WHERE friendly_token = ?", [token]
20
+ )
21
+
22
+ async def save_enrichment_state(self, token: str, **fields) -> None:
23
+ """Upsert enrichment state fields for a token.
24
+
25
+ Safely handles any subset of columns; unknown keys are silently ignored.
26
+ """
27
+ if not fields:
28
+ return
29
+ allowed = {
30
+ "content_type",
31
+ "hosted_show",
32
+ "lookup_title",
33
+ "lookup_year",
34
+ "tv_show",
35
+ "tv_season",
36
+ "tv_episode_num",
37
+ "description_score",
38
+ "tmdb_id",
39
+ "imdb_id",
40
+ "meta_json",
41
+ "last_classify_at",
42
+ "last_title_at",
43
+ "last_meta_at",
44
+ "last_art_at",
45
+ "last_tags_at",
46
+ }
47
+ safe = {k: v for k, v in fields.items() if k in allowed}
48
+ if not safe:
49
+ return
50
+ await self.ensure_enrichment_state(token)
51
+ sets = ", ".join(f"{k} = ?" for k in safe)
52
+ await self._execute(
53
+ f"UPDATE item_enrichment_state SET {sets} WHERE friendly_token = ?",
54
+ [*safe.values(), token],
55
+ )
56
+
57
+ async def get_catalog_for_enrichment(
58
+ self,
59
+ *,
60
+ step: str,
61
+ tokens: list[str] | None = None,
62
+ force: bool = False,
63
+ limit: int | None = None,
64
+ ) -> list[dict]:
65
+ """Return catalog rows joined with enrichment state, filtered to items that
66
+ need the given step.
67
+
68
+ In normal mode an item needs a step when ``last_{step}_at IS NULL``.
69
+ ``force=True`` returns all items regardless.
70
+ """
71
+ step_col = f"last_{step}_at"
72
+ sql = """
73
+ SELECT c.friendly_token, c.title, c.duration_sec,
74
+ c.cover_art_path, c.cover_art_source, c.thumbnail_url,
75
+ c.description, c.manifest_url,
76
+ e.content_type, e.hosted_show, e.lookup_title, e.lookup_year,
77
+ e.tv_show, e.tv_season, e.tv_episode_num,
78
+ e.description_score, e.tmdb_id, e.imdb_id, e.meta_json,
79
+ e.last_classify_at, e.last_title_at, e.last_meta_at,
80
+ e.last_art_at, e.last_tags_at
81
+ FROM catalog c
82
+ LEFT JOIN item_enrichment_state e ON e.friendly_token = c.friendly_token
83
+ """
84
+ params: list = []
85
+ conditions: list[str] = []
86
+
87
+ if tokens:
88
+ ph = ",".join("?" * len(tokens))
89
+ conditions.append(f"c.friendly_token IN ({ph})")
90
+ params.extend(tokens)
91
+
92
+ if not force:
93
+ conditions.append(f"(e.{step_col} IS NULL)")
94
+
95
+ if conditions:
96
+ sql += " WHERE " + " AND ".join(conditions)
97
+
98
+ sql += " ORDER BY c.title"
99
+
100
+ if limit:
101
+ sql += " LIMIT ?"
102
+ params.append(limit)
103
+
104
+ return await self._fetch_all(sql, params)
105
+
106
+ def parse_meta_json(self, row: dict) -> dict | None:
107
+ """Deserialise the cached meta_json field from an enrichment row."""
108
+ raw = (row or {}).get("meta_json")
109
+ if not raw:
110
+ return None
111
+ try:
112
+ return json.loads(raw)
113
+ except (ValueError, TypeError):
114
+ return None
@@ -0,0 +1,30 @@
1
+ from .pipeline import CatalogEnrichmentPipeline, ALL_STEPS
2
+ from .classify import (
3
+ classify_item,
4
+ ItemClassification,
5
+ HostedInfo,
6
+ HostedShowEntry,
7
+ HOSTED_SHOW_REGISTRY,
8
+ )
9
+ from .providers import TMDBProvider, OMDBProvider, MovieMetadata, merge_metadata
10
+ from .normalise import normalize_and_clean, normalize_leading_year, strip_extension
11
+ from .report import EnrichmentReport, StepResult
12
+
13
+ __all__ = [
14
+ "CatalogEnrichmentPipeline",
15
+ "ALL_STEPS",
16
+ "classify_item",
17
+ "ItemClassification",
18
+ "HostedInfo",
19
+ "HostedShowEntry",
20
+ "HOSTED_SHOW_REGISTRY",
21
+ "TMDBProvider",
22
+ "OMDBProvider",
23
+ "MovieMetadata",
24
+ "merge_metadata",
25
+ "normalize_and_clean",
26
+ "normalize_leading_year",
27
+ "strip_extension",
28
+ "EnrichmentReport",
29
+ "StepResult",
30
+ ]
@@ -0,0 +1,343 @@
1
+ """Item classification — hosted-show detection, content-type determination.
2
+
3
+ Single authoritative source for HOSTED_SHOW_REGISTRY and all classification
4
+ logic, replacing scattered _HOSTED_PATTERNS lists in enrichmeta.py and images.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+
12
+ from .normalise import normalize_and_clean, strip_extension
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Hosted show registry
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ @dataclass
20
+ class HostedShowEntry:
21
+ pattern: re.Pattern
22
+ show_name: str
23
+ content_type: str # "hosted_movie" | "riffed_movie"
24
+ title_treatment: str # "keep" | "reformat"
25
+ reformat_template: str | None
26
+ cms_tag: str
27
+
28
+
29
+ HOSTED_SHOW_REGISTRY: list[HostedShowEntry] = [
30
+ HostedShowEntry(
31
+ re.compile(r"Joe\s*Bob'?s?\s+Drive[\s\-]*In\s+Theater", re.I),
32
+ "Joe Bob's Drive-In Theater",
33
+ "hosted_movie",
34
+ "keep",
35
+ None,
36
+ "joebobbriggs",
37
+ ),
38
+ HostedShowEntry(
39
+ re.compile(r"JBBTLDI|Joe\s*Bob\s+TLDI", re.I),
40
+ "The Last Drive-In with Joe Bob Briggs",
41
+ "hosted_movie",
42
+ "keep",
43
+ None,
44
+ "lastdrivein",
45
+ ),
46
+ HostedShowEntry(
47
+ re.compile(r"(?:The\s+)?Last\s+Drive[\s\-]*In", re.I),
48
+ "The Last Drive-In with Joe Bob Briggs",
49
+ "hosted_movie",
50
+ "keep",
51
+ None,
52
+ "lastdrivein",
53
+ ),
54
+ HostedShowEntry(
55
+ re.compile(r"Monster\s*Vision", re.I),
56
+ "MonsterVision with Joe Bob Briggs",
57
+ "hosted_movie",
58
+ "keep",
59
+ None,
60
+ "monstervision",
61
+ ),
62
+ HostedShowEntry(
63
+ re.compile(r"Svengoolie", re.I),
64
+ "Svengoolie",
65
+ "hosted_movie",
66
+ "keep",
67
+ None,
68
+ "svengoolie",
69
+ ),
70
+ HostedShowEntry(
71
+ re.compile(r"Riff\s*Trax(?:\s+Live)?", re.I),
72
+ "RiffTrax",
73
+ "riffed_movie",
74
+ "reformat",
75
+ "RiffTrax Presents: {title} ({year})",
76
+ "rifftrax",
77
+ ),
78
+ HostedShowEntry(
79
+ re.compile(r"MST3K|Mystery\s*Science\s*Theater", re.I),
80
+ "Mystery Science Theater 3000",
81
+ "riffed_movie",
82
+ "keep",
83
+ None,
84
+ "mst3k",
85
+ ),
86
+ ]
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # TV episode detection
90
+ # ---------------------------------------------------------------------------
91
+
92
+ _TV_RE = re.compile(
93
+ r"(?:[Ss]\d{1,2}\s*[Ee]\d{1,2}"
94
+ r"|\b\d{1,2}[Xx]\d{2,3}\b"
95
+ r"|\b[Ss]eason\s*\d+"
96
+ r"|\b[Ee]pisode\s*\d+"
97
+ r"|\b[Ee][Pp]\.?\s*\d+"
98
+ r")"
99
+ )
100
+ _SXE_RE = re.compile(r"[Ss](\d{1,2})[Ee](\d{1,2})")
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Archive detection (broadcast dates, wrestling, etc.)
104
+ # ---------------------------------------------------------------------------
105
+
106
+ _ARCHIVE_RE = re.compile(
107
+ r"(?:\b(?:19|20)\d{2}-\d{2}-\d{2}\b" # ISO date
108
+ r"|\b\d{1,2}-\d{1,2}-(?:19|20)?\d{2}\b" # MM-DD-YY
109
+ r"|\bWWF\b|\bWCW\b|\bNWA\b|\bAWA\b" # wrestling promotions
110
+ r"|Complete\s+Broadcast"
111
+ r"|Original\s+Broadcast"
112
+ r")",
113
+ re.I,
114
+ )
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Hosted extraction helpers (adapted from enrichmeta.py detect_hosted)
118
+ # ---------------------------------------------------------------------------
119
+
120
+ _EXT_RE = re.compile(r"\.\w{2,5}$")
121
+ _YEAR_PAREN_RE = re.compile(r"\((\d{4})\)")
122
+ _YEAR_BRACKET_RE = re.compile(r"[\[\]{}]\s*(\d{4})\s*[\[\]{}]")
123
+ _YEAR_BARE_RE = re.compile(r"\b((?:19|20)\d{2})\b")
124
+ _DATE_RE = re.compile(
125
+ r"(?:(?:19|20)\d{2}-\d{1,2}-\d{1,2})|(?:\d{1,2}-\d{1,2}-(?:19|20)?\d{2})"
126
+ )
127
+ _EPISODE_CODE_RE = re.compile(r"[SW]\d+[eE]\d+", re.I)
128
+ _SHOW_YEAR_PREFIX_RE = re.compile(
129
+ r"^\s*\(\d{4}\)\s*S\d+-Wk\s*\d+\s*Film\s*\d+\s*[-\u2013\u2014]?\s*", re.I
130
+ )
131
+ _WEEK_PREFIX_RE = re.compile(
132
+ r"(?:S\d+-)?(?:Wk|Week)\s*\d+\s*[-\u2013\u2014]?\s*(?:(?:Film|Movie)\s*\d+\s*[-\u2013\u2014]?\s*)?",
133
+ re.I,
134
+ )
135
+ _LEADING_EPNUM_RE = re.compile(r"^\s*\d{1,2}\s*[-\u2013\u2014]?\s+")
136
+ _SCENE_HOSTED_RE = re.compile(
137
+ r"\[?(?:720p|480p|1080p|who(?:do)?dude\w*|CG|Hybrid)\]?", re.I
138
+ )
139
+
140
+
141
+ @dataclass
142
+ class HostedInfo:
143
+ show_name: str
144
+ movie_title: str
145
+ movie_year: str | None
146
+ cms_tag: str = ""
147
+
148
+
149
+ def _extract_hosted_title(raw: str, entry: HostedShowEntry) -> HostedInfo:
150
+ """Strip show name from raw title and extract the underlying movie title + year."""
151
+ title = _EXT_RE.sub("", raw.strip())
152
+
153
+ # remove the matched show pattern
154
+ m = entry.pattern.search(title)
155
+ if m:
156
+ title = title[: m.start()] + title[m.end() :]
157
+
158
+ # remove any remaining registry patterns (handles double-show mentions)
159
+ for e in HOSTED_SHOW_REGISTRY:
160
+ title = e.pattern.sub("", title)
161
+
162
+ title = _SCENE_HOSTED_RE.sub("", title)
163
+ title = title.replace("_", " ")
164
+ title = _DATE_RE.sub("", title)
165
+ title = _SHOW_YEAR_PREFIX_RE.sub("", title)
166
+ title = _EPISODE_CODE_RE.sub("", title)
167
+ title = _WEEK_PREFIX_RE.sub("", title)
168
+ title = re.sub(r"^[\s\-\u2013\u2014:,]+", "", title)
169
+ title = _LEADING_EPNUM_RE.sub("", title)
170
+ title = re.sub(r"^[\s\-\u2013\u2014:,]+", "", title)
171
+ title = _LEADING_EPNUM_RE.sub("", title)
172
+
173
+ year: str | None = None
174
+ m = _YEAR_PAREN_RE.search(title)
175
+ if m:
176
+ year = m.group(1)
177
+ title = title[: m.start()] + title[m.end() :]
178
+ else:
179
+ m = _YEAR_BRACKET_RE.search(title)
180
+ if m:
181
+ year = m.group(1)
182
+ title = title[: m.start()] + title[m.end() :]
183
+ else:
184
+ m = _YEAR_BARE_RE.search(title)
185
+ if m and 1920 <= int(m.group(1)) <= 2030:
186
+ year = m.group(1)
187
+ title = title[: m.start()] + title[m.end() :]
188
+
189
+ title = re.sub(r"\[\s*\]|\(\s*\)", "", title)
190
+ title = re.sub(r"^[\s\-\u2013\u2014:,]+", "", title)
191
+ title = re.sub(r"[\s\-\u2013\u2014:,]+$", "", title)
192
+ title = re.sub(r"\s*-\s*\d+$", "", title)
193
+ title = re.sub(r"\s+", " ", title).strip()
194
+
195
+ return HostedInfo(
196
+ show_name=entry.show_name,
197
+ movie_title=title or raw,
198
+ movie_year=year,
199
+ cms_tag=entry.cms_tag,
200
+ )
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # ItemClassification
205
+ # ---------------------------------------------------------------------------
206
+
207
+
208
+ @dataclass
209
+ class ItemClassification:
210
+ friendly_token: str
211
+ raw_title: str
212
+ content_type: (
213
+ str # movie | tv_episode | hosted_movie | riffed_movie | archive | unknown
214
+ )
215
+ hosted: HostedInfo | None
216
+ lookup_title: str # used for TMDB/OMDB queries and art resolution
217
+ lookup_year: str | None
218
+ genre_hints: list[str] = field(default_factory=list)
219
+ tv_show: str | None = None
220
+ tv_season: int | None = None
221
+ tv_episode: int | None = None
222
+ duration_sec: int = 0
223
+ description_score: int = 0
224
+ has_real_art: bool = False
225
+
226
+
227
+ # YouTube "Title | English Full Movie | Genre1 Genre2" pattern
228
+ _YT_FULL_MOVIE_RE = re.compile(
229
+ r"^(?P<title>.+?)\s*\|\s*(?:\w+\s+)?Full\s+Movie\s*(?:\|\s*(?P<genres>.+))?$", re.I
230
+ )
231
+
232
+
233
+ def classify_item(
234
+ friendly_token: str,
235
+ raw_title: str,
236
+ duration_sec: int,
237
+ *,
238
+ cover_art_source: str | None = None,
239
+ description: str | None = None,
240
+ description_score: int = 0,
241
+ ) -> ItemClassification:
242
+ """Classify a single catalog item. Pure function; no DB access."""
243
+ has_real_art = (cover_art_source or "") in ("tmdb", "omdb")
244
+
245
+ # 1. Hosted / riffed — check registry first
246
+ for entry in HOSTED_SHOW_REGISTRY:
247
+ if entry.pattern.search(raw_title):
248
+ info = _extract_hosted_title(raw_title, entry)
249
+ return ItemClassification(
250
+ friendly_token=friendly_token,
251
+ raw_title=raw_title,
252
+ content_type=entry.content_type,
253
+ hosted=info,
254
+ lookup_title=info.movie_title,
255
+ lookup_year=info.movie_year,
256
+ duration_sec=duration_sec,
257
+ description_score=description_score,
258
+ has_real_art=has_real_art,
259
+ )
260
+
261
+ # 2. TV episode
262
+ if _TV_RE.search(raw_title):
263
+ tv_show: str | None = None
264
+ tv_season: int | None = None
265
+ tv_ep: int | None = None
266
+ m = _SXE_RE.search(raw_title)
267
+ if m:
268
+ tv_season = int(m.group(1))
269
+ tv_ep = int(m.group(2))
270
+ tv_show = raw_title[: m.start()].strip(" -–—")
271
+ return ItemClassification(
272
+ friendly_token=friendly_token,
273
+ raw_title=raw_title,
274
+ content_type="tv_episode",
275
+ hosted=None,
276
+ lookup_title=tv_show or raw_title,
277
+ lookup_year=None,
278
+ tv_show=tv_show,
279
+ tv_season=tv_season,
280
+ tv_episode=tv_ep,
281
+ duration_sec=duration_sec,
282
+ description_score=description_score,
283
+ has_real_art=has_real_art,
284
+ )
285
+
286
+ # 3. Archive (broadcast recordings, wrestling)
287
+ if duration_sec > 1800 and _ARCHIVE_RE.search(raw_title):
288
+ return ItemClassification(
289
+ friendly_token=friendly_token,
290
+ raw_title=raw_title,
291
+ content_type="archive",
292
+ hosted=None,
293
+ lookup_title=raw_title,
294
+ lookup_year=None,
295
+ duration_sec=duration_sec,
296
+ description_score=description_score,
297
+ has_real_art=has_real_art,
298
+ )
299
+
300
+ # 4. Movie (>= 30 min)
301
+ if duration_sec >= 1800:
302
+ yt = _YT_FULL_MOVIE_RE.match(raw_title.strip())
303
+ if yt:
304
+ clean_title = yt.group("title").strip()
305
+ genres = (yt.group("genres") or "").split() if yt.group("genres") else []
306
+ return ItemClassification(
307
+ friendly_token=friendly_token,
308
+ raw_title=raw_title,
309
+ content_type="movie",
310
+ hosted=None,
311
+ lookup_title=clean_title,
312
+ lookup_year=None,
313
+ genre_hints=genres,
314
+ duration_sec=duration_sec,
315
+ description_score=description_score,
316
+ has_real_art=has_real_art,
317
+ )
318
+
319
+ clean_title, year = normalize_and_clean(raw_title)
320
+ return ItemClassification(
321
+ friendly_token=friendly_token,
322
+ raw_title=raw_title,
323
+ content_type="movie",
324
+ hosted=None,
325
+ lookup_title=clean_title,
326
+ lookup_year=year,
327
+ duration_sec=duration_sec,
328
+ description_score=description_score,
329
+ has_real_art=has_real_art,
330
+ )
331
+
332
+ # 5. Unknown / short
333
+ return ItemClassification(
334
+ friendly_token=friendly_token,
335
+ raw_title=raw_title,
336
+ content_type="unknown",
337
+ hosted=None,
338
+ lookup_title=raw_title,
339
+ lookup_year=None,
340
+ duration_sec=duration_sec,
341
+ description_score=description_score,
342
+ has_real_art=has_real_art,
343
+ )