kryten-webqueue 0.9.6__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.6 → kryten_webqueue-0.9.8}/CHANGELOG.md +22 -0
  2. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/PKG-INFO +2 -1
  3. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/config.example.json +5 -1
  4. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/db.py +13 -0
  5. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/config.py +15 -2
  6. {kryten_webqueue-0.9.6 → 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.6 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/tasks.py +22 -10
  9. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/importer.py +8 -1
  10. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/ordering.py +76 -10
  11. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/schedules.html +1 -1
  12. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/pyproject.toml +3 -1
  13. kryten_webqueue-0.9.8/tests/test_fetchurls_sharepoint.py +135 -0
  14. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/tests/test_playlist_import.py +3 -2
  15. kryten_webqueue-0.9.8/tests/test_queue_announce.py +106 -0
  16. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/.github/workflows/python-publish.yml +0 -0
  17. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/.github/workflows/release.yml +0 -0
  18. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/.gitignore +0 -0
  19. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/README.md +0 -0
  20. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/deploy/kryten-webqueue.service +0 -0
  21. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/deploy/nginx-queue.conf +0 -0
  22. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/IMPLEMENTATION_SPEC.md +0 -0
  23. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/IMPL_API_GATE.md +0 -0
  24. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/IMPL_ECONOMY.md +0 -0
  25. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/IMPL_KRYTEN_PY.md +0 -0
  26. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/IMPL_ROBOT.md +0 -0
  27. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/PRE_PLAN_GAPS.md +0 -0
  28. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/PRODUCT_PLAN.md +0 -0
  29. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  30. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/__init__.py +0 -0
  31. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/__main__.py +0 -0
  32. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/api_gate/__init__.py +0 -0
  33. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/api_gate/client.py +0 -0
  34. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/app.py +0 -0
  35. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/__init__.py +0 -0
  36. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/otp.py +0 -0
  37. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/rate_limit.py +0 -0
  38. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/session.py +0 -0
  39. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/__init__.py +0 -0
  40. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/images.py +0 -0
  41. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/mediacms.py +0 -0
  42. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/sync.py +0 -0
  43. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/__init__.py +0 -0
  44. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  45. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  46. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  47. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  48. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  49. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  50. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  51. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/__init__.py +0 -0
  52. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/manager.py +0 -0
  53. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/__init__.py +0 -0
  54. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/fire.py +0 -0
  55. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/scheduler.py +0 -0
  56. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/__init__.py +0 -0
  57. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/poller.py +0 -0
  58. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/shadow.py +0 -0
  59. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/__init__.py +0 -0
  60. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_catalog.py +0 -0
  61. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_jobs.py +0 -0
  62. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_playlists.py +0 -0
  63. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_queue.py +0 -0
  64. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_schedules.py +0 -0
  65. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/auth.py +0 -0
  66. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/catalog.py +0 -0
  67. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/pages.py +0 -0
  68. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/queue.py +0 -0
  69. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/user.py +0 -0
  70. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/static/css/main.css +0 -0
  71. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/static/js/main.js +0 -0
  72. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/index.html +0 -0
  73. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/playlists.html +0 -0
  74. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  75. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/auth/login.html +0 -0
  76. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/base.html +0 -0
  77. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/browse.html +0 -0
  78. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  79. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  80. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/queue/index.html +0 -0
  81. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/user/dashboard.html +0 -0
  82. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/__init__.py +0 -0
  83. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/handler.py +0 -0
  84. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/manager.py +0 -0
  85. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/tests/__init__.py +0 -0
  86. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/tests/test_phase1.py +0 -0
  87. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/tests/test_phase2_jobs.py +0 -0
  88. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/tests/test_phase3_jobs.py +0 -0
  89. {kryten_webqueue-0.9.6 → kryten_webqueue-0.9.8}/tests/test_phase4_live_fixes.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.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
+
23
+ ## [0.9.7] — 2026-06-11
24
+
25
+ ### Changed
26
+
27
+ - **Paid-queue chat announcement reworded.** A purchased item now announces as `"<title> added to the queue with Zcoin by <user> and is now <position>."` where `<position>` is `next` for the item immediately after the currently-playing one, or an English ordinal counting the now-playing item as first (e.g. `third`, `forty-second`, `one hundred seventh`). Position is computed relative to the currently-playing item and wraps around the playlist.
28
+ - **Admin queueing is no longer announced** in the channel chat (only paid placements are announced).
29
+ - Renamed the admin Schedules heading from “Scheduled Fires” to “Scheduled Events”.
30
+
9
31
  ## [0.9.6] — 2026-06-11
10
32
 
11
33
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.6
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
  })
