kryten-webqueue 0.32.2__tar.gz → 0.33.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 (130) hide show
  1. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/CHANGELOG.md +19 -0
  2. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/SPEC_JOBS_AND_BROWSE.md +3 -3
  4. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/api_gate/client.py +87 -44
  5. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/app.py +65 -20
  6. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/session.py +3 -1
  7. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/__init__.py +6 -1
  8. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_catalog.py +143 -42
  9. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_connection.py +3 -1
  10. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_feedback.py +38 -17
  11. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_playlists.py +94 -25
  12. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_queue.py +50 -15
  13. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/images.py +61 -30
  14. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/mediacms.py +19 -7
  15. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/sync.py +15 -5
  16. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/config.py +9 -9
  17. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +407 -187
  18. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +166 -71
  19. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +372 -183
  20. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +277 -137
  21. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/ytpipe/downloader.py +1680 -1291
  22. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/fetchurls_auth.py +3 -1
  23. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/manager.py +51 -16
  24. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/tasks.py +157 -38
  25. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/logging_config.py +13 -3
  26. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/bulk_add.py +4 -1
  27. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/fire.py +32 -14
  28. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/importer.py +93 -48
  29. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/scheduler.py +21 -5
  30. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/promos/director.py +96 -29
  31. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/completion.py +15 -7
  32. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/ordering.py +170 -46
  33. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/poller.py +11 -1
  34. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/presence.py +32 -12
  35. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/race_poller.py +5 -2
  36. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/shadow.py +36 -12
  37. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_catalog.py +19 -7
  38. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_feedback.py +8 -2
  39. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_jobs.py +9 -2
  40. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_moderation.py +9 -1
  41. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_playlists.py +71 -28
  42. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_queue.py +4 -1
  43. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_schedules.py +12 -4
  44. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/auth.py +3 -1
  45. kryten_webqueue-0.33.1/kryten_webqueue/routes/catalog.py +105 -0
  46. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/feedback.py +31 -11
  47. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/pages.py +106 -50
  48. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/queue.py +21 -7
  49. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/user.py +6 -2
  50. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/ws/handler.py +0 -1
  51. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/pyproject.toml +1 -1
  52. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_config_persistence.py +10 -5
  53. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_feedback.py +136 -47
  54. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_fetchurls_sharepoint.py +51 -18
  55. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_phase1.py +39 -17
  56. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_phase2_jobs.py +22 -5
  57. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_phase3_jobs.py +69 -25
  58. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_phase4_live_fixes.py +70 -26
  59. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_playlist_import.py +52 -30
  60. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_presence_refund.py +33 -23
  61. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_promo_director.py +161 -68
  62. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_promo_pool_exclusion.py +40 -16
  63. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_queue_announce.py +27 -19
  64. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_race_poller.py +34 -25
  65. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_recently_played_hide.py +201 -55
  66. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_save_results_to_playlist.py +51 -30
  67. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_schedule_lock.py +2 -3
  68. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/test_search_facets.py +23 -13
  69. kryten_webqueue-0.32.2/kryten_webqueue/routes/catalog.py +0 -58
  70. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/.github/workflows/python-publish.yml +0 -0
  71. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/.github/workflows/release.yml +0 -0
  72. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/.gitignore +0 -0
  73. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/README.md +0 -0
  74. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/config.example.json +0 -0
  75. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/deploy/kryten-webqueue.service +0 -0
  76. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/deploy/nginx-queue.conf +0 -0
  77. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  78. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/IMPL_API_GATE.md +0 -0
  79. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/IMPL_ECONOMY.md +0 -0
  80. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  81. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/IMPL_ROBOT.md +0 -0
  82. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
  83. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
  84. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
  85. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  86. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/PRE_PLAN_GAPS.md +0 -0
  87. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/PRODUCT_PLAN.md +0 -0
  88. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/docs/UX_POLISH_PLAN.md +0 -0
  89. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/__init__.py +0 -0
  90. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/__main__.py +0 -0
  91. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  92. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/__init__.py +0 -0
  93. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/otp.py +0 -0
  94. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  95. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/__init__.py +0 -0
  96. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/__init__.py +0 -0
  97. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  98. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  99. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  100. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/__init__.py +0 -0
  101. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/__init__.py +0 -0
  102. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/ordering.py +0 -0
  103. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/promos/__init__.py +0 -0
  104. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/__init__.py +0 -0
  105. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/__init__.py +0 -0
  106. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  107. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/css/main.css +0 -0
  108. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-playlists.js +0 -0
  109. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
  110. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
  111. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
  112. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin.js +0 -0
  113. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/main.js +0 -0
  114. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/index.html +0 -0
  115. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  116. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  117. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  118. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  119. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/auth/login.html +0 -0
  120. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/base.html +0 -0
  121. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  122. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  123. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  124. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/feedback/index.html +0 -0
  125. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/queue/index.html +0 -0
  126. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/race.html +0 -0
  127. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  128. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/ws/__init__.py +0 -0
  129. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/kryten_webqueue/ws/manager.py +0 -0
  130. {kryten_webqueue-0.32.2 → kryten_webqueue-0.33.1}/tests/__init__.py +0 -0
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.33.1] — 2026-07-29
9
+
10
+ ### Changed
11
+
12
+ - **Applied `black` formatting across the codebase.** Repository-wide `black` pass (default profile) to bring all source and test modules — including the vendored `integrations/cmsutils/*` tools — into a consistent style. No behavioral changes; purely cosmetic.
13
+
14
+ ## [0.33.0] — 2026-07-29
15
+
16
+ ### Added
17
+
18
+ - **Sunday schedule in the "Fetch URLs (weekend workbook)" job.** `fetchurls` now resolves two additional column-A sections from the weekend workbook: `SUNDAY MORNING` → the **"Sunday Morning"** saved playlist (slug `sunday-morning`), and `SUNDAY AFTERNOON` → the **"Sunday Daytime"** saved playlist (slug `sunday-daytime`). The two new sections join the existing Friday/Saturday sections and are resolved and imported the same way (dropsugar.co validated via HEAD; YouTube/Tubi auto-downloaded and posted to the CMS). The job's `section` parameter gains `sunday-morning` and `sunday-daytime` options (`all` still runs every section). Sunday lives in the existing `M.D-M.D` weekend sheet as extra rows — the worksheet naming scheme is unchanged (first sheet with Sunday rows is `8.7-8.8`).
19
+
20
+ ## [0.32.3] — 2026-07-09
21
+
22
+ ### Fixed
23
+
24
+ - **Tag/category-classified promos are now also exempt from recently-played hiding.** v0.32.2 only exempted promo-*pool* clips, but most station promos/bumpers (e.g. “CHANNEL Z …”) are classified by hidden category (`Z Channel Promos`) or tag (`channelz`, `bumpers`, …) instead, so they were still being recorded and appeared in the recently-played debug list. `record_play_completion` now skips any item hidden from the public catalog by a promo pool **or** a hidden category/tag. On startup the service self-heals by purging any such rows written by earlier builds (`purge_promo_hide_state`, now covering the tag/category case).
25
+
8
26
  ## [0.32.2] — 2026-07-09
