kryten-webqueue 0.47.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 (249) hide show
  1. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/CHANGELOG.md +34 -0
  2. kryten_webqueue-0.48.1/Containerfile +23 -0
  3. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/PKG-INFO +4 -1
  4. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/config.example.json +13 -1
  5. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/deploy/nginx-queue.conf +6 -0
  6. kryten_webqueue-0.48.1/deploy/podman/webqueue/webqueue-app.container +24 -0
  7. kryten_webqueue-0.48.1/deploy/podman/webqueue/webqueue.network +3 -0
  8. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/DATABASE_ARCHITECTURE_PLAN.md +23 -7
  9. kryten_webqueue-0.48.1/docs/postgres-cutover.md +152 -0
  10. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/PRD-postgres-migration.md +3 -0
  11. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-6-tests-cutover-release.md +9 -2
  12. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/app.py +8 -0
  13. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/__init__.py +74 -19
  14. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_catalog.py +41 -6
  15. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_catalog_db.py +24 -0
  16. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_jobs_db.py +28 -0
  17. kryten_webqueue-0.48.1/kryten_webqueue/catalog/db/engine.py +29 -0
  18. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/catalog_schema.py +3 -3
  19. kryten_webqueue-0.48.1/kryten_webqueue/catalog/db/sql/001_initial_schema.sql +463 -0
  20. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/config.py +96 -27
  21. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/tasks.py +27 -0
  22. kryten_webqueue-0.48.1/kryten_webqueue/migrate_sqlite_to_pg.py +350 -0
  23. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1/kryten_webqueue}/scripts/split_databases.py +4 -12
  24. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/pyproject.toml +5 -1
  25. kryten_webqueue-0.48.1/scripts/split_databases.py +13 -0
  26. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_multi_db_connection.py +62 -0
  27. kryten_webqueue-0.48.1/tests/test_postgres_config_and_engine.py +77 -0
  28. kryten_webqueue-0.48.1/tests/test_postgres_live.py +87 -0
  29. kryten_webqueue-0.48.1/tests/test_postgres_pruner.py +131 -0
  30. kryten_webqueue-0.47.0/kryten_webqueue/scripts/split_databases.py +0 -14
  31. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/.github/workflows/gitleaks.yml +0 -0
  32. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/.github/workflows/python-publish.yml +0 -0
  33. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/.github/workflows/release.yml +0 -0
  34. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/.gitignore +0 -0
  35. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/.gitleaks.toml +0 -0
  36. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/.pre-commit-config.yaml +0 -0
  37. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/AGENT-WORKFLOW-GUIDE.md +0 -0
  38. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/AGENTS.md +0 -0
  39. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/README.md +0 -0
  40. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/deploy/kryten-webqueue.service +0 -0
  41. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  42. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/IMPL_API_GATE.md +0 -0
  43. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/IMPL_ECONOMY.md +0 -0
  44. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  45. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/IMPL_ROBOT.md +0 -0
  46. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  47. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  48. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
  49. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  50. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/PRD_TMDB_LOCAL_INDEX.md +0 -0
  51. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/PRE_PLAN_GAPS.md +0 -0
  52. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/PRODUCT_PLAN.md +0 -0
  53. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/PUBLIC_API.md +0 -0
  54. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/SPEC-Visual-UX-Sprint-August-2026.md +0 -0
  55. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/SPEC_ADMIN_ITEM_EDITOR.md +0 -0
  56. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/SPEC_CATALOG_ENRICHMENT_PIPELINE.md +0 -0
  57. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/SPEC_EMOTE_REHOST.md +0 -0
  58. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  59. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/SPEC_TMDB_LOCAL_INDEX.md +0 -0
  60. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/UX_POLISH_PLAN.md +0 -0
  61. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-1-db-config-and-pool.md +0 -0
  62. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-2-connection-layer-port.md +0 -0
  63. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-3-fts5-to-tsvector.md +0 -0
  64. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-4-tmdb-index-database.md +0 -0
  65. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/postgres-migration/SPEC-Sortie-5-etl-migration.md +0 -0
  66. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/PRD-sqlite-domain-separation.md +0 -0
  67. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-1-schema-partitioning-and-config.md +0 -0
  68. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-2-multi-db-connection-layer.md +0 -0
  69. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-3-cross-domain-query-decoupling.md +0 -0
  70. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/docs/sqlite-domain-separation/SPEC-Sortie-4-etl-split-script-and-validation.md +0 -0
  71. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/__init__.py +0 -0
  72. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/__main__.py +0 -0
  73. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  74. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/api_gate/client.py +0 -0
  75. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/__init__.py +0 -0
  76. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/device_keys.py +0 -0
  77. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/otp.py +0 -0
  78. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  79. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/auth/session.py +0 -0
  80. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/__init__.py +0 -0
  81. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_base_domain.py +0 -0
  82. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_blackouts.py +0 -0
  83. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_connection.py +0 -0
  84. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_devices.py +0 -0
  85. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_enrichment.py +0 -0
  86. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_feedback.py +0 -0
  87. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_fetch_queue.py +0 -0
  88. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_motd.py +0 -0
  89. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_people.py +0 -0
  90. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_playlists.py +0 -0
  91. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_queue.py +0 -0
  92. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_queue_db.py +0 -0
  93. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_users_db.py +0 -0
  94. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/_watchlist.py +0 -0
  95. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/__init__.py +0 -0
  96. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/jobs_schema.py +0 -0
  97. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/queue_schema.py +0 -0
  98. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/db/schemas/users_schema.py +0 -0
  99. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/__init__.py +0 -0
  100. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/classify.py +0 -0
  101. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/normalise.py +0 -0
  102. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/pipeline.py +0 -0
  103. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/providers.py +0 -0
  104. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/report.py +0 -0
  105. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/__init__.py +0 -0
  106. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/art.py +0 -0
  107. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/categories.py +0 -0
  108. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/identify.py +0 -0
  109. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/meta.py +0 -0
  110. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/sync.py +0 -0
  111. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/tags.py +0 -0
  112. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/enrichment/steps/title.py +0 -0
  113. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/images.py +0 -0
  114. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  115. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/sync.py +0 -0
  116. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/__init__.py +0 -0
  117. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/_schema.py +0 -0
  118. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/_textmatch.py +0 -0
  119. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/_ttscrape.py +0 -0
  120. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/builder.py +0 -0
  121. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/coverage.py +0 -0
  122. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/catalog/tmdb_index/index.py +0 -0
  123. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/__init__.py +0 -0
  124. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  125. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  126. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/blackout_scan.py +0 -0
  127. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  128. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  129. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  130. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  131. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/motdposters.py +0 -0
  132. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/cmsutils/playedmovies.py +0 -0
  133. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  134. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  135. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/__init__.py +0 -0
  136. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  137. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/job_scheduler.py +0 -0
  138. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/log_capture.py +0 -0
  139. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/manager.py +0 -0
  140. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/motd_publish.py +0 -0
  141. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/jobs/rehost_emotes.py +0 -0
  142. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/logging_config.py +0 -0
  143. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/motd/__init__.py +0 -0
  144. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/motd/builder.py +0 -0
  145. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/motd/render.py +0 -0
  146. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/__init__.py +0 -0
  147. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  148. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/fire.py +0 -0
  149. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/importer.py +0 -0
  150. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/ordering.py +0 -0
  151. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  152. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/promos/__init__.py +0 -0
  153. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/promos/director.py +0 -0
  154. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/__init__.py +0 -0
  155. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/completion.py +0 -0
  156. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/ordering.py +0 -0
  157. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/poller.py +0 -0
  158. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/presence.py +0 -0
  159. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/race_poller.py +0 -0
  160. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/queue/shadow.py +0 -0
  161. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/__init__.py +0 -0
  162. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  163. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_feedback.py +0 -0
  164. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_job_schedules.py +0 -0
  165. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  166. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_moderation.py +0 -0
  167. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_motd.py +0 -0
  168. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  169. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  170. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  171. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  172. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/auth.py +0 -0
  173. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/catalog.py +0 -0
  174. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/devices.py +0 -0
  175. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/feedback.py +0 -0
  176. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/pages.py +0 -0
  177. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/public_api.py +0 -0
  178. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/queue.py +0 -0
  179. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/routes/user.py +0 -0
  180. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/scripts/__init__.py +0 -0
  181. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/css/main.css +0 -0
  182. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-motd.js +0 -0
  183. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  184. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
  185. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  186. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  187. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/admin.js +0 -0
  188. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/keybindings.js +0 -0
  189. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/static/js/main.js +0 -0
  190. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/index.html +0 -0
  191. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/motd.html +0 -0
  192. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  193. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  194. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  195. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  196. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/auth/login.html +0 -0
  197. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/base.html +0 -0
  198. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  199. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/edit_item_modal.html +0 -0
  200. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  201. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  202. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/feedback/index.html +0 -0
  203. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/motd/channel_z.html +0 -0
  204. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/queue/index.html +0 -0
  205. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/race.html +0 -0
  206. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  207. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/user/link_device.html +0 -0
  208. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/templates/user/my_list.html +0 -0
  209. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/ws/__init__.py +0 -0
  210. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/ws/handler.py +0 -0
  211. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/kryten_webqueue/ws/manager.py +0 -0
  212. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/scripts/AGENTS.md +0 -0
  213. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/scripts/patch_mediacms_bulk_facets.py +0 -0
  214. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/scripts/patch_mediacms_tags.py +0 -0
  215. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/scripts/trigger_enrich.py +0 -0
  216. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/__init__.py +0 -0
  217. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_catalog_blackout.py +0 -0
  218. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_catalog_delete.py +0 -0
  219. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_classify.py +0 -0
  220. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_concurrency_split.py +0 -0
  221. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_config_persistence.py +0 -0
  222. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_database_config_and_schemas.py +0 -0
  223. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_device_linking.py +0 -0
  224. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_feedback.py +0 -0
  225. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_fetch_queue_pacing.py +0 -0
  226. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_fetchurls_sharepoint.py +0 -0
  227. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_identify_step.py +0 -0
  228. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_job_logs_and_queue_visibility.py +0 -0
  229. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_motd_publish.py +0 -0
  230. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_people_facets.py +0 -0
  231. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_phase1.py +0 -0
  232. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_phase2_jobs.py +0 -0
  233. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_phase3_jobs.py +0 -0
  234. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_phase4_live_fixes.py +0 -0
  235. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_playlist_import.py +0 -0
  236. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_presence_refund.py +0 -0
  237. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_promo_director.py +0 -0
  238. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_promo_pool_exclusion.py +0 -0
  239. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_providers.py +0 -0
  240. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_queue_announce.py +0 -0
  241. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_queue_preview_fallback.py +0 -0
  242. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_race_poller.py +0 -0
  243. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_recently_played_hide.py +0 -0
  244. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_save_results_to_playlist.py +0 -0
  245. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_schedule_lock.py +0 -0
  246. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_search_facets.py +0 -0
  247. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_title_normalization.py +0 -0
  248. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_tmdb_index.py +0 -0
  249. {kryten_webqueue-0.47.0 → kryten_webqueue-0.48.1}/tests/test_watchlist.py +0 -0
