kryten-webqueue 0.18.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.18.0 → kryten_webqueue-0.20.0}/CHANGELOG.md +21 -0
  2. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/fetchurls.py +29 -5
  4. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/tasks.py +33 -1
  5. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/logging_config.py +13 -2
  6. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/static/css/main.css +33 -13
  7. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/index.html +4 -0
  8. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/user/dashboard.html +56 -28
  9. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/pyproject.toml +1 -1
  10. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/.github/workflows/python-publish.yml +0 -0
  11. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/.github/workflows/release.yml +0 -0
  12. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/.gitignore +0 -0
  13. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/README.md +0 -0
  14. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/config.example.json +0 -0
  15. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/deploy/kryten-webqueue.service +0 -0
  16. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/deploy/nginx-queue.conf +0 -0
  17. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/IMPLEMENTATION_SPEC.md +0 -0
  18. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/IMPL_API_GATE.md +0 -0
  19. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/IMPL_ECONOMY.md +0 -0
  20. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/IMPL_KRYTEN_PY.md +0 -0
  21. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/IMPL_ROBOT.md +0 -0
  22. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/PLAN_PRESENCE_AND_PROMOS.md +0 -0
  23. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/docs/UX_POLISH_PLAN.md +0 -0
  27. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/__init__.py +0 -0
  28. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/__main__.py +0 -0
  29. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/api_gate/__init__.py +0 -0
  30. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/api_gate/client.py +0 -0
  31. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/app.py +0 -0
  32. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/__init__.py +0 -0
  33. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/otp.py +0 -0
  34. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/rate_limit.py +0 -0
  35. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/auth/session.py +0 -0
  36. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/__init__.py +0 -0
  37. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/db.py +0 -0
  38. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/images.py +0 -0
  39. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/mediacms.py +0 -0
  40. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/catalog/sync.py +0 -0
  41. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/config.py +0 -0
  42. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/__init__.py +0 -0
  43. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  44. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  45. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  46. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  47. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  48. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  49. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  50. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/__init__.py +0 -0
  51. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  52. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/jobs/manager.py +0 -0
  53. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/__init__.py +0 -0
  54. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/bulk_add.py +0 -0
  55. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/fire.py +0 -0
  56. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/importer.py +0 -0
  57. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/ordering.py +0 -0
  58. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/playlists/scheduler.py +0 -0
  59. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/promos/__init__.py +0 -0
  60. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/promos/director.py +0 -0
  61. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/__init__.py +0 -0
  62. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/ordering.py +0 -0
  63. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/poller.py +0 -0
  64. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/presence.py +0 -0
  65. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/queue/shadow.py +0 -0
  66. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/__init__.py +0 -0
  67. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_catalog.py +0 -0
  68. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_jobs.py +0 -0
  69. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_playlists.py +0 -0
  70. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_promos.py +0 -0
  71. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_queue.py +0 -0
  72. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/admin_schedules.py +0 -0
  73. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/auth.py +0 -0
  74. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/catalog.py +0 -0
  75. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/pages.py +0 -0
  76. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/queue.py +0 -0
  77. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/routes/user.py +0 -0
  78. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/static/js/main.js +0 -0
  79. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/playlists.html +0 -0
  80. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/promos.html +0 -0
  81. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  82. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/admin/schedules.html +0 -0
  83. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/auth/login.html +0 -0
  84. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/base.html +0 -0
  85. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/catalog/browse.html +0 -0
  86. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  87. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  88. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/templates/queue/index.html +0 -0
  89. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/ws/__init__.py +0 -0
  90. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/ws/handler.py +0 -0
  91. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/kryten_webqueue/ws/manager.py +0 -0
  92. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/__init__.py +0 -0
  93. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_config_persistence.py +0 -0
  94. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_fetchurls_sharepoint.py +0 -0
  95. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_phase1.py +0 -0
  96. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_phase2_jobs.py +0 -0
  97. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_phase3_jobs.py +0 -0
  98. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_phase4_live_fixes.py +0 -0
  99. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_playlist_import.py +0 -0
  100. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_presence_refund.py +0 -0
  101. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_promo_director.py +0 -0
  102. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_promo_pool_exclusion.py +0 -0
  103. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_queue_announce.py +0 -0
  104. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/tests/test_save_results_to_playlist.py +0 -0
  105. {kryten_webqueue-0.18.0 → kryten_webqueue-0.20.0}/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.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
