kryten-webqueue 0.22.0__tar.gz → 0.23.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 (107) hide show
  1. kryten_webqueue-0.23.1/.github/workflows/python-publish.yml +93 -0
  2. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/.github/workflows/release.yml +6 -12
  3. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/CHANGELOG.md +20 -0
  4. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/PKG-INFO +1 -1
  5. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/catalog/db.py +31 -4
  6. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/admin_schedules.py +49 -11
  7. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/static/css/main.css +27 -0
  8. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/admin/schedules.html +63 -3
  9. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/pyproject.toml +1 -1
  10. kryten_webqueue-0.23.1/tests/test_schedule_lock.py +130 -0
  11. kryten_webqueue-0.22.0/.github/workflows/python-publish.yml +0 -75
  12. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/.gitignore +0 -0
  13. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/README.md +0 -0
  14. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/config.example.json +0 -0
  15. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/deploy/kryten-webqueue.service +0 -0
  16. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/deploy/nginx-queue.conf +0 -0
  17. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  18. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/IMPL_API_GATE.md +0 -0
  19. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/IMPL_ECONOMY.md +0 -0
  20. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  21. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/IMPL_ROBOT.md +0 -0
  22. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  23. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/docs/UX_POLISH_PLAN.md +0 -0
  27. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/catalog/images.py +0 -0
  38. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  39. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/catalog/sync.py +0 -0
  40. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/config.py +0 -0
  41. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/__init__.py +0 -0
  42. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  43. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  44. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  45. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  46. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  47. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +0 -0
  48. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  49. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  50. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/jobs/__init__.py +0 -0
  51. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  52. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/jobs/manager.py +0 -0
  53. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/jobs/tasks.py +0 -0
  54. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/logging_config.py +0 -0
  55. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/playlists/__init__.py +0 -0
  56. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  57. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/playlists/fire.py +0 -0
  58. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/playlists/importer.py +0 -0
  59. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/playlists/ordering.py +0 -0
  60. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  61. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/promos/__init__.py +0 -0
  62. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/promos/director.py +0 -0
  63. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/queue/__init__.py +0 -0
  64. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/queue/ordering.py +0 -0
  65. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/queue/poller.py +0 -0
  66. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/queue/presence.py +0 -0
  67. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/queue/shadow.py +0 -0
  68. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/__init__.py +0 -0
  69. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  70. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  71. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  72. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  73. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  74. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/auth.py +0 -0
  75. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/catalog.py +0 -0
  76. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/pages.py +0 -0
  77. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/queue.py +0 -0
  78. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/routes/user.py +0 -0
  79. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/static/js/main.js +0 -0
  80. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/admin/index.html +0 -0
  81. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  82. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  83. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  84. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/auth/login.html +0 -0
  85. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/base.html +0 -0
  86. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  87. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  88. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  89. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/queue/index.html +0 -0
  90. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  91. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/ws/__init__.py +0 -0
  92. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/ws/handler.py +0 -0
  93. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/kryten_webqueue/ws/manager.py +0 -0
  94. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/__init__.py +0 -0
  95. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_config_persistence.py +0 -0
  96. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_fetchurls_sharepoint.py +0 -0
  97. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_phase1.py +0 -0
  98. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_phase2_jobs.py +0 -0
  99. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_phase3_jobs.py +0 -0
  100. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_phase4_live_fixes.py +0 -0
  101. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_playlist_import.py +0 -0
  102. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_presence_refund.py +0 -0
  103. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_promo_director.py +0 -0
  104. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_promo_pool_exclusion.py +0 -0
  105. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_queue_announce.py +0 -0
  106. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_save_results_to_playlist.py +0 -0
  107. {kryten_webqueue-0.22.0 → kryten_webqueue-0.23.1}/tests/test_search_facets.py +0 -0
