kryten-webqueue 0.32.3__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.
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/CHANGELOG.md +12 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/PKG-INFO +1 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/SPEC_JOBS_AND_BROWSE.md +3 -3
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/api_gate/client.py +87 -44
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/app.py +56 -21
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/session.py +3 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/__init__.py +6 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_catalog.py +102 -28
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_connection.py +3 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_feedback.py +38 -17
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_playlists.py +94 -25
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/db/_queue.py +50 -15
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/images.py +61 -30
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/mediacms.py +19 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/sync.py +15 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/config.py +9 -9
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +407 -187
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +166 -71
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +372 -183
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +277 -137
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/ytpipe/downloader.py +1680 -1291
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/fetchurls_auth.py +3 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/manager.py +51 -16
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/tasks.py +157 -38
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/logging_config.py +13 -3
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/bulk_add.py +4 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/fire.py +32 -14
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/importer.py +93 -48
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/scheduler.py +21 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/promos/director.py +96 -29
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/completion.py +15 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/ordering.py +170 -46
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/poller.py +11 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/presence.py +32 -12
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/race_poller.py +5 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/shadow.py +36 -12
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_catalog.py +19 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_feedback.py +8 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_jobs.py +9 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_moderation.py +9 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_playlists.py +71 -28
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_queue.py +4 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_schedules.py +12 -4
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/auth.py +3 -1
- kryten_webqueue-0.33.1/kryten_webqueue/routes/catalog.py +105 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/feedback.py +31 -11
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/pages.py +106 -50
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/queue.py +21 -7
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/user.py +6 -2
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/ws/handler.py +0 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/pyproject.toml +1 -1
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_config_persistence.py +10 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_feedback.py +136 -47
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_fetchurls_sharepoint.py +51 -18
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_phase1.py +39 -17
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_phase2_jobs.py +22 -5
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_phase3_jobs.py +69 -25
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_phase4_live_fixes.py +70 -26
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_playlist_import.py +52 -30
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_presence_refund.py +33 -23
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_promo_director.py +161 -68
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_promo_pool_exclusion.py +40 -16
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_queue_announce.py +27 -19
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_race_poller.py +34 -25
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_recently_played_hide.py +175 -62
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_save_results_to_playlist.py +51 -30
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_schedule_lock.py +2 -3
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/test_search_facets.py +23 -13
- kryten_webqueue-0.32.3/kryten_webqueue/routes/catalog.py +0 -58
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/.gitignore +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/README.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/config.example.json +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/Kryten-Economy Service Quality Survey Report at v0.13.0.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/Kryten-Webqueue Service Quality Survey Report at v0.27.1.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/Log for incorrect promo insertion problem.txt +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/docs/UX_POLISH_PLAN.md +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/playlists/ordering.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/promos/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/routes/admin_promos.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-playlists.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-promos.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-queue-mgmt.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin-schedules.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/admin.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/promos.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/feedback/index.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/race.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.32.3 → kryten_webqueue-0.33.1}/tests/__init__.py +0 -0
|
@@ -5,6 +5,18 @@ 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
|
+
|
|
8
20
|
## [0.32.3] — 2026-07-09
|
|
9
21
|
|
|
10
22
|
### 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
|
)
|
|
@@ -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
|
)
|
|
@@ -174,7 +199,10 @@ async def lifespan(app: FastAPI):
|
|
|
174
199
|
|
|
175
200
|
# Playlist scheduler
|
|
176
201
|
scheduler = PlaylistScheduler(
|
|
177
|
-
db=db,
|
|
202
|
+
db=db,
|
|
203
|
+
api_gate=api_gate,
|
|
204
|
+
shadow=shadow,
|
|
205
|
+
ws_manager=ws_manager,
|
|
178
206
|
add_delay_sec=config.playlist_bulk_add_delay_sec,
|
|
179
207
|
add_max_retries=config.playlist_bulk_add_max_retries,
|
|
180
208
|
promo_director=promo_director,
|
|
@@ -184,7 +212,10 @@ async def lifespan(app: FastAPI):
|
|
|
184
212
|
|
|
185
213
|
# Presence-based cancel/refund monitor
|
|
186
214
|
presence_monitor = PresenceRefundMonitor(
|
|
187
|
-
api_gate=api_gate,
|
|
215
|
+
api_gate=api_gate,
|
|
216
|
+
shadow=shadow,
|
|
217
|
+
db=db,
|
|
218
|
+
ws_manager=ws_manager,
|
|
188
219
|
config=config.presence_refund,
|
|
189
220
|
)
|
|
190
221
|
await presence_monitor.start()
|
|
@@ -215,7 +246,8 @@ async def lifespan(app: FastAPI):
|
|
|
215
246
|
while True:
|
|
216
247
|
await asyncio.sleep(300) # every 5 minutes
|
|
217
248
|
try:
|
|
218
|
-
await db._execute(
|
|
249
|
+
await db._execute(
|
|
250
|
+
"""
|
|
219
251
|
UPDATE saved_playlists SET is_immutable = 0
|
|
220
252
|
WHERE id IN (
|
|
221
253
|
SELECT sp.id FROM saved_playlists sp
|
|
@@ -230,7 +262,8 @@ async def lifespan(app: FastAPI):
|
|
|
230
262
|
AND (ps2.immutability_expires_at IS NULL OR ps2.immutability_expires_at > datetime('now'))
|
|
231
263
|
)
|
|
232
264
|
)
|
|
233
|
-
"""
|
|
265
|
+
"""
|
|
266
|
+
)
|
|
234
267
|
except Exception as e:
|
|
235
268
|
logger.error(f"Immutability expiry error: {e}")
|
|
236
269
|
|
|
@@ -240,7 +273,9 @@ async def lifespan(app: FastAPI):
|
|
|
240
273
|
asyncio.create_task(_immutability_expiry_loop()),
|
|
241
274
|
]
|
|
242
275
|
|
|
243
|
-
logger.info(
|
|
276
|
+
logger.info(
|
|
277
|
+
f"kryten-webqueue v{__version__} started on {config.host}:{config.port}"
|
|
278
|
+
)
|
|
244
279
|
|
|
245
280
|
yield
|
|
246
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(
|
|
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
|
|
@@ -133,7 +133,9 @@ def _recently_played_exclusion(alias: str, days: int) -> tuple[str, list]:
|
|
|
133
133
|
return sql, [f"-{int(days)} days"]
|
|
134
134
|
|
|
135
135
|
|
|
136
|
-
def _facet_filter(
|
|
136
|
+
def _facet_filter(
|
|
137
|
+
alias: str, category: str | None, tag: str | None
|
|
138
|
+
) -> tuple[str, list]:
|
|
137
139
|
"""SQL fragment (+ params) AND-filtering by a category slug and/or tag name.
|
|
138
140
|
|
|
139
141
|
Each filter is an ``AND friendly_token IN (...)`` subquery on the catalog row
|
|
@@ -191,7 +193,17 @@ class _CatalogMixin:
|
|
|
191
193
|
|
|
192
194
|
# --- Catalog ---
|
|
193
195
|
|
|
194
|
-
async def browse(
|
|
196
|
+
async def browse(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
category: str | None = None,
|
|
200
|
+
tag: str | None = None,
|
|
201
|
+
page: int = 1,
|
|
202
|
+
per_page: int = 24,
|
|
203
|
+
show_hidden: bool = False,
|
|
204
|
+
sort: str = "default",
|
|
205
|
+
recently_played_days: int = 0,
|
|
206
|
+
) -> list[dict]:
|
|
195
207
|
query = """
|
|
196
208
|
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url
|
|
197
209
|
FROM catalog c
|
|
@@ -245,7 +257,14 @@ class _CatalogMixin:
|
|
|
245
257
|
params.extend([per_page, (page - 1) * per_page])
|
|
246
258
|
return await self._fetch_all(query, params)
|
|
247
259
|
|
|
248
|
-
async def browse_count(
|
|
260
|
+
async def browse_count(
|
|
261
|
+
self,
|
|
262
|
+
*,
|
|
263
|
+
category: str | None = None,
|
|
264
|
+
tag: str | None = None,
|
|
265
|
+
show_hidden: bool = False,
|
|
266
|
+
recently_played_days: int = 0,
|
|
267
|
+
) -> int:
|
|
249
268
|
query = """
|
|
250
269
|
SELECT COUNT(*) as cnt FROM catalog c
|
|
251
270
|
WHERE c.friendly_token NOT IN (
|
|
@@ -284,8 +303,18 @@ class _CatalogMixin:
|
|
|
284
303
|
row = await self._fetch_one(query, params)
|
|
285
304
|
return row["cnt"] if row else 0
|
|
286
305
|
|
|
287
|
-
async def search(
|
|
288
|
-
|
|
306
|
+
async def search(
|
|
307
|
+
self,
|
|
308
|
+
query_text: str,
|
|
309
|
+
*,
|
|
310
|
+
category: str | None = None,
|
|
311
|
+
tag: str | None = None,
|
|
312
|
+
page: int = 1,
|
|
313
|
+
per_page: int = 24,
|
|
314
|
+
show_hidden: bool = False,
|
|
315
|
+
sort: str = "default",
|
|
316
|
+
recently_played_days: int = 0,
|
|
317
|
+
) -> list[dict]:
|
|
289
318
|
sql = """
|
|
290
319
|
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
|
|
291
320
|
rank AS relevance
|
|
@@ -314,13 +343,24 @@ class _CatalogMixin:
|
|
|
314
343
|
params.extend(facet_params)
|
|
315
344
|
# Relevance is the natural default for a text query; other sort keys let
|
|
316
345
|
# the user reorder the matched set explicitly.
|
|
317
|
-
sql +=
|
|
346
|
+
sql += (
|
|
347
|
+
" ORDER BY rank "
|
|
348
|
+
if (sort or "default") == "default"
|
|
349
|
+
else _browse_order_clause(sort)
|
|
350
|
+
)
|
|
318
351
|
sql += " LIMIT ? OFFSET ? "
|
|
319
352
|
params.extend([per_page, (page - 1) * per_page])
|
|
320
353
|
return await self._fetch_all(sql, params)
|
|
321
354
|
|
|
322
|
-
async def search_count(
|
|
323
|
-
|
|
355
|
+
async def search_count(
|
|
356
|
+
self,
|
|
357
|
+
query_text: str,
|
|
358
|
+
*,
|
|
359
|
+
category: str | None = None,
|
|
360
|
+
tag: str | None = None,
|
|
361
|
+
show_hidden: bool = False,
|
|
362
|
+
recently_played_days: int = 0,
|
|
363
|
+
) -> int:
|
|
324
364
|
sql = """
|
|
325
365
|
SELECT COUNT(*) as cnt
|
|
326
366
|
FROM catalog_fts fts
|
|
@@ -360,7 +400,9 @@ class _CatalogMixin:
|
|
|
360
400
|
return await self._fetch_one(sql, [friendly_token])
|
|
361
401
|
|
|
362
402
|
async def get_item_admin(self, friendly_token: str) -> dict | None:
|
|
363
|
-
return await self._fetch_one(
|
|
403
|
+
return await self._fetch_one(
|
|
404
|
+
"SELECT * FROM catalog WHERE friendly_token = ?", [friendly_token]
|
|
405
|
+
)
|
|
364
406
|
|
|
365
407
|
async def resolve_media(self, media_id: str) -> dict | None:
|
|
366
408
|
"""Resolve a now-playing identifier to its catalog row (token + duration).
|
|
@@ -376,8 +418,9 @@ class _CatalogMixin:
|
|
|
376
418
|
[media_id, media_id],
|
|
377
419
|
)
|
|
378
420
|
|
|
379
|
-
async def record_play_completion(
|
|
380
|
-
|
|
421
|
+
async def record_play_completion(
|
|
422
|
+
self, *, friendly_token: str, duration_sec: int | None, media_type: str = "cm"
|
|
423
|
+
) -> None:
|
|
381
424
|
"""Record a genuine play-completion, routing it to the correct hide rule.
|
|
382
425
|
|
|
383
426
|
Short (<1h) episodes that belong to a mutable (TV-show) playlist are
|
|
@@ -449,7 +492,9 @@ class _CatalogMixin:
|
|
|
449
492
|
)
|
|
450
493
|
return {r["media_id"] for r in rows}
|
|
451
494
|
|
|
452
|
-
async def clear_play_state(
|
|
495
|
+
async def clear_play_state(
|
|
496
|
+
self, friendly_token: str, *, media_type: str = "cm"
|
|
497
|
+
) -> dict:
|
|
453
498
|
"""Remove all recently-played hide state for an item (admin test helper).
|
|
454
499
|
|
|
455
500
|
Deletes both time-boxed ``play_completions`` rows and any
|
|
@@ -473,7 +518,10 @@ class _CatalogMixin:
|
|
|
473
518
|
"DELETE FROM playlist_item_played WHERE media_id = ? AND media_type = ?",
|
|
474
519
|
[friendly_token, media_type],
|
|
475
520
|
)
|
|
476
|
-
return {
|
|
521
|
+
return {
|
|
522
|
+
"completions": pc["c"] if pc else 0,
|
|
523
|
+
"playlist_pass": pip["c"] if pip else 0,
|
|
524
|
+
}
|
|
477
525
|
|
|
478
526
|
async def purge_promo_hide_state(self) -> dict:
|
|
479
527
|
"""Remove any recently-played hide state recorded for promos/bumpers.
|
|
@@ -493,9 +541,16 @@ class _CatalogMixin:
|
|
|
493
541
|
f"SELECT COUNT(*) AS c FROM playlist_item_played WHERE media_id IN ({sub})",
|
|
494
542
|
sub_params,
|
|
495
543
|
)
|
|
496
|
-
await self._execute(
|
|
497
|
-
|
|
498
|
-
|
|
544
|
+
await self._execute(
|
|
545
|
+
f"DELETE FROM play_completions WHERE media_id IN ({sub})", sub_params
|
|
546
|
+
)
|
|
547
|
+
await self._execute(
|
|
548
|
+
f"DELETE FROM playlist_item_played WHERE media_id IN ({sub})", sub_params
|
|
549
|
+
)
|
|
550
|
+
return {
|
|
551
|
+
"completions": pc["c"] if pc else 0,
|
|
552
|
+
"playlist_pass": pip["c"] if pip else 0,
|
|
553
|
+
}
|
|
499
554
|
|
|
500
555
|
async def get_recently_played_debug(self, days: int) -> dict:
|
|
501
556
|
"""Snapshot of what the recently-played rules currently hide (admin test).
|
|
@@ -524,7 +579,9 @@ class _CatalogMixin:
|
|
|
524
579
|
"by_playlist_pass": by_playlist_pass,
|
|
525
580
|
}
|
|
526
581
|
|
|
527
|
-
async def get_catalog_brief(
|
|
582
|
+
async def get_catalog_brief(
|
|
583
|
+
self, tokens: list[str], manifest_urls: list[str]
|
|
584
|
+
) -> dict[str, dict]:
|
|
528
585
|
"""Return a lookup of catalog metadata keyed by BOTH friendly_token and
|
|
529
586
|
manifest_url, for enriching queue-shadow items that may only carry one.
|
|
530
587
|
"""
|
|
@@ -606,7 +663,9 @@ class _CatalogMixin:
|
|
|
606
663
|
"""
|
|
607
664
|
return await self._fetch_all(sql, params)
|
|
608
665
|
|
|
609
|
-
async def get_tags(
|
|
666
|
+
async def get_tags(
|
|
667
|
+
self, *, limit: int = 100, show_hidden: bool = False
|
|
668
|
+
) -> list[dict]:
|
|
610
669
|
"""Most-used tags that have at least one catalog item, for facets."""
|
|
611
670
|
sql = """
|
|
612
671
|
SELECT t.id, t.name, COUNT(ct.friendly_token) AS cnt
|
|
@@ -628,7 +687,9 @@ class _CatalogMixin:
|
|
|
628
687
|
|
|
629
688
|
async def upsert_category(self, name: str) -> int:
|
|
630
689
|
"""Insert a category by name (deriving a unique slug) and return its id."""
|
|
631
|
-
existing = await self._fetch_one(
|
|
690
|
+
existing = await self._fetch_one(
|
|
691
|
+
"SELECT id FROM categories WHERE name = ?", [name]
|
|
692
|
+
)
|
|
632
693
|
if existing:
|
|
633
694
|
return existing["id"]
|
|
634
695
|
base = _slugify(name)
|
|
@@ -651,7 +712,9 @@ class _CatalogMixin:
|
|
|
651
712
|
await self._db.commit()
|
|
652
713
|
return cursor.lastrowid
|
|
653
714
|
|
|
654
|
-
async def set_catalog_categories(
|
|
715
|
+
async def set_catalog_categories(
|
|
716
|
+
self, friendly_token: str, category_ids: list[int]
|
|
717
|
+
):
|
|
655
718
|
"""Replace the category memberships for a catalog item."""
|
|
656
719
|
await self._db.execute(
|
|
657
720
|
"DELETE FROM catalog_categories WHERE friendly_token = ?", [friendly_token]
|
|
@@ -778,7 +841,15 @@ class _CatalogMixin:
|
|
|
778
841
|
async def finish_sync_log(self, log_id: int, stats: dict, status: str):
|
|
779
842
|
await self._execute(
|
|
780
843
|
"UPDATE sync_log SET ended_at=?, items_seen=?, items_new=?, items_updated=?, errors=?, status=? WHERE id=?",
|
|
781
|
-
[
|
|
844
|
+
[
|
|
845
|
+
datetime.now(UTC).isoformat(),
|
|
846
|
+
stats["seen"],
|
|
847
|
+
stats["new"],
|
|
848
|
+
stats["updated"],
|
|
849
|
+
stats["errors"],
|
|
850
|
+
status,
|
|
851
|
+
log_id,
|
|
852
|
+
],
|
|
782
853
|
)
|
|
783
854
|
|
|
784
855
|
async def get_sync_logs(self, limit: int = 10) -> list[dict]:
|
|
@@ -788,8 +859,9 @@ class _CatalogMixin:
|
|
|
788
859
|
|
|
789
860
|
# --- Generic job runs ---
|
|
790
861
|
|
|
791
|
-
async def start_job_run(
|
|
792
|
-
|
|
862
|
+
async def start_job_run(
|
|
863
|
+
self, job_name: str, triggered_by: str | None = None, params: str | None = None
|
|
864
|
+
) -> int:
|
|
793
865
|
cursor = await self._db.execute(
|
|
794
866
|
"INSERT INTO job_runs (job_name, started_at, status, triggered_by, params) "
|
|
795
867
|
"VALUES (?, ?, 'running', ?, ?)",
|
|
@@ -806,11 +878,11 @@ class _CatalogMixin:
|
|
|
806
878
|
|
|
807
879
|
async def update_job_run_detail(self, run_id: int, detail: str | None):
|
|
808
880
|
"""Update only a running job's detail column (used for live progress)."""
|
|
809
|
-
await self._execute(
|
|
810
|
-
"UPDATE job_runs SET detail=? WHERE id=?", [detail, run_id]
|
|
811
|
-
)
|
|
881
|
+
await self._execute("UPDATE job_runs SET detail=? WHERE id=?", [detail, run_id])
|
|
812
882
|
|
|
813
|
-
async def get_job_runs(
|
|
883
|
+
async def get_job_runs(
|
|
884
|
+
self, job_name: str | None = None, limit: int = 10
|
|
885
|
+
) -> list[dict]:
|
|
814
886
|
if job_name:
|
|
815
887
|
return await self._fetch_all(
|
|
816
888
|
"SELECT * FROM job_runs WHERE job_name=? ORDER BY id DESC LIMIT ?",
|
|
@@ -855,4 +927,6 @@ class _CatalogMixin:
|
|
|
855
927
|
return False
|
|
856
928
|
|
|
857
929
|
async def cleanup_expired_otps(self):
|
|
858
|
-
await self._execute(
|
|
930
|
+
await self._execute(
|
|
931
|
+
"DELETE FROM otps WHERE expires_at < datetime('now') OR used=1"
|
|
932
|
+
)
|
|
@@ -339,7 +339,9 @@ class _DBBase:
|
|
|
339
339
|
for version, sql in enumerate(MIGRATIONS[1:], start=1):
|
|
340
340
|
if version > current_version:
|
|
341
341
|
await self._executescript(sql)
|
|
342
|
-
await self._execute(
|
|
342
|
+
await self._execute(
|
|
343
|
+
"INSERT INTO _migrations (version) VALUES (?)", [version]
|
|
344
|
+
)
|
|
343
345
|
|
|
344
346
|
# --- Low-level helpers ---
|
|
345
347
|
|