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.
Files changed (89) hide show
  1. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/CHANGELOG.md +23 -1
  2. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/PKG-INFO +2 -1
  3. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/config.example.json +5 -1
  4. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/db.py +13 -0
  5. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/config.py +15 -2
  6. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py +121 -22
  7. kryten_webqueue-0.9.9/kryten_webqueue/jobs/fetchurls_auth.py +62 -0
  8. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/manager.py +16 -0
  9. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/tasks.py +41 -18
  10. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/importer.py +8 -1
  11. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/index.html +23 -1
  12. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/pyproject.toml +3 -1
  13. kryten_webqueue-0.9.9/tests/test_fetchurls_sharepoint.py +150 -0
  14. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase2_jobs.py +30 -0
  15. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase3_jobs.py +3 -1
  16. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_playlist_import.py +3 -2
  17. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/.github/workflows/python-publish.yml +0 -0
  18. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/.github/workflows/release.yml +0 -0
  19. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/.gitignore +0 -0
  20. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/README.md +0 -0
  21. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/deploy/kryten-webqueue.service +0 -0
  22. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/deploy/nginx-queue.conf +0 -0
  23. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPLEMENTATION_SPEC.md +0 -0
  24. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_API_GATE.md +0 -0
  25. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_ECONOMY.md +0 -0
  26. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_KRYTEN_PY.md +0 -0
  27. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/IMPL_ROBOT.md +0 -0
  28. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/PRE_PLAN_GAPS.md +0 -0
  29. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/PRODUCT_PLAN.md +0 -0
  30. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  31. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/__init__.py +0 -0
  32. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/__main__.py +0 -0
  33. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/api_gate/__init__.py +0 -0
  34. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/api_gate/client.py +0 -0
  35. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/app.py +0 -0
  36. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/__init__.py +0 -0
  37. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/otp.py +0 -0
  38. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/rate_limit.py +0 -0
  39. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/session.py +0 -0
  40. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/__init__.py +0 -0
  41. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/images.py +0 -0
  42. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/mediacms.py +0 -0
  43. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/sync.py +0 -0
  44. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/__init__.py +0 -0
  45. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  46. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  47. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  48. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  49. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  50. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  51. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  52. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/__init__.py +0 -0
  53. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/__init__.py +0 -0
  54. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/fire.py +0 -0
  55. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/scheduler.py +0 -0
  56. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/__init__.py +0 -0
  57. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/ordering.py +0 -0
  58. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/poller.py +0 -0
  59. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/shadow.py +0 -0
  60. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/__init__.py +0 -0
  61. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_catalog.py +0 -0
  62. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_jobs.py +0 -0
  63. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_playlists.py +0 -0
  64. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_queue.py +0 -0
  65. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_schedules.py +0 -0
  66. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/auth.py +0 -0
  67. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/catalog.py +0 -0
  68. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/pages.py +0 -0
  69. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/queue.py +0 -0
  70. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/user.py +0 -0
  71. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/static/css/main.css +0 -0
  72. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/static/js/main.js +0 -0
  73. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/playlists.html +0 -0
  74. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  75. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/schedules.html +0 -0
  76. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/auth/login.html +0 -0
  77. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/base.html +0 -0
  78. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/browse.html +0 -0
  79. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  80. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  81. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/queue/index.html +0 -0
  82. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/user/dashboard.html +0 -0
  83. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/__init__.py +0 -0
  84. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/handler.py +0 -0
  85. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/manager.py +0 -0
  86. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/__init__.py +0 -0
  87. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase1.py +0 -0
  88. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.9}/tests/test_phase4_live_fixes.py +0 -0
  89. {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 / dropsugar.com 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.
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.7
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 (v1 = local file only, OQ-1)."""
7
+ """Settings for the fetchurls job.
8
8
 
9
- workbook_path: str = "" # absolute path to a synced Channel Z Playlist .xlsx
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):
@@ -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 files.
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
- File-only (OQ-1): reads a local ``.xlsx`` (``params['workbook_path']`` or
1275
- ``config.fetchurls.workbook_path``); no SharePoint/Graph/MSAL, no column-F
1276
- writeback. Off-site URLs are downloaded via the in-process yt-pipe
1277
- downloader. Returns counts + per-section resolved ``cm:`` lines for the job
1278
- wrapper to import as saved playlists.
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
- wb_bytes = wb_file.read_bytes()
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"Sheet '{sheet_name}' (upcoming weekend) not found. "
1311
- f"Available: {', '.join(all_sheets)}"
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) if "SECTION_LABELS" in globals() else 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=None,
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
- for dep in deps:
49
- _require(dep)
50
- module = importlib.import_module(module_path)
51
- loop = asyncio.get_running_loop()
52
- progress = _thread_safe_progress(ctx, loop)
53
- fn = functools.partial(module.run, params, config=ctx.config, progress=progress)
54
- return await asyncio.to_thread(fn)
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 as a saved playlist, idempotent by name.
135
+ """Import resolved ``cm:`` lines into a fixed, well-known saved playlist.
125
136
 
126
- Re-running replaces the items of an existing same-named playlist (matched
127
- by name + creator) rather than creating a duplicate.
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(ctx.db, "\n".join(lines))
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.get_playlist_by_name(name, triggered_by)
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=False, created_by=triggered_by,
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 = f"{sheet}-{slug}"
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": "workbook_path", "type": "string", "default": None, "label": "Workbook path (override)"},
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": catalog_item["manifest_url"] if catalog_item else 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.7"
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
- with pytest.raises(RuntimeError, match="yt_dlp"):
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 passes through.
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"])