kryten-webqueue 0.19.0__tar.gz → 0.20.0__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 (105) hide show
  1. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/CHANGELOG.md +13 -0
  2. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +29 -5
  4. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/tasks.py +33 -1
  5. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/logging_config.py +13 -2
  6. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/index.html +4 -0
  7. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/.github/workflows/python-publish.yml +0 -0
  9. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/.github/workflows/release.yml +0 -0
  10. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/.gitignore +0 -0
  11. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/README.md +0 -0
  12. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/config.example.json +0 -0
  13. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/deploy/kryten-webqueue.service +0 -0
  14. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/deploy/nginx-queue.conf +0 -0
  15. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  16. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/IMPL_API_GATE.md +0 -0
  17. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/IMPL_ECONOMY.md +0 -0
  18. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  19. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/IMPL_ROBOT.md +0 -0
  20. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  21. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/PRE_PLAN_GAPS.md +0 -0
  22. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/PRODUCT_PLAN.md +0 -0
  23. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  24. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/docs/UX_POLISH_PLAN.md +0 -0
  25. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/__init__.py +0 -0
  26. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/__main__.py +0 -0
  27. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  28. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/api_gate/client.py +0 -0
  29. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/app.py +0 -0
  30. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/__init__.py +0 -0
  31. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/otp.py +0 -0
  32. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  33. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/session.py +0 -0
  34. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/__init__.py +0 -0
  35. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/db.py +0 -0
  36. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/images.py +0 -0
  37. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  38. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/sync.py +0 -0
  39. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/config.py +0 -0
  40. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/__init__.py +0 -0
  41. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  42. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  43. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  44. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  45. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  46. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  47. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  48. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/__init__.py +0 -0
  49. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  50. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/manager.py +0 -0
  51. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/__init__.py +0 -0
  52. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  53. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/fire.py +0 -0
  54. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/importer.py +0 -0
  55. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/ordering.py +0 -0
  56. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  57. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/promos/__init__.py +0 -0
  58. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/promos/director.py +0 -0
  59. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/__init__.py +0 -0
  60. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/ordering.py +0 -0
  61. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/poller.py +0 -0
  62. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/presence.py +0 -0
  63. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/shadow.py +0 -0
  64. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/__init__.py +0 -0
  65. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  66. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  67. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  68. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  69. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  70. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  71. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/auth.py +0 -0
  72. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/catalog.py +0 -0
  73. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/pages.py +0 -0
  74. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/queue.py +0 -0
  75. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/user.py +0 -0
  76. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/static/css/main.css +0 -0
  77. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  79. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  80. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  81. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  82. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/auth/login.html +0 -0
  83. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/base.html +0 -0
  84. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  85. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  86. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  87. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/queue/index.html +0 -0
  88. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/user/dashboard.html +0 -0
  89. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_save_results_to_playlist.py +0 -0
  105. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.0}/tests/test_search_facets.py +0 -0
