kryten-webqueue 0.9.8__tar.gz → 0.9.9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/CHANGELOG.md +9 -1
  2. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/PKG-INFO +1 -1
  3. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/fetchurls.py +6 -2
  4. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/manager.py +16 -0
  5. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/tasks.py +19 -8
  6. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/index.html +23 -1
  7. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/pyproject.toml +1 -1
  8. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_fetchurls_sharepoint.py +15 -0
  9. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_phase2_jobs.py +30 -0
  10. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_phase3_jobs.py +3 -1
  11. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/.github/workflows/python-publish.yml +0 -0
  12. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/.github/workflows/release.yml +0 -0
  13. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/.gitignore +0 -0
  14. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/README.md +0 -0
  15. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/config.example.json +0 -0
  16. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/deploy/kryten-webqueue.service +0 -0
  17. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/deploy/nginx-queue.conf +0 -0
  18. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/IMPLEMENTATION_SPEC.md +0 -0
  19. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/IMPL_API_GATE.md +0 -0
  20. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/IMPL_ECONOMY.md +0 -0
  21. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/IMPL_KRYTEN_PY.md +0 -0
  22. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/IMPL_ROBOT.md +0 -0
  23. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/PRE_PLAN_GAPS.md +0 -0
  24. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/PRODUCT_PLAN.md +0 -0
  25. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/docs/SPEC_JOBS_AND_BROWSE.md +0 -0
  26. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/__init__.py +0 -0
  27. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/__main__.py +0 -0
  28. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/api_gate/__init__.py +0 -0
  29. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/api_gate/client.py +0 -0
  30. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/app.py +0 -0
  31. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/__init__.py +0 -0
  32. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/otp.py +0 -0
  33. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/rate_limit.py +0 -0
  34. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/auth/session.py +0 -0
  35. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/__init__.py +0 -0
  36. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/db.py +0 -0
  37. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/images.py +0 -0
  38. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/mediacms.py +0 -0
  39. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/catalog/sync.py +0 -0
  40. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/config.py +0 -0
  41. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/__init__.py +0 -0
  42. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/__init__.py +0 -0
  43. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/_common.py +0 -0
  44. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichmeta.py +0 -0
  45. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtitles.py +0 -0
  46. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/cmsutils/enrichtv.py +0 -0
  47. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/__init__.py +0 -0
  48. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/integrations/ytpipe/downloader.py +0 -0
  49. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/__init__.py +0 -0
  50. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/jobs/fetchurls_auth.py +0 -0
  51. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/__init__.py +0 -0
  52. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/fire.py +0 -0
  53. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/importer.py +0 -0
  54. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/playlists/scheduler.py +0 -0
  55. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/__init__.py +0 -0
  56. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/ordering.py +0 -0
  57. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/poller.py +0 -0
  58. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/queue/shadow.py +0 -0
  59. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/__init__.py +0 -0
  60. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_catalog.py +0 -0
  61. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_jobs.py +0 -0
  62. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_playlists.py +0 -0
  63. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_queue.py +0 -0
  64. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/admin_schedules.py +0 -0
  65. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/auth.py +0 -0
  66. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/catalog.py +0 -0
  67. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/pages.py +0 -0
  68. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/queue.py +0 -0
  69. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/routes/user.py +0 -0
  70. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/static/css/main.css +0 -0
  71. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/static/js/main.js +0 -0
  72. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/playlists.html +0 -0
  73. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/queue_mgmt.html +0 -0
  74. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/admin/schedules.html +0 -0
  75. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/auth/login.html +0 -0
  76. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/base.html +0 -0
  77. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/browse.html +0 -0
  78. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_detail.html +0 -0
  79. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/catalog/item_not_found.html +0 -0
  80. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/queue/index.html +0 -0
  81. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/templates/user/dashboard.html +0 -0
  82. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/__init__.py +0 -0
  83. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/handler.py +0 -0
  84. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/kryten_webqueue/ws/manager.py +0 -0
  85. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/__init__.py +0 -0
  86. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_phase1.py +0 -0
  87. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_phase4_live_fixes.py +0 -0
  88. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_playlist_import.py +0 -0
  89. {kryten_webqueue-0.9.8 → kryten_webqueue-0.9.9}/tests/test_queue_announce.py +0 -0