@@ -26,7 +26,7 @@ def _add_failure_reason(add_result: dict | None, exc: httpx.HTTPStatusError | No
26
26
 
27
27
 
28
28
  def _announcement_position(shadow, uid: int) -> int | None:
29
- """Position of the item for chat announcement.
29
+ """Position of the item for chat announcement (deprecated; kept for tests).
30
30
 
31
31
  Counting starts at the currently-playing item (position 0), so the next
32
32
  item to play is position 1. The shadow mirrors the full CyTube playlist
@@ -39,13 +39,80 @@ def _announcement_position(shadow, uid: int) -> int | None:
39
39
  return None
40
40
 
41
41
 
42
- async def _announce_queued(api_gate, shadow, *, uid: int, title: str, username: str) -> None:
43
- """Announce a successful queue placement to the channel chat."""
44
- pos = _announcement_position(shadow, uid)
45
- if pos is None:
42
+ _ONES_ORDINAL = {
43
+ "one": "first", "two": "second", "three": "third", "five": "fifth",
44
+ "eight": "eighth", "nine": "ninth", "twelve": "twelfth",
45
+ }
46
+ _ONES = ["", "one", "two", "three", "four", "five", "six", "seven", "eight",
47
+ "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
48
+ "sixteen", "seventeen", "eighteen", "nineteen"]
49
+ _TENS = ["", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy",
50
+ "eighty", "ninety"]
51
+
52
+
53
+ def _cardinal_words(n: int) -> str:
54
+ """English cardinal words for 1..999 (queue positions never exceed this)."""
55
+ if n < 20:
56
+ return _ONES[n]
57
+ if n < 100:
58
+ tens, ones = divmod(n, 10)
59
+ return _TENS[tens] + (f"-{_ONES[ones]}" if ones else "")
60
+ hundreds, rem = divmod(n, 100)
61
+ head = f"{_ONES[hundreds]} hundred"
62
+ return f"{head} {_cardinal_words(rem)}" if rem else head
63
+
64
+
65
+ def _to_ordinal_word(word: str) -> str:
66
+ """Convert a single cardinal word to its ordinal form."""
67
+ if word in _ONES_ORDINAL:
68
+ return _ONES_ORDINAL[word]
69
+ if word.endswith("y"):
70
+ return word[:-1] + "ieth"
71
+ return word + "th"
72
+
73
+
74
+ def _ordinal_words(n: int) -> str:
75
+ """English ordinal words for n (e.g. 3 -> 'third', 42 -> 'forty-second')."""
76
+ cardinal = _cardinal_words(n)
77
+ # Convert only the final word (handles 'forty-two'->'forty-second',
78
+ # 'one hundred seven'->'one hundred seventh').
79
+ if "-" in cardinal:
80
+ head, last = cardinal.rsplit("-", 1)
81
+ return f"{head}-{_to_ordinal_word(last)}"
82
+ parts = cardinal.rsplit(" ", 1)
83
+ if len(parts) == 2:
84
+ return f"{parts[0]} {_to_ordinal_word(parts[1])}"
85
+ return _to_ordinal_word(cardinal)
86
+
87
+
88
+ async def _announce_paid_queued(api_gate, shadow, *, uid: int, title: str, username: str) -> None:
89
+ """Announce a paid queue placement to the channel chat.
90
+
91
+ Position is counted from the currently-playing item, wrapping around the
92
+ playlist (CyTube loops). The item immediately after now-playing reads
93
+ "next"; everything else uses an English ordinal counting the now-playing
94
+ item as first (so the item two slots away is "third").
95
+ """
96
+ items = shadow.items
97
+ np_uid = await _now_playing_uid(api_gate, shadow)
98
+ np_index = None
99
+ item_index = None
100
+ for i, it in enumerate(items):
101
+ if it.get("uid") == np_uid:
102
+ np_index = i
103
+ if it.get("uid") == uid:
104
+ item_index = i
105
+ if item_index is None:
46
106
  return
107
+ n = len(items)
108
+ offset = item_index if np_index is None else (item_index - np_index) % n
109
+ if offset <= 0:
110
+ return
111
+ position = "next" if offset == 1 else _ordinal_words(offset + 1)
47
112
  try:
48
- await api_gate.send_chat(f"{title} has been queued in position {pos} by {username}")
113
+ await api_gate.send_chat(
114
+ f"{title} added to the queue with Zcoin by {username} and is now {position}."
115
+ )
49
116
  except Exception:
50
117
  logger.warning("Failed to send queue announcement", exc_info=True)
51
118
 
@@ -238,7 +305,7 @@ async def insert_pay_queue(
238
305
  )
239
306
 
240
307
  # Announce placement to the channel
241
- await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
308
+ await _announce_paid_queued(api_gate, shadow, uid=uid, title=title, username=username)
242
309
 
243
310
  return {"success": True, "uid": uid, "request_id": request_id}
244
311
 
@@ -351,7 +418,7 @@ async def insert_pay_playnext(
351
418
  )
352
419
 
353
420
  # Announce placement to the channel
354
- await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
421
+ await _announce_paid_queued(api_gate, shadow, uid=uid, title=title, username=username)
355
422
 
356
423
  return {"success": True, "uid": uid, "request_id": request_id}
357
424
 
@@ -479,8 +546,7 @@ async def insert_admin_queue(
479
546
  title=title, tier="admin", z_cost=0,
480
547
  )
481
548
 
482
- # Announce placement to the channel
483
- await _announce_queued(api_gate, shadow, uid=uid, title=title, username=username)
549
+ # Admin queueing is intentionally NOT announced in the channel.
484
550
 
485
551
  return {"success": True, "uid": uid, "refunded": removed}
486
552
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  <div class="admin-section">
13
13
  <div class="section-head">
14
- <h2>Scheduled Fires</h2>
14
+ <h2>Scheduled Events</h2>
15
15
  <button class="btn btn-primary" onclick="showScheduleModal()">+ New Schedule</button>
16
16
  </div>
17
17
  <div id="schedules-list">Loading…</div>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.9.6"
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"])
@@ -0,0 +1,106 @@
1
+ """Queue chat-announcement behaviour (v0.9.7).
2
+
3
+ Covers the English-ordinal position words, the paid-queue announcement message
4
+ format/position (counted from now-playing, wrapping), and that admin queueing
5
+ is not announced.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from kryten_webqueue.queue import ordering
11
+ from kryten_webqueue.queue.ordering import _ordinal_words, _announce_paid_queued
12
+
13
+
14
+ class _FakeApiGate:
15
+ def __init__(self, np_uid=None):
16
+ self.sent = []
17
+ self._np_uid = np_uid
18
+
19
+ async def get_now_playing(self):
20
+ return {"uid": self._np_uid} if self._np_uid is not None else None
21
+
22
+ async def send_chat(self, message):
23
+ self.sent.append(message)
24
+ return {"success": True}
25
+
26
+
27
+ class _FakeShadow:
28
+ def __init__(self, items, now_playing=None):
29
+ self._items = items
30
+ self.now_playing = now_playing
31
+
32
+ @property
33
+ def items(self):
34
+ return self._items
35
+
36
+
37
+ @pytest.mark.parametrize("n,word", [
38
+ (2, "second"),
39
+ (3, "third"),
40
+ (4, "fourth"),
41
+ (5, "fifth"),
42
+ (8, "eighth"),
43
+ (9, "ninth"),
44
+ (11, "eleventh"),
45
+ (12, "twelfth"),
46
+ (20, "twentieth"),
47
+ (21, "twenty-first"),
48
+ (42, "forty-second"),
49
+ (53, "fifty-third"),
50
+ (100, "one hundredth"),
51
+ (107, "one hundred seventh"),
52
+ (123, "one hundred twenty-third"),
53
+ ])
54
+ def test_ordinal_words(n, word):
55
+ assert _ordinal_words(n) == word
56
+
57
+
58
+ def _items(*uids):
59
+ return [{"uid": u, "title": f"Item {u}"} for u in uids]
60
+
61
+
62
+ async def test_paid_announcement_next():
63
+ # now-playing uid=10 at index 0; the new item uid=11 is immediately next.
64
+ api = _FakeApiGate(np_uid=10)
65
+ shadow = _FakeShadow(_items(10, 11))
66
+ await _announce_paid_queued(api, shadow, uid=11, title="Airplane (1980)", username="Hollis")
67
+ assert api.sent == [
68
+ "Airplane (1980) added to the queue with Zcoin by Hollis and is now next."
69
+ ]
70
+
71
+
72
+ async def test_paid_announcement_third():
73
+ # now-playing uid=10 at index 0; new item is two slots away -> "third".
74
+ api = _FakeApiGate(np_uid=10)
75
+ shadow = _FakeShadow(_items(10, 99, 11))
76
+ await _announce_paid_queued(api, shadow, uid=11, title="Airplane (1980)", username="Hollis")
77
+ assert api.sent == [
78
+ "Airplane (1980) added to the queue with Zcoin by Hollis and is now third."
79
+ ]
80
+
81
+
82
+ async def test_paid_announcement_wraps_around():
83
+ # now-playing is uid=99 at index 2; list wraps: after 99 comes 10 (next),
84
+ # then 11 (third).
85
+ api = _FakeApiGate(np_uid=99)
86
+ shadow = _FakeShadow(_items(10, 11, 99))
87
+ await _announce_paid_queued(api, shadow, uid=11, title="X", username="U")
88
+ assert api.sent == ["X added to the queue with Zcoin by U and is now third."]
89
+
90
+
91
+ async def test_admin_queue_not_announced(monkeypatch):
92
+ """insert_admin_queue must never call the announcement helper."""
93
+ called = False
94
+
95
+ async def _fail(*a, **k):
96
+ nonlocal called
97
+ called = True
98
+
99
+ monkeypatch.setattr(ordering, "_announce_paid_queued", _fail)
100
+ # Source check: the admin path has no announce call (the helper is only wired
101
+ # to the paid paths). This guards against a regression re-adding it.
102
+ import inspect
103
+ src = inspect.getsource(ordering.insert_admin_queue)
104
+ assert "_announce_paid_queued" not in src
105
+ assert "_announce_queued" not in src
106
+ assert called is False