kryten-webqueue 0.9.7__tar.gz → 0.9.9__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.9.7 → kryten_webqueue-0.9.9}/CHANGELOG.md +23 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/PKG-INFO +2 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/config.example.json +5 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/db.py +13 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/config.py +15 -2
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py +121 -22
- kryten_webqueue-0.9.9/kryten_webqueue/jobs/fetchurls_auth.py +62 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/manager.py +16 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/tasks.py +41 -18
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/importer.py +8 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/index.html +23 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/pyproject.toml +3 -1
- kryten_webqueue-0.9.9/tests/test_fetchurls_sharepoint.py +150 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase2_jobs.py +30 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase3_jobs.py +3 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_playlist_import.py +3 -2
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/.gitignore +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/README.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase4_live_fixes.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_queue_announce.py +0 -0
|
@@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.9] — 2026-06-12
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Graceful handling of expected job failures.** Misconfiguration and bad-input errors (e.g. `fetchurls` not finding this weekend's worksheet, a missing/unauthenticated workbook, or a missing optional dependency) are now recorded as a clean, actionable message in the job-run history and logged at WARNING — no stack trace. A new internal `JobError` distinguishes these expected failures from unexpected bugs (which still log a full traceback and keep their exception-type prefix).
|
|
14
|
+
- The `fetchurls` "sheet not found" message now reads as guidance ("This weekend's worksheet 'M.D-M.D' was not found…") and lists only the date-format weekend sheets instead of every tab in the workbook.
|
|
15
|
+
- The admin Jobs history table now shows a **Detail** column — the failure message for failed runs, or a compact summary (sheet, imported playlists, counts) for successful ones.
|
|
16
|
+
|
|
17
|
+
## [0.9.8] — 2026-06-12
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`fetchurls` job now reads the Channel Z workbook from SharePoint** (Microsoft Graph) and **writes resolved dropsugar URLs back to column F**, restoring the original tool's full round-trip. The service authenticates *silently* from a pre-seeded MSAL token cache and never prompts interactively. A one-time sign-in helper seeds the cache: `python -m kryten_webqueue.jobs.fetchurls_auth` (also installed as `kryten-webqueue-fetchurls-auth`). It prints a device-code URL; sign in once and the ~90-day refresh token is cached for unattended runs. If the cache is missing/expired the job fails with a clear "run fetchurls_auth" message.
|
|
22
|
+
- New config under `fetchurls`: `sharepoint_tenant_id`, `sharepoint_client_id`, `sharepoint_sharing_url`, `token_cache_path`. A local `workbook_path` (or a per-run `workbook_path` param) still works as a fallback/override.
|
|
23
|
+
- New `writeback` job parameter (default on) controls column-F writeback; `dry_run` and local-file mode skip it.
|
|
24
|
+
- Added `msal` as a core dependency.
|
|
25
|
+
- **`fetchurls` imports into three fixed, well-known playlists** — `Friday Night`, `Saturday Morning`, `Saturday Night` — instead of date-prefixed names. The playlists are matched by name (any creator), their items replaced in place on every run (idempotent), and their existing immutability preserved; if a playlist is missing it is (re)created as immutable so the reserved status is retained.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- Bulk/`cm:` playlist imports for items not yet in the local catalog (e.g. freshly downloaded by `fetch`/`fetchurls` before a sync) now construct the CyTube manifest URL from the token instead of leaving a bare token that wouldn't play.
|
|
30
|
+
|
|
9
31
|
## [0.9.7] — 2026-06-11
|
|
10
32
|
|
|
11
33
|
### Changed
|
|
@@ -19,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
19
41
|
### Added
|
|
20
42
|
|
|
21
43
|
- **Richer Bulk Text Import on the admin Playlists editor.** The text import now accepts, one entry per line:
|
|
22
|
-
- **dropsugar.co
|
|
44
|
+
- **dropsugar.co links** (watch `?m=TOKEN` or manifest `/api/v1/media/cytube/TOKEN.json`) — resolved against the catalog for title/duration, falling back to a constructed manifest URL when the token isn't catalogued yet.
|
|
23
45
|
- **YouTube / youtu.be links** — playlist (`list=`), start-time (`t`/`start`) and all other arguments are stripped, leaving a clean `yt:VIDEOID` item.
|
|
24
46
|
- Legacy `cm:token`, `yt:id`, and bare catalog tokens (unchanged).
|
|
25
47
|
- Trailing free text after a URL (e.g. `URL - My Title`) is used as a title hint.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: kryten-webqueue
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.9
|
|
4
4
|
Summary: Netflix/Tubi-style catalog browser and pay-to-play queue management for CyTube
|
|
5
5
|
Author: grobertson
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,6 +10,7 @@ Requires-Dist: apscheduler>=3.10
|
|
|
10
10
|
Requires-Dist: fastapi>=0.115
|
|
11
11
|
Requires-Dist: httpx>=0.27
|
|
12
12
|
Requires-Dist: jinja2>=3.1
|
|
13
|
+
Requires-Dist: msal>=1.28
|
|
13
14
|
Requires-Dist: openpyxl>=3.1
|
|
14
15
|
Requires-Dist: pillow>=10.0
|
|
15
16
|
Requires-Dist: pydantic>=2.0
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
|
|
17
17
|
"fetch_cookies_path": "",
|
|
18
18
|
"fetchurls": {
|
|
19
|
-
"workbook_path": ""
|
|
19
|
+
"workbook_path": "",
|
|
20
|
+
"sharepoint_tenant_id": "",
|
|
21
|
+
"sharepoint_client_id": "",
|
|
22
|
+
"sharepoint_sharing_url": "",
|
|
23
|
+
"token_cache_path": "/var/lib/kryten-webqueue/.fetchurls_tokens.bin"
|
|
20
24
|
},
|
|
21
25
|
|
|
22
26
|
"db_path": "/var/lib/kryten-webqueue/webqueue.db",
|
|
@@ -956,6 +956,19 @@ class Database:
|
|
|
956
956
|
[name, created_by],
|
|
957
957
|
)
|
|
958
958
|
|
|
959
|
+
async def get_playlist_by_name_any(self, name: str) -> dict | None:
|
|
960
|
+
"""Match an existing saved playlist by exact name, any creator.
|
|
961
|
+
|
|
962
|
+
Used for the fetchurls fixed section playlists ("Friday Night",
|
|
963
|
+
"Saturday Morning", "Saturday Night") which pre-exist (possibly created
|
|
964
|
+
by a different admin) and must be reused/replaced in place rather than
|
|
965
|
+
duplicated.
|
|
966
|
+
"""
|
|
967
|
+
return await self._fetch_one(
|
|
968
|
+
"SELECT * FROM saved_playlists WHERE name=? ORDER BY id LIMIT 1",
|
|
969
|
+
[name],
|
|
970
|
+
)
|
|
971
|
+
|
|
959
972
|
# --- Schedules ---
|
|
960
973
|
|
|
961
974
|
async def get_schedules(self) -> list[dict]:
|
|
@@ -4,9 +4,22 @@ import json
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class FetchUrlsConfig(BaseModel):
|
|
7
|
-
"""Settings for the fetchurls job
|
|
7
|
+
"""Settings for the fetchurls job.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Reads the Channel Z workbook from SharePoint (Microsoft Graph) when the
|
|
10
|
+
SharePoint fields are configured, otherwise falls back to a local ``.xlsx``
|
|
11
|
+
at ``workbook_path``. SharePoint auth uses a pre-seeded MSAL token cache
|
|
12
|
+
(see ``python -m kryten_webqueue.jobs.fetchurls_auth``); the service only
|
|
13
|
+
acquires tokens *silently* from that cache and never prompts interactively.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
workbook_path: str = "" # local .xlsx fallback (used when SharePoint unset)
|
|
17
|
+
|
|
18
|
+
# SharePoint / Microsoft Graph (read workbook + write resolved URLs to col F)
|
|
19
|
+
sharepoint_tenant_id: str = ""
|
|
20
|
+
sharepoint_client_id: str = ""
|
|
21
|
+
sharepoint_sharing_url: str = ""
|
|
22
|
+
token_cache_path: str = "" # MSAL cache file, pre-seeded out-of-band
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
class Config(BaseModel):
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
@@ -287,6 +287,43 @@ def acquire_graph_token(tenant_id: str, client_id: str, cache_path: str = "") ->
|
|
|
287
287
|
return result["access_token"]
|
|
288
288
|
|
|
289
289
|
|
|
290
|
+
def acquire_graph_token_silent(tenant_id: str, client_id: str, cache_path: str) -> Optional[str]:
|
|
291
|
+
"""Return a Graph access token from a pre-seeded MSAL cache, or None.
|
|
292
|
+
|
|
293
|
+
Unlike :func:`acquire_graph_token`, this NEVER prompts interactively — it is
|
|
294
|
+
safe to call from the headless webqueue service. The cache must have been
|
|
295
|
+
seeded out-of-band (``python -m kryten_webqueue.jobs.fetchurls_auth``). A
|
|
296
|
+
None return means the admin must (re)authenticate.
|
|
297
|
+
"""
|
|
298
|
+
if not _HAS_MSAL:
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
"fetchurls SharePoint integration requires 'msal'. "
|
|
301
|
+
"Install the optional extra: pip install 'kryten-webqueue[jobs]'"
|
|
302
|
+
)
|
|
303
|
+
if not cache_path:
|
|
304
|
+
return None
|
|
305
|
+
cache_file = Path(cache_path)
|
|
306
|
+
if not cache_file.exists():
|
|
307
|
+
return None
|
|
308
|
+
token_cache = msal.SerializableTokenCache()
|
|
309
|
+
token_cache.deserialize(cache_file.read_text(encoding="utf-8"))
|
|
310
|
+
app = msal.PublicClientApplication(
|
|
311
|
+
client_id,
|
|
312
|
+
authority=f"https://login.microsoftonline.com/{tenant_id}",
|
|
313
|
+
token_cache=token_cache,
|
|
314
|
+
)
|
|
315
|
+
accounts = app.get_accounts()
|
|
316
|
+
if not accounts:
|
|
317
|
+
return None
|
|
318
|
+
result = app.acquire_token_silent(GRAPH_SCOPES, account=accounts[0])
|
|
319
|
+
if token_cache.has_state_changed:
|
|
320
|
+
cache_file.write_text(token_cache.serialize(), encoding="utf-8")
|
|
321
|
+
if result and "access_token" in result:
|
|
322
|
+
return result["access_token"]
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
290
327
|
def _encode_sharing_url(url: str) -> str:
|
|
291
328
|
"""Encode a SharePoint sharing/document URL for use with the Graph shares API."""
|
|
292
329
|
encoded = base64.urlsafe_b64encode(url.encode()).decode().rstrip("=")
|
|
@@ -1269,13 +1306,21 @@ def _make_inprocess_fetch(config):
|
|
|
1269
1306
|
|
|
1270
1307
|
|
|
1271
1308
|
def run(params: dict, *, config, progress=None) -> dict:
|
|
1272
|
-
"""Resolve the upcoming weekend's Channel Z workbook into playlist
|
|
1309
|
+
"""Resolve the upcoming weekend's Channel Z workbook into playlist sections.
|
|
1310
|
+
|
|
1311
|
+
Source precedence:
|
|
1312
|
+
1. ``params['workbook_path']`` (one-off local override, e.g. for tests)
|
|
1313
|
+
2. SharePoint via Microsoft Graph when ``config.fetchurls.sharepoint_*``
|
|
1314
|
+
are set (token acquired *silently* from the pre-seeded MSAL cache)
|
|
1315
|
+
3. ``config.fetchurls.workbook_path`` (local file fallback)
|
|
1273
1316
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1317
|
+
When the source is SharePoint and ``writeback`` is on (and not a dry run),
|
|
1318
|
+
resolved dropsugar URLs are written back to column F via the Graph Excel
|
|
1319
|
+
API. Off-site URLs are downloaded via the in-process yt-pipe downloader.
|
|
1320
|
+
|
|
1321
|
+
Returns counts plus, per section slug, the resolved ``cm:`` lines and the
|
|
1322
|
+
human label ("Friday Night", etc.) so the job wrapper can import each into a
|
|
1323
|
+
fixed, well-known saved playlist.
|
|
1279
1324
|
"""
|
|
1280
1325
|
_check_openpyxl()
|
|
1281
1326
|
import openpyxl as _oxl # noqa: F811 - explicit local import for clarity
|
|
@@ -1283,32 +1328,64 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
1283
1328
|
section = params.get("section") or "all"
|
|
1284
1329
|
dry_run = bool(params.get("dry_run", False))
|
|
1285
1330
|
validate = bool(params.get("validate", True))
|
|
1286
|
-
|
|
1287
|
-
cfg_fetchurls = getattr(config, "fetchurls", None)
|
|
1288
|
-
cfg_path = ""
|
|
1289
|
-
if cfg_fetchurls is not None:
|
|
1290
|
-
cfg_path = getattr(cfg_fetchurls, "workbook_path", "") or ""
|
|
1291
|
-
workbook_path = params.get("workbook_path") or cfg_path
|
|
1292
|
-
if not workbook_path:
|
|
1293
|
-
raise RuntimeError("fetchurls requires a workbook path (config.fetchurls.workbook_path)")
|
|
1294
|
-
wb_file = Path(workbook_path)
|
|
1295
|
-
if not wb_file.exists():
|
|
1296
|
-
raise RuntimeError(f"Workbook not found: {wb_file}")
|
|
1331
|
+
writeback = bool(params.get("writeback", True))
|
|
1297
1332
|
|
|
1298
1333
|
def _emit(detail):
|
|
1299
1334
|
if progress:
|
|
1300
1335
|
progress(detail)
|
|
1301
1336
|
|
|
1302
|
-
|
|
1337
|
+
cfg = getattr(config, "fetchurls", None)
|
|
1338
|
+
local_override = params.get("workbook_path") or ""
|
|
1339
|
+
sp_tenant = getattr(cfg, "sharepoint_tenant_id", "") if cfg else ""
|
|
1340
|
+
sp_client = getattr(cfg, "sharepoint_client_id", "") if cfg else ""
|
|
1341
|
+
sp_share = getattr(cfg, "sharepoint_sharing_url", "") if cfg else ""
|
|
1342
|
+
sp_cache = getattr(cfg, "token_cache_path", "") if cfg else ""
|
|
1343
|
+
cfg_local = getattr(cfg, "workbook_path", "") if cfg else ""
|
|
1344
|
+
|
|
1345
|
+
use_sharepoint = bool(sp_tenant and sp_client and sp_share) and not local_override
|
|
1346
|
+
|
|
1347
|
+
wb_ctx = None
|
|
1348
|
+
drive_id = item_id = ""
|
|
1349
|
+
graph_token = ""
|
|
1350
|
+
|
|
1351
|
+
if use_sharepoint:
|
|
1352
|
+
_emit({"phase": "auth", "source": "sharepoint"})
|
|
1353
|
+
graph_token = acquire_graph_token_silent(sp_tenant, sp_client, sp_cache)
|
|
1354
|
+
if not graph_token:
|
|
1355
|
+
raise RuntimeError(
|
|
1356
|
+
"SharePoint authentication unavailable: no valid token in the MSAL "
|
|
1357
|
+
"cache. Run a one-time sign-in on the server: "
|
|
1358
|
+
"python -m kryten_webqueue.jobs.fetchurls_auth"
|
|
1359
|
+
)
|
|
1360
|
+
_emit({"phase": "download", "source": "sharepoint"})
|
|
1361
|
+
try:
|
|
1362
|
+
wb_bytes, drive_id, item_id = download_sharepoint_xlsx(graph_token, sp_share)
|
|
1363
|
+
except SystemExit as exc: # the vendored reader uses sys.exit on failure
|
|
1364
|
+
raise RuntimeError(f"SharePoint download failed: {exc}") from exc
|
|
1365
|
+
else:
|
|
1366
|
+
workbook_path = local_override or cfg_local
|
|
1367
|
+
if not workbook_path:
|
|
1368
|
+
raise RuntimeError(
|
|
1369
|
+
"fetchurls needs a workbook source: configure "
|
|
1370
|
+
"fetchurls.sharepoint_* (recommended) or fetchurls.workbook_path."
|
|
1371
|
+
)
|
|
1372
|
+
wb_file = Path(workbook_path)
|
|
1373
|
+
if not wb_file.exists():
|
|
1374
|
+
raise RuntimeError(f"Workbook not found: {wb_file}")
|
|
1375
|
+
wb_bytes = wb_file.read_bytes()
|
|
1303
1376
|
|
|
1304
1377
|
sheet_name, friday, saturday = upcoming_weekend_sheet()
|
|
1305
1378
|
wb_peek = _oxl.load_workbook(io.BytesIO(wb_bytes), read_only=True)
|
|
1306
1379
|
all_sheets = wb_peek.sheetnames
|
|
1307
1380
|
wb_peek.close()
|
|
1308
1381
|
if sheet_name not in all_sheets:
|
|
1382
|
+
# Suggest only date-format weekend sheets (ignore Sheet1/Played Movies/etc).
|
|
1383
|
+
weekend_sheets = [s for s in all_sheets if _SHEET_DATE_RE.match(s.strip())]
|
|
1384
|
+
available = ", ".join(weekend_sheets) if weekend_sheets else ", ".join(all_sheets)
|
|
1309
1385
|
raise RuntimeError(
|
|
1310
|
-
f"
|
|
1311
|
-
f"
|
|
1386
|
+
f"This weekend's worksheet '{sheet_name}' was not found in the workbook. "
|
|
1387
|
+
f"Add a sheet named '{sheet_name}' (Friday.date-Saturday.date), or check "
|
|
1388
|
+
f"the sheet name matches. Available weekend sheets: {available}"
|
|
1312
1389
|
)
|
|
1313
1390
|
|
|
1314
1391
|
_emit({"phase": "parsing", "sheet": sheet_name})
|
|
@@ -1316,6 +1393,14 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
1316
1393
|
if section and section != "all":
|
|
1317
1394
|
sections = {k: v for k, v in sections.items() if k == section}
|
|
1318
1395
|
|
|
1396
|
+
# Build the writeback context (SharePoint only, non-dry-run, writeback on).
|
|
1397
|
+
if use_sharepoint and writeback and not dry_run:
|
|
1398
|
+
wb_ctx = WritebackContext(
|
|
1399
|
+
token=graph_token, drive_id=drive_id, item_id=item_id,
|
|
1400
|
+
sheet_name=sheet_name, dry_run=False,
|
|
1401
|
+
tenant_id=sp_tenant, client_id=sp_client, cache_path=sp_cache,
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1319
1404
|
# Swap in the in-process fetch (no fetch.ps1 subprocess in a service).
|
|
1320
1405
|
global run_fetch # noqa: PLW0603
|
|
1321
1406
|
original_run_fetch = run_fetch
|
|
@@ -1328,15 +1413,17 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
1328
1413
|
downloaded = 0
|
|
1329
1414
|
failures = 0
|
|
1330
1415
|
section_lines: dict[str, list[str]] = {}
|
|
1416
|
+
section_labels: dict[str, str] = {}
|
|
1331
1417
|
all_results: dict[str, list[ProcessResult]] = {}
|
|
1332
1418
|
|
|
1333
1419
|
try:
|
|
1334
1420
|
for slug, url_rows in sections.items():
|
|
1335
1421
|
if not url_rows:
|
|
1336
1422
|
continue
|
|
1337
|
-
label = SECTION_LABELS.get(slug, slug)
|
|
1423
|
+
label = SECTION_LABELS.get(slug, slug)
|
|
1424
|
+
section_labels[slug] = label
|
|
1338
1425
|
results = process_section(
|
|
1339
|
-
label, url_rows, Path("."), dry_run, validate, wb_ctx=
|
|
1426
|
+
label, url_rows, Path("."), dry_run, validate, wb_ctx=wb_ctx,
|
|
1340
1427
|
)
|
|
1341
1428
|
all_results[slug] = results
|
|
1342
1429
|
write_playlist(out_dir / f"{sheet_name}-{slug}.txt", results)
|
|
@@ -1354,14 +1441,26 @@ def run(params: dict, *, config, progress=None) -> dict:
|
|
|
1354
1441
|
write_failures(out_dir / f"{sheet_name}-failures.txt", all_results)
|
|
1355
1442
|
finally:
|
|
1356
1443
|
run_fetch = original_run_fetch
|
|
1444
|
+
if wb_ctx is not None:
|
|
1445
|
+
try:
|
|
1446
|
+
_close_workbook_session(wb_ctx)
|
|
1447
|
+
except Exception: # noqa: BLE001 - best-effort cleanup
|
|
1448
|
+
pass
|
|
1449
|
+
|
|
1450
|
+
writeback_stats = None
|
|
1451
|
+
if wb_ctx is not None:
|
|
1452
|
+
writeback_stats = {"ok": wb_ctx.writes_ok, "failed": wb_ctx.writes_fail}
|
|
1357
1453
|
|
|
1358
1454
|
_emit({"phase": "done", "sheet": sheet_name, "resolved": resolved, "failures": failures})
|
|
1359
1455
|
return {
|
|
1360
1456
|
"sheet": sheet_name,
|
|
1457
|
+
"source": "sharepoint" if use_sharepoint else "local",
|
|
1361
1458
|
"resolved": resolved,
|
|
1362
1459
|
"downloaded": downloaded,
|
|
1363
1460
|
"failures": failures,
|
|
1461
|
+
"writeback": writeback_stats,
|
|
1364
1462
|
"section_lines": section_lines,
|
|
1463
|
+
"section_labels": section_labels,
|
|
1365
1464
|
"imported_playlists": [], # filled in by the async job wrapper
|
|
1366
1465
|
"dry_run": dry_run,
|
|
1367
1466
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""One-time SharePoint sign-in for the fetchurls job.
|
|
2
|
+
|
|
3
|
+
The webqueue service only acquires Microsoft Graph tokens *silently* from a
|
|
4
|
+
pre-seeded MSAL cache (it never prompts). Run this once on the server to create
|
|
5
|
+
that cache via the device-code flow:
|
|
6
|
+
|
|
7
|
+
python -m kryten_webqueue.jobs.fetchurls_auth [--config /path/to/config.json]
|
|
8
|
+
|
|
9
|
+
It prints a URL + code; sign in with the curiousmotors account and grant the
|
|
10
|
+
Files.ReadWrite.All consent. The refresh token is cached (~90 days) at
|
|
11
|
+
``fetchurls.token_cache_path`` so subsequent unattended job runs authenticate
|
|
12
|
+
silently. Re-run this when the cache expires.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from ..config import Config
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: list[str] | None = None) -> int:
|
|
23
|
+
parser = argparse.ArgumentParser(description="One-time SharePoint sign-in for fetchurls")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--config",
|
|
26
|
+
default=os.environ.get("WQ_CONFIG", "/etc/kryten-webqueue/config.json"),
|
|
27
|
+
help="Path to the webqueue config.json (default: $WQ_CONFIG or /etc/kryten-webqueue/config.json)",
|
|
28
|
+
)
|
|
29
|
+
args = parser.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
config = Config.from_file(args.config)
|
|
32
|
+
fu = config.fetchurls
|
|
33
|
+
if not (fu.sharepoint_tenant_id and fu.sharepoint_client_id):
|
|
34
|
+
print(
|
|
35
|
+
"ERROR: fetchurls.sharepoint_tenant_id and sharepoint_client_id must be "
|
|
36
|
+
"set in the config before authenticating.",
|
|
37
|
+
file=sys.stderr,
|
|
38
|
+
)
|
|
39
|
+
return 2
|
|
40
|
+
if not fu.token_cache_path:
|
|
41
|
+
print(
|
|
42
|
+
"ERROR: fetchurls.token_cache_path must be set so the token can be cached "
|
|
43
|
+
"for the service to reuse.",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return 2
|
|
47
|
+
|
|
48
|
+
from ..integrations.cmsutils.fetchurls import acquire_graph_token
|
|
49
|
+
|
|
50
|
+
token = acquire_graph_token(
|
|
51
|
+
fu.sharepoint_tenant_id, fu.sharepoint_client_id, fu.token_cache_path
|
|
52
|
+
)
|
|
53
|
+
if token:
|
|
54
|
+
print(f"\n✓ Authenticated. Token cached at: {fu.token_cache_path}")
|
|
55
|
+
print(" The fetchurls job can now run unattended until the cache expires.")
|
|
56
|
+
return 0
|
|
57
|
+
print("ERROR: Authentication did not complete.", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
raise SystemExit(main())
|
|
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
|
|
|
24
24
|
JobFunc = Callable[[dict, "JobContext"], Awaitable[dict | None]]
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
class JobError(Exception):
|
|
28
|
+
"""An expected, user-facing job failure (bad input / config, not a bug).
|
|
29
|
+
|
|
30
|
+
Raising this from a job records a clean ``failed`` run with the message and
|
|
31
|
+
logs it at WARNING without a stack trace, so misconfiguration (e.g. a
|
|
32
|
+
missing workbook sheet) reads as actionable guidance rather than a crash.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
27
36
|
def _option_values(field: dict) -> list:
|
|
28
37
|
"""Return the allowed values for an enum field's ``options``.
|
|
29
38
|
|
|
@@ -184,6 +193,13 @@ class JobManager:
|
|
|
184
193
|
except asyncio.CancelledError:
|
|
185
194
|
await self._db.finish_job_run(run_id, "cancelled", None)
|
|
186
195
|
raise
|
|
196
|
+
except JobError as exc:
|
|
197
|
+
# Expected, user-facing failure (bad input/config): record a clean
|
|
198
|
+
# message and log without a stack trace.
|
|
199
|
+
logger.warning("Job '%s' failed: %s", name, exc)
|
|
200
|
+
await self._db.finish_job_run(
|
|
201
|
+
run_id, "failed", json.dumps({"error": str(exc)})
|
|
202
|
+
)
|
|
187
203
|
except Exception as exc: # noqa: BLE001 - record any failure
|
|
188
204
|
logger.exception("Job '%s' failed", name)
|
|
189
205
|
await self._db.finish_job_run(
|
|
@@ -15,6 +15,8 @@ import functools
|
|
|
15
15
|
import importlib
|
|
16
16
|
import logging
|
|
17
17
|
|
|
18
|
+
from .manager import JobError
|
|
19
|
+
|
|
18
20
|
logger = logging.getLogger(__name__)
|
|
19
21
|
|
|
20
22
|
|
|
@@ -44,14 +46,23 @@ def _thread_safe_progress(ctx, loop):
|
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
async def _run_vendored(module_path: str, params: dict, ctx, *, deps: list[str]):
|
|
47
|
-
"""Import a vendored module, verify deps, and run its ``run()`` off-loop.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
"""Import a vendored module, verify deps, and run its ``run()`` off-loop.
|
|
50
|
+
|
|
51
|
+
The vendored tools raise ``RuntimeError`` for expected, user-facing failures
|
|
52
|
+
(missing/unauthenticated workbook, a sheet that isn't present, a missing
|
|
53
|
+
optional dependency). Surface those as :class:`JobError` so the run history
|
|
54
|
+
shows a clean, actionable message instead of a stack trace.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
for dep in deps:
|
|
58
|
+
_require(dep)
|
|
59
|
+
module = importlib.import_module(module_path)
|
|
60
|
+
loop = asyncio.get_running_loop()
|
|
61
|
+
progress = _thread_safe_progress(ctx, loop)
|
|
62
|
+
fn = functools.partial(module.run, params, config=ctx.config, progress=progress)
|
|
63
|
+
return await asyncio.to_thread(fn)
|
|
64
|
+
except RuntimeError as exc:
|
|
65
|
+
raise JobError(str(exc)) from exc
|
|
55
66
|
|
|
56
67
|
|
|
57
68
|
# ── Enrich jobs ────────────────────────────────────────────────────────────────
|
|
@@ -121,33 +132,43 @@ async def fetch_job(params: dict, ctx):
|
|
|
121
132
|
# ── fetchurls job ──────────────────────────────────────────────────────────────
|
|
122
133
|
|
|
123
134
|
async def _import_section_as_playlist(ctx, name: str, lines: list[str], triggered_by: str) -> dict | None:
|
|
124
|
-
"""Import resolved ``cm:`` lines
|
|
135
|
+
"""Import resolved ``cm:`` lines into a fixed, well-known saved playlist.
|
|
125
136
|
|
|
126
|
-
|
|
127
|
-
|
|
137
|
+
The three fetchurls playlists ("Friday Night", "Saturday Morning",
|
|
138
|
+
"Saturday Night") pre-exist and are immutable. We match by **name only**
|
|
139
|
+
(any creator), replace their items in place (idempotent re-runs), and
|
|
140
|
+
preserve their existing immutability. If a playlist is missing it is created
|
|
141
|
+
as immutable so a recreated playlist keeps the reserved status.
|
|
128
142
|
"""
|
|
129
143
|
from ..playlists.importer import import_playlist_text
|
|
130
144
|
|
|
131
145
|
if not lines:
|
|
132
146
|
return None
|
|
133
|
-
parsed = await import_playlist_text(
|
|
147
|
+
parsed = await import_playlist_text(
|
|
148
|
+
ctx.db, "\n".join(lines), mediacms_url=ctx.config.mediacms_url
|
|
149
|
+
)
|
|
134
150
|
items = parsed.get("items") or []
|
|
135
151
|
if not items:
|
|
136
152
|
return None
|
|
137
153
|
|
|
138
|
-
existing = await ctx.db.
|
|
154
|
+
existing = await ctx.db.get_playlist_by_name_any(name)
|
|
139
155
|
if existing:
|
|
140
156
|
playlist_id = existing["id"]
|
|
141
157
|
else:
|
|
142
158
|
playlist_id = await ctx.db.create_saved_playlist(
|
|
143
|
-
name=name, description=None, is_immutable=
|
|
159
|
+
name=name, description=None, is_immutable=True, created_by=triggered_by,
|
|
144
160
|
)
|
|
145
161
|
await ctx.db.replace_playlist_items(playlist_id, items)
|
|
146
162
|
return {"id": playlist_id, "name": name, "count": len(items)}
|
|
147
163
|
|
|
148
164
|
|
|
149
165
|
async def fetchurls_job(params: dict, ctx):
|
|
150
|
-
"""Resolve the upcoming-weekend workbook, then import each section playlist.
|
|
166
|
+
"""Resolve the upcoming-weekend workbook, then import each section playlist.
|
|
167
|
+
|
|
168
|
+
Sections map to the fixed playlists by their human label:
|
|
169
|
+
friday → "Friday Night", saturday-morning → "Saturday Morning",
|
|
170
|
+
saturday-night → "Saturday Night".
|
|
171
|
+
"""
|
|
151
172
|
result = await _run_vendored(
|
|
152
173
|
"kryten_webqueue.integrations.cmsutils.fetchurls", params, ctx,
|
|
153
174
|
deps=["openpyxl", "yaml", "requests"],
|
|
@@ -156,16 +177,17 @@ async def fetchurls_job(params: dict, ctx):
|
|
|
156
177
|
if result.get("dry_run"):
|
|
157
178
|
return result
|
|
158
179
|
|
|
159
|
-
sheet = result.get("sheet")
|
|
160
180
|
triggered_by = ctx.triggered_by or "system"
|
|
181
|
+
labels = result.get("section_labels") or {}
|
|
161
182
|
imported = []
|
|
162
183
|
for slug, lines in (result.get("section_lines") or {}).items():
|
|
163
|
-
name =
|
|
184
|
+
name = labels.get(slug) or slug
|
|
164
185
|
info = await _import_section_as_playlist(ctx, name, lines, triggered_by)
|
|
165
186
|
if info:
|
|
166
187
|
imported.append(info["name"])
|
|
167
188
|
result["imported_playlists"] = imported
|
|
168
189
|
result.pop("section_lines", None) # keep the persisted detail compact
|
|
190
|
+
result.pop("section_labels", None)
|
|
169
191
|
return result
|
|
170
192
|
|
|
171
193
|
|
|
@@ -210,5 +232,6 @@ FETCHURLS_SCHEMA = [
|
|
|
210
232
|
"options": ["all", "friday", "saturday-night", "saturday-morning"], "label": "Section"},
|
|
211
233
|
{"name": "dry_run", "type": "bool", "default": False, "label": "Dry run (resolve only)"},
|
|
212
234
|
{"name": "validate", "type": "bool", "default": True, "label": "Validate existing URLs"},
|
|
213
|
-
{"name": "
|
|
235
|
+
{"name": "writeback", "type": "bool", "default": True, "label": "Write resolved URLs back to SharePoint (col F)"},
|
|
236
|
+
{"name": "workbook_path", "type": "string", "default": None, "label": "Local workbook path (override SharePoint)"},
|
|
214
237
|
]
|
|
@@ -179,9 +179,16 @@ async def import_playlist_text(db, text: str, *, mediacms_url: str | None = None
|
|
|
179
179
|
if line.startswith("cm:"):
|
|
180
180
|
media_id = line[3:].strip()
|
|
181
181
|
catalog_item = await db.get_item_admin(media_id)
|
|
182
|
+
if catalog_item:
|
|
183
|
+
resolved_media = catalog_item["manifest_url"]
|
|
184
|
+
else:
|
|
185
|
+
# Not yet in the local catalog (e.g. freshly downloaded by the
|
|
186
|
+
# fetch/fetchurls job before a sync) — construct the CyTube
|
|
187
|
+
# manifest URL from the token so it still plays.
|
|
188
|
+
resolved_media = _manifest_url_for_token(media_id, mediacms_url) or media_id
|
|
182
189
|
items.append({
|
|
183
190
|
"media_type": "cm",
|
|
184
|
-
"media_id":
|
|
191
|
+
"media_id": resolved_media,
|
|
185
192
|
"title": catalog_item["title"] if catalog_item else None,
|
|
186
193
|
"duration_sec": catalog_item["duration_sec"] if catalog_item else None,
|
|
187
194
|
})
|
|
@@ -212,13 +212,14 @@ async function loadJobs() {
|
|
|
212
212
|
const el = document.getElementById('job-runs');
|
|
213
213
|
if (runs.length > 0) {
|
|
214
214
|
el.innerHTML = `<table class="admin-table">
|
|
215
|
-
<tr><th>Job</th><th>Started</th><th>Ended</th><th>Status</th></tr>
|
|
215
|
+
<tr><th>Job</th><th>Started</th><th>Ended</th><th>Status</th><th>Detail</th></tr>
|
|
216
216
|
${runs.map(r => `
|
|
217
217
|
<tr>
|
|
218
218
|
<td>${escapeHtml(r.job_name || '')}</td>
|
|
219
219
|
<td>${formatLocalDateTime(r.started_at)}</td>
|
|
220
220
|
<td>${r.ended_at ? formatLocalDateTime(r.ended_at) : '—'}</td>
|
|
221
221
|
<td><span class="job-status job-status-${escapeHtml(r.status || '')}">${escapeHtml(r.status || '')}</span></td>
|
|
222
|
+
<td class="job-detail">${escapeHtml(summarizeRunDetail(r.detail))}</td>
|
|
222
223
|
</tr>
|
|
223
224
|
`).join('')}
|
|
224
225
|
</table>`;
|
|
@@ -269,6 +270,27 @@ function escapeHtml(str) {
|
|
|
269
270
|
return div.innerHTML;
|
|
270
271
|
}
|
|
271
272
|
|
|
273
|
+
// Summarize a job_runs.detail JSON blob for the history table. Prefers a
|
|
274
|
+
// failure message, otherwise a compact success summary.
|
|
275
|
+
function summarizeRunDetail(detail) {
|
|
276
|
+
if (!detail) return '';
|
|
277
|
+
let d;
|
|
278
|
+
try { d = typeof detail === 'string' ? JSON.parse(detail) : detail; }
|
|
279
|
+
catch { return String(detail).slice(0, 160); }
|
|
280
|
+
if (d && d.error) return d.error;
|
|
281
|
+
if (d && typeof d === 'object') {
|
|
282
|
+
const parts = [];
|
|
283
|
+
if (d.sheet) parts.push(d.sheet);
|
|
284
|
+
if (d.imported_playlists && d.imported_playlists.length) parts.push(`imported: ${d.imported_playlists.join(', ')}`);
|
|
285
|
+
if (typeof d.resolved === 'number') parts.push(`resolved ${d.resolved}`);
|
|
286
|
+
if (typeof d.failures === 'number' && d.failures) parts.push(`${d.failures} failed`);
|
|
287
|
+
if (typeof d.committed === 'number') parts.push(`committed ${d.committed}`);
|
|
288
|
+
if (typeof d.added_to_playlist !== 'undefined' && d.added_to_playlist) parts.push(`→ playlist ${d.added_to_playlist}`);
|
|
289
|
+
if (parts.length) return parts.join(' · ');
|
|
290
|
+
}
|
|
291
|
+
return '';
|
|
292
|
+
}
|
|
293
|
+
|
|
272
294
|
loadAdminData();
|
|
273
295
|
loadJobs();
|
|
274
296
|
</script>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kryten-webqueue"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.9"
|
|
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"
|
|
@@ -25,6 +25,7 @@ dependencies = [
|
|
|
25
25
|
"requests>=2.31",
|
|
26
26
|
"pyyaml>=6.0",
|
|
27
27
|
"python-slugify>=8.0",
|
|
28
|
+
"msal>=1.28",
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.optional-dependencies]
|
|
@@ -39,6 +40,7 @@ dev = [
|
|
|
39
40
|
|
|
40
41
|
[project.scripts]
|
|
41
42
|
kryten-webqueue = "kryten_webqueue.__main__:main"
|
|
43
|
+
kryten-webqueue-fetchurls-auth = "kryten_webqueue.jobs.fetchurls_auth:main"
|
|
42
44
|
|
|
43
45
|
[build-system]
|
|
44
46
|
requires = ["hatchling"]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""fetchurls SharePoint + fixed-name playlist behaviour (v0.9.8).
|
|
2
|
+
|
|
3
|
+
Covers the silent-token guard, source selection / clear error when the MSAL
|
|
4
|
+
cache is unseeded, the fixed section-label playlist names, immutable-preserve on
|
|
5
|
+
re-import, and the importer ``cm:`` manifest-URL fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from kryten_webqueue.catalog.db import Database
|
|
13
|
+
from kryten_webqueue.jobs.manager import JobContext
|
|
14
|
+
from kryten_webqueue.jobs import tasks
|
|
15
|
+
from kryten_webqueue.integrations.cmsutils import fetchurls
|
|
16
|
+
from kryten_webqueue.playlists.importer import import_playlist_text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
async def db(tmp_path):
|
|
21
|
+
database = Database(str(tmp_path / "fu.db"))
|
|
22
|
+
await database.connect()
|
|
23
|
+
await database.run_migrations()
|
|
24
|
+
yield database
|
|
25
|
+
await database.close()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _config(**fu):
|
|
29
|
+
return SimpleNamespace(
|
|
30
|
+
mediacms_url="https://cms.example",
|
|
31
|
+
mediacms_token="tok",
|
|
32
|
+
tmdb_api_key="", omdb_api_key="",
|
|
33
|
+
image_dir="/tmp/kqimg",
|
|
34
|
+
fetch_cookies_path="",
|
|
35
|
+
fetchurls=SimpleNamespace(
|
|
36
|
+
workbook_path=fu.get("workbook_path", ""),
|
|
37
|
+
sharepoint_tenant_id=fu.get("sharepoint_tenant_id", ""),
|
|
38
|
+
sharepoint_client_id=fu.get("sharepoint_client_id", ""),
|
|
39
|
+
sharepoint_sharing_url=fu.get("sharepoint_sharing_url", ""),
|
|
40
|
+
token_cache_path=fu.get("token_cache_path", ""),
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ctx(db, config=None):
|
|
46
|
+
return JobContext(db=db, api_gate=None, config=config or _config(), run_id=1, triggered_by="admin")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- silent token guard ---
|
|
50
|
+
|
|
51
|
+
def test_silent_token_none_without_cache(tmp_path):
|
|
52
|
+
missing = str(tmp_path / "nope.bin")
|
|
53
|
+
assert fetchurls.acquire_graph_token_silent("tenant", "client", missing) is None
|
|
54
|
+
assert fetchurls.acquire_graph_token_silent("tenant", "client", "") is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# --- run() source selection / clear error ---
|
|
58
|
+
|
|
59
|
+
def test_run_sharepoint_without_token_raises_clear_error(tmp_path):
|
|
60
|
+
config = _config(
|
|
61
|
+
sharepoint_tenant_id="t", sharepoint_client_id="c",
|
|
62
|
+
sharepoint_sharing_url="https://sp/share",
|
|
63
|
+
token_cache_path=str(tmp_path / "absent.bin"),
|
|
64
|
+
)
|
|
65
|
+
with pytest.raises(RuntimeError, match="fetchurls_auth"):
|
|
66
|
+
fetchurls.run({}, config=config, progress=None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_run_no_source_raises(tmp_path):
|
|
70
|
+
with pytest.raises(RuntimeError, match="workbook source"):
|
|
71
|
+
fetchurls.run({}, config=_config(), progress=None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- vendored RuntimeError → JobError translation ---
|
|
75
|
+
|
|
76
|
+
async def test_run_vendored_translates_runtimeerror_to_joberror(db, monkeypatch):
|
|
77
|
+
from kryten_webqueue.jobs.manager import JobError
|
|
78
|
+
|
|
79
|
+
def boom(params, *, config, progress):
|
|
80
|
+
raise RuntimeError("This weekend's worksheet was not found.")
|
|
81
|
+
|
|
82
|
+
fake_mod = SimpleNamespace(run=boom)
|
|
83
|
+
monkeypatch.setattr(tasks.importlib, "import_module", lambda name, *a, **k: fake_mod)
|
|
84
|
+
|
|
85
|
+
with pytest.raises(JobError, match="worksheet was not found"):
|
|
86
|
+
await tasks._run_vendored("whatever", {}, _ctx(db), deps=[])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- fixed section-label playlist names + immutable preserve ---
|
|
90
|
+
|
|
91
|
+
async def _add_catalog(db, token, title="T"):
|
|
92
|
+
await db.insert_catalog({
|
|
93
|
+
"friendly_token": token, "title": title, "description": "",
|
|
94
|
+
"duration_sec": 600, "manifest_url": f"https://cms/api/v1/media/cytube/{token}.json",
|
|
95
|
+
"thumbnail_url": "", "synced_at": "2026-01-01T00:00:00+00:00",
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async def test_fetchurls_job_uses_fixed_label_names(db, monkeypatch):
|
|
100
|
+
await _add_catalog(db, "f1")
|
|
101
|
+
await _add_catalog(db, "s1")
|
|
102
|
+
|
|
103
|
+
async def fake_run_vendored(module_path, params, ctx, *, deps):
|
|
104
|
+
return {
|
|
105
|
+
"sheet": "3.6-3.7", "dry_run": False, "resolved": 2, "failures": 0,
|
|
106
|
+
"section_lines": {"friday": ["cm:f1"], "saturday-night": ["cm:s1"]},
|
|
107
|
+
"section_labels": {"friday": "Friday Night", "saturday-night": "Saturday Night"},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
monkeypatch.setattr(tasks, "_run_vendored", fake_run_vendored)
|
|
111
|
+
result = await tasks.fetchurls_job({}, _ctx(db))
|
|
112
|
+
|
|
113
|
+
assert sorted(result["imported_playlists"]) == ["Friday Night", "Saturday Night"]
|
|
114
|
+
names = {p["name"] for p in await db.get_saved_playlists()}
|
|
115
|
+
assert {"Friday Night", "Saturday Night"} <= names
|
|
116
|
+
# Created playlists are immutable (reserved).
|
|
117
|
+
fri = await db.get_playlist_by_name_any("Friday Night")
|
|
118
|
+
assert fri["is_immutable"] == 1
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def test_fetchurls_import_preserves_existing_immutability(db, monkeypatch):
|
|
122
|
+
await _add_catalog(db, "f1")
|
|
123
|
+
await _add_catalog(db, "f2")
|
|
124
|
+
# Pre-existing MUTABLE playlist with the fixed name, created by someone else.
|
|
125
|
+
pid = await db.create_saved_playlist(
|
|
126
|
+
name="Friday Night", description=None, is_immutable=False, created_by="other-admin",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
info = await tasks._import_section_as_playlist(_ctx(db), "Friday Night", ["cm:f1", "cm:f2"], "admin")
|
|
130
|
+
assert info["id"] == pid # reused, not duplicated
|
|
131
|
+
pl = await db.get_playlist_by_name_any("Friday Night")
|
|
132
|
+
assert pl["is_immutable"] == 0 # existing flag preserved (not forced)
|
|
133
|
+
assert pl["created_by"] == "other-admin" # original owner kept
|
|
134
|
+
items = await db.get_saved_playlist_items(pid)
|
|
135
|
+
assert len(items) == 2
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --- importer cm: manifest fallback for not-yet-synced items ---
|
|
139
|
+
|
|
140
|
+
async def test_importer_cm_fallback_builds_manifest(db):
|
|
141
|
+
out = await import_playlist_text(db, "cm:freshtoken", mediacms_url="https://cms.example")
|
|
142
|
+
assert len(out["items"]) == 1
|
|
143
|
+
assert out["items"][0]["media_id"] == "https://cms.example/api/v1/media/cytube/freshtoken.json?format=json"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def test_importer_cm_uses_catalog_when_present(db):
|
|
147
|
+
await _add_catalog(db, "known", "Known Title")
|
|
148
|
+
out = await import_playlist_text(db, "cm:known", mediacms_url="https://cms.example")
|
|
149
|
+
assert out["items"][0]["media_id"] == "https://cms/api/v1/media/cytube/known.json"
|
|
150
|
+
assert out["items"][0]["title"] == "Known Title"
|
|
@@ -135,6 +135,36 @@ async def test_unknown_job_raises_keyerror(db):
|
|
|
135
135
|
await jm.run("nope")
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
async def test_job_error_records_clean_message(db):
|
|
139
|
+
from kryten_webqueue.jobs.manager import JobError
|
|
140
|
+
|
|
141
|
+
async def job(params, ctx):
|
|
142
|
+
raise JobError("This weekend's worksheet '6.12-6.13' was not found.")
|
|
143
|
+
|
|
144
|
+
jm = JobManager(db)
|
|
145
|
+
jm.register("fetchurls", job)
|
|
146
|
+
await jm.run("fetchurls")
|
|
147
|
+
run = await _wait_terminal(db, "fetchurls")
|
|
148
|
+
assert run["status"] == "failed"
|
|
149
|
+
# Clean message, no "RuntimeError:"/"JobError:" type prefix or traceback.
|
|
150
|
+
assert json.loads(run["detail"]) == {
|
|
151
|
+
"error": "This weekend's worksheet '6.12-6.13' was not found."
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_unexpected_error_keeps_type_prefix(db):
|
|
156
|
+
async def job(params, ctx):
|
|
157
|
+
raise ValueError("boom")
|
|
158
|
+
|
|
159
|
+
jm = JobManager(db)
|
|
160
|
+
jm.register("crashy", job)
|
|
161
|
+
await jm.run("crashy")
|
|
162
|
+
run = await _wait_terminal(db, "crashy")
|
|
163
|
+
assert run["status"] == "failed"
|
|
164
|
+
# Unexpected (bug) failures retain the exception type for debugging.
|
|
165
|
+
assert json.loads(run["detail"]) == {"error": "ValueError: boom"}
|
|
166
|
+
|
|
167
|
+
|
|
138
168
|
async def test_already_running_guard(db):
|
|
139
169
|
started = asyncio.Event()
|
|
140
170
|
release = asyncio.Event()
|
|
@@ -69,6 +69,7 @@ def test_extract_manifest_token():
|
|
|
69
69
|
async def test_fetch_job_missing_ytdlp_fails_fast(db, monkeypatch):
|
|
70
70
|
# Simulate yt_dlp absent regardless of the host environment.
|
|
71
71
|
import importlib
|
|
72
|
+
from kryten_webqueue.jobs.manager import JobError
|
|
72
73
|
real_import = importlib.import_module
|
|
73
74
|
|
|
74
75
|
def fake_import(name, *a, **k):
|
|
@@ -77,7 +78,8 @@ async def test_fetch_job_missing_ytdlp_fails_fast(db, monkeypatch):
|
|
|
77
78
|
return real_import(name, *a, **k)
|
|
78
79
|
|
|
79
80
|
monkeypatch.setattr(tasks.importlib, "import_module", fake_import)
|
|
80
|
-
|
|
81
|
+
# A missing optional dependency is a clean, user-facing JobError.
|
|
82
|
+
with pytest.raises(JobError, match="yt_dlp"):
|
|
81
83
|
await tasks.fetch_job({"url": "http://x"}, _ctx(db))
|
|
82
84
|
|
|
83
85
|
|
|
@@ -129,8 +129,9 @@ async def test_legacy_tokens_still_work(db):
|
|
|
129
129
|
yt = [i for i in out["items"] if i["media_type"] == "yt"]
|
|
130
130
|
cm = [i for i in out["items"] if i["media_type"] == "cm"]
|
|
131
131
|
assert yt[0]["media_id"] == "dQw4w9WgXcQ"
|
|
132
|
-
# bare1 resolves to its manifest URL; cm:cmtoken
|
|
132
|
+
# bare1 resolves to its manifest URL; cm:cmtoken (not in catalog) now falls
|
|
133
|
+
# back to a constructed manifest URL so not-yet-synced items still play.
|
|
133
134
|
assert any(i["media_id"].endswith("bare1.json?format=json") for i in cm)
|
|
134
|
-
assert any(i["media_id"] == "cmtoken" for i in cm)
|
|
135
|
+
assert any(i["media_id"] == f"{MEDIACMS}/api/v1/media/cytube/cmtoken.json?format=json" for i in cm)
|
|
135
136
|
# unknownbare is not in catalog -> error
|
|
136
137
|
assert any(e["token"] == "unknownbare" for e in out["errors"])
|
|
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.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichmeta.py
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
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.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|