kryten-webqueue 0.48.0__tar.gz → 0.48.1__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 (248) hide show
  1. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/CHANGELOG.md +21 -0
  2. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/deploy/nginx-queue.conf +6 -0
  4. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/DATABASE_ARCHITECTURE_PLAN.md +23 -7
  5. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-cutover.md +61 -3
  6. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/PRD-postgres-migration.md +3 -0
  7. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-6-tests-cutover-release.md +9 -2
  8. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/__init__.py +47 -4
  9. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_catalog.py +17 -6
  10. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_catalog_db.py +24 -0
  11. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/pyproject.toml +1 -1
  12. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_multi_db_connection.py +62 -0
  13. kryten_webqueue-0.48.1/tests/test_postgres_live.py +87 -0
  14. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/.github/workflows/gitleaks.yml +0 -0
  15. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/.github/workflows/python-publish.yml +0 -0
  16. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/.github/workflows/release.yml +0 -0
  17. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/.gitignore +0 -0
  18. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/.gitleaks.toml +0 -0
  19. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/.pre-commit-config.yaml +0 -0
  20. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/AGENT-WORKFLOW-GUIDE.md +0 -0
  21. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/AGENTS.md +0 -0
  22. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/Containerfile +0 -0
  23. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/README.md +0 -0
  24. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/config.example.json +0 -0
  25. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/deploy/kryten-webqueue.service +0 -0
  26. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/deploy/podman/webqueue/webqueue-app.container +0 -0
  27. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/deploy/podman/webqueue/webqueue.network +0 -0
  28. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  29. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/IMPL_API_GATE.md +0 -0
  30. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/IMPL_ECONOMY.md +0 -0
  31. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  32. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/IMPL_ROBOT.md +0 -0
  33. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  34. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  35. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
  36. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  37. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  38. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/PRE_PLAN_GAPS.md +0 -0
  39. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/PRODUCT_PLAN.md +0 -0
  40. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/PUBLIC_API.md +0 -0
  41. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  42. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  43. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  44. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/SPEC_EMOTE_REHOST.md +0 -0
  45. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  46. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  47. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/UX_POLISH_PLAN.md +0 -0
  48. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-1-db-config-and-pool.md +0 -0
  49. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-2-connection-layer-port.md +0 -0
  50. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-3-fts5-to-tsvector.md +0 -0
  51. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-4-tmdb-index-database.md +0 -0
  52. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-5-etl-migration.md +0 -0
  53. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/PRD-sqlite-domain-separation.md +0 -0
  54. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-1-schema-partitioning-and-config.md +0 -0
  55. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-2-multi-db-connection-layer.md +0 -0
  56. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-3-cross-domain-query-decoupling.md +0 -0
  57. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-4-etl-split-script-and-validation.md +0 -0
  58. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/__init__.py +0 -0
  59. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/__main__.py +0 -0
  60. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  61. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/api_gate/client.py +0 -0
  62. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/app.py +0 -0
  63. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/__init__.py +0 -0
  64. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/device_keys.py +0 -0
  65. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/otp.py +0 -0
  66. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  67. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/session.py +0 -0
  68. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/__init__.py +0 -0
  69. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_base_domain.py +0 -0
  70. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  71. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_connection.py +0 -0
  72. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_devices.py +0 -0
  73. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  74. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  75. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
  76. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_jobs_db.py +0 -0
  77. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_motd.py +0 -0
  78. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_people.py +0 -0
  79. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  80. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_queue.py +0 -0
  81. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_queue_db.py +0 -0
  82. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_users_db.py +0 -0
  83. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  84. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/engine.py +0 -0
  85. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/__init__.py +0 -0
  86. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/catalog_schema.py +0 -0
  87. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/jobs_schema.py +0 -0
  88. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/queue_schema.py +0 -0
  89. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/users_schema.py +0 -0
  90. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/sql/001_initial_schema.sql +0 -0
  91. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  92. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  93. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  94. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  95. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  96. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  97. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  98. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  99. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  100. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  101. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  102. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  103. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  104. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  105. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/images.py +0 -0
  106. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  107. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/sync.py +0 -0
  108. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  109. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  110. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  111. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  112. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  113. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  114. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  115. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/config.py +0 -0
  116. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/__init__.py +0 -0
  117. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  118. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  119. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  120. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  121. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  122. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  123. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  124. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  125. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  126. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  127. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  128. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/__init__.py +0 -0
  129. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  130. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  131. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/log_capture.py +0 -0
  132. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/manager.py +0 -0
  133. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/motd_publish.py +0 -0
  134. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  135. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/tasks.py +0 -0
  136. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/logging_config.py +0 -0
  137. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/migrate_sqlite_to_pg.py +0 -0
  138. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/motd/__init__.py +0 -0
  139. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/motd/builder.py +0 -0
  140. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/motd/render.py +0 -0
  141. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/__init__.py +0 -0
  142. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  143. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/fire.py +0 -0
  144. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/importer.py +0 -0
  145. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/ordering.py +0 -0
  146. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  147. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/promos/__init__.py +0 -0
  148. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/promos/director.py +0 -0
  149. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/__init__.py +0 -0
  150. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/completion.py +0 -0
  151. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/ordering.py +0 -0
  152. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/poller.py +0 -0
  153. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/presence.py +0 -0
  154. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/race_poller.py +0 -0
  155. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/shadow.py +0 -0
  156. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/__init__.py +0 -0
  157. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  158. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_feedback.py +0 -0
  159. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  160. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  161. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_moderation.py +0 -0
  162. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_motd.py +0 -0
  163. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  164. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  165. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  166. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  167. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/auth.py +0 -0
  168. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/catalog.py +0 -0
  169. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/devices.py +0 -0
  170. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/feedback.py +0 -0
  171. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/pages.py +0 -0
  172. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/public_api.py +0 -0
  173. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/queue.py +0 -0
  174. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/user.py +0 -0
  175. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/scripts/__init__.py +0 -0
  176. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/scripts/split_databases.py +0 -0
  177. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/css/main.css +0 -0
  178. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-motd.js +0 -0
  179. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  180. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
  181. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  182. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  183. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin.js +0 -0
  184. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/keybindings.js +0 -0
  185. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/main.js +0 -0
  186. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/index.html +0 -0
  187. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/motd.html +0 -0
  188. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  189. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  190. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  191. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  192. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/auth/login.html +0 -0
  193. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/base.html +0 -0
  194. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  195. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  196. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  197. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  198. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/feedback/index.html +0 -0
  199. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/motd/channel_z.html +0 -0
  200. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/queue/index.html +0 -0
  201. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/race.html +0 -0
  202. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  203. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/user/link_device.html +0 -0
  204. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/user/my_list.html +0 -0
  205. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/ws/__init__.py +0 -0
  206. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/ws/handler.py +0 -0
  207. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/kryten_webqueue/ws/manager.py +0 -0
  208. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/scripts/AGENTS.md +0 -0
  209. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/scripts/patch_mediacms_bulk_facets.py +0 -0
  210. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/scripts/patch_mediacms_tags.py +0 -0
  211. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/scripts/split_databases.py +0 -0
  212. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/scripts/trigger_enrich.py +0 -0
  213. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/__init__.py +0 -0
  214. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_catalog_blackout.py +0 -0
  215. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_catalog_delete.py +0 -0
  216. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_classify.py +0 -0
  217. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_concurrency_split.py +0 -0
  218. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_config_persistence.py +0 -0
  219. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_database_config_and_schemas.py +0 -0
  220. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_device_linking.py +0 -0
  221. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_feedback.py +0 -0
  222. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_fetch_queue_pacing.py +0 -0
  223. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_fetchurls_sharepoint.py +0 -0
  224. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_identify_step.py +0 -0
  225. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_job_logs_and_queue_visibility.py +0 -0
  226. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_motd_publish.py +0 -0
  227. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_people_facets.py +0 -0
  228. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_phase1.py +0 -0
  229. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_phase2_jobs.py +0 -0
  230. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_phase3_jobs.py +0 -0
  231. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_phase4_live_fixes.py +0 -0
  232. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_playlist_import.py +0 -0
  233. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_postgres_config_and_engine.py +0 -0
  234. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_postgres_pruner.py +0 -0
  235. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_presence_refund.py +0 -0
  236. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_promo_director.py +0 -0
  237. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_promo_pool_exclusion.py +0 -0
  238. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_providers.py +0 -0
  239. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_queue_announce.py +0 -0
  240. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_queue_preview_fallback.py +0 -0
  241. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_race_poller.py +0 -0
  242. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_recently_played_hide.py +0 -0
  243. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_save_results_to_playlist.py +0 -0
  244. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_schedule_lock.py +0 -0
  245. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_search_facets.py +0 -0
  246. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_title_normalization.py +0 -0
  247. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_tmdb_index.py +0 -0
  248. {kryten_webqueue-0.48.0 → kryten_webqueue-0.48.1}/tests/test_watchlist.py +0 -0
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.48.1] - 2026-09-23
4
+
5
+ ### Fixed
6
+
7
+ - **Production 500 on `/catalog/browse` in partitioned SQLite mode**: `get_tags()` (facet
8
+ counts) unconditionally ran the monolith reserved-item exclusion subquery referencing
9
+ `saved_playlist_items`/`saved_playlists`, tables that live in `queue.sqlite3`, not
10
+ `catalog.sqlite3`. Every browse/search page load crashed with
11
+ `sqlite3.OperationalError: no such table: saved_playlist_items` once the site was
12
+ cut over to `layout="partitioned"`. `get_tags` now accepts `exclude_tokens`/
13
+ `is_partitioned`, and the `Database` facade resolves reserved/blackout exclusions via
14
+ the `queue` domain before calling it, matching the existing `browse`/`search` pattern.
15
+ - **Reserved-item exclusion leak in partitioned mode**: `queue.get_reserved_media_ids()`
16
+ returns raw `media_id` values, which are usually CyTube manifest URLs, not bare
17
+ `friendly_token`s. `browse`, `browse_count`, `search`, and `search_count` were comparing
18
+ these raw values directly against `catalog.friendly_token`, so immutable/promo-pool
19
+ items whose playlist entry stored a manifest URL were **not excluded** from public
20
+ results. Added `catalog.resolve_friendly_tokens()` to resolve either shape back to
21
+ `friendly_token`, and all four call sites now use it via a shared
22
+ `Database._get_reserved_tokens()` helper.
23
+
3
24
  ## [0.48.0] - 2026-09-23
