kryten-webqueue 0.8.2__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/CHANGELOG.md +26 -0
  2. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/PKG-INFO +6 -1
  3. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/config.example.json +5 -0
  4. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/app.py +35 -2
  5. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/catalog/db.py +139 -22
  6. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/catalog/images.py +37 -0
  7. kryten_webqueue-0.9.0/kryten_webqueue/catalog/mediacms.py +112 -0
  8. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/catalog/sync.py +3 -0
  9. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/config.py +10 -0
  10. kryten_webqueue-0.9.0/kryten_webqueue/integrations/__init__.py +8 -0
  11. kryten_webqueue-0.9.0/kryten_webqueue/integrations/cmsutils/__init__.py +7 -0
  12. kryten_webqueue-0.9.0/kryten_webqueue/integrations/cmsutils/_common.py +15 -0
  13. kryten_webqueue-0.9.0/kryten_webqueue/integrations/cmsutils/enrichmeta.py +1844 -0
  14. kryten_webqueue-0.9.0/kryten_webqueue/integrations/cmsutils/enrichtitles.py +642 -0
  15. kryten_webqueue-0.9.0/kryten_webqueue/integrations/cmsutils/enrichtv.py +1884 -0
  16. kryten_webqueue-0.9.0/kryten_webqueue/integrations/cmsutils/fetchurls.py +1382 -0
  17. kryten_webqueue-0.9.0/kryten_webqueue/integrations/ytpipe/__init__.py +1 -0
  18. kryten_webqueue-0.9.0/kryten_webqueue/integrations/ytpipe/downloader.py +3437 -0
  19. kryten_webqueue-0.9.0/kryten_webqueue/jobs/__init__.py +3 -0
  20. kryten_webqueue-0.9.0/kryten_webqueue/jobs/manager.py +193 -0
  21. kryten_webqueue-0.9.0/kryten_webqueue/jobs/tasks.py +214 -0
  22. kryten_webqueue-0.9.0/kryten_webqueue/routes/admin_catalog.py +45 -0
  23. kryten_webqueue-0.9.0/kryten_webqueue/routes/admin_jobs.py +61 -0
  24. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/admin_playlists.py +49 -0
  25. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/catalog.py +7 -6
  26. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/pages.py +40 -4
  27. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/static/css/main.css +59 -12
  28. kryten_webqueue-0.9.0/kryten_webqueue/templates/admin/index.html +275 -0
  29. kryten_webqueue-0.9.0/kryten_webqueue/templates/catalog/browse.html +275 -0
  30. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/queue/index.html +0 -2
  31. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/pyproject.toml +7 -1
  32. kryten_webqueue-0.9.0/tests/__init__.py +0 -0
  33. kryten_webqueue-0.9.0/tests/test_phase1.py +113 -0
  34. kryten_webqueue-0.9.0/tests/test_phase2_jobs.py +168 -0
  35. kryten_webqueue-0.9.0/tests/test_phase3_jobs.py +152 -0
  36. kryten_webqueue-0.8.2/kryten_webqueue/jobs/__init__.py +0 -3
  37. kryten_webqueue-0.8.2/kryten_webqueue/jobs/manager.py +0 -70
  38. kryten_webqueue-0.8.2/kryten_webqueue/routes/admin_jobs.py +0 -32
  39. kryten_webqueue-0.8.2/kryten_webqueue/templates/admin/index.html +0 -157
  40. kryten_webqueue-0.8.2/kryten_webqueue/templates/catalog/browse.html +0 -118
  41. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/.github/workflows/python-publish.yml +0 -0
  42. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/.github/workflows/release.yml +0 -0
  43. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/.gitignore +0 -0
  44. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/README.md +0 -0
  45. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/deploy/kryten-webqueue.service +0 -0
  46. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/deploy/nginx-queue.conf +0 -0
  47. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  48. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/IMPL_API_GATE.md +0 -0
  49. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/IMPL_ECONOMY.md +0 -0
  50. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  51. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/IMPL_ROBOT.md +0 -0
  52. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/PRE_PLAN_GAPS.md +0 -0
  53. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/PRODUCT_PLAN.md +0 -0
  54. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  55. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/__init__.py +0 -0
  56. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/__main__.py +0 -0
  57. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  58. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/api_gate/client.py +0 -0
  59. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/auth/__init__.py +0 -0
  60. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/auth/otp.py +0 -0
  61. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  62. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/auth/session.py +0 -0
  63. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/catalog/__init__.py +0 -0
  64. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/playlists/__init__.py +0 -0
  65. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/playlists/fire.py +0 -0
  66. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/playlists/importer.py +0 -0
  67. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  68. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/queue/__init__.py +0 -0
  69. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/queue/ordering.py +0 -0
  70. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/queue/poller.py +0 -0
  71. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/queue/shadow.py +0 -0
  72. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/__init__.py +0 -0
  73. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  74. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  75. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/auth.py +0 -0
  76. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/queue.py +0 -0
  77. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/routes/user.py +0 -0
  78. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/static/js/main.js +0 -0
  79. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  81. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  82. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/auth/login.html +0 -0
  83. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/base.html +0 -0
  84. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  85. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  86. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  87. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/ws/__init__.py +0 -0
  88. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/ws/handler.py +0 -0
  89. {kryten_webqueue-0.8.2 → kryten_webqueue-0.9.0}/kryten_webqueue/ws/manager.py +0 -0