@@ -0,0 +1,93 @@
1
+ name: Publish Python Package to PyPI
2
+
3
+ # Builds and publishes to PyPI. This workflow MUST run top-level (never invoked
4
+ # via a reusable `uses:` call). PyPI Trusted Publishing matches the OIDC
5
+ # `job_workflow_ref` claim — the filename of the workflow that contains the
6
+ # running job — against the configured publisher (python-publish.yml). Running
7
+ # this as a reusable workflow would change that filename and break publishing
8
+ # (and is explicitly unsupported by PyPI).
9
+ on:
10
+ # Primary automated path: fire after "Release Automation" (release.yml)
11
+ # finishes creating the tag + GitHub Release. workflow_run keeps this workflow
12
+ # top-level, unlike a reusable `uses:` invocation.
13
+ workflow_run:
14
+ workflows: ["Release Automation"]
15
+ types: [completed]
16
+ # Manual fallbacks: publishing a GitHub Release by hand, or pushing a tag.
17
+ release:
18
+ types: [published]
19
+ push:
20
+ tags:
21
+ - 'kryten-webqueue-v*'
22
+ - 'v*'
23
+
24
+ permissions:
25
+ contents: read
26
+
27
+ jobs:
28
+ release-build:
29
+ name: Build distribution packages
30
+ runs-on: ubuntu-latest
31
+ # When triggered by release.yml's completion, only proceed on success.
32
+ # release/tag triggers aren't workflow_run events, so they always build.
33
+ if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
34
+
35
+ steps:
36
+ - name: Checkout repository
37
+ uses: actions/checkout@v7
38
+ with:
39
+ # For a workflow_run trigger, build the exact commit release.yml
40
+ # processed; otherwise build the tag/release commit.
41
+ ref: ${{ github.event.workflow_run.head_sha || github.sha }}
42
+
43
+ - name: Install uv
44
+ uses: astral-sh/setup-uv@v8.2.0
45
+ with:
46
+ version: "latest"
47
+
48
+ - name: Set up Python
49
+ uses: actions/setup-python@v6
50
+ with:
51
+ python-version: "3.12"
52
+
53
+ - name: Build release distributions
54
+ run: |
55
+ uv build
56
+ echo "📦 Built packages:"
57
+ ls -lh dist/
58
+
59
+ - name: Upload distributions
60
+ uses: actions/upload-artifact@v7
61
+ with:
62
+ name: release-dists
63
+ path: dist/
64
+
65
+ pypi-publish:
66
+ name: Publish to PyPI
67
+ runs-on: ubuntu-latest
68
+ needs:
69
+ - release-build
70
+ environment:
71
+ name: pypi
72
+ permissions:
73
+ id-token: write
74
+
75
+ steps:
76
+ - name: Retrieve release distributions
77
+ uses: actions/download-artifact@v8
78
+ with:
79
+ name: release-dists
80
+ path: dist/
81
+
82
+ - name: Publish release distributions to PyPI
83
+ uses: pypa/gh-action-pypi-publish@release/v1
84
+ with:
85
+ packages-dir: dist/
86
+ skip-existing: true
87
+ attestations: false
88
+
89
+ - name: Success notification
90
+ if: success()
91
+ run: |
92
+ echo "✅ Successfully published to PyPI!"
93
+ echo "🔗 Package URL: https://pypi.org/project/kryten-webqueue/"
@@ -1,6 +1,10 @@
1
1
  name: Release Automation
2
2
 
3
- # Automatically create GitHub releases when version in pyproject.toml changes
3
+ # Automatically create GitHub releases when version in pyproject.toml changes.
4
+ # Publishing to PyPI is handled separately by python-publish.yml, which triggers
5
+ # on this workflow's completion (see its workflow_run trigger). Publishing is
6
+ # intentionally NOT inlined here: PyPI Trusted Publishing matches the OIDC
7
+ # job_workflow_ref filename, which is configured as python-publish.yml.
4
8
  on:
5
9
  push:
6
10
  branches:
@@ -11,8 +15,6 @@ on:
11
15
 
12
16
  permissions:
13
17
  contents: write
14
- pull-requests: read
15
- id-token: write
16
18
 
17
19
  jobs:
18
20
  create-release:
@@ -20,7 +22,7 @@ jobs:
20
22
  runs-on: ubuntu-latest
21
23
 
22
24
  steps:
23
- - uses: actions/checkout@v4
25
+ - uses: actions/checkout@v7
24
26
  with:
25
27
  fetch-depth: 0 # Full history for changelog
26
28
 
@@ -93,11 +95,3 @@ jobs:
93
95
  if: steps.check_tag.outputs.exists == 'true'