@@ -6,6 +6,14 @@ 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.9] — 2026-06-12
10
+
11
+ ### Changed
12
+
13
+ - **Graceful handling of expected job failures.** Misconfiguration and bad-input errors (e.g. `fetchurls` not finding this weekend's worksheet, a missing/unauthenticated workbook, or a missing optional dependency) are now recorded as a clean, actionable message in the job-run history and logged at WARNING — no stack trace. A new internal `JobError` distinguishes these expected failures from unexpected bugs (which still log a full traceback and keep their exception-type prefix).
14
+ - The `fetchurls` "sheet not found" message now reads as guidance ("This weekend's worksheet 'M.D-M.D' was not found…") and lists only the date-format weekend sheets instead of every tab in the workbook.
15
+ - The admin Jobs history table now shows a **Detail** column — the failure message for failed runs, or a compact summary (sheet, imported playlists, counts) for successful ones.
16
+
9
17
  ## [0.9.8] — 2026-06-12
10
18
 
11
19
  ### Added
@@ -33,7 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
33
41
  ### Added
34
42
 
35
43
  - **Richer Bulk Text Import on the admin Playlists editor.** The text import now accepts, one entry per line:
36
- - **dropsugar.co / dropsugar.com links** (watch `?m=TOKEN` or manifest `/api/v1/media/cytube/TOKEN.json`) — resolved against the catalog for title/duration, falling back to a constructed manifest URL when the token isn't catalogued yet.
44
+ - **dropsugar.co links** (watch `?m=TOKEN` or manifest `/api/v1/media/cytube/TOKEN.json`) — resolved against the catalog for title/duration, falling back to a constructed manifest URL when the token isn't catalogued yet.
37
45
  - **YouTube / youtu.be links** — playlist (`list=`), start-time (`t`/`start`) and all other arguments are stripped, leaving a clean `yt:VIDEOID` item.
38
46
  - Legacy `cm:token`, `yt:id`, and bare catalog tokens (unchanged).
39
47
  - Trailing free text after a URL (e.g. `URL - My Title`) is used as a title hint.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kryten-webqueue
3
- Version: 0.9.8
3
+ Version: 0.9.9
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
@@ -1379,9 +1379,13 @@ def run(params: dict, *, config, progress=None) -> dict:
1379
1379
  all_sheets = wb_peek.sheetnames
1380
1380
  wb_peek.close()
1381
1381
  if sheet_name not in all_sheets:
1382
+ # Suggest only date-format weekend sheets (ignore Sheet1/Played Movies/etc).
1383
+ weekend_sheets = [s for s in all_sheets if _SHEET_DATE_RE.match(s.strip())]
1384
+ available = ", ".join(weekend_sheets) if weekend_sheets else ", ".join(all_sheets)
1382
1385
  raise RuntimeError(
1383
- f"Sheet '{sheet_name}' (upcoming weekend) not found. "
1384
- f"Available: {', '.join(all_sheets)}"
1386
+ f"This weekend's worksheet '{sheet_name}' was not found in the workbook. "
1387
+ f"Add a sheet named '{sheet_name}' (Friday.date-Saturday.date), or check "
1388
+ f"the sheet name matches. Available weekend sheets: {available}"
1385
1389
  )
1386
1390
 
1387
1391
  _emit({"phase": "parsing", "sheet": sheet_name})
@@ -24,6 +24,15 @@ logger = logging.getLogger(__name__)
24
24
  JobFunc = Callable[[dict, "JobContext"], Awaitable[dict | None]]
25
25
 
26
26
 
27
+ class JobError(Exception):
28
+ """An expected, user-facing job failure (bad input / config, not a bug).
29
+
30
+ Raising this from a job records a clean ``failed`` run with the message and
31
+ logs it at WARNING without a stack trace, so misconfiguration (e.g. a
32
+ missing workbook sheet) reads as actionable guidance rather than a crash.
33
+ """
34
+
35
+
27
36
  def _option_values(field: dict) -> list:
28
37
  """Return the allowed values for an enum field's ``options``.
29
38
 
@@ -184,6 +193,13 @@ class JobManager:
184
193
  except asyncio.CancelledError:
185
194
  await self._db.finish_job_run(run_id, "cancelled", None)
186
195
  raise
196
+ except JobError as exc:
197
+ # Expected, user-facing failure (bad input/config): record a clean
198
+ # message and log without a stack trace.
199
+ logger.warning("Job '%s' failed: %s", name, exc)
200
+ await self._db.finish_job_run(
201
+ run_id, "failed", json.dumps({"error": str(exc)})
202
+ )
187
203
  except Exception as exc: # noqa: BLE001 - record any failure
188
204
  logger.exception("Job '%s' failed", name)
189
205
  await self._db.finish_job_run(
@@ -15,6 +15,8 @@ import functools
15
15
  import importlib
16
16
  import logging
17
17
 
18
+ from .manager import JobError
19
+
18
20
  logger = logging.getLogger(__name__)
19
21
 
20
22
 
@@ -44,14 +46,23 @@ def _thread_safe_progress(ctx, loop):
44
46
 
45
47
 
46
48
  async def _run_vendored(module_path: str, params: dict, ctx, *, deps: list[str]):
47
- """Import a vendored module, verify deps, and run its ``run()`` off-loop."""
48
- for dep in deps:
49
- _require(dep)
50
- module = importlib.import_module(module_path)
51
- loop = asyncio.get_running_loop()
52
- progress = _thread_safe_progress(ctx, loop)
53
- fn = functools.partial(module.run, params, config=ctx.config, progress=progress)
54
- return await asyncio.to_thread(fn)
49
+ """Import a vendored module, verify deps, and run its ``run()`` off-loop.
50
+
51
+ The vendored tools raise ``RuntimeError`` for expected, user-facing failures
52
+ (missing/unauthenticated workbook, a sheet that isn't present, a missing
53
+ optional dependency). Surface those as :class:`JobError` so the run history
54
+ shows a clean, actionable message instead of a stack trace.
55
+ """
56
+ try:
57
+ for dep in deps:
58
+ _require(dep)
59
+ module = importlib.import_module(module_path)
60
+ loop = asyncio.get_running_loop()
61
+ progress = _thread_safe_progress(ctx, loop)
62
+ fn = functools.partial(module.run, params, config=ctx.config, progress=progress)
63
+ return await asyncio.to_thread(fn)
64
+ except RuntimeError as exc:
65
+ raise JobError(str(exc)) from exc
55
66
 
56
67
 
57
68
  # ── Enrich jobs ────────────────────────────────────────────────────────────────
@@ -212,13 +212,14 @@ async function loadJobs() {
212
212
  const el = document.getElementById('job-runs');
213
213
  if (runs.length > 0) {
214
214
  el.innerHTML = `<table class="admin-table">
215
- <tr><th>Job</th><th>Started</th><th>Ended</th><th>Status</th></tr>
215
+ <tr><th>Job</th><th>Started</th><th>Ended</th><th>Status</th><th>Detail</th></tr>
216
216
  ${runs.map(r => `
217
217
  <tr>
218
218
  <td>${escapeHtml(r.job_name || '')}</td>
219
219
  <td>${formatLocalDateTime(r.started_at)}</td>
220
220
  <td>${r.ended_at ? formatLocalDateTime(r.ended_at) : '—'}</td>
221
221
  <td><span class="job-status job-status-${escapeHtml(r.status || '')}">${escapeHtml(r.status || '')}</span></td>
222
+ <td class="job-detail">${escapeHtml(summarizeRunDetail(r.detail))}</td>
222
223
  </tr>
223
224
  `).join('')}
224
225
  </table>`;
@@ -269,6 +270,27 @@ function escapeHtml(str) {
269
270
  return div.innerHTML;
270
271
  }
271
272
 
273
+ // Summarize a job_runs.detail JSON blob for the history table. Prefers a
274
+ // failure message, otherwise a compact success summary.
275
+ function summarizeRunDetail(detail) {
276
+ if (!detail) return '';
277
+ let d;
278
+ try { d = typeof detail === 'string' ? JSON.parse(detail) : detail; }
279
+ catch { return String(detail).slice(0, 160); }
280
+ if (d && d.error) return d.error;
281
+ if (d && typeof d === 'object') {
282
+ const parts = [];
283
+ if (d.sheet) parts.push(d.sheet);
284
+ if (d.imported_playlists && d.imported_playlists.length) parts.push(`imported: ${d.imported_playlists.join(', ')}`);
285
+ if (typeof d.resolved === 'number') parts.push(`resolved ${d.resolved}`);
286
+ if (typeof d.failures === 'number' && d.failures) parts.push(`${d.failures} failed`);
287
+ if (typeof d.committed === 'number') parts.push(`committed ${d.committed}`);
288
+ if (typeof d.added_to_playlist !== 'undefined' && d.added_to_playlist) parts.push(`→ playlist ${d.added_to_playlist}`);
289
+ if (parts.length) return parts.join(' · ');
290
+ }
291
+ return '';
292
+ }
293
+
272
294
  loadAdminData();
273
295
  loadJobs();
274
296
  </script>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "kryten-webqueue"
3
- version = "0.9.8"
3
+ version = "0.9.9"
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"
@@ -71,6 +71,21 @@ def test_run_no_source_raises(tmp_path):
71
71
  fetchurls.run({}, config=_config(), progress=None)
72
72
 
73
73
 
74
+ # --- vendored RuntimeError → JobError translation ---
75
+
76
+ async def test_run_vendored_translates_runtimeerror_to_joberror(db, monkeypatch):
77
+ from kryten_webqueue.jobs.manager import JobError
78
+
79
+ def boom(params, *, config, progress):
80
+ raise RuntimeError("This weekend's worksheet was not found.")
81
+
82
+ fake_mod = SimpleNamespace(run=boom)
83
+ monkeypatch.setattr(tasks.importlib, "import_module", lambda name, *a, **k: fake_mod)
84
+
85
+ with pytest.raises(JobError, match="worksheet was not found"):
86
+ await tasks._run_vendored("whatever", {}, _ctx(db), deps=[])
87
+
88
+
74
89
  # --- fixed section-label playlist names + immutable preserve ---
75
90
 
76
91
  async def _add_catalog(db, token, title="T"):
@@ -135,6 +135,36 @@ async def test_unknown_job_raises_keyerror(db):
135
135
  await jm.run("nope")
136
136
 
137
137
 
138
+ async def test_job_error_records_clean_message(db):
139
+ from kryten_webqueue.jobs.manager import JobError
140
+
141
+ async def job(params, ctx):
142
+ raise JobError("This weekend's worksheet '6.12-6.13' was not found.")
143
+
144
+ jm = JobManager(db)
145
+ jm.register("fetchurls", job)
146
+ await jm.run("fetchurls")
147
+ run = await _wait_terminal(db, "fetchurls")
148
+ assert run["status"] == "failed"
149
+ # Clean message, no "RuntimeError:"/"JobError:" type prefix or traceback.
150
+ assert json.loads(run["detail"]) == {
151
+ "error": "This weekend's worksheet '6.12-6.13' was not found."
152
+ }
153
+
154
+
155
+ async def test_unexpected_error_keeps_type_prefix(db):
156
+ async def job(params, ctx):
157
+ raise ValueError("boom")
158
+
159
+ jm = JobManager(db)
160
+ jm.register("crashy", job)
161
+ await jm.run("crashy")
162
+ run = await _wait_terminal(db, "crashy")
163
+ assert run["status"] == "failed"
164
+ # Unexpected (bug) failures retain the exception type for debugging.
165
+ assert json.loads(run["detail"]) == {"error": "ValueError: boom"}
166
+
167
+
138
168
  async def test_already_running_guard(db):
139
169
  started = asyncio.Event()
140
170
  release = asyncio.Event()
@@ -69,6 +69,7 @@ def test_extract_manifest_token():
69
69
  async def test_fetch_job_missing_ytdlp_fails_fast(db, monkeypatch):
70
70
  # Simulate yt_dlp absent regardless of the host environment.
71
71
  import importlib
72
+ from kryten_webqueue.jobs.manager import JobError
72
73
  real_import = importlib.import_module
73
74
 
74
75
  def fake_import(name, *a, **k):
@@ -77,7 +78,8 @@ async def test_fetch_job_missing_ytdlp_fails_fast(db, monkeypatch):
77
78
  return real_import(name, *a, **k)
78
79
 
79
80
  monkeypatch.setattr(tasks.importlib, "import_module", fake_import)
80
- with pytest.raises(RuntimeError, match="yt_dlp"):
81
+ # A missing optional dependency is a clean, user-facing JobError.
82
+ with pytest.raises(JobError, match="yt_dlp"):
81
83
  await tasks.fetch_job({"url": "http://x"}, _ctx(db))
82
84
 
83
85