@@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ ## [0.9.0] — 2026-06-09
8
+
9
+ ### Added
10
+
11
+ - **Reimplemented content jobs (vendored).** Five tools are vendored into `kryten_webqueue/integrations/` and driven in-process as parameterized jobs, replacing the Windows-script workflow:
12
+ - `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`).
13
+ - `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.
14
+ - `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).
15
+ 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`.
16
+ - **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).
17
+ - **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).
18
+ - **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.
19
+ - **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`.
20
+
21
+ ### Changed
22
+
23
+ - **Tile action buttons stack vertically**, full-width, for clearer affordance at narrow tile widths.
24
+
25
+ ### Fixed
26
+
27
+ - **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).
28
+
29
+ ### Removed
30
+
31
+ - **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.
32
+
7
33
  ## [0.8.2] - 2026-06-08
8
34
 
9
35
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.8.2
3
+ Version: 0.9.0
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
@@ -23,6 +23,11 @@ Requires-Dist: mypy>=1.10; extra == 'dev'
23
23
  Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
24
24
  Requires-Dist: pytest>=8.0; extra == 'dev'
25
25
  Requires-Dist: ruff>=0.4; extra == 'dev'
26
+ Provides-Extra: jobs
27
+ Requires-Dist: openpyxl>=3.1; extra == 'jobs'
28
+ Requires-Dist: pyyaml>=6.0; extra == 'jobs'
29
+ Requires-Dist: requests>=2.31; extra == 'jobs'
30
+ Requires-Dist: yt-dlp>=2024.1; extra == 'jobs'
26
31
  Description-Content-Type: text/markdown
27
32
 
28
33
  # kryten-webqueue
@@ -14,6 +14,11 @@
14
14
  "tmdb_api_key": "",
15
15
  "omdb_api_key": "",
16
16
 
17
+ "fetch_cookies_path": "",
18
+ "fetchurls": {
19
+ "workbook_path": ""
20
+ },
21
+
17
22
  "db_path": "/var/lib/kryten-webqueue/webqueue.db",
18
23
 
19
24
  "image_dir": "/var/lib/kryten-webqueue/images",
@@ -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
- catalog_sync.sync,
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
- ORDER BY
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
- sql += """
398
- ORDER BY rank
399
- LIMIT ? OFFSET ?
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, synced_at=:synced_at, updated_at=:synced_at
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) -> int:
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"]