kryten-webqueue 0.32.3__tar.gz → 0.33.2__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.
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/CHANGELOG.md +18 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/PKG-INFO +1 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/SPEC_JOBS_AND_BROWSE.md +3 -3
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/api_gate/client.py +87 -44
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/app.py +62 -22
- kryten_webqueue-0.33.2/kryten_webqueue/auth/rate_limit.py +62 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/session.py +3 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/__init__.py +6 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_catalog.py +102 -28
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_connection.py +3 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_feedback.py +38 -17
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_playlists.py +94 -25
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/db/_queue.py +50 -15
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/images.py +61 -30
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/mediacms.py +19 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/sync.py +15 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/config.py +9 -9
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +407 -187
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +166 -71
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/enrichtv.py +372 -183
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/fetchurls.py +277 -137
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/downloader.py +1680 -1291
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/fetchurls_auth.py +3 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/manager.py +51 -16
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/tasks.py +157 -38
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/logging_config.py +13 -3
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/bulk_add.py +4 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/fire.py +32 -14
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/importer.py +93 -48
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/scheduler.py +21 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/promos/director.py +96 -29
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/completion.py +15 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/ordering.py +170 -46
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/poller.py +11 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/presence.py +32 -12
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/race_poller.py +5 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/shadow.py +36 -12
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_catalog.py +19 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_feedback.py +8 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_jobs.py +9 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_moderation.py +9 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_playlists.py +71 -28
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_queue.py +4 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_schedules.py +12 -4
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/auth.py +3 -1
- kryten_webqueue-0.33.2/kryten_webqueue/routes/catalog.py +105 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/feedback.py +53 -11
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/pages.py +106 -50
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/queue.py +21 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/user.py +6 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/handler.py +0 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/pyproject.toml +1 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_config_persistence.py +10 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_feedback.py +209 -48
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_fetchurls_sharepoint.py +51 -18
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_phase1.py +39 -17
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_phase2_jobs.py +22 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_phase3_jobs.py +69 -25
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_phase4_live_fixes.py +70 -26
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_playlist_import.py +52 -30
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_presence_refund.py +33 -23
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_promo_director.py +161 -68
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_promo_pool_exclusion.py +40 -16
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_queue_announce.py +27 -19
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_race_poller.py +34 -25
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_recently_played_hide.py +175 -62
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_save_results_to_playlist.py +51 -30
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_schedule_lock.py +2 -3
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/tests/test_search_facets.py +23 -13
- kryten_webqueue-0.32.3/kryten_webqueue/auth/rate_limit.py +0 -29
- kryten_webqueue-0.32.3/kryten_webqueue/routes/catalog.py +0 -58
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/.gitignore +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/README.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/config.example.json +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.2}/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.2] — 2026-07-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Per-user submission quota on feedback and title suggestions.** Each user may now submit at most **2 per day and 6 per week** for feedback *and* (independently) for suggestions. Exceeding either tier returns `429` with a message pointing at the day/week limit. Implemented as a new `QuotaLimiter` (multi-tier sliding window) wired into the two submit endpoints, in addition to the existing short-burst throttle. Search/resolve requests are unaffected.
|
|
13
|
+
|
|
14
|
+
## [0.33.1] — 2026-07-29
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **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.
|
|
19
|
+
|
|
20
|
+
## [0.33.0] — 2026-07-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **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`).
|
|
25
|
+
|
|
8
26
|
## [0.32.3] — 2026-07-09
|
|
9
27
|
|
|
10
28
|
### Fixed
|
|
@@ -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(
|
|
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(
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
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(
|
|
104
|
-
|
|
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(
|
|
111
|
-
"
|
|
112
|
-
|
|
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(
|
|
117
|
-
"
|
|
118
|
-
|
|
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(
|
|
123
|
-
"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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(
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
|
|
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(
|
|
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(
|
|
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
|
)
|
|
@@ -22,7 +22,7 @@ from .queue.presence import PresenceRefundMonitor
|
|
|
22
22
|
from .promos.director import PromoDirector
|
|
23
23
|
from .ws.manager import WebSocketManager
|
|
24
24
|
from .playlists.scheduler import PlaylistScheduler
|
|
25
|
-
from .auth.rate_limit import RateLimiter
|
|
25
|
+
from .auth.rate_limit import QuotaLimiter, RateLimiter
|
|
26
26
|
|
|
27
27
|
from .routes.auth import router as auth_router
|
|
28
28
|
from .routes.catalog import router as catalog_router
|
|
@@ -59,7 +59,8 @@ async def lifespan(app: FastAPI):
|
|
|
59
59
|
if purged["completions"] or purged["playlist_pass"]:
|
|
60
60
|
logger.info(
|
|
61
61
|
"Purged promo recently-played state: %d completion(s), %d pass row(s)",
|
|
62
|
-
purged["completions"],
|
|
62
|
+
purged["completions"],
|
|
63
|
+
purged["playlist_pass"],
|
|
63
64
|
)
|
|
64
65
|
# Any job run still marked 'running' is an orphan from a prior crash/restart
|
|
65
66
|
# (the running flag is in-memory only). Reconcile before registering jobs.
|
|
@@ -108,21 +109,42 @@ async def lifespan(app: FastAPI):
|
|
|
108
109
|
# register regardless of optional deps; a missing dep fails the run fast
|
|
109
110
|
# with a clear message rather than crashing startup.
|
|
110
111
|
from .jobs.tasks import (
|
|
111
|
-
enrichtitles_job,
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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,
|
|
115
147
|
)
|
|
116
|
-
job_manager.register("enrichtitles", enrichtitles_job,
|
|
117
|
-
label="Enrich Titles", schema=ENRICHTITLES_SCHEMA)
|
|
118
|
-
job_manager.register("enrichmeta", enrichmeta_job,
|
|
119
|
-
label="Enrich Movie Metadata", schema=ENRICHMETA_SCHEMA)
|
|
120
|
-
job_manager.register("enrichtv", enrichtv_job,
|
|
121
|
-
label="Enrich TV Metadata", schema=ENRICHTV_SCHEMA)
|
|
122
|
-
job_manager.register("fetch", fetch_job,
|
|
123
|
-
label="Fetch (download → MediaCMS)", schema=FETCH_SCHEMA)
|
|
124
|
-
job_manager.register("fetchurls", fetchurls_job,
|
|
125
|
-
label="Fetch URLs (weekend workbook)", schema=FETCHURLS_SCHEMA)
|
|
126
148
|
app.state.job_manager = job_manager
|
|
127
149
|
|
|
128
150
|
# WebSocket manager
|
|
@@ -143,7 +165,10 @@ async def lifespan(app: FastAPI):
|
|
|
143
165
|
|
|
144
166
|
# Promo director (poller-driven; also used synchronously by the pay path)
|
|
145
167
|
promo_director = PromoDirector(
|
|
146
|
-
api_gate=api_gate,
|
|
168
|
+
api_gate=api_gate,
|
|
169
|
+
db=db,
|
|
170
|
+
shadow=shadow,
|
|
171
|
+
config=config.promos,
|
|
147
172
|
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
148
173
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
149
174
|
)
|
|
@@ -171,10 +196,18 @@ async def lifespan(app: FastAPI):
|
|
|
171
196
|
# feedback/suggestion endpoints (per-user, namespaced keys).
|
|
172
197
|
app.state.rate_limiter = RateLimiter()
|
|
173
198
|
app.state.feedback_rate_limiter = RateLimiter(max_requests=12, window_seconds=300)
|
|
199
|
+
# Hard per-user submission quota for feedback and suggestions: 2/day, 6/week
|
|
200
|
+
# (namespaced keys, checked in addition to the short-burst limiter above).
|
|
201
|
+
app.state.feedback_quota_limiter = QuotaLimiter(
|
|
202
|
+
[("day", 2, 86_400), ("week", 6, 604_800)]
|
|
203
|
+
)
|
|
174
204
|
|
|
175
205
|
# Playlist scheduler
|
|
176
206
|
scheduler = PlaylistScheduler(
|
|
177
|
-
db=db,
|
|
207
|
+
db=db,
|
|
208
|
+
api_gate=api_gate,
|
|
209
|
+
shadow=shadow,
|
|
210
|
+
ws_manager=ws_manager,
|
|
178
211
|
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
179
212
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
180
213
|
promo_director=promo_director,
|
|
@@ -184,7 +217,10 @@ async def lifespan(app: FastAPI):
|
|
|
184
217
|
|
|
185
218
|
# Presence-based cancel/refund monitor
|
|
186
219
|
presence_monitor = PresenceRefundMonitor(
|
|
187
|
-
api_gate=api_gate,
|
|
220
|
+
api_gate=api_gate,
|
|
221
|
+
shadow=shadow,
|
|
222
|
+
db=db,
|
|
223
|
+
ws_manager=ws_manager,
|
|
188
224
|
config=config.presence_refund,
|
|
189
225
|
)
|
|
190
226
|
await presence_monitor.start()
|
|
@@ -215,7 +251,8 @@ async def lifespan(app: FastAPI):
|
|
|
215
251
|
while True:
|
|
216
252
|
await asyncio.sleep(300) # every 5 minutes
|
|
217
253
|
try:
|
|
218
|
-
await db._execute(
|
|
254
|
+
await db._execute(
|
|
255
|
+
"""
|
|
219
256
|
UPDATE saved_playlists SET is_immutable = 0
|
|
220
257
|
WHERE id IN (
|
|
221
258
|
SELECT sp.id FROM saved_playlists sp
|
|
@@ -230,7 +267,8 @@ async def lifespan(app: FastAPI):
|
|
|
230
267
|
AND (ps2.immutability_expires_at IS NULL OR ps2.immutability_expires_at > datetime('now'))
|
|
231
268
|
)
|
|
232
269
|
)
|
|
233
|
-
"""
|
|
270
|
+
"""
|
|
271
|
+
)
|
|
234
272
|
except Exception as e:
|
|
235
273
|
logger.error(f"Immutability expiry error: {e}")
|
|
236
274
|
|
|
@@ -240,7 +278,9 @@ async def lifespan(app: FastAPI):
|
|
|
240
278
|
asyncio.create_task(_immutability_expiry_loop()),
|
|
241
279
|
]
|
|
242
280
|
|
|
243
|
-
logger.info(
|
|
281
|
+
logger.info(
|
|
282
|
+
f"kryten-webqueue v{__version__} started on {config.host}:{config.port}"
|
|
283
|
+
)
|
|
244
284
|
|
|
245
285
|
yield
|
|
246
286
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class RateLimiter:
|
|
6
|
+
"""Sliding-window rate limiter for OTP requests."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, max_requests: int = 3, window_seconds: int = 300):
|
|
9
|
+
self._max = max_requests
|
|
10
|
+
self._window = window_seconds
|
|
11
|
+
self._requests: dict[str, deque] = defaultdict(deque)
|
|
12
|
+
|
|
13
|
+
def is_allowed(self, key: str) -> bool:
|
|
14
|
+
now = time.time()
|
|
15
|
+
window = self._requests[key]
|
|
16
|
+
# Remove expired entries
|
|
17
|
+
while window and window[0] < now - self._window:
|
|
18
|
+
window.popleft()
|
|
19
|
+
if len(window) >= self._max:
|
|
20
|
+
return False
|
|
21
|
+
window.append(now)
|
|
22
|
+
return True
|
|
23
|
+
|
|
24
|
+
def remaining(self, key: str) -> int:
|
|
25
|
+
now = time.time()
|
|
26
|
+
window = self._requests[key]
|
|
27
|
+
while window and window[0] < now - self._window:
|
|
28
|
+
window.popleft()
|
|
29
|
+
return max(0, self._max - len(window))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class QuotaLimiter:
|
|
33
|
+
"""Sliding-window limiter that enforces several named tiers at once.
|
|
34
|
+
|
|
35
|
+
Each tier is ``(label, max_requests, window_seconds)``. An action is only
|
|
36
|
+
permitted when *every* tier still has room; a permitted action is recorded
|
|
37
|
+
against all tiers atomically. Used for hard per-user submission quotas
|
|
38
|
+
(e.g. 2/day and 6/week) rather than short-burst throttling.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, tiers: list[tuple[str, int, int]]):
|
|
42
|
+
if not tiers:
|
|
43
|
+
raise ValueError("QuotaLimiter requires at least one tier")
|
|
44
|
+
self._tiers = list(tiers)
|
|
45
|
+
self._max_window = max(window for _, _, window in self._tiers)
|
|
46
|
+
self._events: dict[str, deque] = defaultdict(deque)
|
|
47
|
+
|
|
48
|
+
def check(self, key: str) -> str | None:
|
|
49
|
+
"""Return the label of the first exhausted tier, or ``None`` if allowed.
|
|
50
|
+
|
|
51
|
+
When allowed the action is recorded; when blocked nothing is recorded.
|
|
52
|
+
"""
|
|
53
|
+
now = time.time()
|
|
54
|
+
events = self._events[key]
|
|
55
|
+
while events and events[0] < now - self._max_window:
|
|
56
|
+
events.popleft()
|
|
57
|
+
for label, max_requests, window in self._tiers:
|
|
58
|
+
count = sum(1 for ts in events if ts >= now - window)
|
|
59
|
+
if count >= max_requests:
|
|
60
|
+
return label
|
|
61
|
+
events.append(now)
|
|
62
|
+
return None
|
|
@@ -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(
|
|
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
|
|
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
|