9
27
 
10
28
  ### Fixed
@@ -35,6 +53,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
35
53
  [0.32.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.0
36
54
  [0.32.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.1
37
55
  [0.32.2]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.2
56
+ [0.32.3]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.32.3
38
57
 
39
58
  ## [0.31.0] — 2026-07-04
40
59
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.32.2
3
+ Version: 0.33.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
@@ -146,14 +146,14 @@ Reimplements `fetchurls.py`: read the Channel Z Excel workbook from SharePoint,
146
146
 
147
147
  | Field | Type | Default | Notes |
148
148
  |-------|------|---------|-------|
149
- | `section` | enum | `all` | `all` \| `friday` \| `saturday-night` \| `saturday-morning`. |
149
+ | `section` | enum | `all` | `all` \| `friday` \| `saturday-night` \| `saturday-morning` \| `sunday-morning` \| `sunday-daytime`. |
150
150
  | `dry_run` | bool | `false` | Resolve/preview only; no downloads, no writeback, no import. |
151
151
  | `writeback` | bool | `true` | Write resolved URLs back to column F. |
152
152
  | `validate` | bool | `true` | HEAD-check existing dropsugar.co URLs. |
153
153
 
154
154
  **Import resulting playlists (new):**
155
- - The tool produces `playlists/{sheet}-friday.txt`, `{sheet}-saturday-night.txt`, `{sheet}-saturday-morning.txt`, and `{sheet}-failures.txt`.
156
- - After a successful (non-dry-run) run, **import each non-failures file as a saved playlist** named exactly like the file stem: `{sheet}-friday`, `{sheet}-saturday-night`, `{sheet}-saturday-morning`. Reuse `import_playlist_text()` to resolve lines, then `create_saved_playlist(name=stem, created_by=triggered_by)` + `replace_playlist_items()`.
155
+ - The tool produces `playlists/{sheet}-friday.txt`, `{sheet}-saturday-night.txt`, `{sheet}-saturday-morning.txt`, `{sheet}-sunday-morning.txt`, `{sheet}-sunday-daytime.txt`, and `{sheet}-failures.txt`.
156
+ - After a successful (non-dry-run) run, **import each non-failures file as a saved playlist** named exactly like the file stem: `{sheet}-friday`, `{sheet}-saturday-night`, `{sheet}-saturday-morning`, `{sheet}-sunday-morning`, `{sheet}-sunday-daytime`. Reuse `import_playlist_text()` to resolve lines, then `create_saved_playlist(name=stem, created_by=triggered_by)` + `replace_playlist_items()`.
157
157
  - If a playlist of that name already exists, **replace its items** (idempotent re-runs) rather than creating a duplicate. (Match by exact name + `created_by`.)
158
158
  - The `{sheet}-failures` file is **not** imported; surface its count in the run detail.
159
159
 
@@ -59,14 +59,24 @@ class ApiGateClient:
59
59
 
60
60
  # --- Playlist CRUD ---
61
61
 
62
- async def playlist_add(self, media_type: str, media_id: str, *, position: str = "end", temp: bool = True) -> dict:
62
+ async def playlist_add(
63
+ self,
64
+ media_type: str,
65
+ media_id: str,
66
+ *,
67
+ position: str = "end",
68
+ temp: bool = True,
69
+ ) -> dict:
63
70
  """Add item to playlist. Returns {"success": bool, "uid": int|None}."""
64
- return await self.post("/playlist/add", json={
65
- "type": media_type,
66
- "id": media_id,
67
- "position": position,
68
- "temp": temp,
69
- })
71
+ return await self.post(
72
+ "/playlist/add",
73
+ json={
74
+ "type": media_type,
75
+ "id": media_id,
76
+ "position": position,
77
+ "temp": temp,
78
+ },
79
+ )
70
80
 
71
81
  async def playlist_move(self, uid: int, position: int | str) -> dict:
72
82
  """Move item. position is a UID (int) or "prepend"/"append"."""
@@ -87,7 +97,9 @@ class ApiGateClient:
87
97
  return await self.post("/chat/send", json={"message": message})
88
98
 
89
99
  async def send_pm(self, username: str, message: str) -> dict:
90
- return await self.post("/chat/pm", json={"username": username, "message": message})
100
+ return await self.post(
101
+ "/chat/pm", json={"username": username, "message": message}
102
+ )
91
103
 
92
104
  # --- Admin ---
93
105
 
@@ -100,51 +112,77 @@ class ApiGateClient:
100
112
  async def get_balance(self, username: str) -> dict:
101
113
  return await self.get(f"/economy/balance/{username}")
102
114
 
103
- async def get_transactions(self, username: str, limit: int = 20, offset: int = 0) -> dict:
104
- return await self.get(f"/economy/transactions/{username}", limit=limit, offset=offset)
115
+ async def get_transactions(
116
+ self, username: str, limit: int = 20, offset: int = 0
117
+ ) -> dict:
118
+ return await self.get(
119
+ f"/economy/transactions/{username}", limit=limit, offset=offset
120
+ )
105
121
 
106
122
  async def get_account_summary(self, username: str) -> dict:
107
123
  return await self.get(f"/economy/account/{username}")
108
124
 
109
125
  async def set_vanity_greeting(self, username: str, value: str) -> dict:
110
- return await self.post("/economy/vanity/greeting", json={
111
- "username": username,
112
- "value": value,
113
- })
126
+ return await self.post(
127
+ "/economy/vanity/greeting",
128
+ json={
129
+ "username": username,
130
+ "value": value,
131
+ },
132
+ )
114
133
 
115
134
  async def set_vanity_color(self, username: str, value: str) -> dict:
116
- return await self.post("/economy/vanity/color", json={
117
- "username": username,
118
- "value": value,
119
- })
135
+ return await self.post(
136
+ "/economy/vanity/color",
137
+ json={
138
+ "username": username,
139
+ "value": value,
140
+ },
141
+ )
120
142
 
121
143
  async def set_vanity_shoutout(self, username: str, value: str) -> dict:
122
- return await self.post("/economy/vanity/shoutout", json={
123
- "username": username,
124
- "value": value,
125
- })
126
-
127
- async def queue_preview(self, username: str, duration_sec: int, tier: str = "queue") -> dict:
128
- return await self.post("/economy/queue-preview", json={
129
- "username": username,
130
- "duration_sec": duration_sec,
131
- "tier": tier,
132
- })
133
-
134
- async def queue_spend(self, username: str, duration_sec: int, tier: str, request_id: str) -> dict:
135
- return await self.post("/economy/queue-spend", json={
136
- "username": username,
137
- "duration_sec": duration_sec,
138
- "tier": tier,
139
- "request_id": request_id,
140
- })
144
+ return await self.post(
145
+ "/economy/vanity/shoutout",
146
+ json={
147
+ "username": username,
148
+ "value": value,
149
+ },
150
+ )
151
+
152
+ async def queue_preview(
153
+ self, username: str, duration_sec: int, tier: str = "queue"
154
+ ) -> dict:
155
+ return await self.post(
156
+ "/economy/queue-preview",
157
+ json={
158
+ "username": username,
159
+ "duration_sec": duration_sec,
160
+ "tier": tier,
161
+ },
162
+ )
163
+
164
+ async def queue_spend(
165
+ self, username: str, duration_sec: int, tier: str, request_id: str
166
+ ) -> dict:
167
+ return await self.post(
168
+ "/economy/queue-spend",
169
+ json={
170
+ "username": username,
171
+ "duration_sec": duration_sec,
172
+ "tier": tier,
173
+ "request_id": request_id,
174
+ },
175
+ )
141
176
 
142
177
  async def queue_refund(self, username: str, request_id: str, reason: str) -> dict:
143
- return await self.post("/economy/queue-refund", json={
144
- "username": username,
145
- "request_id": request_id,
146
- "reason": reason,
147
- })
178
+ return await self.post(
179
+ "/economy/queue-refund",
180
+ json={
181
+ "username": username,
182
+ "request_id": request_id,
183
+ "reason": reason,
184
+ },
185
+ )
148
186
 
149
187
  # --- Moderator service status ---
150
188
 
@@ -159,7 +197,9 @@ class ApiGateClient:
159
197
 
160
198
  # --- Moderation entries ---
161
199
 
162
- async def mod_list_entries(self, channel: str, action_filter: str | None = None) -> dict:
200
+ async def mod_list_entries(
201
+ self, channel: str, action_filter: str | None = None
202
+ ) -> dict:
163
203
  params: dict = {}
164
204
  if action_filter:
165
205
  params["filter"] = action_filter
@@ -206,12 +246,15 @@ class ApiGateClient:
206
246
 
207
247
  async def mod_remove_pattern(self, channel: str, pattern: str) -> dict:
208
248
  from urllib.parse import quote
249
+
209
250
  encoded = quote(pattern, safe="")
210
251
  return await self.delete(f"/channels/{channel}/patterns/{encoded}")
211
252
 
212
253
  # --- Recent users ---
213
254
 
214
- async def mod_recent_users(self, channel: str, window_minutes: float = 60.0) -> dict:
255
+ async def mod_recent_users(
256
+ self, channel: str, window_minutes: float = 60.0
257
+ ) -> dict:
215
258
  return await self.get(
216
259
  f"/channels/{channel}/users/recent", window_minutes=window_minutes
217
260
  )
@@ -51,6 +51,17 @@ async def lifespan(app: FastAPI):
51
51
  db = Database(config.db_path)
52
52
  await db.connect()
53
53
  await db.run_migrations()
54
+ # Self-heal: purge any recently-played hide state recorded for promos/bumpers
55
+ # (they are excluded from the public catalog and must never be hidden by the
56
+ # recently-played rules). Cheap and idempotent; also cleans rows written by
57
+ # older builds that classified promos only by pool membership.
58
+ purged = await db.purge_promo_hide_state()
59
+ if purged["completions"] or purged["playlist_pass"]:
60
+ logger.info(
61
+ "Purged promo recently-played state: %d completion(s), %d pass row(s)",
62
+ purged["completions"],
63
+ purged["playlist_pass"],
64
+ )
54
65
  # Any job run still marked 'running' is an orphan from a prior crash/restart
55
66
  # (the running flag is in-memory only). Reconcile before registering jobs.
56
67
  orphaned = await db.reconcile_orphaned_job_runs()
@@ -98,21 +109,42 @@ async def lifespan(app: FastAPI):
98
109
  # register regardless of optional deps; a missing dep fails the run fast
99
110
  # with a clear message rather than crashing startup.
100
111
  from .jobs.tasks import (
101
- enrichtitles_job, enrichmeta_job, enrichtv_job,
102
- fetch_job, fetchurls_job,
103
- ENRICHTITLES_SCHEMA, ENRICHMETA_SCHEMA, ENRICHTV_SCHEMA,
104
- FETCH_SCHEMA, FETCHURLS_SCHEMA,
112
+ enrichtitles_job,
113
+ enrichmeta_job,
114
+ enrichtv_job,
115
+ fetch_job,
116
+ fetchurls_job,
117
+ ENRICHTITLES_SCHEMA,
118
+ ENRICHMETA_SCHEMA,
119
+ ENRICHTV_SCHEMA,
120
+ FETCH_SCHEMA,
121
+ FETCHURLS_SCHEMA,
122
+ )
123
+
124
+ job_manager.register(
125
+ "enrichtitles",
126
+ enrichtitles_job,
127
+ label="Enrich Titles",
128
+ schema=ENRICHTITLES_SCHEMA,
129
+ )
130
+ job_manager.register(
131
+ "enrichmeta",
132
+ enrichmeta_job,
133
+ label="Enrich Movie Metadata",
134
+ schema=ENRICHMETA_SCHEMA,
135
+ )
136
+ job_manager.register(
137
+ "enrichtv", enrichtv_job, label="Enrich TV Metadata", schema=ENRICHTV_SCHEMA
138
+ )
139
+ job_manager.register(
140
+ "fetch", fetch_job, label="Fetch (download → MediaCMS)", schema=FETCH_SCHEMA
141
+ )
142
+ job_manager.register(
143
+ "fetchurls",
144
+ fetchurls_job,
145
+ label="Fetch URLs (weekend workbook)",
146
+ schema=FETCHURLS_SCHEMA,
105
147
  )
106
- job_manager.register("enrichtitles", enrichtitles_job,
107
- label="Enrich Titles", schema=ENRICHTITLES_SCHEMA)
108
- job_manager.register("enrichmeta", enrichmeta_job,
109
- label="Enrich Movie Metadata", schema=ENRICHMETA_SCHEMA)
110
- job_manager.register("enrichtv", enrichtv_job,
111
- label="Enrich TV Metadata", schema=ENRICHTV_SCHEMA)
112
- job_manager.register("fetch", fetch_job,
113
- label="Fetch (download → MediaCMS)", schema=FETCH_SCHEMA)
114
- job_manager.register("fetchurls", fetchurls_job,
115
- label="Fetch URLs (weekend workbook)", schema=FETCHURLS_SCHEMA)
116
148
  app.state.job_manager = job_manager
117
149
 
118
150
  # WebSocket manager
@@ -133,7 +165,10 @@ async def lifespan(app: FastAPI):
133
165
 
134
166
  # Promo director (poller-driven; also used synchronously by the pay path)
135
167
  promo_director = PromoDirector(
136
- api_gate=api_gate, db=db, shadow=shadow, config=config.promos,
168
+ api_gate=api_gate,
169
+ db=db,
170
+ shadow=shadow,
171
+ config=config.promos,
137
172
  add_delay_sec=config.playlist_bulk_add_delay_sec,
138
173
  add_max_retries=config.playlist_bulk_add_max_retries,
139
174
  )
@@ -164,7 +199,10 @@ async def lifespan(app: FastAPI):
164
199
 
165
200
  # Playlist scheduler
166
201
  scheduler = PlaylistScheduler(
167
- db=db, api_gate=api_gate, shadow=shadow, ws_manager=ws_manager,
202
+ db=db,
203
+ api_gate=api_gate,
204
+ shadow=shadow,
205
+ ws_manager=ws_manager,
168
206
  add_delay_sec=config.playlist_bulk_add_delay_sec,
169
207
  add_max_retries=config.playlist_bulk_add_max_retries,
170
208
  promo_director=promo_director,
@@ -174,7 +212,10 @@ async def lifespan(app: FastAPI):
174
212
 
175
213
  # Presence-based cancel/refund monitor
176
214
  presence_monitor = PresenceRefundMonitor(
177
- api_gate=api_gate, shadow=shadow, db=db, ws_manager=ws_manager,
215
+ api_gate=api_gate,
216
+ shadow=shadow,
217
+ db=db,
218
+ ws_manager=ws_manager,
178
219
  config=config.presence_refund,
179
220
  )
180
221
  await presence_monitor.start()
@@ -205,7 +246,8 @@ async def lifespan(app: FastAPI):
205
246
  while True:
206
247
  await asyncio.sleep(300) # every 5 minutes
207
248
  try:
208
- await db._execute("""
249
+ await db._execute(
250
+ """
209
251
  UPDATE saved_playlists SET is_immutable = 0
210
252
  WHERE id IN (
211
253
  SELECT sp.id FROM saved_playlists sp
@@ -220,7 +262,8 @@ async def lifespan(app: FastAPI):
220
262
  AND (ps2.immutability_expires_at IS NULL OR ps2.immutability_expires_at > datetime('now'))
221
263
  )
222
264
  )
223
- """)
265
+ """
266
+ )
224
267
  except Exception as e:
225
268
  logger.error(f"Immutability expiry error: {e}")
226
269
 
@@ -230,7 +273,9 @@ async def lifespan(app: FastAPI):
230
273
  asyncio.create_task(_immutability_expiry_loop()),
231
274
  ]
232
275
 
233
- logger.info(f"kryten-webqueue v{__version__} started on {config.host}:{config.port}")
276
+ logger.info(
277
+ f"kryten-webqueue v{__version__} started on {config.host}:{config.port}"
278
+ )
234
279
 
235
280
  yield
236
281
 
@@ -3,7 +3,9 @@ from datetime import datetime, timedelta, UTC
3
3
  from fastapi import Request, HTTPException
4
4
 
5
5
 
6
- def create_session_token(username: str, rank: int, secret_key: str, ttl_hours: int = 24) -> str:
6
+ def create_session_token(
7
+ username: str, rank: int, secret_key: str, ttl_hours: int = 24
8
+ ) -> str:
7
9
  payload = {
8
10
  "sub": username,
9
11
  "rank": rank,
@@ -1,5 +1,10 @@
1
1
  from ._connection import _DBBase
2
- from ._catalog import _CatalogMixin, HIDDEN_ITEM_TAG, HIDDEN_CATEGORY_NAMES, HIDDEN_TAG_NAMES
2
+ from ._catalog import (
3
+ _CatalogMixin,
4
+ HIDDEN_ITEM_TAG,
5
+ HIDDEN_CATEGORY_NAMES,
6
+ HIDDEN_TAG_NAMES,
7
+ )
3
8
  from ._playlists import _PlaylistsMixin
4
9
  from ._queue import _QueueMixin
5
10
  from ._feedback import _FeedbackMixin