94
96
  run: |
95
97
  echo "ℹ️ Release ${{ steps.version.outputs.tag }} already exists - skipping"
96
-
97
- publish-to-pypi:
98
- name: Publish to PyPI
99
- needs: [create-release]
100
- uses: ./.github/workflows/python-publish.yml
101
- permissions:
102
- id-token: write
103
- contents: read
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
 
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
+ ## [0.23.1] — 2026-06-21
8
+
9
+ ### Changed
10
+
11
+ - **Release CI hardening (no runtime changes).** PyPI publishing was moved out of a reusable workflow into a top-level one. PyPI Trusted Publishing matches the OIDC `job_workflow_ref` claim — the filename of the workflow containing the running job — against the configured publisher (`python-publish.yml`); invoking that workflow via a reusable `uses:` call is unsupported by PyPI and raised a "workflow misconfiguration" warning. `python-publish.yml` now triggers via `workflow_run` after `release.yml` completes (keeping it top-level so the publisher filename stays `python-publish.yml`), and `release.yml` no longer calls it reusably. All GitHub Actions were bumped off the deprecated Node 20 runtime: `actions/checkout@v7`, `actions/setup-python@v6`, `actions/upload-artifact@v7`, `actions/download-artifact@v8`, and `astral-sh/setup-uv@v8.2.0`.
12
+
13
+ [0.23.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.23.1
14
+
15
+ ## [0.23.0] — 2026-06-21
16
+
17
+ ### Fixed
18
+
19
+ - **Scheduled-event pre-fire lock lingered until midnight.** The pay-to-play pre-fire lock lives in `playlist_schedules` and was gated on `fire_at > datetime('now')`. `fire_at` is stored as a raw ISO string with a `T` separator (e.g. `2026-06-21T15:00:00+00:00`, or `…Z` from the admin UI) while SQLite's `datetime('now')` is space-separated, so the two were compared as **strings** — `'T'` sorts after `' '`, keeping the condition true from fire time until the calendar date rolled over. A 15-minute pre-fire lock effectively lasted until midnight, the queue stayed locked with no active-schedule row to show for it, and "Clear Active" couldn't lift it. The lock queries now wrap `fire_at` in `datetime(…)` so the comparison is over normalized timestamps and the lock releases exactly at `fire_at`. `get_next_schedule` shared the same flaw (already-fired events showed as "next" until midnight) and is fixed too.
20
+
21
+ ### Added
22
+
23
+ - **Clear admin lockout indicator + one-click "End lockout now".** The admin Schedules page now shows a prominent banner whenever pay-to-play is closed — covering the pre-fire window (which has no active-schedule row and was previously invisible until a queue attempt failed) as well as an in-progress immutable event. A new `GET /admin/schedules/lock-status` reports the authoritative state, and `POST /admin/schedules/unlock` now lifts an in-progress event lock **and** every active pre-fire lock in a single action (it previously lifted only one), so one click reliably reopens the queue. Schedules stay armed — a recurring event re-locks on its next firing.
24
+
25
+ [0.23.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.23.0
26
+
7
27
  ## [0.22.0] - 2026-06-19
8
28
 
9
29
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.22.0
3
+ Version: 0.23.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
@@ -1166,12 +1166,19 @@ class Database:
1166
1166
  # --- Pre-fire lock check ---
1167
1167
 
1168
1168
  async def is_pre_fire_lock_active(self) -> bool:
1169
+ # NOTE: fire_at must be wrapped in datetime() on BOTH sides. fire_at is
1170
+ # stored as a raw ISO string ('2026-06-21T15:00:00+00:00' or '...Z'),
1171
+ # whose 'T' separator sorts lexically AFTER the space-separated string
1172
+ # returned by datetime('now'). A bare `fire_at > datetime('now')` is a
1173
+ # string comparison that stays true from fire time until the calendar
1174
+ # day rolls over, so the lock lingered until midnight instead of
1175
+ # releasing at fire_at.
1169
1176
  row = await self._fetch_one("""
1170
1177
  SELECT 1 FROM playlist_schedules
1171
1178
  WHERE is_active = 1
1172
1179
  AND lock_disabled = 0
1173
1180
  AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
1174
- AND fire_at > datetime('now')
1181
+ AND datetime(fire_at) > datetime('now')
1175
1182
  LIMIT 1
1176
1183
  """)
1177
1184
  return row is not None
@@ -1187,12 +1194,32 @@ class Database:
1187
1194
  WHERE is_active = 1
1188
1195
  AND lock_disabled = 0
1189
1196
  AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
1190
- AND fire_at > datetime('now')
1191
- ORDER BY fire_at
1197
+ AND datetime(fire_at) > datetime('now')
1198
+ ORDER BY datetime(fire_at)
1192
1199
  LIMIT 1
1193
1200
  """)
1194
1201
 
1202
+ async def disable_active_pre_fire_locks(self) -> int:
1203
+ """Lift ALL currently-active pre-fire locks in a single operation.
1204
+
1205
+ Sets ``lock_disabled = 1`` for every schedule whose pre-fire window is
1206
+ open right now, so one admin action ends the lockout even when more than
1207
+ one schedule's window overlaps. Recurring schedules reset
1208
+ ``lock_disabled`` to 0 when they re-arm, so future firings still lock.
1209
+ Returns the number of schedules affected.
1210
+ """
1211
+ cursor = await self._db.execute("""
1212
+ UPDATE playlist_schedules
1213
+ SET lock_disabled = 1
1214
+ WHERE is_active = 1
1215
+ AND lock_disabled = 0
1216
+ AND datetime(fire_at, '-' || pre_fire_lock_minutes || ' minutes') <= datetime('now')
1217
+ AND datetime(fire_at) > datetime('now')
1218
+ """)
1219
+ await self._db.commit()
1220
+ return cursor.rowcount
1221
+
1195
1222
  async def get_next_schedule(self) -> dict | None:
1196
1223
  return await self._fetch_one(
1197
- "SELECT * FROM playlist_schedules WHERE is_active=1 AND fire_at > datetime('now') ORDER BY fire_at LIMIT 1"
1224
+ "SELECT * FROM playlist_schedules WHERE is_active=1 AND datetime(fire_at) > datetime('now') ORDER BY datetime(fire_at) LIMIT 1"
1198
1225
  )
@@ -134,24 +134,62 @@ async def clear_active(request: Request, user: dict = Depends(require_admin)):
134
134
  return {"success": True}
135
135
 
136
136
 
137
+ @router.get("/lock-status")
138
+ async def lock_status(request: Request, user: dict = Depends(require_admin)):
139
+ """Authoritative pay-to-play lock state for the admin lock banner.
140
+
141
+ Reports *both* lock types so an admin always sees why the queue is closed
142
+ and can end it — whether a schedule is in its pre-fire window (no active
143
+ schedule row) or an immutable event is mid-play.
144
+ """
145
+ db = request.app.state.db
146
+
147
+ # Pre-fire window lives entirely in playlist_schedules (no active_schedule
148
+ # row), which is why "Clear Active" can't see or clear it.
149
+ if await db.is_pre_fire_lock_active():
150
+ lock = await db.get_active_pre_fire_lock() or {}
151
+ return {
152
+ "locked": True,
153
+ "type": "pre_fire",
154
+ "label": lock.get("label"),
155
+ "fire_at": lock.get("fire_at"),
156
+ }
157
+
158
+ # In-progress immutable scheduled event.
159
+ if await db.is_event_lock_active():
160
+ active = await db.get_active_schedule() or {}
161
+ label = None
162
+ if active.get("playlist_id"):
163
+ playlist = await db.get_saved_playlist(active["playlist_id"])
164
+ if playlist:
165
+ label = playlist.get("name")
166
+ return {
167
+ "locked": True,
168
+ "type": "event",
169
+ "label": label,
170
+ "estimated_end_at": active.get("estimated_end_at"),
171
+ }
172
+
173
+ return {"locked": False, "type": None}
174
+
175
+
137
176
  @router.post("/unlock")
138
177
  async def unlock(request: Request, user: dict = Depends(require_admin)):
139
- """Lift the currently-active pay-to-play lock without deleting the schedule.
178
+ """End the active pay-to-play lockout, whatever its source.
140
179
 
141
- Targets the in-progress scheduled-event lock first (keeps the event banner
142
- and any recurring schedule armed); otherwise lifts an active pre-fire lock
143
- for its current occurrence only (a recurring schedule re-locks on its next
144
- firing).
180
+ Lifts an in-progress event lock *and* every currently-active pre-fire lock
181
+ in a single action, so one click reliably reopens pay-to-play. The schedules
182
+ themselves stay armed: a recurring schedule re-locks on its next firing.
145
183
  """
146
184
  db = request.app.state.db
185
+ lifted: list[str] = []
147
186
 
148
187
  if await db.is_event_lock_active():
149
188
  await db.disable_active_lock()
150
- return {"success": True, "lifted": "event"}
189
+ lifted.append("event")
151
190
 
152
- prefire = await db.get_active_pre_fire_lock()
153
- if prefire:
154
- await db.update_schedule(prefire["id"], lock_disabled=1)
155
- return {"success": True, "lifted": "pre_fire"}
191
+ pre_fire_count = await db.disable_active_pre_fire_locks()
192
+ if pre_fire_count:
193
+ lifted.append("pre_fire")
156
194
 
157
- return {"success": True, "lifted": None}
195
+ return {"success": True, "lifted": lifted, "pre_fire_count": pre_fire_count}
@@ -1210,6 +1210,33 @@ a.np-chip {
1210
1210
  border-left: 3px solid var(--warning);
1211
1211
  }
1212
1212
 
1213
+ /* Pay-to-play lock banner (admin schedules) — deliberately prominent so a
1214
+ lockout is never a surprise discovered only when a queue attempt fails. */
1215
+ .lock-banner {
1216
+ background: rgba(225, 112, 85, 0.12);
1217
+ border: 1px solid var(--danger);
1218
+ border-left: 4px solid var(--danger);
1219
+ border-radius: var(--radius);
1220
+ padding: 0.9rem 1.1rem;
1221
+ margin-bottom: 1.5rem;
1222
+ }
1223
+ .lock-banner-row {
1224
+ display: flex;
1225
+ align-items: center;
1226
+ gap: 0.9rem;
1227
+ flex-wrap: wrap;
1228
+ }
1229
+ .lock-banner-icon {
1230
+ font-size: 1.4rem;
1231
+ line-height: 1;
1232
+ }
1233
+ .lock-banner-text {
1234
+ flex: 1 1 240px;
1235
+ }
1236
+ .lock-banner-text > strong {
1237
+ color: var(--danger);
1238
+ }
1239
+
1213
1240
  /* Modal */
1214
1241
  .modal-overlay {
1215
1242
  position: fixed;
@@ -7,6 +7,7 @@
7
7
  <h1>Schedules</h1>
8
8
  <p><a href="/admin">&larr; Back to Admin</a></p>
9
9
 
10
+ <div id="lock-banner" class="lock-banner hidden"></div>
10
11
  <div id="active-banner" class="schedule-info hidden" style="margin-bottom:1.5rem;"></div>
11
12
 
12
13
  <div class="admin-section">
@@ -36,6 +37,58 @@ async function loadPlaylistsForSelect() {
36
37
  return rows;
37
38
  }
38
39
 
40
+ async function loadLockStatus() {
41
+ // Authoritative, server-side lock indicator. Unlike the active-schedule
42
+ // banner, this also surfaces a pre-fire lockout (which has no active_schedule
43
+ // row) — the case where the queue is locked with nothing obviously "running".
44
+ const banner = document.getElementById('lock-banner');
45
+ let st;
46
+ try {
47
+ const resp = await fetch('/admin/schedules/lock-status');
48
+ if (!resp.ok) { banner.classList.add('hidden'); return; }
49
+ st = await resp.json();
50
+ } catch { banner.classList.add('hidden'); return; }
51
+
52
+ if (!st || !st.locked) {
53
+ banner.classList.add('hidden');
54
+ banner.innerHTML = '';
55
+ return;
56
+ }
57
+
58
+ const label = st.label ? escapeHtml(st.label) : 'a scheduled event';
59
+ let detail;
60
+ if (st.type === 'pre_fire') {
61
+ const when = st.fire_at ? formatLocalDateTime(st.fire_at) : null;
62
+ detail = `Pre-event lockout for <strong>${label}</strong>${when ? ` — fires ${when}` : ''}. Pay-to-play reopens when the event starts.`;
63
+ } else {
64
+ const when = st.estimated_end_at ? formatLocalDateTime(st.estimated_end_at) : null;
65
+ detail = `Event <strong>${label}</strong> is playing${when ? ` — ends ~${when}` : ''}. Pay-to-play reopens when the last scheduled item begins.`;
66
+ }
67
+
68
+ banner.classList.remove('hidden');
69
+ banner.innerHTML = `
70
+ <div class="lock-banner-row">
71
+ <span class="lock-banner-icon" aria-hidden="true">&#128274;</span>
72
+ <div class="lock-banner-text">
73
+ <strong>Queue locked &mdash; pay-to-play is closed.</strong>
74
+ <div class="muted">${detail}</div>
75
+ </div>
76
+ <button class="btn btn-danger btn-sm" onclick="endLockout()">End lockout now</button>
77
+ </div>`;
78
+ }
79
+
80
+ async function endLockout() {
81
+ if (!confirm('End the pay-to-play lockout now? Users can queue items immediately. Schedules stay armed — a recurring event re-locks on its next firing.')) return;
82
+ let ok = false;
83
+ try {
84
+ const resp = await fetch('/admin/schedules/unlock', {method: 'POST'});
85
+ ok = resp.ok;
86
+ } catch { ok = false; }
87
+ showToast(ok ? 'Lockout ended' : 'Failed to end lockout', ok ? 'success' : 'error');
88
+ await loadLockStatus();
89
+ loadSchedules();
90
+ }
91
+
39
92
  async function loadActive() {
40
93
  const banner = document.getElementById('active-banner');
41
94
  const resp = await fetch('/admin/schedules/active');
@@ -69,6 +122,7 @@ async function unlockNow() {
69
122
  if (!confirm('Lift the pay-to-play lock now? Users will be able to queue items again. The schedule stays armed for future firings.')) return;
70
123
  const resp = await fetch('/admin/schedules/unlock', {method: 'POST'});
71
124
  showToast(resp.ok ? 'Lock lifted' : 'Failed', resp.ok ? 'success' : 'error');
125
+ await loadLockStatus();
72
126
  loadSchedules();
73
127
  }
74
128
 
@@ -76,11 +130,13 @@ async function clearActive() {
76
130
  if (!confirm('Clear the active schedule and return to free mode?')) return;
77
131
  const resp = await fetch('/admin/schedules/clear-active', {method: 'POST'});
78
132
  showToast(resp.ok ? 'Cleared' : 'Failed', resp.ok ? 'success' : 'error');
133
+ await loadLockStatus();
79
134
  loadActive();
80
135
  }
81
136
 
82
137
  async function loadSchedules() {
83
138
  await loadPlaylistsForSelect();
139
+ await loadLockStatus();
84
140
  await loadActive();
85
141
  const resp = await fetch('/admin/schedules/');
86
142
  const el = document.getElementById('schedules-list');
@@ -213,10 +269,14 @@ function closeModal() { const m = document.getElementById('admin-modal'); if (m)
213
269
 
214
270
  loadSchedules();
215
271
 
216
- // Keep the active-event banner fresh without a reload: re-check every 15s while
217
- // the tab is visible (the backend clears the row once the event plays out).
272
+ // Keep the lock indicator and active-event banner fresh without a reload:
273
+ // re-check every 15s while the tab is visible (the backend lifts the lock at
274
+ // fire time and clears the active row once the event plays out).
218
275
  setInterval(() => {
219
- if (document.visibilityState === 'visible') loadActive();
276
+ if (document.visibilityState === 'visible') {
277
+ loadLockStatus();
278
+ loadActive();
279
+ }
220
280
  }, 15000);
221
281
  </script>
222
282
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.22.0"
3
+ version = "0.23.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"
@@ -0,0 +1,130 @@
1
+ """Regression tests for the scheduled-event pre-fire pay-to-play lock.
2
+
3
+ These cover a bug where the pre-fire lock lingered far past ``fire_at`` (until
4
+ the calendar day rolled over at midnight). ``fire_at`` is stored as a raw ISO
5
+ string with a ``T`` separator (e.g. ``2026-06-21T15:00:00+00:00`` from the
6
+ scheduler, or ``...Z`` from the admin UI's ``toISOString``). The lock predicate
7
+ compared it against ``datetime('now')`` (space-separated) without normalizing,
8
+ so SQLite did a *string* comparison in which ``'T'`` (84) sorts after ``' '``
9
+ (32). That kept ``fire_at > datetime('now')`` true from fire time until the date
10
+ prefix changed, so a 15-minute lock effectively lasted until midnight.
11
+
12
+ The fix wraps ``fire_at`` in ``datetime(...)`` on both sides so the comparison
13
+ is over normalized timestamps.
14
+ """
15
+
16
+ from datetime import datetime, timedelta, UTC
17
+
18
+ import pytest
19
+
20
+ from kryten_webqueue.catalog.db import Database
21
+
22
+
23
+ @pytest.fixture
24
+ async def db(tmp_path):
25
+ database = Database(str(tmp_path / "test.db"))
26
+ await database.connect()
27
+ await database.run_migrations()
28
+ yield database
29
+ await database.close()
30
+
31
+
32
+ def _iso_offset(dt: datetime) -> str:
33
+ """Scheduler storage format, e.g. '2026-06-21T15:00:00+00:00'."""
34
+ return dt.astimezone(UTC).isoformat()
35
+
36
+
37
+ def _iso_zulu(dt: datetime) -> str:
38
+ """Admin-UI storage format (JS Date.toISOString), e.g. '...T15:00:00.000Z'."""
39
+ return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
40
+
41
+
42
+ async def _make_schedule(db: Database, *, fire_at: str, lock_minutes: int = 15) -> int:
43
+ return await db.create_schedule(
44
+ playlist_id=None,
45
+ label="Test Event",
46
+ fire_at=fire_at,
47
+ pre_fire_lock_minutes=lock_minutes,
48
+ is_active=True,
49
+ created_by="tester",
50
+ )
51
+
52
+
53
+ @pytest.mark.parametrize("fmt", [_iso_offset, _iso_zulu])
54
+ async def test_pre_fire_lock_releases_at_fire_time(db, fmt):
55
+ """A schedule whose fire_at has passed must NOT keep the queue locked.
56
+
57
+ Before the fix this returned True for the rest of the calendar day.
58
+ """
59
+ past = datetime.now(UTC) - timedelta(hours=2)
60
+ await _make_schedule(db, fire_at=fmt(past))
61
+ assert await db.is_pre_fire_lock_active() is False
62
+ assert await db.get_active_pre_fire_lock() is None
63
+
64
+
65
+ async def test_pre_fire_lock_active_inside_window(db):
66
+ """Inside the pre-fire window (future fire_at), the lock is active."""
67
+ soon = datetime.now(UTC) + timedelta(minutes=5)
68
+ await _make_schedule(db, fire_at=_iso_offset(soon), lock_minutes=15)
69
+ assert await db.is_pre_fire_lock_active() is True
70
+ lock = await db.get_active_pre_fire_lock()
71
+ assert lock is not None and lock["label"] == "Test Event"
72
+
73
+
74
+ async def test_pre_fire_lock_inactive_before_window(db):
75
+ """Before the pre-fire window opens, the lock is not active."""
76
+ later = datetime.now(UTC) + timedelta(hours=2)
77
+ await _make_schedule(db, fire_at=_iso_offset(later), lock_minutes=15)
78
+ assert await db.is_pre_fire_lock_active() is False
79
+
80
+
81
+ async def test_get_next_schedule_ignores_past_fire(db):
82
+ """The 'next schedule' must skip events whose fire_at has already passed.
83
+
84
+ Before the fix a same-day past schedule was returned (and could even sort
85
+ ahead of a genuine upcoming one).
86
+ """
87
+ past = datetime.now(UTC) - timedelta(hours=2)
88
+ future = datetime.now(UTC) + timedelta(hours=3)
89
+ await _make_schedule(db, fire_at=_iso_offset(past))
90
+ future_id = await _make_schedule(db, fire_at=_iso_offset(future))
91
+
92
+ nxt = await db.get_next_schedule()
93
+ assert nxt is not None
94
+ assert nxt["id"] == future_id
95
+
96
+
97
+ async def test_disable_active_pre_fire_locks_ends_lockout(db):
98
+ """One call lifts EVERY active pre-fire lock, across both stored formats."""
99
+ soon = datetime.now(UTC) + timedelta(minutes=5)
100
+ later = datetime.now(UTC) + timedelta(minutes=10)
101
+ await _make_schedule(db, fire_at=_iso_offset(soon), lock_minutes=15)
102
+ await _make_schedule(db, fire_at=_iso_zulu(later), lock_minutes=15)
103
+
104
+ assert await db.is_pre_fire_lock_active() is True
105
+ count = await db.disable_active_pre_fire_locks()
106
+ assert count == 2
107
+ assert await db.is_pre_fire_lock_active() is False
108
+
109
+
110
+ async def test_disable_active_pre_fire_locks_noop_when_unlocked(db):
111
+ """No active window → nothing lifted, returns 0 (idempotent end-lockout)."""
112
+ later = datetime.now(UTC) + timedelta(hours=2) # before its pre-fire window
113
+ await _make_schedule(db, fire_at=_iso_offset(later), lock_minutes=15)
114
+
115
+ assert await db.is_pre_fire_lock_active() is False
116
+ assert await db.disable_active_pre_fire_locks() == 0
117
+
118
+
119
+ async def test_disable_active_pre_fire_locks_leaves_future_windows(db):
120
+ """Lifting the current lockout must not pre-emptively unlock a later event
121
+ whose pre-fire window has not opened yet."""
122
+ soon = datetime.now(UTC) + timedelta(minutes=5) # in-window now
123
+ later = datetime.now(UTC) + timedelta(hours=4) # window opens much later
124
+ await _make_schedule(db, fire_at=_iso_offset(soon), lock_minutes=15)
125
+ later_id = await _make_schedule(db, fire_at=_iso_offset(later), lock_minutes=15)
126
+
127
+ assert await db.disable_active_pre_fire_locks() == 1
128
+ later_sched = await db.get_schedule(later_id)
129
+ assert later_sched["lock_disabled"] == 0
130
+
@@ -1,75 +0,0 @@
1
- name: Publish Python Package to PyPI
2
-
3
- on:
4
- workflow_call:
5
- release:
6
- types: [published]
7
- push:
8
- tags:
9
- - 'kryten-webqueue-v*'
10
- - 'v*'
11
-
12
- permissions:
13
- contents: read
14
- id-token: write
15
-
16
- jobs:
17
- release-build:
18
- name: Build distribution packages
19
- runs-on: ubuntu-latest
20
-
21
- steps:
22
- - name: Checkout repository
23
- uses: actions/checkout@v4
24
-
25
- - name: Install uv
26
- uses: astral-sh/setup-uv@v4
27
- with:
28
- version: "latest"
29
-
30
- - name: Set up Python
31
- uses: actions/setup-python@v5
32
- with:
33
- python-version: "3.12"
34
-
35
- - name: Build release distributions
36
- run: |
37
- uv build
38
- echo "📦 Built packages:"
39
- ls -lh dist/
40
-
41
- - name: Upload distributions
42
- uses: actions/upload-artifact@v4
43
- with:
44
- name: release-dists
45
- path: dist/
46
-
47
- pypi-publish:
48
- name: Publish to PyPI
49
- runs-on: ubuntu-latest
50
- needs:
51
- - release-build
52
- environment:
53
- name: pypi
54
- permissions:
55
- id-token: write
56
-
57
- steps:
58
- - name: Retrieve release distributions
59
- uses: actions/download-artifact@v4
60
- with:
61
- name: release-dists
62
- path: dist/
63
-
64
- - name: Publish release distributions to PyPI
65
- uses: pypa/gh-action-pypi-publish@release/v1
66
- with:
67
- packages-dir: dist/
68
- skip-existing: true
69
- attestations: false
70
-
71
- - name: Success notification
72
- if: success()
73
- run: |
74
- echo "✅ Successfully published to PyPI!"
75
- echo "🔗 Package URL: https://pypi.org/project/kryten-webqueue/"