kryten-webqueue 0.9.7__tar.gz → 0.9.8__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.8}/CHANGELOG.md +14 -0
  2. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/PKG-INFO +2 -1
  3. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/config.example.json +5 -1
  4. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/db.py +13 -0
  5. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/config.py +15 -2
  6. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/fetchurls.py +115 -20
  7. kryten_webqueue-0.9.8/kryten_webqueue/jobs/fetchurls_auth.py +62 -0
  8. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/tasks.py +22 -10
  9. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/importer.py +8 -1
  10. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/pyproject.toml +3 -1
  11. kryten_webqueue-0.9.8/tests/test_fetchurls_sharepoint.py +135 -0
  12. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_playlist_import.py +3 -2
  13. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/.github/workflows/python-publish.yml +0 -0
  14. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/.github/workflows/release.yml +0 -0
  15. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/.gitignore +0 -0
  16. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/README.md +0 -0
  17. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/deploy/kryten-webqueue.service +0 -0
  18. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/deploy/nginx-queue.conf +0 -0
  19. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPLEMENTATION_SPEC.md +0 -0
  20. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_API_GATE.md +0 -0
  21. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_ECONOMY.md +0 -0
  22. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_KRYTEN_PY.md +0 -0
  23. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_ROBOT.md +0 -0
  24. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/PRE_PLAN_GAPS.md +0 -0
  25. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/PRODUCT_PLAN.md +0 -0
  26. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  27. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/images.py +0 -0
  38. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/mediacms.py +0 -0
  39. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/sync.py +0 -0
  40. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/__init__.py +0 -0
  41. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  42. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  43. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  44. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  45. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  46. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  47. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  48. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/__init__.py +0 -0
  49. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/manager.py +0 -0
  50. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/__init__.py +0 -0
  51. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/fire.py +0 -0
  52. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/scheduler.py +0 -0
  53. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/__init__.py +0 -0
  54. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/ordering.py +0 -0
  55. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/poller.py +0 -0
  56. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/shadow.py +0 -0
  57. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/__init__.py +0 -0
  58. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_catalog.py +0 -0
  59. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_jobs.py +0 -0
  60. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_playlists.py +0 -0
  61. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_queue.py +0 -0
  62. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_schedules.py +0 -0
  63. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/auth.py +0 -0
  64. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/catalog.py +0 -0
  65. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/pages.py +0 -0
  66. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/queue.py +0 -0
  67. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/user.py +0 -0
  68. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/static/css/main.css +0 -0
  69. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/static/js/main.js +0 -0
  70. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/index.html +0 -0
  71. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/playlists.html +0 -0
  72. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  73. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/schedules.html +0 -0
  74. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/auth/login.html +0 -0
  75. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/base.html +0 -0
  76. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/browse.html +0 -0
  77. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  78. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  79. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/queue/index.html +0 -0
  80. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/user/dashboard.html +0 -0
  81. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/__init__.py +0 -0
  82. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/handler.py +0 -0
  83. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/manager.py +0 -0
  84. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/__init__.py +0 -0
  85. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase1.py +0 -0
  86. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase2_jobs.py +0 -0
  87. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase3_jobs.py +0 -0
  88. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase4_live_fixes.py +0 -0
  89. {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_queue_announce.py +0 -0
@@ -6,6 +6,20 @@ 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.8] — 2026-06-12
10
+
11
+ ### Added
12
+
13
+ - **`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.
14
+ - 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.
15
+ - New `writeback` job parameter (default on) controls column-F writeback; `dry_run` and local-file mode skip it.
16
+ - Added `msal` as a core dependency.
17
+ - **`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.
18
+
19
+ ### Fixed
20
+
21
+ - 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.
22
+
9
23
  ## [0.9.7] — 2026-06-11
10
24
 
11
25
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.7
3
+ Version: 0.9.8
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,23 +1328,51 @@ 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)
@@ -1316,6 +1389,14 @@ def run(params: dict, *, config, progress=None) -> dict:
1316
1389
  if section and section != "all":
1317
1390
  sections = {k: v for k, v in sections.items() if k == section}
1318
1391
 
1392
+ # Build the writeback context (SharePoint only, non-dry-run, writeback on).
1393
+ if use_sharepoint and writeback and not dry_run:
1394
+ wb_ctx = WritebackContext(
1395
+ token=graph_token, drive_id=drive_id, item_id=item_id,
1396
+ sheet_name=sheet_name, dry_run=False,
1397
+ tenant_id=sp_tenant, client_id=sp_client, cache_path=sp_cache,
1398
+ )
1399
+
1319
1400
  # Swap in the in-process fetch (no fetch.ps1 subprocess in a service).
1320
1401
  global run_fetch # noqa: PLW0603
1321
1402
  original_run_fetch = run_fetch
@@ -1328,15 +1409,17 @@ def run(params: dict, *, config, progress=None) -> dict:
1328
1409
  downloaded = 0
1329
1410
  failures = 0
1330
1411
  section_lines: dict[str, list[str]] = {}