@@ -1,5 +1,39 @@
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
+
24
+ ## [0.48.0] - 2026-09-23
25
+
26
+ ### Added
27
+
28
+ - **PostgreSQL & Chandra-1 Podman Migration Foundations (`postgres-migration`)**:
29
+ - Added PostgreSQL backend support and configuration in `kryten_webqueue/config.py` with `PostgresConfig` and `DatabaseConfig(backend="sqlite"|"postgres")`.
30
+ - Added SQLAlchemy 2.0 async engine and session factory builder in `kryten_webqueue/catalog/db/engine.py` (`create_pg_engine`) utilizing `asyncpg` with connection pooling (`pool_size=10`, `max_overflow=20`, `pool_pre_ping=True`).
31
+ - Added schema DDL `kryten_webqueue/catalog/db/sql/001_initial_schema.sql` defining 40 tables across 5 logical schemas (`catalog`, `queue`, `jobs`, `users`, `tmdb`), English `tsvector` generated search vector, and `pg_trgm` fuzzy trigram matching.
32
+ - Implemented automated 30-day job run log pruning task (`job_log_prune_job`) in `kryten_webqueue/jobs/tasks.py` and `kryten_webqueue/catalog/db/_jobs_db.py` with strict exemptions for financial, chat, feedback, and audit history.
33
+ - Implemented one-shot ETL streaming script `kryten_webqueue/migrate_sqlite_to_pg.py` (`kryten-webqueue-migrate-pg`) with batched row streaming, identity sequence synchronization (`setval`), type normalization, and verification reporting.
34
+ - Added rootful Podman Quadlet specifications (`deploy/podman/webqueue/webqueue.network` and `webqueue-app.container`) and Containerfile for deployment on `chandra-1`.
35
+ - Added comprehensive operational cutover runbook `docs/postgres-cutover.md`.
36
+
3
37
  ## [0.47.0] - 2026-09-23