@@ -6,6 +6,19 @@ 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.20.0] — 2026-06-17
10
+
11
+ ### Added
12
+
13
+ - **Richer fetchurls / job logging.** The fetchurls job now logs a per-section resolved/failed summary and a WARNING line for every failing URL (with its Excel row and the reason), and folds a compact `failures_detail` list into the `job_runs` record so the admin "Detail" column shows a concrete example instead of just a count. The `run()` result gained `section_summary` and `failure_details`.
14
+ - **Actionable log format.** Application loggers (`kryten_webqueue.*`) now include the source `file:line` in each line via a dedicated formatter, while uvicorn keeps its leaner format.
15
+
16
+ ### Fixed
17
+
18
+ - **SharePoint download failures lost their detail.** `download_sharepoint_xlsx` raised `SystemExit` (a `BaseException` the job manager's `except Exception` couldn't catch), so a failed download could bubble up uncaught with no recorded detail. It now raises `RuntimeError` with the HTTP status and a response excerpt, so the failure is caught, logged, and recorded in the job run.
19
+
20
+ [0.20.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.20.0
21
+
9
22
  ## [0.19.0] — 2026-06-17
10
23
 
11
24
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.19.0
3
+ Version: 0.20.0
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
@@ -345,9 +345,9 @@ def download_sharepoint_xlsx(token: str, sharing_url: str) -> tuple[bytes, str,
345
345
  meta_url = f"{GRAPH_BASE}/shares/{encoded}/driveItem"
346
346
  r = requests.get(meta_url, headers=headers, timeout=REQUEST_TIMEOUT)
347
347
  if r.status_code != 200:
348
- sys.exit(
349
- f"ERROR: Could not resolve SharePoint file via Graph API "
350
- f"(HTTP {r.status_code}).\n{r.text}"
348
+ raise RuntimeError(
349
+ f"Could not resolve SharePoint file via Graph API (HTTP {r.status_code}). "
350
+ f"Response: {r.text[:500]}"
351
351
  )
352
352
  item = r.json()
353
353
 
@@ -368,10 +368,17 @@ def download_sharepoint_xlsx(token: str, sharing_url: str) -> tuple[bytes, str,
368
368
  if dl_r.status_code == 302:
369
369
  download_url = dl_r.headers["Location"]
370
370
  else:
371
- sys.exit(f"ERROR: Could not get download URL (HTTP {dl_r.status_code})")
371
+ raise RuntimeError(
372
+ f"Could not get SharePoint download URL (HTTP {dl_r.status_code}). "
373
+ f"Response: {dl_r.text[:500]}"
374
+ )
372
375
 
373
376
  content_r = requests.get(download_url, timeout=60)
374
- content_r.raise_for_status()
377
+ if not content_r.ok:
378
+ raise RuntimeError(
379
+ f"SharePoint file download failed (HTTP {content_r.status_code}). "
380
+ f"Response: {content_r.text[:500]}"
381
+ )
375
382
  return content_r.content, drive_id, item_id
376
383
 
377
384
 
@@ -1415,6 +1422,10 @@ def run(params: dict, *, config, progress=None) -> dict:
1415
1422
  section_lines: dict[str, list[str]] = {}
1416
1423
  section_labels: dict[str, str] = {}
1417
1424
  all_results: dict[str, list[ProcessResult]] = {}
1425
+ # Per-section {resolved, failed} counts and a flat list of failed rows for
1426
+ # actionable diagnostics (surfaced in the job log + job_runs detail).
1427
+ section_summary: dict[str, dict] = {}
1428
+ failure_details: list[dict] = []
1418
1429
 
1419
1430
  try:
1420
1431
  for slug, url_rows in sections.items():
@@ -1428,16 +1439,27 @@ def run(params: dict, *, config, progress=None) -> dict:
1428
1439
  all_results[slug] = results
1429
1440
  write_playlist(out_dir / f"{sheet_name}-{slug}.txt", results)
1430
1441
  lines = []
1442
+ sec_resolved = 0
1443
+ sec_failed = 0
1431
1444
  for r in results:
1432
1445
  if r.success:
1433
1446
  resolved += 1
1447
+ sec_resolved += 1
1434
1448
  if r.resolved_url != r.original_url:
1435
1449
  downloaded += 1
1436
1450
  token = _extract_manifest_token(r.resolved_url)
1437
1451
  lines.append(f"cm:{token}" if token else r.resolved_url)
1438
1452
  else:
1439
1453
  failures += 1
1454
+ sec_failed += 1
1455
+ failure_details.append({
1456
+ "section": label,
1457
+ "row": r.row_number,
1458
+ "url": r.original_url,
1459
+ "note": r.note,
1460
+ })
1440
1461
  section_lines[slug] = lines
1462
+ section_summary[label] = {"resolved": sec_resolved, "failed": sec_failed}
1441
1463
  write_failures(out_dir / f"{sheet_name}-failures.txt", all_results)
1442
1464
  finally:
1443
1465
  run_fetch = original_run_fetch
@@ -1461,6 +1483,8 @@ def run(params: dict, *, config, progress=None) -> dict:
1461
1483
  "writeback": writeback_stats,
1462
1484
  "section_lines": section_lines,
1463
1485
  "section_labels": section_labels,
1486
+ "section_summary": section_summary,
1487
+ "failure_details": failure_details,
1464
1488
  "imported_playlists": [], # filled in by the async job wrapper
1465
1489
  "dry_run": dry_run,
1466
1490
  }
@@ -185,9 +185,41 @@ async def fetchurls_job(params: dict, ctx):
185
185
  info = await _import_section_as_playlist(ctx, name, lines, triggered_by)
186
186
  if info:
187
187
  imported.append(info["name"])
188
+ logger.info("fetchurls: imported %d item(s) into '%s'", info["count"], info["name"])
188
189
  result["imported_playlists"] = imported
189
- result.pop("section_lines", None) # keep the persisted detail compact
190
+
191
+ # Per-section resolved/failed summary for the process log.
192
+ sheet = result.get("sheet", "?")
193
+ for label, counts in (result.get("section_summary") or {}).items():
194
+ logger.info(
195
+ "fetchurls[%s] section '%s': resolved %d / failed %d",
196
+ sheet, label, counts.get("resolved", 0), counts.get("failed", 0),
197
+ )
198
+
199
+ # Surface each failing URL (with its Excel row) at WARNING, and keep a
200
+ # compact copy in the job result so the admin "Detail" column shows it.
201
+ failure_details = result.get("failure_details") or []
202
+ if failure_details:
203
+ logger.warning(
204
+ "fetchurls[%s]: %d URL(s) failed to resolve", sheet, len(failure_details)
205
+ )
206
+ for f in failure_details:
207
+ logger.warning(
208
+ "fetchurls[%s] [%s row %s] %s — %s",
209
+ sheet, f.get("section", "?"), f.get("row", "?"),
210
+ f.get("url", ""), f.get("note", ""),
211
+ )
212
+ # Keep at most 25 in the persisted detail to stay compact.
213
+ result["failures_detail"] = [
214
+ {"section": f.get("section"), "row": f.get("row"),
215
+ "url": f.get("url"), "note": f.get("note")}
216
+ for f in failure_details[:25]
217
+ ]
218
+
219
+ # Trim the bulky intermediates from the persisted detail.
220
+ result.pop("section_lines", None)
190
221
  result.pop("section_labels", None)
222
+ result.pop("failure_details", None)
191
223
  return result
192
224
 
193
225
 
@@ -43,6 +43,12 @@ def build_log_config(log_level: str = "INFO", promo_log_level: str | None = None
43
43
  "format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
44
44
  "datefmt": "%Y-%m-%d %H:%M:%S",
45
45
  },
46
+ # Application formatter includes the source file:line so every app
47
+ # log line is actionable (uvicorn keeps the leaner "default").
48
+ "app": {
49
+ "format": "%(asctime)s %(levelname)-8s %(name)s %(filename)s:%(lineno)d: %(message)s",
50
+ "datefmt": "%Y-%m-%d %H:%M:%S",
51
+ },
46
52
  "access": {
47
53
  "format": "%(asctime)s %(levelname)-8s %(name)s: %(message)s",
48
54
  "datefmt": "%Y-%m-%d %H:%M:%S",
@@ -54,6 +60,11 @@ def build_log_config(log_level: str = "INFO", promo_log_level: str | None = None
54
60
  "formatter": "default",
55
61
  "stream": "ext://sys.stderr",
56
62
  },
63
+ "appconsole": {
64
+ "class": "logging.StreamHandler",
65
+ "formatter": "app",
66
+ "stream": "ext://sys.stderr",
67
+ },
57
68
  "access": {
58
69
  "class": "logging.StreamHandler",
59
70
  "formatter": "access",
@@ -64,14 +75,14 @@ def build_log_config(log_level: str = "INFO", promo_log_level: str | None = None
64
75
  "loggers": {
65
76
  "kryten_webqueue": {
66
77
  "level": app_level,
67
- "handlers": ["console"],
78
+ "handlers": ["appconsole"],
68
79
  "propagate": False,
69
80
  },
70
81
  # Promo subsystem: independently tunable so operators can crank it to
71
82
  # DEBUG for a deep dive without flooding the rest of the app.
72
83
  "kryten_webqueue.promos": {
73
84
  "level": promo_level,
74
- "handlers": ["console"],
85
+ "handlers": ["appconsole"],
75
86
  "propagate": False,
76
87
  },
77
88
  "uvicorn": {"level": "INFO", "handlers": ["console"], "propagate": False},
@@ -321,6 +321,10 @@ function summarizeRunDetail(detail) {
321
321
  if (d.imported_playlists && d.imported_playlists.length) parts.push(`imported: ${d.imported_playlists.join(', ')}`);
322
322
  if (typeof d.resolved === 'number') parts.push(`resolved ${d.resolved}`);
323
323
  if (typeof d.failures === 'number' && d.failures) parts.push(`${d.failures} failed`);
324
+ if (d.failures_detail && d.failures_detail.length) {
325
+ const f = d.failures_detail[0];
326
+ parts.push(`e.g. [${f.section || '?'} row ${f.row || '?'}] ${f.note || ''}`.trim());
327
+ }
324
328
  if (typeof d.committed === 'number') parts.push(`committed ${d.committed}`);
325
329
  if (typeof d.added_to_playlist !== 'undefined' && d.added_to_playlist) parts.push(`→ playlist ${d.added_to_playlist}`);
326
330
  if (parts.length) return parts.join(' · ');
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.19.0"
3
+ version = "0.20.0"
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"