+
22
+ ## [0.19.0] — 2026-06-17
23
+
24
+ ### Changed
25
+
26
+ - **Z-Coin dashboard reorganized into tabs.** The account page was overcrowded with three side-by-side columns. It's now a widened account card (balance, rank, progress, perks) beside a tabbed container with **Queue History**, **Recent Transactions**, and a new **Vanity Items** tab. Each tab lazy-loads its data on first view; the vanity greeting/color editors moved out of the cramped left column into their own roomier tab. Collapses to a single column on narrow screens.
27
+
28
+ [0.19.0]: https://github.com/grobertson/kryten-webqueue/releases/tag/v0.19.0
29
+
9
30
  ## [0.18.0] — 2026-06-17
10
31
 
11
32
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.18.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},
@@ -797,20 +797,50 @@ a.np-chip {
797
797
  }
798
798
  .dashboard-grid {
799
799
  display: grid;
800
- grid-template-columns: 1fr 1fr 1fr;
800
+ grid-template-columns: minmax(280px, 360px) 1fr;
801
801
  gap: 1.5rem;
802
802
  margin-top: 1.5rem;
803
+ align-items: start;
803
804
  }
804
805
  @media (max-width: 900px) {
805
806
  .dashboard-grid {
806
807
  grid-template-columns: 1fr;
807
808
  }
808
809
  }
