kryten-webqueue 0.19.0__tar.gz → 0.20.1__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.1}/CHANGELOG.md +21 -0
  2. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/cmsutils/fetchurls.py +29 -5
  4. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/jobs/tasks.py +33 -1
  5. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/logging_config.py +13 -2
  6. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/static/css/main.css +15 -8
  7. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/admin/index.html +4 -0
  8. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/pyproject.toml +1 -1
  9. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/.github/workflows/python-publish.yml +0 -0
  10. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/.github/workflows/release.yml +0 -0
  11. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/.gitignore +0 -0
  12. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/README.md +0 -0
  13. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/config.example.json +0 -0
  14. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/deploy/kryten-webqueue.service +0 -0
  15. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/deploy/nginx-queue.conf +0 -0
  16. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/IMPLEMENTATION_SPEC.md +0 -0
  17. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/IMPL_API_GATE.md +0 -0
  18. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/IMPL_ECONOMY.md +0 -0
  19. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/IMPL_KRYTEN_PY.md +0 -0
  20. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/IMPL_ROBOT.md +0 -0
  21. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  22. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/PRE_PLAN_GAPS.md +0 -0
  23. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/PRODUCT_PLAN.md +0 -0
  24. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  25. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/docs/UX_POLISH_PLAN.md +0 -0
  26. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/__init__.py +0 -0
  27. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/__main__.py +0 -0
  28. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/api_gate/__init__.py +0 -0
  29. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/api_gate/client.py +0 -0
  30. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/app.py +0 -0
  31. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/auth/__init__.py +0 -0
  32. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/auth/otp.py +0 -0
  33. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/auth/rate_limit.py +0 -0
  34. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/auth/session.py +0 -0
  35. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/catalog/__init__.py +0 -0
  36. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/catalog/db.py +0 -0
  37. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/catalog/images.py +0 -0
  38. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/catalog/mediacms.py +0 -0
  39. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/catalog/sync.py +0 -0
  40. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/config.py +0 -0
  41. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/__init__.py +0 -0
  42. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  43. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  44. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  45. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  46. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  47. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  48. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  49. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/jobs/__init__.py +0 -0
  50. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  51. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/jobs/manager.py +0 -0
  52. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/playlists/__init__.py +0 -0
  53. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/playlists/bulk_add.py +0 -0
  54. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/playlists/fire.py +0 -0
  55. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/playlists/importer.py +0 -0
  56. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/playlists/ordering.py +0 -0
  57. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/playlists/scheduler.py +0 -0
  58. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/promos/__init__.py +0 -0
  59. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/promos/director.py +0 -0
  60. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/queue/__init__.py +0 -0
  61. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/queue/ordering.py +0 -0
  62. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/queue/poller.py +0 -0
  63. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/queue/presence.py +0 -0
  64. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/queue/shadow.py +0 -0
  65. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/__init__.py +0 -0
  66. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/admin_catalog.py +0 -0
  67. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/admin_jobs.py +0 -0
  68. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/admin_playlists.py +0 -0
  69. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/admin_promos.py +0 -0
  70. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/admin_queue.py +0 -0
  71. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/admin_schedules.py +0 -0
  72. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/auth.py +0 -0
  73. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/catalog.py +0 -0
  74. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/pages.py +0 -0
  75. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/queue.py +0 -0
  76. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/routes/user.py +0 -0
  77. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/static/js/main.js +0 -0
  78. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/admin/playlists.html +0 -0
  79. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/admin/promos.html +0 -0
  80. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  81. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/admin/schedules.html +0 -0
  82. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/auth/login.html +0 -0
  83. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/base.html +0 -0
  84. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/catalog/browse.html +0 -0
  85. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  86. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  87. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/queue/index.html +0 -0
  88. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/templates/user/dashboard.html +0 -0
  89. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_save_results_to_playlist.py +0 -0
  105. {kryten_webqueue-0.19.0 → kryten_webqueue-0.20.1}/tests/test_search_facets.py +0 -0
@@ -6,6 +6,27 @@ 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.1] — 2026-06-17
10
+
11
+ ### Fixed
12
+
13
+ - **Z-Coin dashboard layout polish.** The left account column is now a fixed 320px width (was a flexible 280–360px range that shifted with content), and the tab strip has proper folder-tab styling — filled inactive tabs with hover feedback and an active tab that visually connects to its panel — instead of the previous near-invisible underline.
14
+
15
+ [0.20.1]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.20.1
16
+
17
+ ## [0.20.0] — 2026-06-17
18
+
19
+ ### Added
20
+
21
+ - **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`.
22
+ - **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.
23
+
24
+ ### Fixed
25
+
26
+ - **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.
27
+
28
+ [0.20.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.20.0
29
+
9
30
  ## [0.19.0] — 2026-06-17
10
31
 
11
32
  ### 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.1
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},
@@ -797,7 +797,7 @@ a.np-chip {
797
797
  }
798
798
  .dashboard-grid {
799
799
  display: grid;
800
- grid-template-columns: minmax(280px, 360px) 1fr;
800
+ grid-template-columns: 320px minmax(0, 1fr);
801
801
  gap: 1.5rem;
802
802
  margin-top: 1.5rem;
803
803
  align-items: start;
@@ -816,27 +816,34 @@ a.np-chip {
816
816
  /* Dashboard tabbed container */
817
817
  .tabs {
818
818
  display: flex;
819
- gap: 0.25rem;
819
+ gap: 0.35rem;
820
820
  border-bottom: 1px solid var(--border);
821
- margin-bottom: 1rem;
821
+ margin-bottom: 1.25rem;
822
822
  }
823
823
  .tab-btn {
824
- background: transparent;
825
- border: none;
826
- border-bottom: 2px solid transparent;
824
+ appearance: none;
825
+ background: var(--bg-secondary);
826
+ border: 1px solid var(--border);
827
+ border-bottom: none;
828
+ border-radius: var(--radius) var(--radius) 0 0;
827
829
  color: var(--text-secondary);
828
830
  cursor: pointer;
831
+ font-family: inherit;
829
832
  font-size: 0.95rem;
830
833
  font-weight: 600;
831
- padding: 0.6rem 0.9rem;
834
+ padding: 0.6rem 1.1rem;
832
835
  margin-bottom: -1px;
836
+ transition: color 0.15s, background 0.15s;
833
837
  }
834
838
  .tab-btn:hover {
835
839
  color: var(--text-primary);
840
+ background: var(--bg-hover);
836
841
  }
837
842
  .tab-btn.active {
838
843
  color: var(--accent);
839
- border-bottom-color: var(--accent);
844
+ background: var(--bg-card);
845
+ border-color: var(--border);
846
+ border-bottom: 1px solid var(--bg-card);
840
847
  }
841
848
  .tab-panel[hidden] {
842
849
  display: none;
@@ -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.1"
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"