kryten-webqueue 0.8.0__tar.gz → 0.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/CHANGELOG.md +12 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/PKG-INFO +2 -1
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/IMPLEMENTATION_SPEC.md +1 -1
- kryten_webqueue-0.8.2/docs/SPEC_JOBS_AND_BROWSE.md +353 -0
- kryten_webqueue-0.8.2/kryten_webqueue/playlists/scheduler.py +131 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/queue/shadow.py +20 -7
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/queue/index.html +15 -9
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/pyproject.toml +2 -1
- kryten_webqueue-0.8.0/kryten_webqueue/playlists/scheduler.py +0 -72
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/.gitignore +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/README.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/config.example.json +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/catalog/db.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/config.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/playlists/importer.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/ws/manager.py +0 -0
|
@@ -4,6 +4,18 @@ 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.8.2] - 2026-06-08
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Queue ETAs no longer depend on the server clock/timezone.** Predicted start times were emitted as absolute UTC timestamps, so any host clock skew or timezone misconfiguration shifted every ETA by the offset (the persistent "TZ issue"). The shadow now also emits a clock-independent relative offset (`estimated_start_in_sec` = seconds-from-now until an item plays), and the queue page computes the wall-clock time from the **browser's** own clock (`Date.now() + offset`). The absolute timestamp is retained for compatibility and as a fallback. Result: ETAs are correct regardless of server clock/timezone.
|
|
12
|
+
|
|
13
|
+
## [0.8.1] - 2026-06-08
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **True RRULE-based recurring schedules.** Recurring schedules now auto-re-arm: after an automatic timed fire, the scheduler computes the next occurrence from the schedule's `rrule` (anchored on its fire time), advances `fire_at`, clears `fired_at`, and registers the next job — no manual re-arming needed. On startup, recurring schedules whose fire time elapsed while the service was down are advanced to their next future occurrence. Manual "Fire Now" intentionally does **not** advance the recurrence; the originally scheduled occurrence stays armed. Unparseable or exhausted rules are logged and left inert. Adds an explicit `python-dateutil` dependency for RRULE parsing.
|
|
18
|
+
|
|
7
19
|
## [0.8.0] - 2026-06-08
|
|
8
20
|
|
|
9
21
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -13,6 +13,7 @@ Requires-Dist: jinja2>=3.1
|
|
|
13
13
|
Requires-Dist: pillow>=10.0
|
|
14
14
|
Requires-Dist: pydantic>=2.0
|
|
15
15
|
Requires-Dist: pyjwt>=2.8
|
|
16
|
+
Requires-Dist: python-dateutil>=2.8
|
|
16
17
|
Requires-Dist: uvicorn[standard]>=0.30
|
|
17
18
|
Requires-Dist: websockets>=12.0
|
|
18
19
|
Provides-Extra: dev
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
# kryten-webqueue — Jobs & Browse Enhancements Spec
|
|
2
|
+
|
|
3
|
+
**Version:** 1.1
|
|
4
|
+
**Date:** 2026-06-08
|
|
5
|
+
**Status:** Design — open questions resolved — ready for phased implementation
|
|
6
|
+
**Author direction:** self-authored implementation plan (GitHub Copilot)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 0. Decisions captured (from clarification)
|
|
11
|
+
|
|
12
|
+
| # | Question | Decision |
|
|
13
|
+
|---|----------|----------|
|
|
14
|
+
| 1 | How to run fetch/fetchurls/enrich* (Windows scripts vs Linux service) | **Reimplement the tools' logic inside webqueue** by vendoring the existing Python modules into an internal `integrations/` package and driving them in-process (see §A2). |
|
|
15
|
+
| 2 | Tool identity | Confirmed: `d:\devel\cmsutils\{fetchurls,enrichtitles,enrichmeta,enrichtv}.py` + `d:\devel\yt-pipe` downloader (`youtube_to_mediacms.py`, invoked today via `fetch.ps1`). The original request's "cmstools / enhance*" names map to these. |
|
|
16
|
+
| 3 | "Jobs never run at the same time" | **Per-job lock only** (already enforced in memory). The real defect the user is seeing is the **job-run history list** showing phantom `running` rows — fix that (see §A1.2). |
|
|
17
|
+
| 4 | Hide Item tag + write target | Write tag **`kryten-hidden`** to MediaCMS via the API token (MediaCMS is the source of truth). Hide **immediately in the local catalog**; the next sync confirms it. |
|
|
18
|
+
| 5 | Browse sort options + scope | `Default (quality)`, `Title A–Z`, `Title Z–A`, `Newest first`, `Oldest first`. **`Newest first` is available to everyone** (not admin-only). |
|
|
19
|
+
| 6 | "Most recent playlist" | **Most recently *created* saved playlist by the current admin** (`saved_playlists` where `created_by = user ORDER BY created_at DESC LIMIT 1`). |
|
|
20
|
+
| 7 | fetchurls weekend | **Always the upcoming weekend**: compute the next Friday and target the sheet named `M.D-M.D` (e.g. `3.6-3.7`), overriding the tool's current/just-past auto-select. |
|
|
21
|
+
|
|
22
|
+
### 0.1 Resolved open questions (was §I)
|
|
23
|
+
|
|
24
|
+
| OQ | Question | **Resolution** |
|
|
25
|
+
|----|----------|----------------|
|
|
26
|
+
| OQ-1 | fetchurls SharePoint auth in a headless service | **v1 = local file only.** The job reads the workbook from a configured/uploaded `.xlsx` path (`sharepoint.workbook_path` or an admin upload), reusing the tool's existing `--file` code path. **No Microsoft Graph / MSAL device-code in v1.** A future phase MAY add Graph with the device code surfaced in the admin UI; spec'd but not built now. Column-F writeback is **disabled** in file-only mode unless the file is writable in place. |
|
|
27
|
+
| OQ-2 | Vendor vs packaged dependency | **Vendor-and-adapt** into `kryten_webqueue/integrations/` (accept drift from `d:\devel\cmsutils`). Record the upstream commit/date in each vendored file's header. A future option to repackage `cmsutils` as an installable dependency is noted but not pursued now. |
|
|
28
|
+
| OQ-3 | "Upcoming weekend" when run on a Friday | **Use today's weekend** (the imminent Fri/Sat). `friday = today + ((4 - weekday) mod 7)` yields today when run on Friday; only Sat/Sun roll forward to next Friday. |
|
|
29
|
+
| OQ-4 | Random branded art stability | **Per server-render.** The browse route picks `random.choice(placeholders)` per affected tile when building the page; the src is stable for that page load (no client reshuffle, no layout thrash). Hover still reveals the real thumbnail. |
|
|
30
|
+
| OQ-5 | Include `unhide`? | **Yes.** Ship `POST /admin/catalog/{token}/unhide` (removes `kryten-hidden` in MediaCMS + locally) so a mis-hide is reversible from the admin "show hidden" view. |
|
|
31
|
+
| OQ-6 | MediaCMS tag-write endpoint | **Reuse the enrich tools' MediaCMS edit path.** Tags are written via the media-edit call to `POST /api/v1/media/{friendly_token}` with the `tags` field and the API token — the same mechanism `enrichmeta`/`enrichtv` use. Extract into `integrations/cmsutils/_common.py:MediaCMSClient.set_tags(token, tags)` and **read-modify-write** (fetch current tags, add/remove `kryten-hidden`, submit) to preserve existing tags. **Verify exact field/verb against the live instance during B6** with a round-trip integration test on a disposable item before wiring the UI. |
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 1. Scope
|
|
36
|
+
|
|
37
|
+
Two feature areas plus a jobs-framework fix:
|
|
38
|
+
|
|
39
|
+
- **A. Admin → Jobs**: framework fix (history reconciliation), and five new jobs that reimplement `fetch`, `fetchurls`, `enrichtitles`, `enrichmeta`, `enrichtv` with reasonable defaults and a small amount of new wiring (fetch → playlist, fetchurls → imported saved playlists).
|
|
40
|
+
- **B. Browse / Results**: sort control, random branded art with hover-to-real-thumbnail, vertically stacked tile buttons, admin "Add to playlist" / "Add to most-recent playlist" / "Hide Item".
|
|
41
|
+
- **C. Queue page**: hide the order number, remove the drag handle and all drag-reorder affordance.
|
|
42
|
+
|
|
43
|
+
Out of scope: changing the economy/pay flow, the scheduler, or the public catalog filtering rules beyond the new hide tag.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## A. Admin → Jobs
|
|
48
|
+
|
|
49
|
+
### A1. Job framework changes
|
|
50
|
+
|
|
51
|
+
#### A1.1 Per-job concurrency (confirm existing)
|
|
52
|
+
`JobManager.run()` already rejects a second start of the same job while one is in `self._running`. **No change required** to the guard itself. Document it: a job that is running returns `{"started": false, "reason": "already_running"}` and the UI disables its Run button (already implemented in `admin/index.html`).
|
|
53
|
+
|
|
54
|
+
#### A1.2 Fix the job-run history list (the actual bug)
|
|
55
|
+
**Problem:** `start_job_run()` inserts a row with `status='running'`; `_running` is in-memory only. If the service restarts (or the worker is killed) mid-run, the row is **never updated** and shows `running` forever in the history table. Long-running jobs (fetch/enrich) make this common.
|
|
56
|
+
|
|
57
|
+
**Fix (required):**
|
|
58
|
+
1. **Startup reconciliation.** On app startup, before registering jobs, run:
|
|
59
|
+
```sql
|
|
60
|
+
UPDATE job_runs SET status='interrupted',
|
|
61
|
+
ended_at = COALESCE(ended_at, CURRENT_TIMESTAMP)
|
|
62
|
+
WHERE status='running';
|
|
63
|
+
```
|
|
64
|
+
Add a `Database.reconcile_orphaned_job_runs()` method; call it in the `lifespan` startup (after `run_migrations`, before background workers).
|
|
65
|
+
2. **Status vocabulary.** Add `interrupted` to the known statuses; style it in CSS (`.job-status-interrupted { color: var(--warning); }`).
|
|
66
|
+
3. **History query.** `get_job_runs` ordering by `id DESC` is fine; ensure the admin dashboard groups/labels by `job_name` and shows `triggered_by`. (Optional polish: a per-job "last run" summary above the raw history.)
|
|
67
|
+
4. **Heartbeat (optional, phase 2).** For very long jobs, periodically `UPDATE job_runs SET detail=? WHERE id=?` with progress (e.g. `{"processed": N, "total": M}`) so the UI can show live progress; the dashboard already polls.
|
|
68
|
+
|
|
69
|
+
**Acceptance:** after a hard restart during a job, the history shows that run as `interrupted`, never a perpetual `running`; a fresh run of the same job is allowed.
|
|
70
|
+
|
|
71
|
+
#### A1.3 Parameterized jobs
|
|
72
|
+
The current `JobManager.register(name, func)` takes a zero-arg coroutine. New jobs need **parameters** (URL, quality, playlist id, limits, dry-run, etc.). Extend the framework:
|
|
73
|
+
|
|
74
|
+
- `register(name, func, *, label, schema=None)` where `schema` is a small declarative list of fields (name, type, default, label, required, options) used to render a parameter form in the admin UI and to validate input.
|
|
75
|
+
- `run(name, *, triggered_by, params: dict | None = None)` passes validated `params` to the job function: `func(params, ctx)` where `ctx` exposes `db`, `api_gate`, `config`, and an async `progress(detail: dict)` callback.
|
|
76
|
+
- Persist the submitted `params` into the `job_runs.detail` (or a new `params` column) so history shows what was run.
|
|
77
|
+
- Back-compat: existing `catalog_sync` registers with no schema and a `func(params, ctx)` that ignores params.
|
|
78
|
+
|
|
79
|
+
**Admin UI:** the Run button opens a small modal generated from `schema` (reusing the shared admin modal/`field` CSS from v0.8.0). Jobs with no schema run immediately as today.
|
|
80
|
+
|
|
81
|
+
**New endpoint:** `GET /admin/jobs/{name}/schema` (or include schema in `GET /admin/jobs`) so the UI can render the form. `POST /admin/jobs/{name}/run` accepts a JSON `params` body.
|
|
82
|
+
|
|
83
|
+
### A2. Reimplementation strategy ("logic inside webqueue")
|
|
84
|
+
|
|
85
|
+
The enrich tools are large (`enrichtv.py` ≈ 1700 lines) and battle-tested. **Do not rewrite from scratch.** Instead:
|
|
86
|
+
|
|
87
|
+
1. **Vendor** the source modules into `kryten_webqueue/integrations/`:
|
|
88
|
+
```
|
|
89
|
+
kryten_webqueue/integrations/
|
|
90
|
+
__init__.py
|
|
91
|
+
cmsutils/ # vendored from d:\devel\cmsutils
|
|
92
|
+
enrichtitles.py
|
|
93
|
+
enrichmeta.py
|
|
94
|
+
enrichtv.py
|
|
95
|
+
fetchurls.py
|
|
96
|
+
_common.py # shared MediaCMS client / scoring helpers if extracted
|
|
97
|
+
ytpipe/ # vendored from d:\devel\yt-pipe
|
|
98
|
+
downloader.py # from youtube_to_mediacms.py
|
|
99
|
+
```
|
|
100
|
+
2. **Refactor each module's entry point** from `main(argv)` (argparse + console-UTF8 + `print` + interactive prompts) into a **callable** `run(params: dict, *, config, progress) -> dict`:
|
|
101
|
+
- Remove `argparse`, `sys.stdin` prompts, and `-i/--interactive` paths (service is headless — interactive enrich modes are **disabled**).
|
|
102
|
+
- Replace `print(...)` with the `progress()` callback + `logging`.
|
|
103
|
+
- Accept config (MediaCMS URL/token, TMDb/OMDb keys) from webqueue `Config`, not from `config.yaml`/CLI.
|
|
104
|
+
- Return a result dict (counts, errors) for `job_runs.detail`.
|
|
105
|
+
3. **Run blocking work off the event loop.** These modules use synchronous `requests`, `openpyxl`, `yt_dlp`, `msal`. Each job function is an `async def` that does `await asyncio.to_thread(module.run, params, config=..., progress=thread_safe_progress)`. The `progress` callback must hop back to the loop (`asyncio.run_coroutine_threadsafe` or a queue) to write `job_runs`.
|
|
106
|
+
4. **Config reuse.** MediaCMS token and TMDb/OMDb keys already exist in webqueue `Config` (`mediacms_token`, `tmdb_api_key`, `omdb_api_key`). The enrich tools accept exactly these. fetchurls needs **new** SharePoint config (§A4).
|
|
107
|
+
5. **System dependencies.** `fetch` needs `yt-dlp` + `ffmpeg` on the host; document in deploy notes. `fetchurls` needs `openpyxl` + `msal`. Add Python deps to `pyproject.toml` as an **optional extra** (`jobs`) so a minimal deployment without these tools still installs:
|
|
108
|
+
```toml
|
|
109
|
+
[project.optional-dependencies]
|
|
110
|
+
jobs = ["yt-dlp>=2024.1", "openpyxl>=3.1", "msal>=1.28", "requests>=2.31"]
|
|
111
|
+
```
|
|
112
|
+
Jobs whose deps are missing register but fail fast with a clear "dependency not installed" message (don't crash startup).
|
|
113
|
+
|
|
114
|
+
### A3. Job: `fetch` (yt-pipe downloader)
|
|
115
|
+
|
|
116
|
+
Reimplements `youtube_to_mediacms.py` (today wrapped by `fetch.ps1`). Downloads a yt-dlp-supported URL and uploads it to MediaCMS.
|
|
117
|
+
|
|
118
|
+
**Parameters (schema):**
|
|
119
|
+
|
|
120
|
+
| Field | Type | Default | Notes |
|
|
121
|
+
|-------|------|---------|-------|
|
|
122
|
+
| `url` | string (required) | — | Source URL (YouTube/Tubi/etc.); apply the existing Mix/Radio playlist cleanup. |
|
|
123
|
+
| `quality` | enum | `medium` | `best` \| `good` \| `medium` (same mapping as `fetch.ps1`). |
|
|
124
|
+
| `max_videos` | int | `50` | For playlist URLs. |
|
|
125
|
+
| `add_to_playlist` | playlist picker | none | **New.** If set, append the resulting MediaCMS item(s) to this saved playlist after upload. |
|
|
126
|
+
|
|
127
|
+
**Config:** `mediacms_url`, `mediacms_token` (already present). Cookies: optional `fetch_cookies_path` config for age/region-gated sources (mirrors `cookies.txt`).
|
|
128
|
+
|
|
129
|
+
**Add-to-playlist behavior (new):**
|
|
130
|
+
- After a successful upload, the downloader returns the new `friendly_token`(s).
|
|
131
|
+
- For each, append `{media_type: 'cm', media_id: <manifest_url or token>, title, duration_sec}` to the chosen `saved_playlist` via the existing `replace_playlist_items` (read → append → write) or a new `append_playlist_item` helper.
|
|
132
|
+
- If `add_to_playlist` is set but upload yields no token, record a non-fatal warning in the run detail.
|
|
133
|
+
|
|
134
|
+
**Result detail:** `{"downloaded": N, "uploaded": N, "tokens": [...], "added_to_playlist": <id|null>, "errors": [...]}`.
|
|
135
|
+
|
|
136
|
+
### A4. Job: `fetchurls`
|
|
137
|
+
|
|
138
|
+
Reimplements `fetchurls.py`: read the Channel Z Excel workbook from SharePoint, resolve each source URL (validate dropsugar.co with HEAD; download YouTube/Tubi via the `fetch` downloader), and produce per-section playlists.
|
|
139
|
+
|
|
140
|
+
**Always-upcoming-weekend rule (new, overrides tool):**
|
|
141
|
+
- Compute the **next Friday** from today: `friday = today + ((4 - today.weekday()) % 7)`. When run **on a Friday this yields today** (the imminent weekend, per OQ-3); Sat/Sun roll forward to the next Friday. Saturday = friday + 1.
|
|
142
|
+
- Sheet name = `f"{friday.month}.{friday.day}-{saturday.month}.{saturday.day}"` (e.g. `3.6-3.7`), matching `_SHEET_DATE_RE`. Pass this explicitly as the target sheet rather than calling `_auto_select_sheet` (which selects current/just-past).
|
|
143
|
+
- If the computed sheet is absent from the workbook, fail the run with a clear message listing available sheet names.
|
|
144
|
+
|
|
145
|
+
**Parameters (schema):**
|
|
146
|
+
|
|
147
|
+
| Field | Type | Default | Notes |
|
|
148
|
+
|-------|------|---------|-------|
|
|
149
|
+
| `section` | enum | `all` | `all` \| `friday` \| `saturday-night` \| `saturday-morning`. |
|
|
150
|
+
| `dry_run` | bool | `false` | Resolve/preview only; no downloads, no writeback, no import. |
|
|
151
|
+
| `writeback` | bool | `true` | Write resolved URLs back to column F. |
|
|
152
|
+
| `validate` | bool | `true` | HEAD-check existing dropsugar.co URLs. |
|
|
153
|
+
|
|
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()`.
|
|
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
|
+
- The `{sheet}-failures` file is **not** imported; surface its count in the run detail.
|
|
159
|
+
|
|
160
|
+
**Workbook source (OQ-1 resolved — local file only in v1):** `fetchurls` reads the Channel Z workbook from a **configured/uploaded `.xlsx`**, reusing the tool's existing `--file` path. **No SharePoint/Graph/MSAL in v1.** New config:
|
|
161
|
+
```jsonc
|
|
162
|
+
"fetchurls": {
|
|
163
|
+
"workbook_path": "" // absolute path to a synced/exported Channel Z Playlist .xlsx
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
Optionally the admin Run modal accepts a one-off file upload that overrides `workbook_path` for that run. Column-F writeback is **disabled** in file-only mode unless the configured file is writable in place (toggle `writeback`). A future phase may add Graph auth (device code surfaced in the admin UI) — out of scope here.
|
|
167
|
+
|
|
168
|
+
**Result detail:** `{"sheet": "3.6-3.7", "resolved": N, "downloaded": N, "failures": N, "imported_playlists": ["3.6-3.7-friday", ...]}`.
|
|
169
|
+
|
|
170
|
+
### A5. Jobs: `enrichtitles`, `enrichmeta`, `enrichtv`
|
|
171
|
+
|
|
172
|
+
Reimplement the three enrich tools. All three already accept the same core config webqueue has (`--token`, `--tmdb-key`, `--omdb-key`, `--api-url`) and default to **dry-run** unless `--commit`. For one-click admin jobs we default to **commit on** (the point is to apply enrichment) with a `dry_run` toggle for safety. **Interactive mode is disabled.**
|
|
173
|
+
|
|
174
|
+
Common parameters:
|
|
175
|
+
|
|
176
|
+
| Field | Type | Default | Applies to |
|
|
177
|
+
|-------|------|---------|-----------|
|
|
178
|
+
| `dry_run` | bool | `false` | all (true = report/scan only, no writes) |
|
|
179
|
+
| `limit` | int | none | all |
|
|
180
|
+
| `days` | int | none | all (only items uploaded in last N days) |
|
|
181
|
+
|
|
182
|
+
Tool-specific defaults (mirror the CLIs):
|
|
183
|
+
|
|
184
|
+
- **enrichtitles** — params: `dry_run`, `limit`, `days`. Cleans/normalizes titles. Default commit.
|
|
185
|
+
- **enrichmeta** — params: `dry_run`, `limit`, `days`, `tubi_upgrade` (bool, default false), `min_score` (default = tool's `MIN_SCORE_THRESHOLD`), `min_duration` (default `MIN_DURATION`), `delay` (default `REQUEST_DELAY` 0.25s). Uses TMDb/OMDb keys from config.
|
|
186
|
+
- **enrichtv** — params: `dry_run`, `limit`, `days`, `min_score` (default 50), `min_duration` (default 600), `max_duration` (default 3599), `delay` (default 0.25s). Uses TMDb/OMDb keys from config.
|
|
187
|
+
|
|
188
|
+
**MediaCMS writes:** these tools push enriched metadata to MediaCMS via the API token — this is the existing, working write path we also rely on for the Hide tag (§B6). When vendoring, **extract the MediaCMS edit call into `integrations/cmsutils/_common.py`** so Hide and enrich share one client.
|
|
189
|
+
|
|
190
|
+
**Result detail:** per tool, `{"scanned": N, "matched": N, "committed": N, "skipped": N, "errors": [...]}`.
|
|
191
|
+
|
|
192
|
+
**Phasing note:** enrich jobs are the lowest-risk reimplementations (pure HTTP, no auth dance, no large downloads). Do these **first** to prove the vendor+thread pattern, then `fetch`, then `fetchurls` last.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## B. Browse / Results
|
|
197
|
+
|
|
198
|
+
Applies to both `catalog/browse.html` (browse + search results share this template) and the JSON `/catalog/browse` + `/catalog/search` routes.
|
|
199
|
+
|
|
200
|
+
### B1. Sort control
|
|
201
|
+
- Add a **Sort by** `<select>` beside the existing Category/Tag facets: `Default` (current quality-weighted order), `Title A–Z`, `Title Z–A`, `Newest first`, `Oldest first`. All options available to everyone (per decision #5).
|
|
202
|
+
- **DB:** add a `sort` parameter to `db.browse(...)` and `db.search(...)` mapping to an `ORDER BY`:
|
|
203
|
+
- `default` → existing quality-weighted clause.
|
|
204
|
+
- `title_asc` / `title_desc` → `c.title ASC|DESC`.
|
|
205
|
+
- `newest` / `oldest` → `c.added_at DESC|ASC` (with `synced_at` tiebreaker).
|
|
206
|
+
- **Data gap (must fix):** `catalog.added_at` is currently **not populated** on insert (it has no default and `insert_catalog` omits it), so `Newest first` would be unreliable. During sync, **populate `added_at` from MediaCMS `add_date`** (the media list includes `add_date`). Backfill existing rows in a migration: `UPDATE catalog SET added_at = synced_at WHERE added_at IS NULL` as a stopgap, then let sync overwrite with the true `add_date`.
|
|
207
|
+
- **Routes/UI:** carry `sort` as a query param through pagination and the facet form (extend `applyFacets()`); persist the user's choice in `localStorage` for convenience.
|
|
208
|
+
|
|
209
|
+
### B2. Random branded art with hover-to-thumbnail
|
|
210
|
+
Goal: stop showing "shitty" MediaCMS thumbnails as the primary tile art; show a random branded placeholder instead, but reveal the real thumbnail on hover (sometimes useful).
|
|
211
|
+
|
|
212
|
+
- **When it applies:** a tile whose best art is *not* a real poster — i.e. `cover_art_source` is `null`/`thumbnail` (no TMDB/OMDB match). Tiles with a real `cover_art_path` from `tmdb`/`omdb` are unchanged.
|
|
213
|
+
- **Front art:** a **random** image from the branded placeholder folder (`config.placeholder_dir`, served under `/images/placeholders/`), chosen **per server-render** (OQ-4): the browse route picks `random.choice(...)` per affected tile when building the page, so each tile has a stable src for that response (no client reshuffle, no layout thrash).
|
|
214
|
+
- **Hover:** on pointer-over, swap/overlay the real `thumbnail_url` (the "shitty" art) so it can still be inspected; restore the placeholder on pointer-out. Implement as two stacked `<img>`s with a CSS hover crossfade (no JS needed), guarded so it only renders when a `thumbnail_url` exists.
|
|
215
|
+
- **Expose the placeholder list:** add a tiny endpoint or template-context helper that lists files in `placeholder_dir` (filename only). The browse route picks `random.choice(...)` per affected tile. Cache the directory listing in memory (refresh on an interval) to avoid disk scans per request.
|
|
216
|
+
- **Fallback:** if `placeholder_dir` is empty, keep the current letter placeholder.
|
|
217
|
+
|
|
218
|
+
### B3. Vertical tile button stack
|
|
219
|
+
- The tile `.card-actions` (currently a horizontal row of `Queue` / `Queue as Admin`) should **stack vertically**, full-width buttons. CSS only: `.card-actions { flex-direction: column; align-items: stretch; gap: 0.4rem; }` plus `.card-actions .btn { width: 100%; }`. Verify it reads well at the grid's min tile width (180px).
|
|
220
|
+
|
|
221
|
+
### B4. Admin "Add to playlist" (per tile)
|
|
222
|
+
- Add an admin-only button to each tile: **Add to playlist** (visible when `user.rank >= 3`).
|
|
223
|
+
- Opens the shared admin modal with a playlist `<select>` (populated from `GET /admin/playlists/`) and an "Add" action.
|
|
224
|
+
- **Endpoint:** `POST /admin/playlists/{id}/append` `{friendly_token}` — resolves the catalog item (`get_item_admin`) and appends `{media_type:'cm', media_id: manifest_url, title, duration_sec}` to the playlist (read → append → `replace_playlist_items`, or a dedicated `append_playlist_item`). Returns the new item count.
|
|
225
|
+
- Toast on success/failure (existing pattern).
|
|
226
|
+
|
|
227
|
+
### B5. Admin "Add to most-recent playlist" (no modal)
|
|
228
|
+
- Add a second admin-only tile button: **+ Recent** (or "Add to <name>"), which appends to the admin's most-recently-**created** playlist **without a modal**.
|
|
229
|
+
- **Endpoint:** `POST /admin/playlists/recent/append` `{friendly_token}`:
|
|
230
|
+
- Resolve most-recent: `SELECT * FROM saved_playlists WHERE created_by=? ORDER BY created_at DESC LIMIT 1`.
|
|
231
|
+
- If none exists, return a 409 with a message the UI shows as a toast ("Create a playlist first").
|
|
232
|
+
- Otherwise append as in B4 and return `{playlist_id, name, count}` so the toast can say `Added to "<name>"`.
|
|
233
|
+
- Optional polish: label the button with the resolved playlist name if cheaply available (e.g. fetched once on page load and cached client-side).
|
|
234
|
+
|
|
235
|
+
### B6. Admin "Hide Item" → MediaCMS `kryten-hidden` tag
|
|
236
|
+
- Add an admin-only tile button **Hide** with a confirm step ("Hide this item from the catalog? It will be tagged `kryten-hidden` in MediaCMS.").
|
|
237
|
+
- **Source of truth = MediaCMS.** On confirm:
|
|
238
|
+
1. **Write** the `kryten-hidden` tag to the item in MediaCMS via the API token, using the same edit mechanism the enrich tools use (`integrations/cmsutils/_common.py` MediaCMS client). Verify the exact endpoint/payload for tag editing against MediaCMS (the enrich tools already PATCH/POST media edits — reuse that).
|
|
239
|
+
2. **Immediately hide locally** so the admin sees it disappear without waiting for sync: either (a) add `kryten-hidden` to the local `catalog_tags` join for that token, or (b) maintain a local `hidden` flag column. Prefer (a) so the existing hidden-tag filter (v0.7.5) applies uniformly. Ensure `kryten-hidden` is in the hidden-tags exclusion set.
|
|
240
|
+
3. The next catalog sync re-reads tags from MediaCMS and the hide persists (and propagates to any other consumer).
|
|
241
|
+
- **Endpoint:** `POST /admin/catalog/{friendly_token}/hide` (admin). Returns success; the tile is removed from the grid client-side on success.
|
|
242
|
+
- **Unhide (recommended, low cost):** since admins can already reveal hidden items (`?show_hidden=1` from v0.7.5), add the inverse `POST /admin/catalog/{friendly_token}/unhide` that removes the `kryten-hidden` tag in MediaCMS + locally, so a mis-hide is reversible from the admin "show hidden" view. (See OQ-5.)
|
|
243
|
+
- **Config:** ensure `kryten-hidden` is registered in `HIDDEN_TAG_NAMES` (or a dedicated constant) so browse/search/facets exclude it for non-admins.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## C. Queue page (`queue/index.html`)
|
|
248
|
+
|
|
249
|
+
### C1. Hide the order number
|
|
250
|
+
- Remove the `qi-pos` index number from each queue item in `renderQueue()` (and its CSS), or hide via CSS (`.qi-pos { display: none; }`). Prefer removing the element from the template render to keep the DOM clean.
|
|
251
|
+
|
|
252
|
+
### C2. Remove the drag handle / no drag-drop
|
|
253
|
+
- Remove the `qi-drag` (☰) handle element from each queue item render. There is **no** drag-reorder implemented on this page and none should be added — drop the handle, its title tooltip, and any related CSS. (Admin reordering lives in the playlist editor, not the live queue.)
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## D. Data model & migrations
|
|
258
|
+
|
|
259
|
+
New migration(s):
|
|
260
|
+
|
|
261
|
+
1. **Job-run reconciliation** is runtime, not schema; but add `params` column to `job_runs` (nullable TEXT/JSON) to record submitted parameters:
|
|
262
|
+
```sql
|
|
263
|
+
ALTER TABLE job_runs ADD COLUMN params TEXT;
|
|
264
|
+
```
|
|
265
|
+
2. **catalog.added_at backfill** (stopgap before sync repopulates from `add_date`):
|
|
266
|
+
```sql
|
|
267
|
+
UPDATE catalog SET added_at = synced_at WHERE added_at IS NULL;
|
|
268
|
+
```
|
|
269
|
+
3. No new tables required for B4/B5 (reuse `saved_playlists`/`saved_playlist_items`). B6 reuses `catalog_tags`.
|
|
270
|
+
|
|
271
|
+
Sync change (not a migration): set `added_at` from MediaCMS `add_date` in `_process_item`/`insert_catalog`/`update_catalog`.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## E. Config additions
|
|
276
|
+
|
|
277
|
+
```jsonc
|
|
278
|
+
{
|
|
279
|
+
// existing: mediacms_url, mediacms_token, tmdb_api_key, omdb_api_key, image_dir, placeholder_dir ...
|
|
280
|
+
|
|
281
|
+
"fetch_cookies_path": "", // optional yt-dlp cookies for gated sources
|
|
282
|
+
"fetchurls": { // fetchurls (v1 = local file only, OQ-1)
|
|
283
|
+
"workbook_path": "" // absolute path to a synced Channel Z Playlist .xlsx
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
All new config is optional; jobs whose config/deps are absent register but fail fast with a clear message. (SharePoint/Graph config is intentionally omitted in v1.)
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## F. API surface additions
|
|
293
|
+
|
|
294
|
+
| Method | Path | Purpose |
|
|
295
|
+
|--------|------|---------|
|
|
296
|
+
| `GET` | `/admin/jobs` | (extend) include each job's `schema` + last-run summary |
|
|
297
|
+
| `POST` | `/admin/jobs/{name}/run` | (extend) accept `{params}` body |
|
|
298
|
+
| `POST` | `/admin/playlists/{id}/append` | Append one catalog item to a playlist (B4) |
|
|
299
|
+
| `POST` | `/admin/playlists/recent/append` | Append to the admin's most-recent playlist (B5) |
|
|
300
|
+
| `POST` | `/admin/catalog/{friendly_token}/hide` | Tag `kryten-hidden` in MediaCMS + hide locally (B6) |
|
|
301
|
+
| `POST` | `/admin/catalog/{friendly_token}/unhide` | Remove `kryten-hidden` (B6, recommended) |
|
|
302
|
+
| `GET` | `/catalog/browse`, `/catalog/search` | (extend) accept `sort` param (B1) |
|
|
303
|
+
|
|
304
|
+
All `/admin/*` routes use the existing `require_admin` dependency.
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## G. Dependencies
|
|
309
|
+
|
|
310
|
+
- New optional extra `jobs`: `yt-dlp`, `openpyxl`, `requests`. (`msal` is **not** needed in v1 — SharePoint/Graph is deferred per OQ-1.)
|
|
311
|
+
- System: `ffmpeg` on the host for `fetch`. Document in `deploy/` notes.
|
|
312
|
+
- No new deps for Browse/Queue work.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## H. Phasing / sequencing
|
|
317
|
+
|
|
318
|
+
1. **Phase 1 — Quick wins (no external tools):**
|
|
319
|
+
- A1.2 job-history reconciliation (`interrupted` status).
|
|
320
|
+
- C1/C2 queue page cleanup.
|
|
321
|
+
- B3 vertical buttons, B1 sort (incl. `added_at` backfill + sync populate), B2 random art + hover.
|
|
322
|
+
- B4/B5/B6 admin tile actions (B6 needs the MediaCMS write client — see Phase 3 note).
|
|
323
|
+
2. **Phase 2 — Job framework:** A1.3 parameterized jobs + schema-driven Run modal.
|
|
324
|
+
3. **Phase 3 — Reimplemented jobs (in dependency order of risk):**
|
|
325
|
+
- `enrichtitles`, `enrichmeta`, `enrichtv` (proves vendor+thread pattern; yields the shared MediaCMS write client used by B6).
|
|
326
|
+
- `fetch` (+ add-to-playlist); needs yt-dlp/ffmpeg.
|
|
327
|
+
- `fetchurls` (+ playlist import, upcoming-weekend, **local-file workbook per OQ-1**) — **last**, highest risk.
|
|
328
|
+
|
|
329
|
+
> If B6 must ship in Phase 1 before the enrich vendor work, write a minimal standalone MediaCMS tag-write helper now and fold it into `_common.py` later.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## I. Residual risks
|
|
334
|
+
|
|
335
|
+
All open questions are resolved in §0.1. Remaining implementation risks:
|
|
336
|
+
|
|
337
|
+
- **MediaCMS tag write (B6 / OQ-6):** the exact edit verb/field must be confirmed against the live instance. Mitigation: a round-trip integration test on a disposable item before wiring the Hide UI; reuse the enrich tools' proven edit path.
|
|
338
|
+
- **Vendoring drift (OQ-2):** vendored enrich/fetch logic (~3k lines) will diverge from `d:\devel\cmsutils` over time. Mitigation: header-stamp the upstream commit/date; keep adapters thin so re-vendoring is mechanical.
|
|
339
|
+
- **Long-job UX:** fetch/fetchurls can run for many minutes. The in-memory per-job lock + history `interrupted` reconciliation cover correctness; the optional heartbeat (A1.2.4) gives admins live progress.
|
|
340
|
+
- **`fetch`/`fetchurls` host deps:** require `yt-dlp` + `ffmpeg` (and a readable workbook for fetchurls). Mitigation: jobs register but fail fast with a clear "dependency/config missing" message instead of crashing startup.
|
|
341
|
+
- **`added_at` accuracy (B1):** the stopgap backfill sets `added_at = synced_at`; true `add_date` only becomes correct after the next full sync. Mitigation: trigger a sync after deploy, or document that "Newest first" sharpens once sync runs.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## J. Validation plan
|
|
346
|
+
|
|
347
|
+
- **Jobs framework:** unit-test reconciliation (insert a `running` row → startup → becomes `interrupted`); schema validation rejects bad params; a parameterized job receives params and records them.
|
|
348
|
+
- **enrich jobs:** run against a disposable MediaCMS item in `dry_run` and assert "scanned/matched" counts without writes; one `commit` run asserts the metadata changed and `job_runs.detail` is populated.
|
|
349
|
+
- **fetch:** dry-ish test with a short known clip; assert a `friendly_token` returns and (with `add_to_playlist`) the item appears in the playlist.
|
|
350
|
+
- **fetchurls:** unit-test the upcoming-weekend sheet-name computation across weekdays (incl. Friday → today per OQ-3); integration test against a fixture `.xlsx` via the configured file path; assert imported saved playlists are named `{sheet}-{section}` and re-runs replace rather than duplicate.
|
|
351
|
+
- **Browse:** SQLite fixture tests for each `sort` ordering incl. `added_at` newest/oldest; verify hidden-tag exclusion still applies; render checks for vertical buttons and the hover art markup.
|
|
352
|
+
- **Hide:** assert the MediaCMS write is attempted, the local `catalog_tags` gains `kryten-hidden`, and the item drops from a non-admin browse query.
|
|
353
|
+
- **Queue:** assert rendered items contain neither `qi-pos` nor `qi-drag`.
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
3
|
+
from apscheduler.triggers.date import DateTrigger
|
|
4
|
+
from datetime import datetime, UTC
|
|
5
|
+
|
|
6
|
+
from dateutil.rrule import rrulestr
|
|
7
|
+
|
|
8
|
+
from .fire import fire_schedule
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _next_occurrence(rrule_str: str, dtstart: datetime, after: datetime) -> datetime | None:
|
|
14
|
+
"""Return the next RRULE occurrence strictly after ``after``.
|
|
15
|
+
|
|
16
|
+
``dtstart`` anchors the recurrence pattern (typically the schedule's current
|
|
17
|
+
fire time). Returns None when the rule is exhausted or unparseable.
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
rule = rrulestr(rrule_str, dtstart=dtstart)
|
|
21
|
+
return rule.after(after, inc=False)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
logger.warning(f"Could not compute next occurrence for rrule {rrule_str!r}: {e}")
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PlaylistScheduler:
|
|
28
|
+
"""APScheduler-based scheduler for playlist fire events."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, db, api_gate, shadow, ws_manager):
|
|
31
|
+
self._db = db
|
|
32
|
+
self._api_gate = api_gate
|
|
33
|
+
self._shadow = shadow
|
|
34
|
+
self._ws_manager = ws_manager
|
|
35
|
+
self._scheduler = AsyncIOScheduler()
|
|
36
|
+
|
|
37
|
+
async def start(self):
|
|
38
|
+
"""Start scheduler and load all pending schedules."""
|
|
39
|
+
self._scheduler.start()
|
|
40
|
+
await self._load_schedules()
|
|
41
|
+
logger.info("PlaylistScheduler started")
|
|
42
|
+
|
|
43
|
+
async def stop(self):
|
|
44
|
+
self._scheduler.shutdown(wait=False)
|
|
45
|
+
logger.info("PlaylistScheduler stopped")
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _parse_fire_at(value: str) -> datetime:
|
|
49
|
+
"""Parse a stored fire_at ISO string into a UTC-aware datetime."""
|
|
50
|
+
dt = datetime.fromisoformat(value)
|
|
51
|
+
if dt.tzinfo is None:
|
|
52
|
+
dt = dt.replace(tzinfo=UTC)
|
|
53
|
+
return dt
|
|
54
|
+
|
|
55
|
+
async def _load_schedules(self):
|
|
56
|
+
"""Load active schedules from DB and register jobs.
|
|
57
|
+
|
|
58
|
+
Recurring schedules whose fire time has already passed (e.g. while the
|
|
59
|
+
service was down) are advanced to their next future occurrence.
|
|
60
|
+
"""
|
|
61
|
+
schedules = await self._db.get_schedules()
|
|
62
|
+
now = datetime.now(UTC)
|
|
63
|
+
for sched in schedules:
|
|
64
|
+
if not sched.get("is_active"):
|
|
65
|
+
continue
|
|
66
|
+
fire_at = self._parse_fire_at(sched["fire_at"])
|
|
67
|
+
if fire_at > now:
|
|
68
|
+
self._add_job(sched["id"], fire_at)
|
|
69
|
+
elif sched.get("is_recurring") and sched.get("rrule"):
|
|
70
|
+
nxt = _next_occurrence(sched["rrule"], fire_at, now)
|
|
71
|
+
if nxt:
|
|
72
|
+
nxt_utc = nxt.astimezone(UTC)
|
|
73
|
+
await self._db.update_schedule(
|
|
74
|
+
sched["id"], fire_at=nxt_utc.isoformat(), fired_at=None
|
|
75
|
+
)
|
|
76
|
+
self._add_job(sched["id"], nxt_utc)
|
|
77
|
+
logger.info(
|
|
78
|
+
f"Advanced missed recurring schedule {sched['id']} to {nxt_utc}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _add_job(self, schedule_id: int, fire_at: datetime):
|
|
82
|
+
job_id = f"schedule_{schedule_id}"
|
|
83
|
+
self._scheduler.add_job(
|
|
84
|
+
self._fire,
|
|
85
|
+
trigger=DateTrigger(run_date=fire_at),
|
|
86
|
+
id=job_id,
|
|
87
|
+
replace_existing=True,
|
|
88
|
+
kwargs={"schedule_id": schedule_id},
|
|
89
|
+
)
|
|
90
|
+
logger.info(f"Scheduled job {job_id} for {fire_at}")
|
|
91
|
+
|
|
92
|
+
async def _fire(self, schedule_id: int):
|
|
93
|
+
await fire_schedule(
|
|
94
|
+
schedule_id=schedule_id,
|
|
95
|
+
api_gate=self._api_gate,
|
|
96
|
+
db=self._db,
|
|
97
|
+
shadow=self._shadow,
|
|
98
|
+
ws_manager=self._ws_manager,
|
|
99
|
+
)
|
|
100
|
+
# After an automatic timed fire, advance recurring schedules to their
|
|
101
|
+
# next occurrence and re-arm. (Manual "Fire Now" does NOT advance the
|
|
102
|
+
# recurrence — the originally scheduled occurrence stays armed.)
|
|
103
|
+
await self._reschedule_if_recurring(schedule_id)
|
|
104
|
+
|
|
105
|
+
async def _reschedule_if_recurring(self, schedule_id: int):
|
|
106
|
+
sched = await self._db.get_schedule(schedule_id)
|
|
107
|
+
if not sched or not sched.get("is_active"):
|
|
108
|
+
return
|
|
109
|
+
if not sched.get("is_recurring") or not sched.get("rrule"):
|
|
110
|
+
return
|
|
111
|
+
fired_from = self._parse_fire_at(sched["fire_at"])
|
|
112
|
+
nxt = _next_occurrence(sched["rrule"], fired_from, fired_from)
|
|
113
|
+
if not nxt:
|
|
114
|
+
logger.info(f"Recurring schedule {schedule_id} has no further occurrences")
|
|
115
|
+
return
|
|
116
|
+
nxt_utc = nxt.astimezone(UTC)
|
|
117
|
+
await self._db.update_schedule(
|
|
118
|
+
schedule_id, fire_at=nxt_utc.isoformat(), fired_at=None
|
|
119
|
+
)
|
|
120
|
+
self._add_job(schedule_id, nxt_utc)
|
|
121
|
+
logger.info(f"Recurring schedule {schedule_id} re-armed for {nxt_utc}")
|
|
122
|
+
|
|
123
|
+
async def add_schedule(self, schedule_id: int, fire_at: datetime):
|
|
124
|
+
self._add_job(schedule_id, fire_at)
|
|
125
|
+
|
|
126
|
+
async def remove_schedule(self, schedule_id: int):
|
|
127
|
+
job_id = f"schedule_{schedule_id}"
|
|
128
|
+
try:
|
|
129
|
+
self._scheduler.remove_job(job_id)
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
@@ -110,21 +110,34 @@ class QueueShadow:
|
|
|
110
110
|
await self._recalculate_estimated_starts()
|
|
111
111
|
|
|
112
112
|
async def _recalculate_estimated_starts(self):
|
|
113
|
-
"""Recalculate estimated start times based on position and now-playing.
|
|
113
|
+
"""Recalculate estimated start times based on position and now-playing.
|
|
114
|
+
|
|
115
|
+
Emits BOTH an absolute UTC timestamp (``estimated_start_at``, kept for
|
|
116
|
+
compatibility) and a clock-independent relative offset
|
|
117
|
+
(``estimated_start_in_sec``) = seconds from "now" until the item plays.
|
|
118
|
+
|
|
119
|
+
The relative offset is the authoritative value the UI should use: it is
|
|
120
|
+
immune to server clock skew / timezone misconfiguration, which is the
|
|
121
|
+
usual cause of ETAs appearing shifted by a whole UTC offset. The browser
|
|
122
|
+
computes the wall-clock time from its own clock (Date.now() + offset).
|
|
123
|
+
"""
|
|
114
124
|
if not self._items:
|
|
115
125
|
return
|
|
116
126
|
|
|
117
|
-
#
|
|
118
|
-
|
|
127
|
+
# Offset (seconds from now) until the head of the queue starts playing.
|
|
128
|
+
offset = 0.0
|
|
119
129
|
if self._now_playing:
|
|
120
130
|
np_total = _to_seconds(self._now_playing.get("seconds", self._now_playing.get("duration")))
|
|
121
131
|
remaining = np_total - _to_seconds(self._now_playing.get("currentTime"))
|
|
122
|
-
|
|
132
|
+
offset = max(0.0, remaining)
|
|
123
133
|
|
|
134
|
+
now = datetime.now(UTC)
|
|
124
135
|
for item in self._items:
|
|
125
|
-
item["
|
|
126
|
-
|
|
127
|
-
|
|
136
|
+
item["estimated_start_in_sec"] = round(offset)
|
|
137
|
+
# Absolute timestamp retained for compatibility; relative offset is
|
|
138
|
+
# what the UI renders.
|
|
139
|
+
item["estimated_start_at"] = (now + timedelta(seconds=offset)).isoformat()
|
|
140
|
+
offset += _to_seconds(item.get("duration_sec"))
|
|
128
141
|
|
|
129
142
|
async def insert_at(self, item: dict, position: int):
|
|
130
143
|
"""Insert a new item at given position in local shadow."""
|
|
@@ -155,7 +155,7 @@ function renderQueue(state) {
|
|
|
155
155
|
</div>
|
|
156
156
|
<div class="qi-right">
|
|
157
157
|
<span class="qi-duration">${formatTime(item.duration_sec || 0)}</span>
|
|
158
|
-
${item.estimated_start_at ? `<span class="qi-eta">~${formatEta(item
|
|
158
|
+
${(item.estimated_start_in_sec != null || item.estimated_start_at) ? `<span class="qi-eta">~${formatEta(item)}</span>` : ''}
|
|
159
159
|
</div>
|
|
160
160
|
</div>
|
|
161
161
|
`).join('');
|
|
@@ -196,15 +196,21 @@ function coverHtml(item) {
|
|
|
196
196
|
return `<div class="cover-placeholder">${escapeHtml(letter)}</div>`;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
function formatEta(
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
199
|
+
function formatEta(item) {
|
|
200
|
+
// Prefer the clock-independent relative offset: the server tells us how many
|
|
201
|
+
// seconds from now the item plays, and we compute the wall-clock time from
|
|
202
|
+
// THIS browser's clock. This is immune to server clock skew / timezone
|
|
203
|
+
// misconfiguration (the usual cause of ETAs shifted by a whole UTC offset).
|
|
204
|
+
let d;
|
|
205
|
+
if (item && item.estimated_start_in_sec != null) {
|
|
206
|
+
d = new Date(Date.now() + Number(item.estimated_start_in_sec) * 1000);
|
|
207
|
+
} else {
|
|
208
|
+
// Fallback: absolute UTC ISO timestamp. Append 'Z' if no tz designator
|
|
209
|
+
// so it's parsed as UTC, then rendered in the viewer's local zone.
|
|
210
|
+
let s = String(item && item.estimated_start_at ? item.estimated_start_at : item);
|
|
211
|
+
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) s += 'Z';
|
|
212
|
+
d = new Date(s);
|
|
206
213
|
}
|
|
207
|
-
const d = new Date(s);
|
|
208
214
|
if (isNaN(d.getTime())) return '';
|
|
209
215
|
return d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
|
|
210
216
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kryten-webqueue"
|
|
3
|
-
version = "0.8.
|
|
3
|
+
version = "0.8.2"
|
|
4
4
|
description = "Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -19,6 +19,7 @@ dependencies = [
|
|
|
19
19
|
"jinja2>=3.1",
|
|
20
20
|
"websockets>=12.0",
|
|
21
21
|
"pydantic>=2.0",
|
|
22
|
+
"python-dateutil>=2.8",
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
[project.optional-dependencies]
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
3
|
-
from apscheduler.triggers.date import DateTrigger
|
|
4
|
-
from datetime import datetime, UTC
|
|
5
|
-
|
|
6
|
-
from .fire import fire_schedule
|
|
7
|
-
|
|
8
|
-
logger = logging.getLogger(__name__)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class PlaylistScheduler:
|
|
12
|
-
"""APScheduler-based scheduler for playlist fire events."""
|
|
13
|
-
|
|
14
|
-
def __init__(self, *, db, api_gate, shadow, ws_manager):
|
|
15
|
-
self._db = db
|
|
16
|
-
self._api_gate = api_gate
|
|
17
|
-
self._shadow = shadow
|
|
18
|
-
self._ws_manager = ws_manager
|
|
19
|
-
self._scheduler = AsyncIOScheduler()
|
|
20
|
-
|
|
21
|
-
async def start(self):
|
|
22
|
-
"""Start scheduler and load all pending schedules."""
|
|
23
|
-
self._scheduler.start()
|
|
24
|
-
await self._load_schedules()
|
|
25
|
-
logger.info("PlaylistScheduler started")
|
|
26
|
-
|
|
27
|
-
async def stop(self):
|
|
28
|
-
self._scheduler.shutdown(wait=False)
|
|
29
|
-
logger.info("PlaylistScheduler stopped")
|
|
30
|
-
|
|
31
|
-
async def _load_schedules(self):
|
|
32
|
-
"""Load active schedules from DB and register jobs."""
|
|
33
|
-
schedules = await self._db.get_schedules()
|
|
34
|
-
now = datetime.now(UTC)
|
|
35
|
-
for sched in schedules:
|
|
36
|
-
if not sched.get("is_active"):
|
|
37
|
-
continue
|
|
38
|
-
fire_at_str = sched["fire_at"]
|
|
39
|
-
fire_at = datetime.fromisoformat(fire_at_str)
|
|
40
|
-
if fire_at <= now:
|
|
41
|
-
continue
|
|
42
|
-
self._add_job(sched["id"], fire_at)
|
|
43
|
-
|
|
44
|
-
def _add_job(self, schedule_id: int, fire_at: datetime):
|
|
45
|
-
job_id = f"schedule_{schedule_id}"
|
|
46
|
-
self._scheduler.add_job(
|
|
47
|
-
self._fire,
|
|
48
|
-
trigger=DateTrigger(run_date=fire_at),
|
|
49
|
-
id=job_id,
|
|
50
|
-
replace_existing=True,
|
|
51
|
-
kwargs={"schedule_id": schedule_id},
|
|
52
|
-
)
|
|
53
|
-
logger.info(f"Scheduled job {job_id} for {fire_at}")
|
|
54
|
-
|
|
55
|
-
async def _fire(self, schedule_id: int):
|
|
56
|
-
await fire_schedule(
|
|
57
|
-
schedule_id=schedule_id,
|
|
58
|
-
api_gate=self._api_gate,
|
|
59
|
-
db=self._db,
|
|
60
|
-
shadow=self._shadow,
|
|
61
|
-
ws_manager=self._ws_manager,
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
async def add_schedule(self, schedule_id: int, fire_at: datetime):
|
|
65
|
-
self._add_job(schedule_id, fire_at)
|
|
66
|
-
|
|
67
|
-
async def remove_schedule(self, schedule_id: int):
|
|
68
|
-
job_id = f"schedule_{schedule_id}"
|
|
69
|
-
try:
|
|
70
|
-
self._scheduler.remove_job(job_id)
|
|
71
|
-
except Exception:
|
|
72
|
-
pass
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.8.0 → kryten_webqueue-0.8.2}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|