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.
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/CHANGELOG.md +14 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/PKG-INFO +2 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/config.example.json +5 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/db.py +13 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/config.py +15 -2
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/fetchurls.py +115 -20
- kryten_webqueue-0.9.8/kryten_webqueue/jobs/fetchurls_auth.py +62 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/tasks.py +22 -10
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/importer.py +8 -1
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/pyproject.toml +3 -1
- kryten_webqueue-0.9.8/tests/test_fetchurls_sharepoint.py +135 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_playlist_import.py +3 -2
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/.github/workflows/python-publish.yml +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/.github/workflows/release.yml +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/.gitignore +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/README.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/deploy/kryten-webqueue.service +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/deploy/nginx-queue.conf +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPLEMENTATION_SPEC.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_API_GATE.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_ECONOMY.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_KRYTEN_PY.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/IMPL_ROBOT.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/PRE_PLAN_GAPS.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/PRODUCT_PLAN.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/__main__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/api_gate/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/api_gate/client.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/app.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/otp.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/rate_limit.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/auth/session.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/images.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/mediacms.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/catalog/sync.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/jobs/manager.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/fire.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/playlists/scheduler.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/ordering.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/poller.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/queue/shadow.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_catalog.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_jobs.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_playlists.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_queue.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/admin_schedules.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/auth.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/catalog.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/pages.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/queue.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/routes/user.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/static/css/main.css +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/static/js/main.js +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/index.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/playlists.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/schedules.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/auth/login.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/base.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/browse.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/queue/index.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/user/dashboard.html +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/handler.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/ws/manager.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/__init__.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase1.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase2_jobs.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase3_jobs.py +0 -0
- {kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/tests/test_phase4_live_fixes.py +0 -0
- {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.
|
|
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
|
|
7
|
+
"""Settings for the fetchurls job.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Reads the Channel Z workbook from SharePoint (Microsoft Graph) when the
|
|
10
|
+
SharePoint fields are configured, otherwise falls back to a local ``.xlsx``
|
|
11
|
+
at ``workbook_path``. SharePoint auth uses a pre-seeded MSAL token cache
|
|
12
|
+
(see ``python -m kryten_webqueue.jobs.fetchurls_auth``); the service only
|
|
13
|
+
acquires tokens *silently* from that cache and never prompts interactively.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
workbook_path: str = "" # local .xlsx fallback (used when SharePoint unset)
|
|
17
|
+
|
|
18
|
+
# SharePoint / Microsoft Graph (read workbook + write resolved URLs to col F)
|
|
19
|
+
sharepoint_tenant_id: str = ""
|
|
20
|
+
sharepoint_client_id: str = ""
|
|
21
|
+
sharepoint_sharing_url: str = ""
|
|
22
|
+
token_cache_path: str = "" # MSAL cache file, pre-seeded out-of-band
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
class Config(BaseModel):
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/fetchurls.py
RENAMED
|
@@ -287,6 +287,43 @@ def acquire_graph_token(tenant_id: str, client_id: str, cache_path: str = "") ->
|
|
|
287
287
|
return result["access_token"]
|
|
288
288
|
|
|
289
289
|
|
|
290
|
+
def acquire_graph_token_silent(tenant_id: str, client_id: str, cache_path: str) -> Optional[str]:
|
|
291
|
+
"""Return a Graph access token from a pre-seeded MSAL cache, or None.
|
|
292
|
+
|
|
293
|
+
Unlike :func:`acquire_graph_token`, this NEVER prompts interactively — it is
|
|
294
|
+
safe to call from the headless webqueue service. The cache must have been
|
|
295
|
+
seeded out-of-band (``python -m kryten_webqueue.jobs.fetchurls_auth``). A
|
|
296
|
+
None return means the admin must (re)authenticate.
|
|
297
|
+
"""
|
|
298
|
+
if not _HAS_MSAL:
|
|
299
|
+
raise RuntimeError(
|
|
300
|
+
"fetchurls SharePoint integration requires 'msal'. "
|
|
301
|
+
"Install the optional extra: pip install 'kryten-webqueue[jobs]'"
|
|
302
|
+
)
|
|
303
|
+
if not cache_path:
|
|
304
|
+
return None
|
|
305
|
+
cache_file = Path(cache_path)
|
|
306
|
+
if not cache_file.exists():
|
|
307
|
+
return None
|
|
308
|
+
token_cache = msal.SerializableTokenCache()
|
|
309
|
+
token_cache.deserialize(cache_file.read_text(encoding="utf-8"))
|
|
310
|
+
app = msal.PublicClientApplication(
|
|
311
|
+
client_id,
|
|
312
|
+
authority=f"https://login.microsoftonline.com/{tenant_id}",
|
|
313
|
+
token_cache=token_cache,
|
|
314
|
+
)
|
|
315
|
+
accounts = app.get_accounts()
|
|
316
|
+
if not accounts:
|
|
317
|
+
return None
|
|
318
|
+
result = app.acquire_token_silent(GRAPH_SCOPES, account=accounts[0])
|
|
319
|
+
if token_cache.has_state_changed:
|
|
320
|
+
cache_file.write_text(token_cache.serialize(), encoding="utf-8")
|
|
321
|
+
if result and "access_token" in result:
|
|
322
|
+
return result["access_token"]
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
290
327
|
def _encode_sharing_url(url: str) -> str:
|
|
291
328
|
"""Encode a SharePoint sharing/document URL for use with the Graph shares API."""
|
|
292
329
|
encoded = base64.urlsafe_b64encode(url.encode()).decode().rstrip("=")
|
|
@@ -1269,13 +1306,21 @@ def _make_inprocess_fetch(config):
|
|
|
1269
1306
|
|
|
1270
1307
|
|
|
1271
1308
|
def run(params: dict, *, config, progress=None) -> dict:
|
|
1272
|
-
"""Resolve the upcoming weekend's Channel Z workbook into playlist
|
|
1309
|
+
"""Resolve the upcoming weekend's Channel Z workbook into playlist sections.
|
|
1310
|
+
|
|
1311
|
+
Source precedence:
|
|
1312
|
+
1. ``params['workbook_path']`` (one-off local override, e.g. for tests)
|
|
1313
|
+
2. SharePoint via Microsoft Graph when ``config.fetchurls.sharepoint_*``
|
|
1314
|
+
are set (token acquired *silently* from the pre-seeded MSAL cache)
|
|
1315
|
+
3. ``config.fetchurls.workbook_path`` (local file fallback)
|
|
1273
1316
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1317
|
+
When the source is SharePoint and ``writeback`` is on (and not a dry run),
|
|
1318
|
+
resolved dropsugar URLs are written back to column F via the Graph Excel
|
|
1319
|
+
API. Off-site URLs are downloaded via the in-process yt-pipe downloader.
|
|
1320
|
+
|
|
1321
|
+
Returns counts plus, per section slug, the resolved ``cm:`` lines and the
|
|
1322
|
+
human label ("Friday Night", etc.) so the job wrapper can import each into a
|
|
1323
|
+
fixed, well-known saved playlist.
|
|
1279
1324
|
"""
|
|
1280
1325
|
_check_openpyxl()
|
|
1281
1326
|
import openpyxl as _oxl # noqa: F811 - explicit local import for clarity
|
|
@@ -1283,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
|
-
|
|
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)
|
|
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=
|
|
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
|
|
124
|
+
"""Import resolved ``cm:`` lines into a fixed, well-known saved playlist.
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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.
|
|
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=
|
|
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 =
|
|
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": "
|
|
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":
|
|
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.
|
|
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
|
|
132
|
+
# bare1 resolves to its manifest URL; cm:cmtoken (not in catalog) now falls
|
|
133
|
+
# back to a constructed manifest URL so not-yet-synced items still play.
|
|
133
134
|
assert any(i["media_id"].endswith("bare1.json?format=json") for i in cm)
|
|
134
|
-
assert any(i["media_id"] == "cmtoken" for i in cm)
|
|
135
|
+
assert any(i["media_id"] == f"{MEDIACMS}/api/v1/media/cytube/cmtoken.json?format=json" for i in cm)
|
|
135
136
|
# unknownbare is not in catalog -> error
|
|
136
137
|
assert any(e["token"] == "unknownbare" for e in out["errors"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/_common.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichmeta.py
RENAMED
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/cmsutils/enrichtv.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/__init__.py
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/integrations/ytpipe/downloader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/playlists.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/queue_mgmt.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/admin/schedules.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/browse.html
RENAMED
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/catalog/item_detail.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{kryten_webqueue-0.9.7 → kryten_webqueue-0.9.8}/kryten_webqueue/templates/user/dashboard.html
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|