4
38
 
5
39
  ### Added
@@ -0,0 +1,23 @@
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends build-essential ffmpeg curl \
7
+ && rm -rf /var/lib/apt/lists/* \
8
+ && useradd --system --create-home --uid 1001 kryten
9
+
10
+ COPY . /tmp/kryten-webqueue
11
+
12
+ RUN pip install --no-cache-dir --upgrade pip \
13
+ && pip install --no-cache-dir /tmp/kryten-webqueue
14
+
15
+ RUN mkdir -p /etc/kryten-webqueue /var/lib/kryten-webqueue /var/log/kryten-webqueue \
16
+ && chown -R kryten:kryten /etc/kryten-webqueue /var/lib/kryten-webqueue /var/log/kryten-webqueue
17
+
18
+ USER kryten
19
+
20
+ EXPOSE 2010
21
+
22
+ ENTRYPOINT ["kryten-webqueue"]
23
+ CMD ["--config", "/etc/kryten-webqueue/config.json"]
@@ -1,13 +1,15 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: kryten-webqueue
3
- Version: 0.47.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
7
7
  Requires-Python: >=3.12
8
8
  Requires-Dist: aiosqlite>=0.20
9
9
  Requires-Dist: apscheduler>=3.10
10
+ Requires-Dist: asyncpg>=0.29.0
10
11
  Requires-Dist: fastapi>=0.115
12
+ Requires-Dist: greenlet>=3.0
11
13
  Requires-Dist: httpx>=0.27
12
14
  Requires-Dist: jinja2>=3.1
13
15
  Requires-Dist: msal>=1.28
@@ -21,6 +23,7 @@ Requires-Dist: python-multipart>=0.0.9
21
23
  Requires-Dist: python-slugify>=8.0
22
24
  Requires-Dist: pyyaml>=6.0
23
25
  Requires-Dist: requests>=2.31
26
+ Requires-Dist: sqlalchemy>=2.0.30
24
27
  Requires-Dist: uvicorn[standard]>=0.30
25
28
  Requires-Dist: websockets>=12.0
26
29
  Requires-Dist: yt-dlp>=2024.1
@@ -63,13 +63,25 @@
63
63
  },
64
64
 
65
65
  "database": {
66
+ "backend": "sqlite",
66
67
  "layout": "monolith",
67
68
  "db_path": "/var/lib/kryten-webqueue/webqueue.db",
68
69
  "data_dir": "/var/lib/kryten-webqueue/data",
69
70
  "catalog_db_path": null,
70
71
  "queue_db_path": null,
71
72
  "jobs_db_path": null,
72
- "users_db_path": null
73
+ "users_db_path": null,
74
+ "postgres": {
75
+ "host": "host.containers.internal",
76
+ "port": 5432,
77
+ "user": "kryten",
78
+ "dbname": "webqueue",
79
+ "password_env": "KRYTEN_WEBQUEUE_PG_PASSWORD",
80
+ "dsn_env": null,
81
+ "dsn": null,
82
+ "pool_size": 10,
83
+ "max_overflow": 20
84
+ }
73
85
  },
74
86
  "db_path": "/var/lib/kryten-webqueue/webqueue.db",
75
87
 
@@ -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 "";
@@ -0,0 +1,24 @@
1
+ [Unit]
2
+ Description=kryten-webqueue - CyTube Queue Management (FastAPI app)
3
+ After=network-online.target
4
+
5
+ [Container]
6
+ ContainerName=webqueue-app
7
+ Image=localhost/kryten-webqueue:latest
8
+ Network=podman-webqueue
9
+ PublishPort=2010:2010
10
+ AddHost=host.containers.internal:host-gateway
11
+
12
+ EnvironmentFile=/etc/kryten/webqueue/webqueue.env
13
+ Volume=/etc/kryten/webqueue/config.json:/etc/kryten-webqueue/config.json:ro,z
14
+ Volume=/var/lib/kryten/media:/var/lib/kryten-webqueue:Z
15
+ Volume=/var/log/kryten/webqueue:/var/log/kryten-webqueue:Z
16
+
17
+ Exec=uvicorn kryten_webqueue.app:app --host 0.0.0.0 --port 2010
18
+
19
+ [Service]
20
+ Restart=always
21
+ RestartSec=5
22
+
23
+ [Install]
24
+ WantedBy=multi-user.target default.target
@@ -0,0 +1,3 @@
1
+ [Network]
2
+ NetworkName=podman-webqueue
3
+ Subnet=10.89.30.0/24
@@ -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`).
@@ -0,0 +1,152 @@
1
+ # PostgreSQL Cutover Runbook: Kryten-WebQueue
2
+
3
+ **Target Version**: `0.48.0`
4
+ **Host Target**: `chandra-1` (PostgreSQL 16 + Podman Quadlet)
5
+ **Source Host**: `grindhouse.local` (pipx systemd / SQLite partitioned data)
6
+ **Parent Plan**: [DATABASE_ARCHITECTURE_PLAN.md](DATABASE_ARCHITECTURE_PLAN.md)
7
+ **PRD**: [postgres-migration/PRD-postgres-migration.md](postgres-migration/PRD-postgres-migration.md)
8
+
9
+ ---
10
+
11
+ ## 1. Pre-Flight Checklist
12
+
13
+ - [ ] `chandra-1` has PostgreSQL 16+ active and `pg_trgm` extension installed.
14
+ - [ ] Database `webqueue` exists with owner `kryten`.
15
+ - [ ] Schema `001_initial_schema.sql` applied cleanly across `catalog`, `queue`, `jobs`, `users`, `tmdb`.
16
+ - [ ] Privileges granted to `kryten`:
17
+ ```sql
18
+ GRANT USAGE, CREATE ON SCHEMA catalog, queue, jobs, users, tmdb TO kryten;
19
+ GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA catalog, queue, jobs, users, tmdb, public TO kryten;
20
+ GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA catalog, queue, jobs, users, tmdb, public TO kryten;
21
+ ALTER DEFAULT PRIVILEGES IN SCHEMA catalog, queue, jobs, users, tmdb GRANT ALL ON TABLES TO kryten;
22
+ ALTER DEFAULT PRIVILEGES IN SCHEMA catalog, queue, jobs, users, tmdb GRANT ALL ON SEQUENCES TO kryten;
23
+ ```
24
+ - [ ] Podman image built on `chandra-1`:
25
+ ```bash
26
+ sudo podman build -f Containerfile -t localhost/kryten-webqueue:latest .
27
+ ```
28
+ - [ ] Environment file `/etc/kryten/webqueue/webqueue.env` created on `chandra-1`:
29
+ ```env
30
+ KRYTEN_WEBQUEUE_PG_PASSWORD=kryten_secret_password
31
+ ```
32
+
33
+ ---
34
+
35
+ ## 2. Maintenance Window Cutover (Step-by-Step)
36
+
37
+ ### Step 1: Stop Source Service & Freeze Writes
38
+ On `grindhouse.local`:
39
+ ```bash
40
+ sudo systemctl stop kryten-webqueue.service
41
+ sudo systemctl status kryten-webqueue.service --no-pager
42
+ ```
43
+
44
+ ### Step 2: Checkpoint SQLite WAL & Create Final Snapshot
45
+ On `grindhouse.local`:
46
+ ```bash
47
+ SNAPSHOT_DIR="/var/lib/kryten-webqueue/backups/cutover_$(date +%Y%m%d_%H%M%S)"
48
+ sudo mkdir -p "$SNAPSHOT_DIR"
49
+
50
+ for db in catalog queue jobs users; do
51
+ sudo sqlite3 "/var/lib/kryten-webqueue/data/$db.sqlite3" "PRAGMA wal_checkpoint(TRUNCATE);"
52
+ done
53
+
54
+ sudo cp -av /var/lib/kryten-webqueue/data "$SNAPSHOT_DIR/"
55
+ sudo cp -av /etc/kryten-webqueue/config.json "$SNAPSHOT_DIR/"
56
+ ```
57
+
58
+ ### Step 3: Stream SQLite Data to PostgreSQL on Chandra-1
59
+ Run the ETL migration script with `--verify`:
60
+ ```bash
61
+ sudo -u kryten /home/kryten/.local/pipx/venvs/kryten-webqueue/bin/python -m kryten_webqueue.migrate_sqlite_to_pg \
62
+ --data-dir /var/lib/kryten-webqueue/data \
63
+ --pg-dsn "postgresql://kryten:kryten_secret_password@chandra-1.local:5432/webqueue"
64
+ ```
65
+ Verify `postgres_migration_report.json` indicates `"status": "success"` and 0 errors.
66
+
67
+ ### Step 4: Transfer Media & Image Assets to Chandra-1
68
+ Sync cover art images to `/var/lib/kryten/media`:
69
+ ```bash
70
+ rsync -avz /var/lib/kryten-webqueue/images/ groberts@chandra-1.local:/var/lib/kryten/media/images/
71
+ ```
72
+
73
+ ### Step 5: Start Podman Quadlet on Chandra-1
74
+ On `chandra-1`:
75
+ ```bash
76
+ sudo cp deploy/podman/webqueue/webqueue.network /etc/containers/systemd/
77
+ sudo cp deploy/podman/webqueue/webqueue-app.container /etc/containers/systemd/
78
+ sudo systemctl daemon-reload
79
+ sudo systemctl start webqueue-app.service
80
+ sudo systemctl status webqueue-app.service --no-pager
81
+ ```
82
+
83
+ Verify local health inside `chandra-1`:
84
+ ```bash
85
+ curl -I http://127.0.0.1:2010/auth/login
86
+ curl -s http://127.0.0.1:2010/queue/next-schedule
87
+ ```
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
+
137
+ ---
138
+
139
+ ## 3. Rollback & Forward Repair Policy
140
+
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
@@ -111,6 +111,7 @@ async def lifespan(app: FastAPI):
111
111
  motd_posters_job,
112
112
  tmdb_index_refresh_job,
113
113
  tmdb_coverage_report_job,
114
+ job_log_prune_job,
114
115
  CATALOG_ENRICH_SCHEMA,
115
116
  CATALOG_BLACKOUT_SCHEMA,
116
117
  DEVICE_KEY_BAN_RECONCILE_SCHEMA,
@@ -121,6 +122,7 @@ async def lifespan(app: FastAPI):
121
122
  MOTD_POSTERS_SCHEMA,
122
123
  TMDB_INDEX_REFRESH_SCHEMA,
123
124
  TMDB_COVERAGE_REPORT_SCHEMA,
125
+ JOB_LOG_PRUNE_SCHEMA,
124
126
  )
125
127
 
126
128
  job_manager.register(
@@ -168,6 +170,12 @@ async def lifespan(app: FastAPI):
168
170
  label="MOTD Posters (weekend poster grid)",
169
171
  schema=MOTD_POSTERS_SCHEMA,
170
172
  )
173
+ job_manager.register(
174
+ "job_log_prune",
175
+ job_log_prune_job,
176
+ label="Job Run Log Pruner (30-day retention)",
177
+ schema=JOB_LOG_PRUNE_SCHEMA,
178
+ )
171
179
  from .jobs.motd_publish import motd_publish_job, MOTD_PUBLISH_SCHEMA
172
180
 
173
181
  job_manager.register(
@@ -107,17 +107,17 @@ _DOMAIN_METHOD_MAP: dict[str, str] = {
107
107
  "get_job_schedule": "jobs",
108
108
  "upsert_job_schedule": "jobs",
109
109
  "delete_job_schedule": "jobs",
110
- "fetch_queue_add": "jobs",
111
- "fetch_queue_get": "jobs",
112
- "fetch_queue_update": "jobs",
113
- "fetch_queue_delete": "jobs",
114
- "fetch_queue_list": "jobs",
115
- "fetch_queue_count": "jobs",
116
- "fetch_queue_get_next_pending": "jobs",
117
- "fetch_queue_mark_started": "jobs",
118
- "fetch_queue_mark_finished": "jobs",
119
- "fetch_queue_requeue": "jobs",
120
- "fetch_queue_cleanup": "jobs",
110
+ "prune_job_run_logs": "jobs",
111
+ "enqueue_fetch": "jobs",
112
+ "claim_next_fetch_item": "jobs",
113
+ "finish_fetch_item": "jobs",
114
+ "requeue_fetch_item": "jobs",
115
+ "requeue_fetch_item_for_retry": "jobs",
116
+ "reset_running_fetch_items": "jobs",
117
+ "get_fetch_queue": "jobs",
118
+ "count_fetch_queue": "jobs",
119
+ "count_fetch_queue_pending": "jobs",
120
+ "delete_fetch_queue_item": "jobs",
121
121
  # users
122
122
  "watchlist_add": "users",
123
123
  "watchlist_remove": "users",
@@ -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",
@@ -303,10 +304,22 @@ class Database(
303
304
  except AttributeError:
304
305
  layout = "monolith"
305
306
 
306
- if layout == "partitioned" and name in _DOMAIN_METHOD_MAP:
307
- domain_name = _DOMAIN_METHOD_MAP[name]
308
- domain_obj = object.__getattribute__(self, domain_name)
309
- return getattr(domain_obj, name)
307
+ if layout == "partitioned":
308
+ if name in Database.__dict__ or name.startswith("_"):
309
+ return object.__getattribute__(self, name)
310
+
311
+ if name in _DOMAIN_METHOD_MAP:
312
+ domain_name = _DOMAIN_METHOD_MAP[name]
313
+ domain_obj = object.__getattribute__(self, domain_name)
314
+ return getattr(domain_obj, name)
315
+
316
+ for domain_name in ("jobs", "queue", "users", "catalog"):
317
+ try:
318
+ domain_obj = object.__getattribute__(self, domain_name)
319
+ if hasattr(domain_obj, name) and not hasattr(_DBBase, name):
320
+ return getattr(domain_obj, name)
321
+ except AttributeError:
322
+ pass
310
323
 
311
324
  return object.__getattribute__(self, name)
312
325
 
@@ -321,6 +334,16 @@ class Database(
321
334
 
322
335
  # --- Decoupled Cross-Domain Orchestrations ---
323
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
+
324
347
  async def browse(
325
348
  self,
326
349
  *,
@@ -345,7 +368,7 @@ class Database(
345
368
  recently_played_days
346
369
  )
347
370
  exclude_set.update(hidden)
348
- reserved = await self.queue.get_reserved_media_ids()
371
+ reserved = await self._get_reserved_tokens()
349
372
  exclude_set.update(reserved)
350
373
  blackouts = await self.queue.get_active_blackout_tokens()
351
374
  if not show_hidden:
@@ -413,7 +436,7 @@ class Database(
413
436
  recently_played_days
414
437
  )
415
438
  exclude_set.update(hidden)
416
- reserved = await self.queue.get_reserved_media_ids()
439
+ reserved = await self._get_reserved_tokens()
417
440
  exclude_set.update(reserved)
418
441
  if not show_hidden:
419
442
  blackouts = await self.queue.get_active_blackout_tokens()
@@ -471,7 +494,7 @@ class Database(
471
494
  recently_played_days
472
495
  )
473
496
  exclude_set.update(hidden)
474
- reserved = await self.queue.get_reserved_media_ids()
497
+ reserved = await self._get_reserved_tokens()
475
498
  exclude_set.update(reserved)
476
499
  blackouts = await self.queue.get_active_blackout_tokens()
477
500
  if not show_hidden:
@@ -542,7 +565,7 @@ class Database(
542
565
  recently_played_days
543
566
  )
544
567
  exclude_set.update(hidden)
545
- reserved = await self.queue.get_reserved_media_ids()
568
+ reserved = await self._get_reserved_tokens()
546
569
  exclude_set.update(reserved)
547
570
  if not show_hidden:
548
571
  blackouts = await self.queue.get_active_blackout_tokens()
@@ -600,6 +623,38 @@ class Database(
600
623
  self, username, page=page, per_page=per_page
601
624
  )
602
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
+
603
658
  async def get_item(self, friendly_token: str) -> dict | None:
604
659
  if self._layout == "partitioned":
605
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
@@ -1331,6 +1342,30 @@ class _CatalogMixin:
1331
1342
  async def delete_job_schedule(self, job_name: str) -> None:
1332
1343
  await self._execute("DELETE FROM job_schedules WHERE job_name=?", [job_name])
1333
1344
 
1345
+ async def prune_job_run_logs(
1346
+ self, retention_days: int = 30, batch_size: int = 5000
1347
+ ) -> int:
1348
+ total_deleted = 0
1349
+ cutoff = f"-{int(retention_days)} days"
1350
+ while True:
1351
+ cursor = await self._db.execute(
1352
+ """
1353
+ DELETE FROM job_run_logs
1354
+ WHERE id IN (
1355
+ SELECT id FROM job_run_logs
1356
+ WHERE logged_at < datetime('now', ?)
1357
+ LIMIT ?
1358
+ )
1359
+ """,
1360
+ [cutoff, batch_size],
1361
+ )
1362
+ await self._db.commit()
1363
+ count = cursor.rowcount or 0
1364
+ total_deleted += count
1365
+ if count < batch_size:
1366
+ break
1367
+ return total_deleted
1368
+
1334
1369
  # --- OTP ---
1335
1370
 
1336
1371
  async def store_otp(self, username: str, code: str, expires_at: str):