809
- .balance-card, .history-card, .transactions-card {
810
+ .balance-card, .history-card, .transactions-card, .dashboard-tabs-card {
810
811
  background: var(--bg-card);
811
812
  border-radius: var(--radius);
812
813
  padding: 1.5rem;
813
814
  }
815
+
816
+ /* Dashboard tabbed container */
817
+ .tabs {
818
+ display: flex;
819
+ gap: 0.25rem;
820
+ border-bottom: 1px solid var(--border);
821
+ margin-bottom: 1rem;
822
+ }
823
+ .tab-btn {
824
+ background: transparent;
825
+ border: none;
826
+ border-bottom: 2px solid transparent;
827
+ color: var(--text-secondary);
828
+ cursor: pointer;
829
+ font-size: 0.95rem;
830
+ font-weight: 600;
831
+ padding: 0.6rem 0.9rem;
832
+ margin-bottom: -1px;
833
+ }
834
+ .tab-btn:hover {
835
+ color: var(--text-primary);
836
+ }
837
+ .tab-btn.active {
838
+ color: var(--accent);
839
+ border-bottom-color: var(--accent);
840
+ }
841
+ .tab-panel[hidden] {
842
+ display: none;
843
+ }
814
844
  .balance-amount {
815
845
  font-size: 2rem;
816
846
  font-weight: 700;
@@ -911,17 +941,7 @@ a.np-chip {
911
941
  color: var(--success);
912
942
  }
913
943
 
914
- /* Vanity items (dashboard left column) */
915
- .vanity-section {
916
- margin-top: 1.25rem;
917
- padding-top: 1.25rem;
918
- border-top: 1px solid var(--border);
919
- }
920
- .vanity-section h3 {
921
- font-size: 0.85rem;
922
- color: var(--text-secondary);
923
- margin-bottom: 0.6rem;
924
- }
944
+ /* Vanity items (dashboard "Vanity Items" tab) */
925
945
  .vanity-item {
926
946
  margin-bottom: 0.85rem;
927
947
  }
@@ -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(' · ');
@@ -10,7 +10,7 @@
10
10
  </div>
11
11
 
12
12
  <div class="dashboard-grid">
13
- <!-- LEFT: Account -->
13
+ <!-- LEFT: Account (widened) -->
14
14
  <div class="balance-card">
15
15
  <h2>Z Coin Balance</h2>
16
16
  <p class="balance-amount" id="balance-amount">Loading…</p>
@@ -18,9 +18,37 @@
18
18
 
19
19
  <div class="account-rank" id="account-rank"></div>
20
20
  <div class="account-perks" id="account-perks"></div>
21
+ </div>
22
+
23
+ <!-- RIGHT: Tabbed container -->
24
+ <div class="dashboard-tabs-card">
25
+ <div class="tabs" role="tablist">
26
+ <button class="tab-btn active" data-tab="queue" role="tab" aria-selected="true">Queue History</button>
27
+ <button class="tab-btn" data-tab="transactions" role="tab" aria-selected="false">Recent Transactions</button>
28
+ <button class="tab-btn" data-tab="vanity" role="tab" aria-selected="false">Vanity Items</button>
29
+ </div>
30
+
31
+ <div class="tab-panel" id="tab-queue" role="tabpanel">
32
+ <div id="queue-history">
33
+ <p class="empty-state">Loading…</p>
34
+ </div>
35
+ <div class="history-pager" id="queue-pager"></div>
36
+ </div>
21
37
 
22
- <div class="vanity-section" id="vanity-section">
23
- <h3>Vanity</h3>
38
+ <div class="tab-panel" id="tab-transactions" role="tabpanel" hidden>
39
+ <div class="tx-toggle" id="tx-toggle">
40
+ <button class="tx-toggle-btn active" data-filter="all">All</button>
41
+ <button class="tx-toggle-btn" data-filter="credit">Credits</button>
42
+ <button class="tx-toggle-btn" data-filter="debit">Debits</button>
43
+ </div>
44
+ <div id="transactions">
45
+ <p class="empty-state">Loading…</p>
46
+ </div>
47
+ <div class="tx-pager" id="tx-pager"></div>
48
+ </div>
49
+
50
+ <div class="tab-panel" id="tab-vanity" role="tabpanel" hidden>
51
+ <p class="muted">Personalize how the bot greets you and your chat color. Each update is a one-time purchase.</p>
24
52
  <div class="vanity-item">
25
53
  <div class="vanity-row">
26
54
  <span class="vanity-label">Greeting</span>
@@ -37,29 +65,6 @@
37
65
  </div>
38
66
  </div>
39
67
  </div>
40
-
41
- <!-- MIDDLE: Queue history -->
42
- <div class="history-card">
43
- <h2>Queue History</h2>
44
- <div id="queue-history">
45
- <p class="empty-state">Loading…</p>
46
- </div>
47
- <div class="history-pager" id="queue-pager"></div>
48
- </div>
49
-
50
- <!-- RIGHT: Transactions -->
51
- <div class="transactions-card">
52
- <h2>Recent Transactions</h2>
53
- <div class="tx-toggle" id="tx-toggle">
54
- <button class="tx-toggle-btn active" data-filter="all">All</button>
55
- <button class="tx-toggle-btn" data-filter="credit">Credits</button>
56
- <button class="tx-toggle-btn" data-filter="debit">Debits</button>
57
- </div>
58
- <div id="transactions">
59
- <p class="empty-state">Loading…</p>
60
- </div>
61
- <div class="tx-pager" id="tx-pager"></div>
62
- </div>
63
68
  </div>
64
69
  </div>
65
70
  {% endblock %}
@@ -442,8 +447,31 @@ document.querySelectorAll('.tx-toggle-btn').forEach(b => b.addEventListener('cli
442
447
  renderTransactions();
443
448
  }));
444
449
 
450
+ // Tabs: show one panel at a time; lazy-load each panel's data on first view.
451
+ const tabLoaded = { queue: false, transactions: false, vanity: false };
452
+ function loadTabOnce(tab) {
453
+ if (tabLoaded[tab]) return;
454
+ tabLoaded[tab] = true;
455
+ if (tab === 'queue') loadQueue(0);
456
+ else if (tab === 'transactions') loadTransactions(true);
457
+ // 'vanity' needs no fetch — populated by loadAccount().
458
+ }
459
+ function showTab(tab) {
460
+ document.querySelectorAll('.tab-btn').forEach(b => {
461
+ const on = b.dataset.tab === tab;
462
+ b.classList.toggle('active', on);
463
+ b.setAttribute('aria-selected', on ? 'true' : 'false');
464
+ });
465
+ document.querySelectorAll('.tab-panel').forEach(p => {
466
+ p.hidden = p.id !== `tab-${tab}`;
467
+ });
468
+ loadTabOnce(tab);
469
+ }
470
+ document.querySelectorAll('.tab-btn').forEach(b => {
471
+ b.addEventListener('click', () => showTab(b.dataset.tab));
472
+ });
473
+
445
474
  loadAccount();
446
- loadQueue(0);
447
- loadTransactions(true);
475
+ showTab('queue'); // default tab; lazy-loads queue history
448
476
  </script>
449
477
  {% endblock %}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.18.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"