4
25
 
5
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.48.0
3
+ Version: 0.48.1
4
4
  Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
5
5
  Author: grobertson
6
6
  License-Expression: MIT
@@ -13,8 +13,13 @@ server {
13
13
 
14
14
  add_header Strict-Transport-Security "max-age=31536000" always;
15
15
 
16
+ # Upstream application backend:
17
+ # - Local grindhouse service: http://127.0.0.1:2010
18
+ # - Cross-network Chandra-1 Podman container: http://chandra-1.local:2010
19
+
16
20
  # WebSocket upgrade
17
21
  location /ws {
22
+ # Use http://chandra-1.local:2010 when deployed to Chandra-1 Podman pod
18
23
  proxy_pass http://127.0.0.1:2010;
19
24
  proxy_http_version 1.1;
20
25
  proxy_set_header Upgrade $http_upgrade;
@@ -28,6 +33,7 @@ server {
28
33
 
29
34
  # API + pages
30
35
  location / {
36
+ # Use http://chandra-1.local:2010 when deployed to Chandra-1 Podman pod
31
37
  proxy_pass http://127.0.0.1:2010;
32
38
  proxy_http_version 1.1;
33
39
  proxy_set_header Connection "";
@@ -133,18 +133,29 @@ Upon completion of Part 1, the partitioned domain model directly maps to Postgre
133
133
  - `tmdb.*` — Persistent, queryable local TMDB dump index.
134
134
 
135
135
  ```
136
+ ┌─────────────────────────────────────────┐
137
+ │ grindhouse.local Host │
138
+ │ │
139
+ │ ┌─────────────────────────────────┐ │
140
+ │ │ nginx proxy │ │
141
+ │ │ queue.dropsugar.co (SSL) │ │
142
+ │ └────────────────┬────────────────┘ │
143
+ └────────────────────┼────────────────────┘
144
+ │ LAN Reverse Proxy
145
+ │ http://chandra-1.local:2010
146
+ ▼
136
147
  ┌────────────────────────────────────────────────────────────────────────────────────────┐
137
148
  │ chandra-1 Host System │
138
149
  │ │
139
150
  │ ┌──────────────────────────────────────────────────────────────────────────────────┐ │
140
- │ │ Podman Pod: kryten-webqueue │ │
151
+ │ │ Podman Container: webqueue-app │ │
141
152
  │ │ │ │
142
- │ │ ┌─────────────────────────────┐ ┌──────────────────────────────────┐ │ │
143
- │ │ │ kryten-webqueue-app │ │ nginx proxy │ │ │
144
- │ │ │ (FastAPI + uvicorn) │◄─────────►│ (Reverse Proxy + SSL) │ │ │
145
- │ │ │ SQLAlchemy 2.0 (asyncpg) │ │ │ │ │
146
- │ │ └──────────────┬──────────────┘ └────────────────┬─────────────────┘ │ │
147
- │ └─────────────────┼───────────────────────────────────────────┼────────────────────┘ │
153
+ │ │ ┌─────────────────────────────┐ │ │
154
+ │ │ │ kryten-webqueue-app │ │ │
155
+ │ │ │ (FastAPI + uvicorn:2010) │ │ │
156
+ │ │ │ SQLAlchemy 2.0 (asyncpg) │ │ │
157
+ │ │ └──────────────┬──────────────┘ │ │
158
+ │ └─────────────────┼───────────────────────────────────────────┬────────────────────┘ │
148
159
  │ │ │ │
149
160
  │ ▼ ▼ │
150
161
  │ ┌─────────────────────────────────────┐ ┌──────────────────────────────────────┐ │
@@ -156,6 +167,11 @@ Upon completion of Part 1, the partitioned domain model directly maps to Postgre
156
167
  └────────────────────────────────────────────────────────────────────────────────────────┘
157
168
  ```
158
169
 
170
+ The public hostname `https://queue.dropsugar.co/` terminates TLS via Let's Encrypt Nginx on
171
+ `grindhouse.local`. During the Chandra-1 cutover, the Nginx reverse proxy configuration on
172
+ `grindhouse.local` (`/etc/nginx/sites-available/queue.conf`) is updated to forward HTTP and
173
+ WebSocket traffic across the local network to `http://chandra-1.local:2010`.
174
+
159
175
  ### 2.2 Core Technical Specifications
160
176
 
161
177
  1. **ORM & Driver**: **SQLAlchemy 2.0 with `asyncpg`** (`create_async_engine`, `async_sessionmaker`, `AsyncSession`).
@@ -80,15 +80,73 @@ sudo systemctl start webqueue-app.service
80
80
  sudo systemctl status webqueue-app.service --no-pager
81
81
  ```
82
82
 
83
- ### Step 6: Smoke Testing & Health Verification
83
+ Verify local health inside `chandra-1`:
84
84
  ```bash
85
85
  curl -I http://127.0.0.1:2010/auth/login
86
86
  curl -s http://127.0.0.1:2010/queue/next-schedule
87
87
  ```
88
88
 
89
+ ### Step 6: Update Grindhouse Nginx Reverse Proxy Across Network
90
+ The public website `https://queue.dropsugar.co/` terminates SSL via Let's Encrypt Nginx on `grindhouse.local`. Update its upstream to proxy across the LAN to `chandra-1.local:2010`.
91
+
92
+ On `grindhouse.local`:
93
+ 1. Edit `/etc/nginx/sites-available/queue.conf`:
94
+ ```nginx
95
+ # WebSocket upgrade
96
+ location /ws {
97
+ proxy_pass http://chandra-1.local:2010;
98
+ proxy_http_version 1.1;
99
+ proxy_set_header Upgrade $http_upgrade;
100
+ proxy_set_header Connection "upgrade";
101
+ proxy_set_header Host $host;
102
+ proxy_set_header X-Real-IP $remote_addr;
103
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
104
+ proxy_set_header X-Forwarded-Proto $scheme;
105
+ proxy_read_timeout 86400;
106
+ }
107
+
108
+ # API + pages
109
+ location / {
110
+ proxy_pass http://chandra-1.local:2010;
111
+ proxy_http_version 1.1;
112
+ proxy_set_header Connection "";
113
+ proxy_set_header Host $host;
114
+ proxy_set_header X-Real-IP $remote_addr;
115
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
116
+ proxy_set_header X-Forwarded-Proto $scheme;
117
+ }
118
+ ```
119
+
120
+ 2. Test and reload Nginx:
121
+ ```bash
122
+ sudo nginx -t
123
+ sudo systemctl reload nginx
124
+ ```
125
+
126
+ ### Step 7: Public Smoke Testing & Verification
127
+ Test public endpoints through the reverse proxy:
128
+ ```bash
129
+ curl -I https://queue.dropsugar.co/auth/login
130
+ curl -s https://queue.dropsugar.co/queue/next-schedule
131
+ ```
132
+ Monitor logs on `chandra-1`:
133
+ ```bash
134
+ sudo journalctl -u webqueue-app.service -f
135
+ ```
136
+
89
137
  ---
90
138
 
91
139
  ## 3. Rollback & Forward Repair Policy
92
140
 
93
- - **Pre-Cutover Failure**: If ETL validation fails or container startup fails before DNS/traffic cutover, immediately restart `kryten-webqueue.service` on `grindhouse.local`. SQLite databases remain untouched and consistent.
94
- - **Post-Cutover Failure**: Once traffic is switched and PostgreSQL accepts writes (new spend requests, logins, completions), recovery is a **forward repair in PostgreSQL**. The retained SQLite snapshot is preserved read-only for forensics.
141
+ - **Pre-Cutover Failure**: If ETL validation fails, container startup fails on `chandra-1`, or initial smoke tests fail before Nginx is updated:
142
+ 1. Do not update or revert Nginx on `grindhouse.local` (`proxy_pass http://127.0.0.1:2010;`).
143
+ 2. Restart `kryten-webqueue.service` on `grindhouse.local`:
144
+ ```bash
145
+ sudo systemctl start kryten-webqueue.service
146
+ ```
147
+ 3. The SQLite databases remain untouched and consistent.
148
+ - **Traffic Rollback (if Nginx was already switched but app has issues)**:
149
+ 1. Revert `/etc/nginx/sites-available/queue.conf` on `grindhouse.local` to point back to `http://127.0.0.1:2010`.
150
+ 2. Reload Nginx: `sudo nginx -t && sudo systemctl reload nginx`.
151
+ 3. Start `kryten-webqueue.service` on `grindhouse.local`.
152
+ - **Post-Cutover Forward Repair**: Once production traffic is accepted by PostgreSQL (user logins, new spend requests, play completions), recovery is a **forward repair in PostgreSQL**. The retained SQLite snapshot is preserved read-only for forensics.
@@ -28,6 +28,9 @@ The following decisions are fixed for this sprint:
28
28
  - One PostgreSQL database named `webqueue` owns all five schemas. `tmdb` is a schema, not a
29
29
  separate database, so catalog enrichment can use relational queries when needed.
30
30
  - The application runs as a rootful Podman Quadlet service on `chandra-1`.
31
+ - Public traffic on `https://queue.dropsugar.co/` terminates TLS at Nginx on `grindhouse.local`.
32
+ During cutover, Nginx on `grindhouse.local` (`/etc/nginx/sites-available/queue.conf`) is updated
33
+ to reverse-proxy HTTP and WebSocket (`/ws`) traffic across the local network to `http://chandra-1.local:2010`.
31
34
  - PostgreSQL is host-managed on `chandra-1`. The rootful application container connects through
32
35
  an explicitly configured host-gateway address; it must not use `localhost`.
33
36
  - The migration uses a maintenance window and a forward-fix rollback policy. There is no
@@ -66,12 +66,19 @@ python -m kryten_webqueue.migrate_sqlite_to_pg --data-dir /path/to/sqlite/data -
66
66
  # 5. Grant runtime/pruner roles only after verification, then start the rootful Quadlet.
67
67
  sudo systemctl start webqueue-app.service
68
68
 
69
- # 6. Verify health and smoke test browse/search/queue/OTP/device-key/job-log paths.
69
+ # 6. Update Nginx reverse proxy on grindhouse.local to forward across the LAN:
70
+ # In /etc/nginx/sites-available/queue.conf:
71
+ # proxy_pass http://chandra-1.local:2010; (for /ws and /)
72
+ # sudo nginx -t && sudo systemctl reload nginx
73
+
74
+ # 7. Verify health and smoke test public HTTPS and WebSocket paths:
75
+ curl -f https://queue.dropsugar.co/auth/login
70
76
  curl -f http://127.0.0.1:2010/api/public/v1/state
71
77
  ```
72
78
 
73
79
  If a gate fails before PostgreSQL receives production writes, restart the unchanged SQLite
74
- service. Once PostgreSQL accepts a write, recovery is a forward repair using request identifiers
80
+ service and revert the Nginx proxy_pass on grindhouse.local back to http://127.0.0.1:2010.
81
+ Once PostgreSQL accepts a write, recovery is a forward repair using request identifiers
75
82
  and audit records; the retained SQLite snapshot is not an automatic rollback target.
76
83
 
77
84
  ## 4. Implementation Plan
@@ -182,6 +182,7 @@ _DOMAIN_METHOD_MAP: dict[str, str] = {
182
182
  "get_item_edit_history": "catalog",
183
183
  "get_items_by_tokens": "catalog",
184
184
  "get_hidden_category_and_tag_tokens": "catalog",
185
+ "resolve_friendly_tokens": "catalog",
185
186
  "get_people": "catalog",
186
187
  "get_studios": "catalog",
187
188
  "upsert_person": "catalog",
@@ -333,6 +334,16 @@ class Database(
333
334
 
334
335
  # --- Decoupled Cross-Domain Orchestrations ---
335
336
 
337
+ async def _get_reserved_tokens(self) -> set[str]:
338
+ """Reserved (immutable/promo) media_ids resolved to friendly_tokens.
339
+
340
+ queue.db stores media_id as either a bare token or a manifest URL, so
341
+ the raw values must be resolved through catalog.db before comparing
342
+ against friendly_token.
343
+ """
344
+ raw = await self.queue.get_reserved_media_ids()
345
+ return await self.catalog.resolve_friendly_tokens(raw)
346
+
336
347
  async def browse(
337
348
  self,
338
349
  *,
@@ -357,7 +368,7 @@ class Database(
357
368
  recently_played_days
358
369
  )
359
370
  exclude_set.update(hidden)
360
- reserved = await self.queue.get_reserved_media_ids()
371
+ reserved = await self._get_reserved_tokens()
361
372
  exclude_set.update(reserved)
362
373
  blackouts = await self.queue.get_active_blackout_tokens()
363
374
  if not show_hidden:
@@ -425,7 +436,7 @@ class Database(
425
436
  recently_played_days
426
437
  )
427
438
  exclude_set.update(hidden)
428
- reserved = await self.queue.get_reserved_media_ids()
439
+ reserved = await self._get_reserved_tokens()
429
440
  exclude_set.update(reserved)
430
441
  if not show_hidden:
431
442
  blackouts = await self.queue.get_active_blackout_tokens()
@@ -483,7 +494,7 @@ class Database(
483
494
  recently_played_days
484
495
  )
485
496
  exclude_set.update(hidden)
486
- reserved = await self.queue.get_reserved_media_ids()
497
+ reserved = await self._get_reserved_tokens()
487
498
  exclude_set.update(reserved)
488
499
  blackouts = await self.queue.get_active_blackout_tokens()
489
500
  if not show_hidden:
@@ -554,7 +565,7 @@ class Database(
554
565
  recently_played_days
555
566
  )
556
567
  exclude_set.update(hidden)
557
- reserved = await self.queue.get_reserved_media_ids()
568
+ reserved = await self._get_reserved_tokens()
558
569
  exclude_set.update(reserved)
559
570
  if not show_hidden:
560
571
  blackouts = await self.queue.get_active_blackout_tokens()
@@ -612,6 +623,38 @@ class Database(
612
623
  self, username, page=page, per_page=per_page
613
624
  )
614
625
 
626
+ async def get_tags(
627
+ self,
628
+ *,
629
+ limit: int = 100,
630
+ show_hidden: bool = False,
631
+ min_duration_sec: int = 0,
632
+ max_duration_sec: int | None = None,
633
+ **kwargs: Any,
634
+ ) -> list[dict]:
635
+ if self._layout == "partitioned":
636
+ exclude_set: set[str] = await self._get_reserved_tokens()
637
+ if not show_hidden:
638
+ blackouts = await self.queue.get_active_blackout_tokens()
639
+ exclude_set.update(blackouts)
640
+ return await self.catalog.get_tags(
641
+ limit=limit,
642
+ show_hidden=show_hidden,
643
+ min_duration_sec=min_duration_sec,
644
+ max_duration_sec=max_duration_sec,
645
+ exclude_tokens=exclude_set,
646
+ is_partitioned=True,
647
+ )
648
+
649
+ return await _CatalogMixin.get_tags(
650
+ self,
651
+ limit=limit,
652
+ show_hidden=show_hidden,
653
+ min_duration_sec=min_duration_sec,
654
+ max_duration_sec=max_duration_sec,
655
+ is_partitioned=False,
656
+ )
657
+
615
658
  async def get_item(self, friendly_token: str) -> dict | None:
616
659
  if self._layout == "partitioned":
617
660
  item = await self.catalog.get_item(friendly_token, is_partitioned=True)
@@ -916,6 +916,8 @@ class _CatalogMixin:
916
916
  show_hidden: bool = False,
917
917
  min_duration_sec: int = 0,
918
918
  max_duration_sec: int | None = None,
919
+ exclude_tokens: set[str] | list[str] | None = None,
920
+ is_partitioned: bool = False,
919
921
  ) -> list[dict]:
920
922
  """Most-used tags that have at least one catalog item, for facets.
921
923
 
@@ -929,16 +931,25 @@ class _CatalogMixin:
929
931
  WHERE 1=1
930
932
  """
931
933
  params: list = []
932
- resv_sql, resv_params = _reserved_exclusion("c")
933
- sql += resv_sql
934
- params.extend(resv_params)
934
+ if not is_partitioned:
935
+ resv_sql, resv_params = _reserved_exclusion("c")
936
+ sql += resv_sql
937
+ params.extend(resv_params)
938
+ if not show_hidden:
939
+ bo_sql, bo_params = _blackout_exclusion("c")
940
+ sql += bo_sql
941
+ params.extend(bo_params)
935
942
  if not show_hidden:
936
- bo_sql, bo_params = _blackout_exclusion("c")
937
- sql += bo_sql
938
- params.extend(bo_params)
939
943
  ph = ",".join("?" * len(HIDDEN_TAG_NAMES))
940
944
  sql += f" AND t.name NOT IN ({ph})"
941
945
  params.extend(HIDDEN_TAG_NAMES)
946
+ if exclude_tokens:
947
+ tokens_list = [t for t in exclude_tokens if t]
948
+ for i in range(0, len(tokens_list), 500):
949
+ chunk = tokens_list[i : i + 500]
950
+ ph = ",".join("?" * len(chunk))
951
+ sql += f" AND c.friendly_token NOT IN ({ph}) "
952
+ params.extend(chunk)
942
953
  if min_duration_sec > 0 or max_duration_sec is not None:
943
954
  dur_sql, dur_params = _duration_range_filter(
944
955
  "c", min_duration_sec or None, max_duration_sec
@@ -55,3 +55,27 @@ class _CatalogDB(_CatalogMixin, _PeopleMixin, _EnrichmentMixin, _MOTDMixin, _Dom
55
55
  """
56
56
  rows = await self._fetch_all(sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES])
57
57
  return {r["friendly_token"] for r in rows if r.get("friendly_token")}
58
+
59
+ async def resolve_friendly_tokens(
60
+ self, identifiers: list[str] | set[str]
61
+ ) -> set[str]:
62
+ """Resolve a mix of bare friendly_tokens and manifest URLs to friendly_tokens.
63
+
64
+ queue.db stores playlist item media_id as either shape; this maps both
65
+ back to the canonical friendly_token so cross-domain exclusion filters
66
+ (reserved/promo items) match regardless of which shape was recorded.
67
+ """
68
+ ids = [i for i in identifiers if i]
69
+ if not ids:
70
+ return set()
71
+ tokens: set[str] = set()
72
+ for i in range(0, len(ids), 500):
73
+ chunk = ids[i : i + 500]
74
+ ph = ",".join("?" * len(chunk))
75
+ rows = await self._fetch_all(
76
+ f"SELECT friendly_token FROM catalog "
77
+ f"WHERE friendly_token IN ({ph}) OR manifest_url IN ({ph})",
78
+ [*chunk, *chunk],
79
+ )
80
+ tokens.update(r["friendly_token"] for r in rows if r.get("friendly_token"))
81
+ return tokens
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.48.0"
3
+ version = "0.48.1"
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"
@@ -195,3 +195,65 @@ async def test_partitioned_is_restricted_decoupling(partitioned_db):
195
195
 
196
196
  # Now is_restricted is True
197
197
  assert await partitioned_db.is_restricted("tok_restricted") is True
198
+
199
+
200
+ async def test_partitioned_get_tags_decoupled(partitioned_db):
201
+ """Regression test: get_tags must not reference saved_playlist_items in catalog.db.
202
+
203
+ Previously crashed with 'sqlite3.OperationalError: no such table:
204
+ saved_playlist_items' because get_tags ran the monolith reserved-item
205
+ exclusion subquery unconditionally, even in partitioned mode.
206
+ """
207
+ for i in range(1, 4):
208
+ token = f"tag_movie_{i}"
209
+ await partitioned_db.catalog.insert_catalog(
210
+ {
211
+ "friendly_token": token,
212
+ "title": f"Tag Movie {i}",
213
+ "manifest_url": f"https://dropsugar.com/cytube/{token}.json",
214
+ "synced_at": "2026-01-01T00:00:00Z",
215
+ }
216
+ )
217
+ tag_id = await partitioned_db.upsert_tag("actionpacked")
218
+ await partitioned_db.set_catalog_tags(token, [tag_id])
219
+
220
+ tags = await partitioned_db.get_tags()
221
+ assert any(t["name"] == "actionpacked" for t in tags)
222
+
223
+
224
+ async def test_partitioned_reserved_exclusion_resolves_manifest_url(partitioned_db):
225
+ """Regression test: reserved-item exclusion must resolve queue.db media_id
226
+ (usually a manifest URL) back to friendly_token before filtering catalog.db,
227
+ or immutable/promo-pool items leak into public browse/search/tags.
228
+ """
229
+ manifest_url = "https://dropsugar.com/cytube/reserved_by_url.json"
230
+ await partitioned_db.catalog.insert_catalog(
231
+ {
232
+ "friendly_token": "tok_reserved_by_url",
233
+ "title": "Reserved By URL",
234
+ "manifest_url": manifest_url,
235
+ "synced_at": "2026-01-01T00:00:00Z",
236
+ }
237
+ )
238
+ await partitioned_db.catalog.insert_catalog(
239
+ {
240
+ "friendly_token": "tok_public",
241
+ "title": "Public Item",
242
+ "manifest_url": "https://dropsugar.com/cytube/public.json",
243
+ "synced_at": "2026-01-01T00:00:00Z",
244
+ }
245
+ )
246
+
247
+ pl_id = await partitioned_db.queue.create_saved_playlist(
248
+ name="Immutable Event", description=None, is_immutable=True, created_by="admin"
249
+ )
250
+ # media_id stored as the manifest URL, the common real-world shape.
251
+ await partitioned_db.queue.append_playlist_items(
252
+ pl_id,
253
+ [{"media_type": "cm", "media_id": manifest_url, "title": "Reserved By URL"}],
254
+ )
255
+
256
+ items = await partitioned_db.browse(show_hidden=False)
257
+ tokens = [i["friendly_token"] for i in items]
258
+ assert "tok_public" in tokens
259
+ assert "tok_reserved_by_url" not in tokens
@@ -0,0 +1,87 @@
1
+ """Integration test suite verifying live PostgreSQL webqueue database on chandra-1."""
2
+
3
+ import os
4
+ import pytest
5
+ import asyncpg
6
+
7
+
8
+ CHANDRA_PG_DSN = os.getenv(
9
+ "KRYTEN_WEBQUEUE_PG_DSN",
10
+ "postgresql://kryten:kryten_secret_password@chandra-1.local:5432/webqueue",
11
+ )
12
+
13
+
14
+ @pytest.fixture
15
+ async def pg_conn():
16
+ try:
17
+ conn = await asyncpg.connect(CHANDRA_PG_DSN, timeout=5)
18
+ yield conn
19
+ await conn.close()
20
+ except (OSError, asyncpg.PostgresError) as exc:
21
+ pytest.skip(f"Chandra-1 PostgreSQL not reachable from this environment: {exc}")
22
+
23
+
24
+ async def test_live_postgres_schemas_and_extensions(pg_conn):
25
+ """Verify webqueue database contains all 5 schemas and pg_trgm."""
26
+ schemas = await pg_conn.fetch(
27
+ "SELECT schema_name FROM information_schema.schemata WHERE schema_name IN ('catalog', 'queue', 'jobs', 'users', 'tmdb')"
28
+ )
29
+ found_schemas = {r["schema_name"] for r in schemas}
30
+ assert found_schemas == {"catalog", "queue", "jobs", "users", "tmdb"}
31
+
32
+ ext = await pg_conn.fetchval("SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm'")
33
+ assert ext == 1
34
+
35
+
36
+ async def test_live_postgres_table_counts(pg_conn):
37
+ """Verify row counts in live migrated schemas."""
38
+ catalog_count = await pg_conn.fetchval("SELECT count(*) FROM catalog.catalog")
39
+ assert catalog_count >= 9900
40
+
41
+ people_count = await pg_conn.fetchval("SELECT count(*) FROM catalog.people")
42
+ assert people_count >= 36000
43
+
44
+ job_logs_count = await pg_conn.fetchval("SELECT count(*) FROM jobs.job_run_logs")
45
+ assert job_logs_count >= 500000
46
+
47
+ playlists_count = await pg_conn.fetchval(
48
+ "SELECT count(*) FROM queue.saved_playlists"
49
+ )
50
+ assert playlists_count >= 30
51
+
52
+ watchlist_count = await pg_conn.fetchval(
53
+ "SELECT count(*) FROM users.user_watchlist"
54
+ )
55
+ assert watchlist_count >= 1700
56
+
57
+
58
+ async def test_live_postgres_full_text_search(pg_conn):
59
+ """Verify tsvector full-text search with GIN index on catalog.catalog."""
60
+ rows = await pg_conn.fetch(
61
+ """
62
+ SELECT friendly_token, title, ts_rank(search_vector, websearch_to_tsquery('english', 'Terminator')) AS rank
63
+ FROM catalog.catalog
64
+ WHERE search_vector @@ websearch_to_tsquery('english', 'Terminator')
65
+ ORDER BY rank DESC
66
+ LIMIT 5;
67
+ """
68
+ )
69
+ assert len(rows) > 0
70
+ titles = [r["title"] for r in rows]
71
+ assert any("Terminator" in t for t in titles)
72
+
73
+
74
+ async def test_live_postgres_trigram_fuzzy_matching(pg_conn):
75
+ """Verify pg_trgm fuzzy matching handles typos like 'Terminatr'."""
76
+ rows = await pg_conn.fetch(
77
+ """
78
+ SELECT friendly_token, title, similarity(title, 'Terminatr') AS sim
79
+ FROM catalog.catalog
80
+ WHERE similarity(title, 'Terminatr') > 0.3
81
+ ORDER BY sim DESC
82
+ LIMIT 5;
83
+ """
84
+ )
85
+ assert len(rows) > 0
86
+ top_title = rows[0]["title"]
87
+ assert "Terminator" in top_title