kryten-webqueue 0.8.2__tar.gz → 0.9.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.8.2 → kryten_webqueue-0.9.1}/CHANGELOG.md +32 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/PKG-INFO +5 -1
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/config.example.json +5 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/app.py +35 -2
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/catalog/db.py +139 -22
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/catalog/images.py +37 -0
- kryten_webqueue-0.9.1/kryten_webqueue/catalog/mediacms.py +112 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/catalog/sync.py +3 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/config.py +10 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/__init__.py +8 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/cmsutils/__init__.py +7 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/cmsutils/_common.py +15 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/cmsutils/enrichmeta.py +1844 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/cmsutils/enrichtitles.py +642 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/cmsutils/enrichtv.py +1884 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/cmsutils/fetchurls.py +1382 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/ytpipe/__init__.py +1 -0
- kryten_webqueue-0.9.1/kryten_webqueue/integrations/ytpipe/downloader.py +3437 -0
- kryten_webqueue-0.9.1/kryten_webqueue/jobs/__init__.py +3 -0
- kryten_webqueue-0.9.1/kryten_webqueue/jobs/manager.py +193 -0
- kryten_webqueue-0.9.1/kryten_webqueue/jobs/tasks.py +214 -0
- kryten_webqueue-0.9.1/kryten_webqueue/routes/admin_catalog.py +45 -0
- kryten_webqueue-0.9.1/kryten_webqueue/routes/admin_jobs.py +61 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/admin_playlists.py +49 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/catalog.py +7 -6
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/pages.py +40 -4
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/static/css/main.css +59 -12
- kryten_webqueue-0.9.1/kryten_webqueue/templates/admin/index.html +275 -0
- kryten_webqueue-0.9.1/kryten_webqueue/templates/catalog/browse.html +275 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/queue/index.html +0 -2
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/pyproject.toml +5 -1
- kryten_webqueue-0.9.1/tests/__init__.py +0 -0
- kryten_webqueue-0.9.1/tests/test_phase1.py +113 -0
- kryten_webqueue-0.9.1/tests/test_phase2_jobs.py +168 -0
- kryten_webqueue-0.9.1/tests/test_phase3_jobs.py +152 -0
- kryten_webqueue-0.8.2/kryten_webqueue/jobs/__init__.py +0 -3
- kryten_webqueue-0.8.2/kryten_webqueue/jobs/manager.py +0 -70
- kryten_webqueue-0.8.2/kryten_webqueue/routes/admin_jobs.py +0 -32
- kryten_webqueue-0.8.2/kryten_webqueue/templates/admin/index.html +0 -157
- kryten_webqueue-0.8.2/kryten_webqueue/templates/catalog/browse.html +0 -118
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/.gitignore +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/README.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.1}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -4,6 +4,38 @@ 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
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Moved `requests`, `openpyxl`, `pyyaml`, and `yt-dlp` from the optional `[jobs]` extra into core dependencies; the `jobs` extra is removed. This fixes installation under `pipx` and other tools that do not support `package[extra]` syntax.
|
|
12
|
+
|
|
13
|
+
## [0.9.0] — 2026-06-09
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Reimplemented content jobs (vendored).** Five tools are vendored into `kryten_webqueue/integrations/` and driven in-process as parameterized jobs, replacing the Windows-script workflow:
|
|
18
|
+
- `enrichtitles`, `enrichmeta`, `enrichtv` — clean titles / enrich movie & TV metadata via TMDb/OMDb, pushing to MediaCMS. Params: `dry_run`, `limit`, `days` (+ tool-specific `tubi_upgrade`, `min_score`, `min_duration`, `max_duration`, `delay`).
|
|
19
|
+
- `fetch` — download a yt-dlp-supported URL (single or playlist) to MediaCMS with `quality` (`best`/`good`/`medium`) and an optional **Add to playlist** that appends the uploaded item(s) to a saved playlist.
|
|
20
|
+
- `fetchurls` — read the upcoming **weekend's** Channel Z workbook from a local `.xlsx` (file-only, no SharePoint/Graph in v1), resolve off-site URLs via the in-process downloader, and import each section as a saved playlist named `{sheet}-{section}` (idempotent re-runs replace items). The target sheet is always the imminent Fri/Sat (Friday → today; Sat/Sun → next Friday).
|
|
21
|
+
Each blocking tool runs off the event loop via `asyncio.to_thread`, bridging progress back to the `job_runs` row. Jobs whose optional dependencies are missing register normally but fail the run fast with a clear "dependency not installed" message. New optional extra `jobs` (`yt-dlp`, `openpyxl`, `requests`, `pyyaml`); `fetch` additionally needs `ffmpeg` on the host. New config: `fetch_cookies_path`, `fetchurls.workbook_path`.
|
|
22
|
+
- **Parameterized background jobs.** The job framework now supports declarative parameter schemas: `JobManager.register(name, func, *, label, schema)` and `run(name, *, triggered_by, params)`. Job functions receive `(params, ctx)` where `ctx` exposes `db`, `api_gate`, `config`, `triggered_by`, and an async `progress(detail)` callback that writes live progress to the run's `job_runs` row. Submitted params are validated/coerced against the schema (required, defaults, `string`/`int`/`float`/`bool`/`enum`/`playlist` types) and persisted to the new `job_runs.params` column. The admin **Run** button opens a schema-driven modal for jobs that declare parameters (and runs immediately for those that don't); each job also shows a last-run summary. New endpoint `GET /admin/jobs/{name}/schema`; `GET /admin/jobs` now includes each job's `schema` + `last_run`; `POST /admin/jobs/{name}/run` accepts a JSON `{params}` body (400 on invalid params).
|
|
23
|
+
- **Browse sort control.** Browse and search now offer a *Sort by* dropdown — `Default` (quality-weighted), `Title A–Z`, `Title Z–A`, `Newest first`, `Oldest first` — available to everyone. The choice carries through pagination and the facet form and is remembered per-browser via `localStorage`. `Newest`/`Oldest` order by the catalog `added_at`, which is now populated from the MediaCMS `add_date` on sync (and backfilled from `synced_at` for existing rows).
|
|
24
|
+
- **Branded placeholder art with hover-to-thumbnail.** Tiles without a real poster match (no TMDB/OMDB art) now show a random branded placeholder from `placeholder_dir` instead of the raw MediaCMS thumbnail; hovering reveals the real thumbnail via a CSS crossfade. The placeholder list is cached in memory and rescanned periodically.
|
|
25
|
+
- **Admin tile actions.** Catalog tiles now offer admins (rank ≥ 3) *Add to playlist* (picker modal), *+ Recent* (append to the admin's most recently created playlist, no modal), and *Hide* (tags the item `kryten-hidden` in MediaCMS and hides it locally immediately). New endpoints: `POST /admin/playlists/{id}/append`, `POST /admin/playlists/recent/append`, `POST /admin/catalog/{token}/hide`, and `POST /admin/catalog/{token}/unhide`.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Tile action buttons stack vertically**, full-width, for clearer affordance at narrow tile widths.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **Job-run history no longer shows phantom `running` rows.** A job's running flag lived only in memory, so a restart or killed worker mid-run left the `job_runs` row stuck at `running` forever. On startup such orphans are now reconciled to a new `interrupted` status (styled in the admin dashboard).
|
|
34
|
+
|
|
35
|
+
### Removed
|
|
36
|
+
|
|
37
|
+
- **Live queue page no longer shows the order number or a drag handle.** The `qi-pos` index and the non-functional `qi-drag` (☰) affordance were removed; reordering lives in the playlist editor, not the live queue.
|
|
38
|
+
|
|
7
39
|
## [0.8.2] - 2026-06-08
|
|
8
40
|
|
|
9
41
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -10,12 +10,16 @@ Requires-Dist: apscheduler>=3.10
|
|
|
10
10
|
Requires-Dist: fastapi>=0.115
|
|
11
11
|
Requires-Dist: httpx>=0.27
|
|
12
12
|
Requires-Dist: jinja2>=3.1
|
|
13
|
+
Requires-Dist: openpyxl>=3.1
|
|
13
14
|
Requires-Dist: pillow>=10.0
|
|
14
15
|
Requires-Dist: pydantic>=2.0
|
|
15
16
|
Requires-Dist: pyjwt>=2.8
|
|
16
17
|
Requires-Dist: python-dateutil>=2.8
|
|
18
|
+
Requires-Dist: pyyaml>=6.0
|
|
19
|
+
Requires-Dist: requests>=2.31
|
|
17
20
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
18
21
|
Requires-Dist: websockets>=12.0
|
|
22
|
+
Requires-Dist: yt-dlp>=2024.1
|
|
19
23
|
Provides-Extra: dev
|
|
20
24
|
Requires-Dist: black>=24.0; extra == 'dev'
|
|
21
25
|
Requires-Dist: httpx; extra == 'dev'
|
|
@@ -28,6 +28,7 @@ from .routes.admin_playlists import router as admin_playlists_router
|
|
|
28
28
|
from .routes.admin_schedules import router as admin_schedules_router
|
|
29
29
|
from .routes.admin_queue import router as admin_queue_router
|
|
30
30
|
from .routes.admin_jobs import router as admin_jobs_router
|
|
31
|
+
from .routes.admin_catalog import router as admin_catalog_router
|
|
31
32
|
from .routes.pages import router as pages_router
|
|
32
33
|
from .ws.handler import router as ws_router
|
|
33
34
|
|
|
@@ -42,6 +43,11 @@ async def lifespan(app: FastAPI):
|
|
|
42
43
|
db = Database(config.db_path)
|
|
43
44
|
await db.connect()
|
|
44
45
|
await db.run_migrations()
|
|
46
|
+
# Any job run still marked 'running' is an orphan from a prior crash/restart
|
|
47
|
+
# (the running flag is in-memory only). Reconcile before registering jobs.
|
|
48
|
+
orphaned = await db.reconcile_orphaned_job_runs()
|
|
49
|
+
if orphaned:
|
|
50
|
+
logger.warning("Reconciled %d orphaned job run(s) to 'interrupted'", orphaned)
|
|
45
51
|
app.state.db = db
|
|
46
52
|
|
|
47
53
|
# API Gate client
|
|
@@ -67,12 +73,38 @@ async def lifespan(app: FastAPI):
|
|
|
67
73
|
app.state.catalog_sync = catalog_sync
|
|
68
74
|
|
|
69
75
|
# Generic background job runner (records run history to job_runs)
|
|
70
|
-
job_manager = JobManager(db)
|
|
76
|
+
job_manager = JobManager(db, api_gate=api_gate, config=config)
|
|
77
|
+
|
|
78
|
+
async def _catalog_sync_job(params, ctx):
|
|
79
|
+
# catalog_sync registers with no schema; params/ctx are ignored. The
|
|
80
|
+
# adapter keeps the zero-arg sync compatible with the params/ctx job API.
|
|
81
|
+
return await catalog_sync.sync()
|
|
82
|
+
|
|
71
83
|
job_manager.register(
|
|
72
84
|
"catalog_sync",
|
|
73
|
-
|
|
85
|
+
_catalog_sync_job,
|
|
74
86
|
label="Catalog Sync",
|
|
75
87
|
)
|
|
88
|
+
|
|
89
|
+
# Reimplemented cmsutils enrichment jobs (vendored, run off-loop). These
|
|
90
|
+
# register regardless of optional deps; a missing dep fails the run fast
|
|
91
|
+
# with a clear message rather than crashing startup.
|
|
92
|
+
from .jobs.tasks import (
|
|
93
|
+
enrichtitles_job, enrichmeta_job, enrichtv_job,
|
|
94
|
+
fetch_job, fetchurls_job,
|
|
95
|
+
ENRICHTITLES_SCHEMA, ENRICHMETA_SCHEMA, ENRICHTV_SCHEMA,
|
|
96
|
+
FETCH_SCHEMA, FETCHURLS_SCHEMA,
|
|
97
|
+
)
|
|
98
|
+
job_manager.register("enrichtitles", enrichtitles_job,
|
|
99
|
+
label="Enrich Titles", schema=ENRICHTITLES_SCHEMA)
|
|
100
|
+
job_manager.register("enrichmeta", enrichmeta_job,
|
|
101
|
+
label="Enrich Movie Metadata", schema=ENRICHMETA_SCHEMA)
|
|
102
|
+
job_manager.register("enrichtv", enrichtv_job,
|
|
103
|
+
label="Enrich TV Metadata", schema=ENRICHTV_SCHEMA)
|
|
104
|
+
job_manager.register("fetch", fetch_job,
|
|
105
|
+
label="Fetch (download → MediaCMS)", schema=FETCH_SCHEMA)
|
|
106
|
+
job_manager.register("fetchurls", fetchurls_job,
|
|
107
|
+
label="Fetch URLs (weekend workbook)", schema=FETCHURLS_SCHEMA)
|
|
76
108
|
app.state.job_manager = job_manager
|
|
77
109
|
|
|
78
110
|
# WebSocket manager
|
|
@@ -184,6 +216,7 @@ def create_app(config: Config) -> FastAPI:
|
|
|
184
216
|
app.include_router(admin_schedules_router)
|
|
185
217
|
app.include_router(admin_queue_router)
|
|
186
218
|
app.include_router(admin_jobs_router)
|
|
219
|
+
app.include_router(admin_catalog_router)
|
|
187
220
|
app.include_router(ws_router)
|
|
188
221
|
|
|
189
222
|
# Health check
|
|
@@ -27,8 +27,14 @@ HIDDEN_TAG_NAMES = [
|
|
|
27
27
|
"grindhousetrailer",
|
|
28
28
|
"publicaccess",
|
|
29
29
|
"religioustv",
|
|
30
|
+
"kryten-hidden",
|
|
30
31
|
]
|
|
31
32
|
|
|
33
|
+
# Tag applied by the admin "Hide Item" action. Source of truth is MediaCMS;
|
|
34
|
+
# this is mirrored into the local catalog_tags join so the hidden-tag filter
|
|
35
|
+
# applies immediately (before the next sync confirms it).
|
|
36
|
+
HIDDEN_ITEM_TAG = "kryten-hidden"
|
|
37
|
+
|
|
32
38
|
|
|
33
39
|
def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
|
|
34
40
|
"""SQL fragment (+ params) excluding items in hidden categories/tags.
|
|
@@ -53,6 +59,29 @@ def _hidden_exclusion(alias: str = "c") -> tuple[str, list]:
|
|
|
53
59
|
return sql, [*HIDDEN_CATEGORY_NAMES, *HIDDEN_TAG_NAMES]
|
|
54
60
|
|
|
55
61
|
|
|
62
|
+
# Default quality-weighted ordering (see browse() for rationale).
|
|
63
|
+
_DEFAULT_ORDER = """
|
|
64
|
+
ORDER BY
|
|
65
|
+
(c.cover_art_source IN ('tmdb', 'omdb')) DESC,
|
|
66
|
+
(CASE WHEN c.title GLOB '[A-Za-z]*' THEN 0 ELSE 1 END) ASC,
|
|
67
|
+
c.title ASC
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
# Map a user-facing sort key to an ORDER BY clause referencing the catalog row
|
|
71
|
+
# under alias ``c``. Unknown keys fall back to the default quality ordering.
|
|
72
|
+
_SORT_CLAUSES = {
|
|
73
|
+
"default": _DEFAULT_ORDER,
|
|
74
|
+
"title_asc": " ORDER BY c.title ASC ",
|
|
75
|
+
"title_desc": " ORDER BY c.title DESC ",
|
|
76
|
+
"newest": " ORDER BY c.added_at DESC, c.synced_at DESC ",
|
|
77
|
+
"oldest": " ORDER BY c.added_at ASC, c.synced_at ASC ",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _browse_order_clause(sort: str | None) -> str:
|
|
82
|
+
return _SORT_CLAUSES.get(sort or "default", _DEFAULT_ORDER)
|
|
83
|
+
|
|
84
|
+
|
|
56
85
|
MIGRATIONS = [
|
|
57
86
|
# v1: Migration tracking table
|
|
58
87
|
"""
|
|
@@ -232,6 +261,16 @@ MIGRATIONS = [
|
|
|
232
261
|
);
|
|
233
262
|
CREATE INDEX IF NOT EXISTS idx_job_runs_name ON job_runs(job_name, started_at);
|
|
234
263
|
""",
|
|
264
|
+
# v5: Record the parameters a job run was started with.
|
|
265
|
+
"""
|
|
266
|
+
ALTER TABLE job_runs ADD COLUMN params TEXT;
|
|
267
|
+
""",
|
|
268
|
+
# v6: Stopgap backfill of catalog.added_at (previously never populated on
|
|
269
|
+
# insert). Lets "Newest first" browse ordering work before the next full
|
|
270
|
+
# sync overwrites these with the true MediaCMS add_date.
|
|
271
|
+
"""
|
|
272
|
+
UPDATE catalog SET added_at = synced_at WHERE added_at IS NULL;
|
|
273
|
+
""",
|
|
235
274
|
]
|
|
236
275
|
|
|
237
276
|
|
|
@@ -286,9 +325,9 @@ class Database:
|
|
|
286
325
|
|
|
287
326
|
# --- Catalog ---
|
|
288
327
|
|
|
289
|
-
async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24, show_hidden: bool = False) -> list[dict]:
|
|
328
|
+
async def browse(self, *, category: str | None = None, tag: str | None = None, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
|
|
290
329
|
query = """
|
|
291
|
-
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url
|
|
330
|
+
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url
|
|
292
331
|
FROM catalog c
|
|
293
332
|
WHERE c.friendly_token NOT IN (
|
|
294
333
|
SELECT spi.media_id FROM saved_playlist_items spi
|
|
@@ -331,13 +370,8 @@ class Database:
|
|
|
331
370
|
# 2. Titles beginning with a letter before number/symbol-prefixed
|
|
332
371
|
# "02 - Episode" style entries.
|
|
333
372
|
# 3. Finally alphabetical for a stable, predictable tail.
|
|
334
|
-
query +=
|
|
335
|
-
|
|
336
|
-
(c.cover_art_source IN ('tmdb', 'omdb')) DESC,
|
|
337
|
-
(CASE WHEN c.title GLOB '[A-Za-z]*' THEN 0 ELSE 1 END) ASC,
|
|
338
|
-
c.title ASC
|
|
339
|
-
LIMIT ? OFFSET ?
|
|
340
|
-
"""
|
|
373
|
+
query += _browse_order_clause(sort)
|
|
374
|
+
query += " LIMIT ? OFFSET ?"
|
|
341
375
|
params.extend([per_page, (page - 1) * per_page])
|
|
342
376
|
return await self._fetch_all(query, params)
|
|
343
377
|
|
|
@@ -376,9 +410,9 @@ class Database:
|
|
|
376
410
|
row = await self._fetch_one(query, params)
|
|
377
411
|
return row["cnt"] if row else 0
|
|
378
412
|
|
|
379
|
-
async def search(self, query_text: str, *, page: int = 1, per_page: int = 24, show_hidden: bool = False) -> list[dict]:
|
|
413
|
+
async def search(self, query_text: str, *, page: int = 1, per_page: int = 24, show_hidden: bool = False, sort: str = "default") -> list[dict]:
|
|
380
414
|
sql = """
|
|
381
|
-
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.thumbnail_url, c.manifest_url,
|
|
415
|
+
SELECT c.friendly_token, c.title, c.duration_sec, c.cover_art_path, c.cover_art_source, c.thumbnail_url, c.manifest_url,
|
|
382
416
|
rank AS relevance
|
|
383
417
|
FROM catalog_fts fts
|
|
384
418
|
JOIN catalog c ON c.rowid = fts.rowid
|
|
@@ -394,10 +428,10 @@ class Database:
|
|
|
394
428
|
excl_sql, excl_params = _hidden_exclusion("c")
|
|
395
429
|
sql += excl_sql
|
|
396
430
|
params.extend(excl_params)
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
""
|
|
431
|
+
# Relevance is the natural default for a text query; other sort keys let
|
|
432
|
+
# the user reorder the matched set explicitly.
|
|
433
|
+
sql += " ORDER BY rank " if (sort or "default") == "default" else _browse_order_clause(sort)
|
|
434
|
+
sql += " LIMIT ? OFFSET ? "
|
|
401
435
|
params.extend([per_page, (page - 1) * per_page])
|
|
402
436
|
return await self._fetch_all(sql, params)
|
|
403
437
|
|
|
@@ -587,13 +621,32 @@ class Database:
|
|
|
587
621
|
)
|
|
588
622
|
await self._db.commit()
|
|
589
623
|
|
|
624
|
+
async def add_catalog_tag(self, friendly_token: str, tag_name: str):
|
|
625
|
+
"""Add a single tag to a catalog item (idempotent), creating it if new."""
|
|
626
|
+
tag_id = await self.upsert_tag(tag_name)
|
|
627
|
+
await self._db.execute(
|
|
628
|
+
"INSERT OR IGNORE INTO catalog_tags (friendly_token, tag_id) VALUES (?, ?)",
|
|
629
|
+
[friendly_token, tag_id],
|
|
630
|
+
)
|
|
631
|
+
await self._db.commit()
|
|
632
|
+
|
|
633
|
+
async def remove_catalog_tag(self, friendly_token: str, tag_name: str):
|
|
634
|
+
"""Remove a single tag from a catalog item (no-op if absent)."""
|
|
635
|
+
await self._db.execute(
|
|
636
|
+
"DELETE FROM catalog_tags WHERE friendly_token = ? AND tag_id IN "
|
|
637
|
+
"(SELECT id FROM tags WHERE name = ?)",
|
|
638
|
+
[friendly_token, tag_name],
|
|
639
|
+
)
|
|
640
|
+
await self._db.commit()
|
|
641
|
+
|
|
590
642
|
async def insert_catalog(self, row: dict):
|
|
591
643
|
sql = """
|
|
592
644
|
INSERT INTO catalog (friendly_token, title, description, duration_sec,
|
|
593
|
-
manifest_url, thumbnail_url, synced_at)
|
|
645
|
+
manifest_url, thumbnail_url, added_at, synced_at)
|
|
594
646
|
VALUES (:friendly_token, :title, :description, :duration_sec,
|
|
595
|
-
:manifest_url, :thumbnail_url, :synced_at)
|
|
647
|
+
:manifest_url, :thumbnail_url, :added_at, :synced_at)
|
|
596
648
|
"""
|
|
649
|
+
row = {"added_at": row.get("synced_at"), **row}
|
|
597
650
|
await self._db.execute(sql, row)
|
|
598
651
|
# Update FTS index
|
|
599
652
|
await self._db.execute(
|
|
@@ -607,9 +660,12 @@ class Database:
|
|
|
607
660
|
sql = """
|
|
608
661
|
UPDATE catalog SET title=:title, description=:description,
|
|
609
662
|
duration_sec=:duration_sec, manifest_url=:manifest_url,
|
|
610
|
-
thumbnail_url=:thumbnail_url,
|
|
663
|
+
thumbnail_url=:thumbnail_url,
|
|
664
|
+
added_at=COALESCE(:added_at, added_at),
|
|
665
|
+
synced_at=:synced_at, updated_at=:synced_at
|
|
611
666
|
WHERE friendly_token=:friendly_token
|
|
612
667
|
"""
|
|
668
|
+
row = {"added_at": None, **row}
|
|
613
669
|
await self._db.execute(sql, row)
|
|
614
670
|
# Rebuild FTS for this row
|
|
615
671
|
await self._db.execute(
|
|
@@ -651,11 +707,12 @@ class Database:
|
|
|
651
707
|
|
|
652
708
|
# --- Generic job runs ---
|
|
653
709
|
|
|
654
|
-
async def start_job_run(self, job_name: str, triggered_by: str | None = None
|
|
710
|
+
async def start_job_run(self, job_name: str, triggered_by: str | None = None,
|
|
711
|
+
params: str | None = None) -> int:
|
|
655
712
|
cursor = await self._db.execute(
|
|
656
|
-
"INSERT INTO job_runs (job_name, started_at, status, triggered_by) "
|
|
657
|
-
"VALUES (?, ?, 'running', ?)",
|
|
658
|
-
[job_name, datetime.now(UTC).isoformat(), triggered_by],
|
|
713
|
+
"INSERT INTO job_runs (job_name, started_at, status, triggered_by, params) "
|
|
714
|
+
"VALUES (?, ?, 'running', ?, ?)",
|
|
715
|
+
[job_name, datetime.now(UTC).isoformat(), triggered_by, params],
|
|
659
716
|
)
|
|
660
717
|
await self._db.commit()
|
|
661
718
|
return cursor.lastrowid
|
|
@@ -666,6 +723,12 @@ class Database:
|
|
|
666
723
|
[datetime.now(UTC).isoformat(), status, detail, run_id],
|
|
667
724
|
)
|
|
668
725
|
|
|
726
|
+
async def update_job_run_detail(self, run_id: int, detail: str | None):
|
|
727
|
+
"""Update only a running job's detail column (used for live progress)."""
|
|
728
|
+
await self._execute(
|
|
729
|
+
"UPDATE job_runs SET detail=? WHERE id=?", [detail, run_id]
|
|
730
|
+
)
|
|
731
|
+
|
|
669
732
|
async def get_job_runs(self, job_name: str | None = None, limit: int = 10) -> list[dict]:
|
|
670
733
|
if job_name:
|
|
671
734
|
return await self._fetch_all(
|
|
@@ -676,6 +739,22 @@ class Database:
|
|
|
676
739
|
"SELECT * FROM job_runs ORDER BY id DESC LIMIT ?", [limit]
|
|
677
740
|
)
|
|
678
741
|
|
|
742
|
+
async def reconcile_orphaned_job_runs(self) -> int:
|
|
743
|
+
"""Mark any job run still flagged ``running`` as ``interrupted``.
|
|
744
|
+
|
|
745
|
+
The ``running`` flag lives only in memory on the JobManager, so a
|
|
746
|
+
service restart (or a killed worker) mid-run leaves the row stuck at
|
|
747
|
+
``running`` forever. Called once on startup to clean up such orphans.
|
|
748
|
+
Returns the number of rows reconciled.
|
|
749
|
+
"""
|
|
750
|
+
cursor = await self._db.execute(
|
|
751
|
+
"UPDATE job_runs SET status='interrupted', "
|
|
752
|
+
"ended_at = COALESCE(ended_at, ?) WHERE status='running'",
|
|
753
|
+
[datetime.now(UTC).isoformat()],
|
|
754
|
+
)
|
|
755
|
+
await self._db.commit()
|
|
756
|
+
return cursor.rowcount or 0
|
|
757
|
+
|
|
679
758
|
# --- OTP ---
|
|
680
759
|
|
|
681
760
|
async def store_otp(self, username: str, code: str, expires_at: str):
|
|
@@ -820,6 +899,44 @@ class Database:
|
|
|
820
899
|
)
|
|
821
900
|
await self._db.commit()
|
|
822
901
|
|
|
902
|
+
async def append_playlist_item(self, playlist_id: int, item: dict) -> int:
|
|
903
|
+
"""Append a single item to the end of a playlist. Returns new item count."""
|
|
904
|
+
row = await self._fetch_one(
|
|
905
|
+
"SELECT COALESCE(MAX(position), -1) AS pos, COUNT(*) AS cnt "
|
|
906
|
+
"FROM saved_playlist_items WHERE playlist_id=?",
|
|
907
|
+
[playlist_id],
|
|
908
|
+
)
|
|
909
|
+
next_pos = (row["pos"] + 1) if row else 0
|
|
910
|
+
count = (row["cnt"] if row else 0) + 1
|
|
911
|
+
await self._db.execute(
|
|
912
|
+
"INSERT INTO saved_playlist_items (playlist_id, position, media_type, media_id, title, duration_sec) "
|
|
913
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
914
|
+
[playlist_id, next_pos, item["media_type"], item["media_id"], item.get("title"), item.get("duration_sec")],
|
|
915
|
+
)
|
|
916
|
+
await self._db.execute(
|
|
917
|
+
"UPDATE saved_playlists SET updated_at=datetime('now') WHERE id=?", [playlist_id]
|
|
918
|
+
)
|
|
919
|
+
await self._db.commit()
|
|
920
|
+
return count
|
|
921
|
+
|
|
922
|
+
async def get_most_recent_playlist(self, created_by: str) -> dict | None:
|
|
923
|
+
"""The given admin's most recently *created* saved playlist, if any."""
|
|
924
|
+
return await self._fetch_one(
|
|
925
|
+
"SELECT * FROM saved_playlists WHERE created_by=? ORDER BY created_at DESC, id DESC LIMIT 1",
|
|
926
|
+
[created_by],
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
async def get_playlist_by_name(self, name: str, created_by: str) -> dict | None:
|
|
930
|
+
"""Match an existing saved playlist by exact name + creator.
|
|
931
|
+
|
|
932
|
+
Used for idempotent re-imports (e.g. the fetchurls job replacing a
|
|
933
|
+
section playlist's items rather than creating a duplicate).
|
|
934
|
+
"""
|
|
935
|
+
return await self._fetch_one(
|
|
936
|
+
"SELECT * FROM saved_playlists WHERE name=? AND created_by=? ORDER BY id LIMIT 1",
|
|
937
|
+
[name, created_by],
|
|
938
|
+
)
|
|
939
|
+
|
|
823
940
|
# --- Schedules ---
|
|
824
941
|
|
|
825
942
|
async def get_schedules(self) -> list[dict]:
|
|
@@ -40,10 +40,47 @@ class CoverArtResolver:
|
|
|
40
40
|
self._client = httpx.AsyncClient(timeout=15.0)
|
|
41
41
|
self._image_dir.mkdir(parents=True, exist_ok=True)
|
|
42
42
|
self._placeholder_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
# Cached list of branded placeholder image URLs (served under /images).
|
|
44
|
+
self._placeholder_urls: list[str] = []
|
|
45
|
+
self._placeholder_cache_at: float = 0.0
|
|
43
46
|
|
|
44
47
|
async def close(self):
|
|
45
48
|
await self._client.aclose()
|
|
46
49
|
|
|
50
|
+
def list_placeholder_urls(self, *, ttl: float = 300.0) -> list[str]:
|
|
51
|
+
"""Return web URLs for branded placeholder images, cached in memory.
|
|
52
|
+
|
|
53
|
+
The directory is rescanned at most once per ``ttl`` seconds to avoid a
|
|
54
|
+
disk scan on every browse request. URLs are resolved relative to the
|
|
55
|
+
``/images`` static mount when the placeholder dir lives under the image
|
|
56
|
+
dir (the default layout); otherwise the bare filename is used.
|
|
57
|
+
"""
|
|
58
|
+
import time
|
|
59
|
+
|
|
60
|
+
now = time.monotonic()
|
|
61
|
+
if self._placeholder_urls and (now - self._placeholder_cache_at) < ttl:
|
|
62
|
+
return self._placeholder_urls
|
|
63
|
+
|
|
64
|
+
exts = {".webp", ".jpg", ".jpeg", ".png", ".gif", ".avif"}
|
|
65
|
+
try:
|
|
66
|
+
files = sorted(
|
|
67
|
+
p.name for p in self._placeholder_dir.iterdir()
|
|
68
|
+
if p.is_file() and p.suffix.lower() in exts
|
|
69
|
+
)
|
|
70
|
+
except OSError:
|
|
71
|
+
files = []
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
rel = self._placeholder_dir.resolve().relative_to(self._image_dir.resolve())
|
|
75
|
+
prefix = "/images/" + rel.as_posix().strip("/") + "/"
|
|
76
|
+
except ValueError:
|
|
77
|
+
prefix = "/images/placeholders/"
|
|
78
|
+
|
|
79
|
+
self._placeholder_urls = [prefix + f for f in files]
|
|
80
|
+
self._placeholder_cache_at = now
|
|
81
|
+
return self._placeholder_urls
|
|
82
|
+
|
|
83
|
+
|
|
47
84
|
async def resolve(self, friendly_token: str, title: str, db) -> str | None:
|
|
48
85
|
"""Try to fetch cover art; return relative path or None."""
|
|
49
86
|
# Check if already cached
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Minimal MediaCMS edit client for the admin Hide/Unhide action (B6).
|
|
2
|
+
|
|
3
|
+
This is the Phase 1 standalone helper called for by the spec ("write a minimal
|
|
4
|
+
standalone MediaCMS tag-write helper now and fold it into ``_common.py`` later").
|
|
5
|
+
It performs a read-modify-write of an item's tags so existing tags are
|
|
6
|
+
preserved, using the same edit path the enrich tools use:
|
|
7
|
+
|
|
8
|
+
PUT /api/v1/media/{friendly_token} (form field ``tags``)
|
|
9
|
+
|
|
10
|
+
MediaCMS has a known quirk where a PUT silently reassigns the owner to whoever
|
|
11
|
+
holds the API token, so we restore the original owner afterwards via
|
|
12
|
+
``/api/v1/media/user/bulk_actions``.
|
|
13
|
+
|
|
14
|
+
NOTE: the exact tag field/verb should be confirmed against the live instance
|
|
15
|
+
(see spec OQ-6 / residual risks). The local catalog mirror is updated
|
|
16
|
+
independently by the caller, so the admin UI hides items immediately even if
|
|
17
|
+
this remote write needs adjustment.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
_TIMEOUT = 30.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _normalize_base(mediacms_url: str) -> str:
|
|
30
|
+
url = (mediacms_url or "").rstrip("/")
|
|
31
|
+
for suffix in ("/api/v1", "/api"):
|
|
32
|
+
if url.endswith(suffix):
|
|
33
|
+
url = url[: -len(suffix)]
|
|
34
|
+
break
|
|
35
|
+
return url
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MediaCMSClient:
|
|
39
|
+
"""Tiny async wrapper for the MediaCMS media-edit endpoints."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, *, mediacms_url: str, token: str):
|
|
42
|
+
self._base = _normalize_base(mediacms_url)
|
|
43
|
+
self._headers = {"Authorization": f"Token {token}"}
|
|
44
|
+
|
|
45
|
+
async def _get_media(self, client: httpx.AsyncClient, friendly_token: str) -> dict | None:
|
|
46
|
+
resp = await client.get(f"{self._base}/api/v1/media/{friendly_token}")
|
|
47
|
+
if resp.status_code != 200:
|
|
48
|
+
logger.warning(
|
|
49
|
+
"MediaCMS GET media %s failed: HTTP %s", friendly_token, resp.status_code
|
|
50
|
+
)
|
|
51
|
+
return None
|
|
52
|
+
return resp.json()
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def _current_tags(media: dict) -> list[str]:
|
|
56
|
+
tags: list[str] = []
|
|
57
|
+
for t in (media.get("tags_info") or []):
|
|
58
|
+
name = t.get("title") if isinstance(t, dict) else t
|
|
59
|
+
if name:
|
|
60
|
+
tags.append(str(name))
|
|
61
|
+
# Fall back to a flat ``tags`` list if present.
|
|
62
|
+
if not tags:
|
|
63
|
+
for t in (media.get("tags") or []):
|
|
64
|
+
if t:
|
|
65
|
+
tags.append(str(t))
|
|
66
|
+
return tags
|
|
67
|
+
|
|
68
|
+
async def _restore_owner(self, client: httpx.AsyncClient, friendly_token: str, owner: str | None):
|
|
69
|
+
if not owner:
|
|
70
|
+
return
|
|
71
|
+
try:
|
|
72
|
+
await client.post(
|
|
73
|
+
f"{self._base}/api/v1/media/user/bulk_actions",
|
|
74
|
+
json={"action": "change_owner", "media_ids": [friendly_token], "owner": owner},
|
|
75
|
+
)
|
|
76
|
+
except httpx.HTTPError:
|
|
77
|
+
pass # best-effort; never fail the hide over ownership restoration
|
|
78
|
+
|
|
79
|
+
async def set_tag(self, friendly_token: str, tag: str, *, present: bool) -> bool:
|
|
80
|
+
"""Add or remove ``tag`` on an item, preserving existing tags.
|
|
81
|
+
|
|
82
|
+
Returns True if the remote write reported success.
|
|
83
|
+
"""
|
|
84
|
+
async with httpx.AsyncClient(headers=self._headers, timeout=_TIMEOUT) as client:
|
|
85
|
+
media = await self._get_media(client, friendly_token)
|
|
86
|
+
if media is None:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
current = self._current_tags(media)
|
|
90
|
+
has = tag in current
|
|
91
|
+
if present and has:
|
|
92
|
+
return True
|
|
93
|
+
if not present and not has:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
new_tags = [t for t in current if t != tag]
|
|
97
|
+
if present:
|
|
98
|
+
new_tags.append(tag)
|
|
99
|
+
|
|
100
|
+
owner = media.get("user")
|
|
101
|
+
resp = await client.put(
|
|
102
|
+
f"{self._base}/api/v1/media/{friendly_token}",
|
|
103
|
+
data={"tags": ",".join(new_tags)},
|
|
104
|
+
)
|
|
105
|
+
ok = resp.status_code in (200, 201)
|
|
106
|
+
if not ok:
|
|
107
|
+
logger.warning(
|
|
108
|
+
"MediaCMS tag write for %s failed: HTTP %s — %s",
|
|
109
|
+
friendly_token, resp.status_code, resp.text[:200],
|
|
110
|
+
)
|
|
111
|
+
await self._restore_owner(client, friendly_token, owner)
|
|
112
|
+
return ok
|
|
@@ -118,6 +118,9 @@ class CatalogSync:
|
|
|
118
118
|
"duration_sec": media.get("duration") or 0,
|
|
119
119
|
"manifest_url": self._build_manifest_url(media),
|
|
120
120
|
"thumbnail_url": media.get("thumbnail_url", ""),
|
|
121
|
+
# True MediaCMS publish time so "Newest first" browse ordering is
|
|
122
|
+
# accurate; falls back to now when the field is absent.
|
|
123
|
+
"added_at": media.get("add_date") or now,
|
|
121
124
|
"synced_at": now,
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -3,6 +3,12 @@ from pydantic import BaseModel
|
|
|
3
3
|
import json
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
class FetchUrlsConfig(BaseModel):
|
|
7
|
+
"""Settings for the fetchurls job (v1 = local file only, OQ-1)."""
|
|
8
|
+
|
|
9
|
+
workbook_path: str = "" # absolute path to a synced Channel Z Playlist .xlsx
|
|
10
|
+
|
|
11
|
+
|
|
6
12
|
class Config(BaseModel):
|
|
7
13
|
"""Application configuration loaded from JSON file."""
|
|
8
14
|
|
|
@@ -25,6 +31,10 @@ class Config(BaseModel):
|
|
|
25
31
|
tmdb_api_key: str = ""
|
|
26
32
|
omdb_api_key: str = ""
|
|
27
33
|
|
|
34
|
+
# Jobs (optional; jobs whose config/deps are absent fail fast at run time)
|
|
35
|
+
fetch_cookies_path: str = "" # optional yt-dlp cookies for gated sources
|
|
36
|
+
fetchurls: FetchUrlsConfig = FetchUrlsConfig()
|
|
37
|
+
|
|
28
38
|
# Database
|
|
29
39
|
db_path: str = "/var/lib/kryten-webqueue/webqueue.db"
|
|
30
40
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Vendored third-party tooling adapted for in-process use by webqueue jobs.
|
|
2
|
+
|
|
3
|
+
Subpackages here are copied (and lightly adapted) from external repositories
|
|
4
|
+
so their battle-tested logic can run inside the webqueue service without
|
|
5
|
+
shelling out. Each vendored module carries a header noting its upstream source
|
|
6
|
+
and the date it was vendored; adapters are kept thin so re-vendoring stays
|
|
7
|
+
mechanical.
|
|
8
|
+
"""
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Vendored from d:\\Devel\\cmsutils (enrichtitles / enrichmeta / enrichtv /
|
|
2
|
+
fetchurls), vendored 2026-06-09. See each module header for its upstream file.
|
|
3
|
+
|
|
4
|
+
Adapters expose a headless ``run(params, *, config, progress)`` entry point on
|
|
5
|
+
top of the original CLI logic; the original ``main()``/argparse paths are left
|
|
6
|
+
intact but unused by the service.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Shared MediaCMS helpers for the vendored cmsutils tools and the Hide action.
|
|
2
|
+
|
|
3
|
+
Per the spec (OQ-6), the admin Hide/Unhide tag write should ultimately share
|
|
4
|
+
one MediaCMS edit client with the enrich tools. This module re-exports the
|
|
5
|
+
async ``MediaCMSClient`` (used by the Hide UI) so future refactors can route
|
|
6
|
+
the enrich tools' read-modify-write tag edits through the same place.
|
|
7
|
+
|
|
8
|
+
The enrich tools (``enrichtitles``/``enrichmeta``/``enrichtv``) currently use
|
|
9
|
+
their own synchronous ``requests``-based update helpers; those remain in their
|
|
10
|
+
respective modules to keep re-vendoring mechanical.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ...catalog.mediacms import MediaCMSClient
|
|
14
|
+
|
|
15
|
+
__all__ = ["MediaCMSClient"]
|