1412
+ section_labels: dict[str, str] = {}
1331
1413
  all_results: dict[str, list[ProcessResult]] = {}
1332
1414
 
1333
1415
  try:
1334
1416
  for slug, url_rows in sections.items():
1335
1417
  if not url_rows:
1336
1418
  continue
1337
- label = SECTION_LABELS.get(slug, slug) if "SECTION_LABELS" in globals() else slug
1419
+ label = SECTION_LABELS.get(slug, slug)
1420
+ section_labels[slug] = label
1338
1421
  results = process_section(
1339
- label, url_rows, Path("."), dry_run, validate, wb_ctx=None,
1422
+ label, url_rows, Path("."), dry_run, validate, wb_ctx=wb_ctx,
1340
1423
  )
1341
1424
  all_results[slug] = results
1342
1425
  write_playlist(out_dir / f"{sheet_name}-{slug}.txt", results)
@@ -1354,14 +1437,26 @@ def run(params: dict, *, config, progress=None) -> dict:
1354
1437
  write_failures(out_dir / f"{sheet_name}-failures.txt", all_results)
1355
1438
  finally:
1356
1439
  run_fetch = original_run_fetch
1440
+ if wb_ctx is not None:
1441
+ try:
1442
+ _close_workbook_session(wb_ctx)
1443
+ except Exception: # noqa: BLE001 - best-effort cleanup
1444
+ pass
1445
+
1446
+ writeback_stats = None
1447
+ if wb_ctx is not None:
1448
+ writeback_stats = {"ok": wb_ctx.writes_ok, "failed": wb_ctx.writes_fail}
1357
1449
 
1358
1450
  _emit({"phase": "done", "sheet": sheet_name, "resolved": resolved, "failures": failures})
1359
1451
  return {
1360
1452
  "sheet": sheet_name,
1453
+ "source": "sharepoint" if use_sharepoint else "local",
1361
1454
  "resolved": resolved,
1362
1455
  "downloaded": downloaded,
1363
1456
  "failures": failures,
1457
+ "writeback": writeback_stats,
1364
1458
  "section_lines": section_lines,
1459
+ "section_labels": section_labels,
1365
1460
  "imported_playlists": [], # filled in by the async job wrapper
1366
1461
  "dry_run": dry_run,
1367
1462
  }
@@ -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())
@@ -121,33 +121,43 @@ async def fetch_job(params: dict, ctx):
121
121
  # ── fetchurls job ──────────────────────────────────────────────────────────────
122
122
 
123
123
  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.
124
+ """Import resolved ``cm:`` lines into a fixed, well-known saved playlist.
125
125
 
126
- Re-running replaces the items of an existing same-named playlist (matched
127
- by name + creator) rather than creating a duplicate.
126
+ The three fetchurls playlists ("Friday Night", "Saturday Morning",
127
+ "Saturday Night") pre-exist and are immutable. We match by **name only**
128
+ (any creator), replace their items in place (idempotent re-runs), and
129
+ preserve their existing immutability. If a playlist is missing it is created
130
+ as immutable so a recreated playlist keeps the reserved status.
128
131
  """
129
132
  from ..playlists.importer import import_playlist_text
130
133
 
131
134
  if not lines:
132
135
  return None
133
- parsed = await import_playlist_text(ctx.db, "\n".join(lines))
136
+ parsed = await import_playlist_text(
137
+ ctx.db, "\n".join(lines), mediacms_url=ctx.config.mediacms_url
138
+ )
134
139
  items = parsed.get("items") or []
135
140
  if not items:
136
141
  return None
137
142
 
138
- existing = await ctx.db.get_playlist_by_name(name, triggered_by)
143
+ existing = await ctx.db.get_playlist_by_name_any(name)
139
144
  if existing:
140
145
  playlist_id = existing["id"]
141
146
  else:
142
147
  playlist_id = await ctx.db.create_saved_playlist(
143
- name=name, description=None, is_immutable=False, created_by=triggered_by,
148
+ name=name, description=None, is_immutable=True, created_by=triggered_by,
144
149
  )
145
150
  await ctx.db.replace_playlist_items(playlist_id, items)
146
151
  return {"id": playlist_id, "name": name, "count": len(items)}
147
152
 
148
153
 
149
154
  async def fetchurls_job(params: dict, ctx):
150
- """Resolve the upcoming-weekend workbook, then import each section playlist."""
155
+ """Resolve the upcoming-weekend workbook, then import each section playlist.
156
+
157
+ Sections map to the fixed playlists by their human label:
158
+ friday → "Friday Night", saturday-morning → "Saturday Morning",
159
+ saturday-night → "Saturday Night".
160
+ """
151
161
  result = await _run_vendored(
152
162
  "kryten_webqueue.integrations.cmsutils.fetchurls", params, ctx,
153
163
  deps=["openpyxl", "yaml", "requests"],
@@ -156,16 +166,17 @@ async def fetchurls_job(params: dict, ctx):
156
166
  if result.get("dry_run"):
157
167
  return result
158
168
 
159
- sheet = result.get("sheet")
160
169
  triggered_by = ctx.triggered_by or "system"
170
+ labels = result.get("section_labels") or {}
161
171
  imported = []
162
172
  for slug, lines in (result.get("section_lines") or {}).items():
163
- name = f"{sheet}-{slug}"
173
+ name = labels.get(slug) or slug
164
174
  info = await _import_section_as_playlist(ctx, name, lines, triggered_by)
165
175
  if info:
166
176
  imported.append(info["name"])
167
177
  result["imported_playlists"] = imported
168
178
  result.pop("section_lines", None) # keep the persisted detail compact
179
+ result.pop("section_labels", None)
169
180
  return result
170
181
 
171
182
 
@@ -210,5 +221,6 @@ FETCHURLS_SCHEMA = [
210
221
  "options": ["all", "friday", "saturday-night", "saturday-morning"], "label": "Section"},
211
222
  {"name": "dry_run", "type": "bool", "default": False, "label": "Dry run (resolve only)"},
212
223
  {"name": "validate", "type": "bool", "default": True, "label": "Validate existing URLs"},
213
- {"name": "workbook_path", "type": "string", "default": None, "label": "Workbook path (override)"},
224
+ {"name": "writeback", "type": "bool", "default": True, "label": "Write resolved URLs back to SharePoint (col F)"},
225
+ {"name": "workbook_path", "type": "string", "default": None, "label": "Local workbook path (override SharePoint)"},
214
226
  ]
@@ -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
  })
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.9.7"
3
+ version = "0.9.8"
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,135 @@
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
+ # --- fixed section-label playlist names + immutable preserve ---
75
+
76
+ async def _add_catalog(db, token, title="T"):
77
+ await db.insert_catalog({
78
+ "friendly_token": token, "title": title, "description": "",
79
+ "duration_sec": 600, "manifest_url": f"https://cms/api/v1/media/cytube/{token}.json",
80
+ "thumbnail_url": "", "synced_at": "2026-01-01T00:00:00+00:00",
81
+ })
82
+
83
+
84
+ async def test_fetchurls_job_uses_fixed_label_names(db, monkeypatch):
85
+ await _add_catalog(db, "f1")
86
+ await _add_catalog(db, "s1")
87
+
88
+ async def fake_run_vendored(module_path, params, ctx, *, deps):
89
+ return {
90
+ "sheet": "3.6-3.7", "dry_run": False, "resolved": 2, "failures": 0,
91
+ "section_lines": {"friday": ["cm:f1"], "saturday-night": ["cm:s1"]},
92
+ "section_labels": {"friday": "Friday Night", "saturday-night": "Saturday Night"},
93
+ }
94
+
95
+ monkeypatch.setattr(tasks, "_run_vendored", fake_run_vendored)
96
+ result = await tasks.fetchurls_job({}, _ctx(db))
97
+
98
+ assert sorted(result["imported_playlists"]) == ["Friday Night", "Saturday Night"]
99
+ names = {p["name"] for p in await db.get_saved_playlists()}
100
+ assert {"Friday Night", "Saturday Night"} <= names
101
+ # Created playlists are immutable (reserved).
102
+ fri = await db.get_playlist_by_name_any("Friday Night")
103
+ assert fri["is_immutable"] == 1
104
+
105
+
106
+ async def test_fetchurls_import_preserves_existing_immutability(db, monkeypatch):
107
+ await _add_catalog(db, "f1")
108
+ await _add_catalog(db, "f2")
109
+ # Pre-existing MUTABLE playlist with the fixed name, created by someone else.
110
+ pid = await db.create_saved_playlist(
111
+ name="Friday Night", description=None, is_immutable=False, created_by="other-admin",
112
+ )
113
+
114
+ info = await tasks._import_section_as_playlist(_ctx(db), "Friday Night", ["cm:f1", "cm:f2"], "admin")
115
+ assert info["id"] == pid # reused, not duplicated
116
+ pl = await db.get_playlist_by_name_any("Friday Night")
117
+ assert pl["is_immutable"] == 0 # existing flag preserved (not forced)
118
+ assert pl["created_by"] == "other-admin" # original owner kept
119
+ items = await db.get_saved_playlist_items(pid)
120
+ assert len(items) == 2
121
+
122
+
123
+ # --- importer cm: manifest fallback for not-yet-synced items ---
124
+
125
+ async def test_importer_cm_fallback_builds_manifest(db):
126
+ out = await import_playlist_text(db, "cm:freshtoken", mediacms_url="https://cms.example")
127
+ assert len(out["items"]) == 1
128
+ assert out["items"][0]["media_id"] == "https://cms.example/api/v1/media/cytube/freshtoken.json?format=json"
129
+
130
+
131
+ async def test_importer_cm_uses_catalog_when_present(db):
132
+ await _add_catalog(db, "known", "Known Title")
133
+ out = await import_playlist_text(db, "cm:known", mediacms_url="https://cms.example")
134
+ assert out["items"][0]["media_id"] == "https://cms/api/v1/media/cytube/known.json"
135
+ assert out["items"][0]["title"] == "Known Title"
@@ -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"])