Compare commits

...

73 Commits

Author SHA1 Message Date
Developer 9bc8fab971 chore: fold pre-existing ServicesPage.tsx formatter stray
Whitespace-only JSX reflow (prettier) from earlier #1 validation-surfacing fix;
folding so the working tree goes pristine before the final push.
2026-07-10 00:17:51 +00:00
Developer 29650ca512 chore(per-instance-hook-scoping): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-per-instance-hook-scoping/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
service-instance-scoping/ remains. Resolves multi-instance wrong-data bug
(hooks now scope by instance.id; instance switcher re-scopes).
Carry-overs: fetchBackupDashboard untouched (design decision 5); subquery
scoping for runs/alerts (schema asymmetry).
2026-07-10 00:17:51 +00:00
Developer f921524d37 spec(per-instance-hook-scoping): sync into new canonical domain
New canonical openspec/specs/service-instance-scoping/spec.md (21 reqs
PI-101..121). Change-side delta + sync-report. web-ui/prometheus-charting/
service-storage/service-credential-testing canonicals untouched.
2026-07-10 00:12:54 +00:00
Developer 8d3c44d87f spec(per-instance-hook-scoping): verify + reconcile tracking
Write apply-progress.md, tick all 17 tasks, add verify-report.md (21/21 PI-101..121
PASS). Gates green: 368 pytest, ruff clean, npm build+lint 0 errors, 165 vitest.
fetchBackupDashboard/useBackupDashboard/get_backup_dashboard confirmed untouched
(design decision 5). No blocking code findings.
2026-07-10 00:05:58 +00:00
Developer 3bc7ce5269 feat(per-instance-hook-scoping): scope observability + backup hooks by instance 2026-07-09 23:54:31 +00:00
Developer ad61d92b32 spec(per-instance-hook-scoping): add tasks (single slice, ~257 lines)
Backend backup endpoint+store filter (subquery for runs/alerts); frontend 6
hooks + 7 API fns + 3 tabs. fetchBackupDashboard excluded (widget path).
Each gate green.
2026-07-09 23:40:39 +00:00
Developer dbc332d1b6 spec(per-instance-hook-scoping): add design
6 decisions: queryKey appends serviceId??''; API client reuses get(path,params);
service_id: str|None=None on backup endpoints; store filter via subquery for
runs/alerts (schema asymmetry — only backup_jobs has service_id column);
fetchBackupDashboard excluded (widget path, PI-117 risk). Single slice ~257
lines. 3 source findings: Alertmanager/Prom endpoints confirmed already take
service_id; backup runs/alerts attributed via FK chain (subquery needed).
2026-07-09 23:37:35 +00:00
Developer 87f42b4ec3 spec(per-instance-hook-scoping): add spec (21 reqs PI-101..121)
Correctness fix for multi-instance: 6 hooks gain optional serviceId in queryKey;
7 API fns append ?service_id; 4 backup endpoints gain service_id filter
(Alertmanager/Prometheus status already take it — zero backend change there);
3 tabs pass instance.id; dashboard widgets unaffected; all params optional
(backward-compat). usePrometheusTargets + useMonitoringMachines stay global.
2026-07-09 23:32:40 +00:00
Developer 5addc9dae9 chore(service-credential-tester): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-service-credential-tester/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
service-credential-testing/ remains. Resolves qBit 'login failed' #3 pain
at the UI layer (auth failure surfaced in result pill, no log-digging).
Carry-overs in archive-report incl N-2 strengthened, N-6 presentational panel,
edit-surface-is-Settings.tsx source-finding.
2026-07-09 23:29:19 +00:00
Developer 6bcb60a74d spec(service-credential-tester): sync into new canonical domain
New canonical openspec/specs/service-credential-testing/spec.md (21 reqs
CT-101..121). Change-side delta + sync-report. web-ui/prometheus-charting/
service-storage canonicals untouched.
2026-07-09 23:22:53 +00:00
Developer 98bf496a98 spec(service-credential-tester): verify + strengthen no-secret-logs test + reconcile
Strengthen test_secrets_not_logged (N-2): now sends real-looking secrets
through prometheus + qbittorrent test_callables (mocked at network boundary),
asserts no fragments leak into caplog, verified non-vacuous. Write
apply-progress.md, tick all 29 tasks, add verify-report.md (21/21 PASS).
Gates green: 362+ pytest, ruff clean, npm build+lint 0 errors, 158 vitest.
2026-07-09 23:15:47 +00:00
Developer f6c67bd3ff feat(service-credential-tester): slice 2 — Test button + gating (shared ServiceTestPanel)
Presentational ServiceTestPanel (props-driven, no internal hooks) wired into
both CreateServiceDialog (ServicesPage.tsx) and ServiceConfigEditor
(Settings.tsx). Parent owns testResult + saveAnyway state; store-previous
pattern resets on input change (avoids setState-in-effect). Create/Save
button gated on testPassed || saveAnyway. 7 panel tests (button states,
success/failure pills, checkbox toggle). All gates: 158 vitest, build exit 0,
lint 0 errors, 362 backend pytest (regression).
2026-07-09 22:57:02 +00:00
Developer 3391fbc85d feat(service-credential-tester): slice 1 — backend test endpoint + per-type routines
TestResult dataclass + translate_connection_error shared helper in base.py.
test_callable field on ServiceDefinition (default None). 7 per-type
test_connection routines (qbittorrent, prometheus via Grafana gateway,
alertmanager, jellyfin, authentik, ssh_tasks via build_ssh_client, nextcloud).
POST /api/services/test endpoint: validation-first (422 on malformed config),
dispatch, no-persistence, no-secret-logs. backups has test_callable=None.
qBit 'Fails.' → specific auth message (resolves #3 at API layer).
Backend: 362 pytest pass (+31 new), ruff clean. Frontend: build green.
2026-07-09 22:41:06 +00:00
Developer c4f68b4938 spec(service-credential-tester): add tasks (2 slices, each <=400 lines)
S1 backend: TestResult + test_callable + translate_connection_error + POST
/api/services/test + 7 per-type routines + tests. S2 frontend: type + API fn +
hook + shared ServiceTestPanel wired into CreateServiceDialog + Settings.tsx
ServiceConfigEditor (per design source-finding). Each slice leaves pytest/npm
build/npm lint green.
2026-07-09 22:25:27 +00:00
Developer 1fc3127b58 spec(service-credential-tester): add design
8 decisions: TestResult dataclass, test_callable(store, config, secrets),
translate_connection_error shared helper (extracts test_machine_ssh patterns),
POST /api/services/test validation-first, shared ServiceTestPanel component,
field-edit-clears-result, Prom test via Grafana gateway, ssh_tasks reuses
build_ssh_client. 2-slice plan. Source findings: edit dialog is in Settings.tsx
ServiceConfigEditor (not ServicePage.tsx — spec drift); test_machine_ssh is
inlined in router (not reusable as-is).
2026-07-09 22:21:03 +00:00
Developer ce5ee4f0a0 spec(service-credential-tester): add spec (21 reqs CT-101..121)
Per-type test routines for 7 remote types (qbittorrent, prometheus via Grafana
gateway, alertmanager, jellyfin, authentik, ssh_tasks, nextcloud) + backups
(no test). Endpoint POST /api/services/test, no persistence, friendly error
translation (resolves qBit login #3 at UI layer). Frontend Test button + gate
Create/Save on pass with Save-anyway override. Stale-proposal correction:
jellyseerr is not in the registry (merged into jellyfin).
2026-07-09 22:12:32 +00:00
Developer a5ca1521fe spec(service-credential-tester): update prom test to gateway path
Dependency on grafana-metric-gateway: the prometheus service now sources via
Grafana /api/ds/query (no direct Prom endpoint). Test routine changes from
GET /api/v1/query?query=up to POST {grafana_url}/api/ds/query with api_key +
datasource_uid, expr 'up'.
2026-07-09 22:07:37 +00:00
Developer 9236fd8ac2 chore(grafana-metric-gateway): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-grafana-metric-gateway/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
prometheus-charting/ (30 reqs, first non-additive sync) remains. Carry-overs
in archive-report: SC-106 stale component name (cosmetic); partial revert of
prometheus-direct-charting per new network constraint.
2026-07-09 22:06:43 +00:00
Developer cb8dd13514 spec(grafana-metric-gateway): sync — FIRST non-additive (MODIFIED) canonical
16 MODIFIED prometheus-charting requirements (transport: direct Prom -> Grafana
gateway; intent preserved where applicable), 3 ADDED (SC-128 gateway status,
SC-129 startup validation, SC-130 sanctioned transport), 11 PRESERVED, 0 REMOVED.
All 16 MODIFIED headers matched canonical exactly. Post-sync: 30 requirements.
web-ui + service-storage canonicals untouched.
2026-07-09 22:01:55 +00:00
Developer c886fcdf09 spec(grafana-metric-gateway): verify + close GM-115 + reconcile tracking
Add 3 test cases (startup old-config validation warning; status auth_failed
for 401/403) closing the GM-115 PARTIAL. Write apply-progress.md, tick all 33
tasks, add verify-report.md (15/16 PASS, 1 PARTIAL->PASS). Gates green: 331+
pytest, ruff clean, npm build+lint 0 errors, 151 vitest.
2026-07-09 21:53:18 +00:00
Developer 7e91e7f931 feat(grafana-metric-gateway): slice 2 — rename widgets to Metric*
git mv PrometheusChartWidget→MetricChartWidget, PrometheusGaugeWidget→
MetricGaugeWidget, PrometheusMeanWidget→MetricMeanWidget (+ 3 test files,
R100 history preserved). Update registry imports/refs + barrel exports.
Adapt PrometheusMetricWidget for §3.4 Option A: read normalized {result:
[{label,points}]} series shape (last-point extraction) instead of old Prom
{resultType,result} vector. PrometheusMetricWidget NOT renamed (design §3.1).
GM-111/112/116 satisfied. All gates: 151 vitest, build exit 0, lint 0 errors.
2026-07-09 21:34:26 +00:00
Developer df80c68f89 feat(grafana-metric-gateway): slice 1 — backend gateway transport
Route all prometheus widget queries through Grafana /api/ds/query instead of
direct Prom HTTP. PrometheusConfig: drop base_url, add grafana_url +
datasource_uid; secret grafana_api_key (required). PrometheusWidgetSource →
MetricSource with _gateway_query POST method. normalize_grafana_frames
recovered from 65bae95 + shared _dedup_label helper. Gateway-path status
check. Startup old-config validation. CHANGELOG migration note. All adapter
tests rewritten for POST /api/ds/query + Grafana frames mock. Backend: 331
pytest pass, ruff clean. Frontend: build green (unchanged in S1).
2026-07-09 21:26:05 +00:00
Developer 798196ffc7 spec(grafana-metric-gateway): add tasks (2 slices, each <=400 lines)
S1 backend transport: gateway config + normalize_grafana_frames (from 65bae95)
+ MetricSource adapter + status + validation + CHANGELOG + tests. S2 frontend:
git mv widget renames to Metric* + registry/barrel updates. Each slice leaves
pytest/npm build/npm lint green. 5 risk flags incl first non-additive sync.
2026-07-09 21:01:38 +00:00
Developer 872e95f8f7 spec(grafana-metric-gateway): add design
8 design decisions: PrometheusConfig (grafana_url/datasource_uid + grafana_api_key
secret), /api/ds/query body per widget kind (window presets -> intervalMs/
maxDataPoints), normalize_grafana_frames refactored from 65bae95 into
prometheus_range.py (shares label-dedup with matrix normalizer), MetricSource
adapter, gateway-path status check, git mv widget renames, startup old-config
validation. 2-slice plan. 3 source findings flagged.
2026-07-09 20:57:07 +00:00
Developer bf8de32815 spec(grafana-metric-gateway): add spec (16 reqs GM-101..116)
Gateway transport: prometheus service config gains grafana_url/api_key/
datasource_uid; all queries via POST /api/ds/query; frames->series restored.
First non-additive canonical sync: MODIFIES 17 SC- requirements (transport
changes, intent preserved), PRESERVES 11, ADDS 3 (gateway status, startup
validation, sanctioned-transport statement). 3 spec assumptions settled where
proposal was silent.
2026-07-09 20:49:45 +00:00
Developer 8afdd9c2bc spec(grafana-metric-gateway): add proposal
Route metric queries through Grafana /api/ds/query instead of direct Prom
(Prom is firewalled / unreachable from Manage; Grafana is the only path).
Partial revert of prometheus-direct-charting: keep the gauge/mean/
LineSeriesChart rendering, restore the frames->series normalizer (recovered
from git 65bae95), change prometheus service config to hold Grafana gateway
fields (url + api_key + datasource_uid). Rename widgets to neutral Metric*.
First non-additive canonical sync (prometheus-charting MODIFIED). Updates the
pending service-credential-tester proposal dependency.
2026-07-09 20:41:59 +00:00
Developer 3e3e69b0be spec(service-credential-tester): add proposal
On-demand per-service-type credential tester for the add/edit dialog. Model on
test_machine_ssh. POST /api/services/test dispatching to per-type routines
(qBit login+maindata, Prom instant query, Jellyfin /Users, etc.) returning
{ok, detail, evidence}. Frontend Test button + gate Create/Save on pass with
'Save anyway' override. Resolves #3 (qBit login failures visible in UI not
logs) and complements #1 (validation surfacing, already shipped).
2026-07-09 20:13:57 +00:00
Developer cadb6d0991 fix(nav): add qBittorrent to per-service-type navbar entries
qBittorrent was missing from SERVICE_TYPE_NAV_ENTRIES, so configured qBit
instances never appeared in the left nav (unlike jellyfin/prometheus/etc.).
Add entry with Magnet icon. navEntries.test.ts filters by configured types
(no fixed-count assertion) so it stays green.
2026-07-09 20:11:12 +00:00
Developer 493c0e1aeb fix(services): surface validation errors in add-service dialog
CreateServiceDialog.save() awaited mutateAsync without a try/catch, so a
backend 422 (e.g. base_url missing http:// schema) threw uncaught and the
dialog sat silent with no feedback. Wrap in try/catch, hold the error in
local state, render a destructive Alert above the footer. Reset/onClose
only on success; on error the user can fix and retry.
2026-07-09 20:10:46 +00:00
Developer a9381e2471 spec(per-instance-hook-scoping): add proposal
Correctness fix: observability + backup hooks query globally, so multi-instance
service pages show data for the wrong instance. Tabs already accept instance
prop with TODO comments; backend mostly supports service_id already. Scope:
add serviceId to 6 hooks + fetch fns + 3 tabs; add service_id to backup
endpoints. Backward-compatible (optional params). ~250-350 lines, single slice.
2026-07-09 14:35:14 +00:00
Developer e461279566 chore(services-as-hub-ia): archive verified change (paperwork-only)
Code merged to main on 2026-06-26 (01527ae) + fix passes. All 10 ACs PASS.
~8600 ins / ~3900 del across 103 files. Only SDD artifacts were untracked.
No code changes, no canonical sync. NOTE: verify-report describes a Grafana
LinksTab later removed by prometheus-direct-charting (2026-07-08); historically
accurate point-in-time record — see archive-report.md footnote.
2026-07-09 14:11:15 +00:00
Developer 1fe7c06083 chore(mobile-responsive-parity): archive verified change (paperwork-only)
Code merged to main on 2026-06-26 via rebase (01527ae) + bug-fix passes
(5a43894/04871bd/f7f590f). All 8 ACs PASS. Only SDD artifacts were untracked;
this archive closes the paperwork gap. No code changes, no canonical sync.
See archive-report.md for carry-over notes (iOS real-device test residual).
2026-07-09 14:11:05 +00:00
Developer ec7ebd7013 chore: regenerate stale .pi-map.md / .pi-map.index.md project maps
Project map had drifted: claimed recharts unused (was used), ObservabilityPage.tsx
exists (refactored to service-tabs/), missed JellyfinNowPlayingWidget +
authentik/backups service types. Regenerate to reflect post-change reality:
new Qbittorrent/LineSeriesChart/Prometheus{Gauge,Mean}Widget files,
service_data.py/qbittorrent_store.py, canonical prometheus-charting +
service-storage specs, archived changes.
2026-07-09 14:05:56 +00:00
Developer 98f17f6e5d chore: apply pre-existing formatter reformat to MediaTab.tsx
Whitespace-only line reflow (formatter-on-save artifact from a prior session,
not logic). Committing to pristine the working tree.
2026-07-09 13:50:36 +00:00
Developer 5cb5e79032 chore(service-storage-harness): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-service-storage-harness/
(history preserved via rename detection). 9 artifacts: proposal/spec/design/
tasks/apply-progress/verify-report/sync-report/archive-report + delta spec.
Canonical openspec/specs/service-storage/ remains. Native status engine
discrepancy (ambiguous change selection) disregarded per parent verification.
2026-07-09 09:47:05 +00:00
Developer c9201a004c spec(service-storage-harness): sync into canonical service-storage domain
New canonical openspec/specs/service-storage/spec.md (28 reqs SS-101..128).
Change-side delta + sync-report. web-ui + prometheus-charting canonicals
untouched.
2026-07-09 09:37:22 +00:00
Developer 40a7ac80d3 spec(service-storage-harness): verify + reconcile tracking
Write apply-progress.md, tick all 35 tasks, add verify-report.md (28/28
SS-101..128 PASS). Gates green: 322 pytest, ruff clean, FE build+lint 0
errors, PrometheusChartWidget extraction 4/4 non-regressive. No blocking
code findings. Process note: slice 2 was +644 lines over 400-line budget
(additive, no scope creep; retrospectively extract+widgets could split).
2026-07-09 09:30:52 +00:00
Developer c9404f0794 spec(service-storage-harness): add spec (28 requirements SS-101..128)
Covers harness lifecycle, QbittorrentSampleStore, QbittorrentClient,
3 widget kinds (totals=item count, active=DL/UL, speed=LineSeriesChart
reuse), MediaIndex migration (+service_id, scoped replace_items bug fix,
backward-compat query), cascade-delete wiring, test/build greenness.
2026-07-09 09:15:32 +00:00
Developer 75c949ad25 feat(service-storage-harness): slice 4 — cascade-delete wiring + integration test
Wire ServiceDataHarness.cascade_delete into SettingsStore.delete_service
(best-effort try/except, logs on failure). Fix migration runner to also
catch 'no such table' on fresh DBs (ALTER TABLE before init_schema).
Integration test proves end-to-end cascade across both concerns (qBit
samples + media items) with multi-instance preservation.

Backend: 322 pytest pass, ruff clean.
2026-07-09 09:11:42 +00:00
Developer c87f398e37 feat(service-storage-harness): slice 3 — migrate MediaIndex onto harness (scoped replace_items, +service_id)
Register MediaIndex as a harness concern with ALTER TABLE migration to add
service_id column (idempotent). Scope replace_items by service_id (FIXES latent
global-clear bug where building for one Jellyfin wiped another's rows). Scope
query by service_id (empty-string = all rows, backward-compat). Thread
service_id through build_media_index + worker + query_media router. New
regression test proves scoped replace preserves other services' rows.

Backend: 321 pytest pass, ruff clean. Frontend: build green.
2026-07-09 09:00:31 +00:00
Developer 1fb12b8a0a feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract 2026-07-09 08:49:20 +00:00
Developer e7bd0afdd1 feat(service-storage-harness): slice 1 — harness + qbit store + client + integration
ServiceDataHarness (services/service_data.py): lifecycle-only registry of
per-concern storage — DB provisioning, idempotent migrations (ALTER TABLE
duplicate-column-name caught per-statement), cascade_delete(service_id).
QbittorrentSampleStore: append/window/prune (MAX_SAMPLES=120) in
qbittorrent.db. QbittorrentClient: cookie-login Web API client (403 re-login,
/sync/maindata). Integration registered with 3 widget kinds (totals/active/
speed). Harness initialized in main.py lifespan.

Backend: 314 pytest pass (21 new), ruff clean.
2026-07-09 08:24:08 +00:00
Developer 9a251db23c spec(service-storage-harness): add tasks (4 slices, each <=400 lines)
S1 harness+store+client+integration; S2 widget adapter+3 FE widgets+
LineSeriesChart extract; S3 MediaIndex +service_id migration (fixes latent
global-clear bug); S4 cascade-delete wiring. Each slice leaves pytest/npm
build/npm lint green. Risk flags on LineSeriesChart extract + MediaIndex migration.
2026-07-09 08:02:31 +00:00
Developer cff082c7a1 spec(service-storage-harness): add design
10 design decisions incl: lifecycle-only harness, per-concern DB files,
QbittorrentSampleStore (120-sample cap), qBit cookie client, 3 widget kinds
(totals=item count, active=DL/UL state, speed=reuses Change A renderer via
shared LineSeriesChart extract), MediaIndex +service_id backfill, scoped
replace_items (fixes latent global-clear bug), best-effort cascade-delete.
4-slice plan. 3 source findings (worker already threads service_id).
2026-07-09 07:55:59 +00:00
Developer bc389ae3f7 spec(service-storage-harness): reconcile proposal post Change A + question round
Resolve Q1 (reuse Change A's PrometheusChartWidget renderer via InService
data path), Q2 (totals = item count, not transfer bytes), Q3 (active =
downloading/uploading), Q4 (N instances), Q5 (username/password cookie
auth). Remove TBD/stale thin-dashboard caveats.
2026-07-09 07:45:55 +00:00
Developer 7efc06a629 chore(prometheus-direct-charting): archive verified+synced change
Move to openspec/changes/archive/2026-07-08-prometheus-direct-charting/
(git mv, history preserved). 9 artifacts: proposal/spec/design/tasks/
apply-progress/verify-report/sync-report/archive-report + delta spec.
Canonical openspec/specs/prometheus-charting/ remains.
2026-07-08 23:01:35 +00:00
Developer 3e77075171 spec(prometheus-direct-charting): sync into canonical prometheus-charting domain
New canonical domain openspec/specs/prometheus-charting/spec.md with all
27 requirements (SC-101..127) as the durable post-change contract. Change-side
delta specs/prometheus-charting/spec.md + sync-report.md. web-ui canonical
untouched (different concern).
2026-07-08 22:57:29 +00:00
Developer 7440603cdb spec(prometheus-direct-charting): verify + close SC-125 + reconcile tracking
Add loading-state tests to the three Prometheus widget test files
(closes SC-125 PARTIAL). Write apply-progress.md, tick all 39 tasks,
add verify-report.md (26/27 PASS, 1 PARTIAL->PASS). All gates green:
293 pytest, ruff clean, npm build+lint 0 errors. No blocking findings.
2026-07-08 22:50:30 +00:00
Developer 67ca0fc3bc feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog
Remove the entire Grafana surface: integrations/grafana.py, GrafanaWidgetSource
(+ _fetch_chart, now redundant since prometheus chart exists), GrafanaLinkWidget,
LinksTab, get_grafana_status endpoint, useGrafanaStatus hook, GrafanaStatus type,
fetchGrafanaStatus client fn, registry/nav/tab entries (FE+BE). Rewrite
config.yaml thin-dashboard rule to match reality (recharts is sanctioned for
Prometheus-backed series). CHANGELOG migration note added.

Backend: 293 pytest pass, ruff clean. Frontend: build+lint green (0 errors).
SC-115/116 grep-clean (only prometheus_range.py migration comments + Dashboard.test
shortcut fixture remain — both spec-allowed).
2026-07-08 22:35:06 +00:00
Developer 65bae95e3c feat(prometheus-direct-charting): slice 2 — gauge + mean widgets
Add gauge widget (recharts RadialBarChart with configurable threshold
bands, scalar-only per SC-111) and mean widget (client-side average over
range-query window, scalar-only per SC-114). Extract shared _instant_query
helper from the metric path; _fetch_gauge and _fetch_mean dispatch in
PrometheusWidgetSource.fetch(). Both new widget kinds declared in
integrations/prometheus.py and frontend registry.

Backend: 305 pytest pass, ruff clean. Frontend: 136 vitest pass, build+lint green.
2026-07-08 22:10:11 +00:00
Developer 5dad98231f feat(prometheus-direct-charting): slice 1 — prom range query + chart rebrand
Add PrometheusWidgetSource._fetch_chart hitting /api/v1/query_range directly
(SC-101..104). New shared helpers in widgets/prometheus_range.py:
step_for_window (window preset -> step, ~200pts) and normalize_prometheus_matrix
(extracted label/dedup rule, retargeted at Prom matrix, robust to malformed
data). Chart widget kind moved grafana->prometheus in both registries;
GrafanaChartWidget renamed -> PrometheusChartWidget (git mv, recharts body
preserved). Grafana binding/service untouched (removed in slice 3).

Backend: 298 pytest pass, ruff clean. Frontend: 128 vitest pass, build+lint green.
2026-07-08 21:55:02 +00:00
Developer d906b0392b spec(prometheus-direct-charting): add tasks (3 slices, each <=400 lines)
S1 adds Prom chart (range query + rebrand, no grafana removal); S2 adds
gauge+mean; S3 removes grafana + config rewrite + changelog. Each slice
leaves pytest/npm build/npm lint green. Review-workload forecast per slice.
2026-07-08 21:33:16 +00:00
Developer 9b7415080b spec(prometheus-direct-charting): patch SC-116/118 for service-tabs refactor
ObservabilityPage.tsx was refactored into service-tabs/; update removal
criteria to name real targets and whitelist the Dashboard.test fixture
shortcut-label collision.
2026-07-08 21:29:09 +00:00
Developer 78e273efe8 spec(prometheus-direct-charting): add design
5 locked design decisions: step derivation formula (max(15, round(s/200))),
shared normalize_prometheus_matrix helper in widgets/prometheus_range.py,
recharts RadialBarChart gauge w/ threshold bands, mean via client-side
avg over query_range, 3-slice plan each <=400 lines.
Source-verified: ObservabilityPage refactored into service-tabs/ (map stale).
2026-07-08 21:28:02 +00:00
Developer b7e5ca3cbc spec(prometheus-direct-charting): add proposal + spec
Drop Grafana as chart middleman; query Prometheus directly via
/api/v1/query_range. Rebrand GrafanaChartWidget -> PrometheusChartWidget,
add gauge + mean widget kinds, remove Grafana surface, rewrite stale
thin-dashboard rule in config.yaml. 27 acceptance requirements (SC-101..127).
2026-07-08 21:21:14 +00:00
Developer 67c51f9fc0 Fix: user_id validation causes read timeout on slow Jellyfin
The previous fix called client.users() unconditionally to validate the
user_id, adding an extra HTTP round-trip before the build. On a slow
Jellyfin connection this burned through the 10s timeout before the
actual libraries() call.

Restructured to try libraries(user_id) directly first. Only when that
fails does the worker resolve the username via the users API. So:
- Correctly configured user_id (internal hash): zero extra round-trips.
- Username like 'admin': libraries() fails → users() resolves it → retry.

283 backend tests pass; ruff clean.
2026-07-06 17:35:19 +00:00
Developer 7497469d5e Fix: user_id 'admin' rejected by Jellyfin API (400)
The Jellyfin service config's user_id field accepts either the internal
Jellyfin user ID (a long hash) or a username (e.g. 'admin'). The worker
passed the raw value directly to client.libraries(user_id), but Jellyfin's
API rejects usernames with a 400.

Now validates the configured user_id against the Jellyfin users API:
1. If it matches a user's Id (internal hash), use it directly.
2. If it matches a user's Name (username like 'admin'), resolve the Id.
3. If no match, fall back to the first user and log a warning.

283 backend tests pass; ruff clean.
2026-07-06 15:45:51 +00:00
Developer a3888026ab Fix: media worker crashes silently before writing error state
The worker called _resolve_jellyfin() and client.libraries() OUTSIDE the
try/except block. If Jellyfin was unreachable, the worker crashed with
an unhandled exception and NO error state was written to the DB — the
status stayed 'queued' forever with zero feedback.

Moved _resolve_jellyfin + client.libraries INSIDE the try block, and
created final_index + staging_index BEFORE the try so the except handler
can write the error state. Now any failure (connection refused, timeout,
missing config) is written to the DB as build_error and the frontend
shows it inline.

283 backend tests pass; ruff clean.
2026-07-06 15:36:07 +00:00
Developer 787f46700f Fix: media index build never starts (blocking Jellyfin dependency)
The build endpoint had Depends(get_jellyfin_client) and Depends(get_user_id)
which executed BEFORE the function body. If Jellyfin was unreachable, these
raised HTTPException(503), the function never ran, and the worker was never
started. The frontend mutation had no onError handler, so the failure was
completely silent — the button briefly showed 'Building...' then reverted
to 'Build index' with zero feedback.

Backend fix: removed the Jellyfin dependencies from post_build_index.
The worker subprocess resolves its own Jellyfin connection via
_resolve_jellyfin(service_id) — the endpoint just needs to start the
worker process. The libraries count starts at 0 and gets updated by
the worker once it connects.

Frontend fix: added onError to useBuildIndex that invalidates the status
query (so the UI reflects the non-building state). MediaTab now displays
the build error inline: 'Build failed: <message>' next to the button.

283 backend tests pass (updated build test for new no-dependency flow);
128 frontend tests pass; ruff/eslint clean.
2026-07-06 15:25:48 +00:00
Developer 57fe04ae7b Fix: edit button on referenced widgets opened list view instead of edit
The WidgetConfigDialog useEffect that auto-enters edit mode when
editWidgetId is set only searched owned widget instances (from
useWidgetInstances). Referenced widgets (from useWidgetReferences)
were never found, so startEdit never fired and the dialog fell through
to the list view.

Now the effect searches both owned instances and referenced widgets,
so clicking edit on any widget — owned or referenced — opens the edit
form directly.

128 tests pass; 0 lint errors; build clean.
2026-07-06 14:59:24 +00:00
Developer 5a43894875 Fix: sheet scroll, direct-edit close, mobile copy btn, service badge
Four fixes:

1. Mobile edit fullscreen scroll: the Sheet primitive's
   data-[side=bottom]:h-auto was overriding our h-[100dvh] on
   SheetForm, preventing scroll. Added data-[side=bottom]:h-[100dvh]
   to the SheetForm className to win the specificity battle.

2. Direct-edit close showed list view: when opened via editWidgetId
   (the hover edit button), saving or canceling called reset() which
   showed the widget list instead of closing the dialog. Now derives
   directEdit from editWidgetId — when true, reset() calls onClose()
   to close entirely.

3. Copy button missing on mobile: MobileWidgetSections didn't pass
   onCopy to its WidgetInstanceCard instances. Now accepts and wires
   onCopyWidget, so referenced widgets show the copy/detach button on
   mobile too.

4. Service enabled badge stale: ServiceConfigEditor showed
   instance.enabled (the initial prop) instead of the local enabled
   state. Now reads the local enabled variable so the badge updates
   when the user toggles the switch.

128 tests pass; 0 lint errors; build clean.
2026-07-06 14:51:14 +00:00
Developer 04871bd7d4 Fix: dialog scroll, isDirty false positive, mobile edit btn, widget copy
Five fixes:

1. Dialog mobile scroll: DialogContent now has max-h-[calc(100dvh-2rem)]
   overflow-y-auto so dialogs that don't fit on screen can scroll
   instead of clipping their footer (and Cancel button) off-screen.

2. isDirty false positive: WidgetConfigDialog's SheetForm used
   isDirty={draft !== null} which was true the moment you opened edit
   mode, even with no changes. Now stores a draftBaseline at startEdit
   time and compares JSON.stringify(draft) !== JSON.stringify(baseline).
   The discard-confirmation only appears when something actually changed.

3. Mobile edit button always visible: the widget card's edit button was
   opacity-0 group-hover:opacity-100 (hover-only). Changed to
   md:opacity-0 md:group-hover:opacity-100 — always visible below md,
   hover-reveal at md+.

4. Copy button for referenced widgets: WidgetInstanceCard gains an onCopy
   prop. On the Dashboard, referenced widgets get a Copy icon button that
   triggers detachRef (creates an independent clone). The edit button on
   referenced widgets edits the original (shared config).

5. ConfirmDialog Cancel: fixed by #1 (the Cancel button was off-screen
   on mobile dialogs that couldn't scroll).

128 tests pass; lint/build green.
2026-07-06 14:26:58 +00:00
Developer a63467e163 Fix: main dashboard edit showed all widgets (missing scope filter)
WidgetConfigDialog fetched useWidgetInstances(serviceId) with no scope
filter. On the main dashboard (serviceId undefined, dashboardScope='main'),
this returned ALL widget instances including service-scoped ones from
service overviews — so editing the main dashboard showed widgets that
were never added there.

Fix: pass scope='dashboard' when serviceId is empty and dashboardScope
is set. This fetches only dashboard-scoped widgets (service_id IS NULL).
The 'Add existing' picker (allWidgets) stays unscoped so users can still
reference service widgets onto the dashboard.

128 tests pass; lint/build green.
2026-07-06 14:09:22 +00:00
Developer d8c0a37210 Fix: settings master/detail, widget kind filter, reorder, media worker
Four fixes:

1. Settings > Services tab: master/detail layout. Replaced the vertical
   stack of ServiceConfigEditor cards with a SelectionRailCard (list on
   left) + SectionCard (details on right) — same pattern as Machines
   and SSH Keys tabs. Click a service in the rail to edit it.

2. Service Overview widget restriction. When adding widgets on a
   service's Overview, the dialog now only shows built-in widgets +
   widgets for THAT service type (not all services). Dashboard/named
   dashboards (no serviceId) still see all.

3. Reorder buttons fixed. The swap-sort_order approach was a no-op when
   both items had sort_order=0 (the default). Now moveInstance
   renumbers ALL items by their new index position (i * 10) after the
   swap, guaranteeing values change. References use updateRef, owned
   widgets use saveWidget, both sequential.

4. Media index worker resolution. The subprocess worker called
   get_jellyfin_client/get_user_id (FastAPI request dependencies) which
   don't work outside request context. Now accepts a service_id
   parameter (passed from post_build_index) and resolves the Jellyfin
   instance directly from the settings store via _resolve_jellyfin.
   Raises RuntimeError (not HTTPException) on failure.

283 backend tests pass; 128 frontend tests pass; ruff/eslint clean.
2026-07-06 14:03:05 +00:00
Developer eeb0cccbce Add hover-reveal edit button to widget cards + auto-open edit mode
Each widget card now shows a settings icon in the top-right corner on
hover (desktop) or always-visible (mobile via mobile-touch-target).
Clicking it opens the WidgetConfigDialog directly in edit mode for that
widget (via a new editWidgetId prop on WidgetConfigDialog that
auto-enters the draft-edit path via useEffect).

Wired across all three widget surfaces:
- Dashboard (both mobile section + desktop grid)
- Service OverviewTab
- NamedDashboardPage

128 tests pass; build/lint green.
2026-07-06 12:53:14 +00:00
Developer 691d78ff06 Dedup _resolve_service_record into shared service_resolution module
Extract the duplicated _resolve_service_record helper (identical in
routers/monitoring.py and routers/authentik_users.py) into a shared
services/service_resolution.py module. Both routers now import
resolve_service_record from the shared module.

The authentik router previously hardcoded service_type='authentik' in
its local copy; the shared helper takes service_type as a param (same
as monitoring's did).

Tests updated: test_api.py patches now target the correct module paths
(resolve_service_record on the monitoring module where it's imported,
build_service_record on the service_resolution module).

283 backend tests pass; ruff clean.
2026-07-06 12:25:17 +00:00
Developer 1e636fdbe2 Follow-ups: reference reorder, detach service_id, named-dashboard widgets
Three reusable-widget follow-up fixes:

1. Reference sort_order independently reorderable. Reordering a
   referenced widget now updates the widget_references.sort_order (per-
   dashboard), not the shared widget instance sort_order. New backend
   update_widget_reference method + PUT /api/widgets/references/{id}
   endpoint. Frontend moveInstance checks _ref_id to choose the right
   mutation (updateRef for references, saveWidget for owned).

2. Detach preserves service_id. detach_widget_reference now copies the
   original widget's service_id into the clone, so service-bound widgets
   (Grafana chart, Jellyfin activity) continue to render after detach.

3. Named dashboards support widget references. NamedDashboardPage
   fetches useWidgetReferences('named:<slug>') and renders them via
   WidgetInstanceCard alongside pinned links. 'Edit widgets' button
   opens WidgetConfigDialog with dashboardScope='named:<slug>'.

Also: removed useMemo on combinedWidgets in WidgetConfigDialog to fix
a react-hooks/preserve-manual-memoization lint error (the React Compiler
ESLint plugin couldn't verify the spread+sort memoization).

283 backend tests pass (+1 update_reference test); 128 frontend tests
pass; ruff clean; 0 lint errors.
2026-07-06 12:11:47 +00:00
Developer c36262d7b6 Reusable widgets: reference widgets across dashboards + detach to clone
Widgets configured on one dashboard (e.g., a Grafana service's Overview)
can now be live-referenced on other dashboards. Editing the widget config
updates it everywhere it's referenced. References can be detached into
independent clones.

Backend: new widget_references table (dashboard_scope, widget_id,
sort_order) with ON DELETE CASCADE. CRUD methods + 4 endpoints:
GET/POST /api/widgets/references, DELETE /api/widgets/references/{id},
POST /api/widgets/references/{id}/detach (clones the widget into a
standalone instance, then removes the reference).

Frontend: WidgetConfigDialog gains a dashboardScope prop. When set
(the main Dashboard passes 'main'), the dialog shows:
- Owned + referenced widgets in a combined list, with a link badge on
  references.
- 'Add existing widget' picker: searchable list of ALL widget instances
  not already on this dashboard. Click to create a reference.
- Detach button on references: clones the widget (service_id=NULL) and
  removes the reference.
- Delete on a reference removes the REFERENCE (not the original widget).

Dashboard renders referenced widgets alongside owned widgets.

Detaching a service-bound widget clones it with service_id=NULL — the
clone may need re-binding to a service to render correctly. Named
dashboards don't pass dashboardScope yet (pinned-links-only); when they
gain widget support, the backend already handles any scope string.

282 backend tests pass (+2 reference lifecycle); 127 frontend tests
pass; ruff/eslint/tsc/vite all green.
2026-07-06 11:34:48 +00:00
Developer 94bf830955 Fix invisible chart lines + extract Prometheus labels for multi-series
Two fixes for the Grafana chart widget:

1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the
   CSS variable is named '--color-chart-1' and already contains a hex
   color (#4f8cff). The hsl() wrapper produced invalid CSS, making
   every stroke invisible. Fixed to var(--color-chart-1).

2. Multiple series collision: the backend labeled all Prometheus series
   with the value field name (often just 'Value'), so multiple time
   series collided on the same recharts dataKey and overwrote each
   other. Now extracts meaningful labels from the Grafana frame metadata:
   prefers displayName, then Prometheus metric labels (e.g.
   'instance=server1:9100 mode=iowait'), then falls back to the field
   name. Duplicate labels get a numeric suffix for uniqueness.

Multi-series queries now render correctly: each Prometheus time series
gets its own colored line with a unique label in the legend/tooltip.

280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/
eslint clean.
2026-07-06 10:59:16 +00:00
Developer f355d04278 Use multi-line textarea for complex widget config fields
The Grafana chart widget's 'query' field (PromQL) and any schema field
marked format:'textarea' now render as a resizable 4-row Textarea with
monospace font, instead of a single-line Input. Makes complex queries
much easier to read and edit.

The field-renderer heuristic: key === 'query' or schema format ===
'textarea' → Textarea; everything else stays an Input (numbers stay
number inputs). 127 tests pass; lint/build green.
2026-07-06 10:43:12 +00:00
Developer bfe7ce7367 Fix: WidgetConfigDialog scope + reorder race condition
Two bugs in the widget config dialog:

1. Service Overview edit showed main-dashboard widgets. The dialog
   called useWidgetInstances() with no args, fetching ALL widgets. Now
   accepts a serviceId prop; OverviewTab passes instance.id so the
   dialog lists + creates only service-scoped widgets. New built-in
   widgets added from a service Overview inherit the serviceId.

2. Reorder up/down buttons did nothing. moveInstance fired two
   saveWidget mutations via Promise.all — the first mutation's
   onSuccess cache invalidation triggered a refetch before the second
   completed, reverting the swap. Changed to sequential awaits so
   both sort_order writes land before the cache refreshes.

127 tests pass; lint/build green.
2026-07-06 10:29:24 +00:00
Developer 447775048c Replace Grafana iframe panel with server-side chart widget
The iframe-based 'panel' widget didn't work: the browser couldn't
authenticate against the OIDC-protected Grafana (Authentik), and
iframes can't carry Bearer tokens or share cross-origin session
cookies. Result: blank iframe or login redirect.

Replace it with a 'chart' widget that queries Grafana's datasource
API server-side:

Backend (GrafanaWidgetSource): POSTs to /api/ds/query with the stored
api_key (which bypasses OIDC), using the widget's configured PromQL
query, datasource_uid, time range, and resolution. Normalizes Grafana's
frame-based response into a simple {series: [{label, points: [{t, v}]}]}
shape. The api_key is never exposed to the browser.

Frontend (GrafanaChartWidget): renders the series data as a recharts
LineChart with dark-mode-aware colors (Tailwind --chart-* tokens),
responsive container, custom tooltip, and per-series lines. Loading
skeleton, error Alert, and empty state. recharts ^3.9.2 added.

The 'link' widget kind (deep-link URL) is unchanged. The 'panel' kind
and GrafanaPanelWidget are fully removed.

Backend: 279 tests pass (+1 net: -2 panel + 3 chart). Frontend: 127
tests pass (net 0: -3 panel + 3 chart). Lint/build green both sides.
2026-07-06 10:19:57 +00:00
Developer b877a32ad8 Add Jellyfin Now Playing + Grafana Panel embed widgets
Two new additive widget kinds:

Jellyfin 'now_playing': like the existing 'activity' widget but filters
to only sessions with active playback (NowPlayingItem present + not
paused). Shows who's actually watching right now. The 'activity' kind
is unchanged (shows all sessions including idle).

Grafana 'panel': embeds a single Grafana panel directly in the app via
an iframe, using Grafana's /d-solo/ endpoint (renders one panel without
dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and
time range (from/to, defaults now-1h/now). Includes a fallback 'Open in
Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The
'link' kind is unchanged (still builds a deep-link URL).

Backend: new widget configs + definitions on jellyfin/grafana; source
adapter logic (session filter for now_playing; d-solo embed URL for
panel); 6 new tests.

Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components;
registry bindings; 6 new tests.

278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build
green both sides.
2026-06-28 15:26:43 +00:00
348 changed files with 19752 additions and 1699 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .claude
## role
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
Configuration and settings directory for Claude AI assistant integration within the project workspace.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .claude
index: .claude/.pi-map.index.md
## role
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
Configuration and settings directory for Claude AI assistant integration within the project workspace.
## files
## arch
Flat configuration structure containing markdown/YAML files that define custom commands, project context, and operational rules for Claude's interactions with the codebase.
Flat configuration directory following standard AI assistant tool conventions, typically containing permission rules, context files, and project-specific behavioral settings.
## tags
-
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .claude/skills
## role
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
## parent
index: .claude/.pi-map.index.md
map: .claude/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .claude/skills
index: .claude/skills/.pi-map.index.md
## role
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
## files
## arch
Flat configuration file structure defining modular skill behaviors and prompts used to extend Claude's domain-specific abilities.
Flat directory structure with markdown-based skill modules that define specialized assistant capabilities.
## tags
-
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .claude/skills/sift-backlog
## role
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
## parent
index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: .claude/skills/sift-backlog
index: .claude/skills/sift-backlog/.pi-map.index.md
## role
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
## files
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. | dep: sf CLI (task, plan, dependency, update subcommands)
## arch
Single-file declarative skill definition following a prompt-driven workflow pattern with structured triage and activation instructions for Claude to execute.
Single-file declarative skill definition following a prompt-engineering pattern that encodes step-by-step procedures and decision rules for Claude to execute when invoked.
## tags
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .opencode
## role
Configuration directory for the opencode tool, managing project-specific settings and preferences.
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .opencode
index: .opencode/.pi-map.index.md
## role
Configuration directory for the opencode tool, managing project-specific settings and preferences.
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
## files
## arch
Flat directory structure containing configuration files that define opencode behavior for the associated project.
Flat directory structure with declarative configuration files; no executable code or architectural patterns involved.
## tags
-
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .opencode/commands
## role
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
## parent
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
+2 -2
View File
@@ -4,14 +4,14 @@ dir: .opencode/commands
index: .opencode/commands/.pi-map.index.md
## role
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
## files
- opsx-apply.md | Defines a workflow for implementing tasks from an OpenSpec change in a structured, iterative manner with pause points for blockers and ambiguity. | dep: openspec CLI, AskUserQuestion tool, filesystem access
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development process, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), tasks.md
- opsx-explore.md | Defines the explore mode stance for a thinking/discussion assistant that investigates problems and clarifies requirements without implementing code | dep: OpenSpec system (openspec CLI, change artifacts like proposal.md/design.md/tasks.md/spec.md)
- opsx-propose.md | Defines a workflow for creating a new change with all required planning artifacts (proposal, design, tasks) in a single step using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, file system
## arch
Markdown-based declarative templates serving as structured prompts/playbooks that guide an AI assistant through specific operational phases of a spec-driven lifecycle.
Markdown-based command-definition pattern where each file encodes a discrete, step-by-step procedural prompt controlling assistant behavior for a specific development phase.
## tags
opsx, defines, tasks, md, workflow, openspec, openspec cli, askuserquestion tool
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .opencode/skills
## role
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
## parent
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .opencode/skills
index: .opencode/skills/.pi-map.index.md
## role
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
## files
## arch
Configuration-based skill definition directory; skills are declared as individual files consumed by the opencode agent runtime to extend or specialize assistant behavior.
Configuration-driven skill registry with declarative definition files (no implementation code present in this directory).
## tags
-
## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-apply-change
## role
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking.
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-apply-change
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
## role
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking.
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
## files
- SKILL.md | Defines a skill for implementing tasks from an OpenSpec change using a schema-driven workflow with progress tracking and contextual file reading. | dep: openspec CLI, AskUserQuestion tool
## arch
Documentation-based skill specification using markdown with defined workflow steps, schema references, and contextual file reading rules.
Declarative skill specification using markdown-based instructions, schema-driven task processing, and progressive context loading patterns.
## tags
skill, defines, implementing, tasks, openspec, change, schema, driven
## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-archive-change
## role
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-archive-change
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
## role
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
## files
- SKILL.md | Defines a skill for archiving a completed change in the openspec experimental workflow, including validation, sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool (subagent_type: general-purpose), openspec-sync-specs skill
## arch
Single-document declarative skill specification following a procedural checklist pattern (validate, assess sync, confirm) designed for an AI agent to execute.
Single-file declarative skill definition using a markdown-based pattern description format, structured as a procedural workflow with validation gates and conditional user interaction steps.
## tags
skill, openspec, sync, defines, archiving, completed, change, experimental
## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-explore
## role
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-explore
index: .opencode/skills/openspec-explore/.pi-map.index.md
## role
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
## files
- SKILL.md | Defines a conversational "explore mode" skill for the OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
## arch
Skill-definition pattern using a single Markdown file (SKILL.md) that declaratively specifies the assistant's behavioral constraints, workflow, and operational guidelines.
Skill-definition pattern using a single Markdown manifest (SKILL.md) that declaratively specifies the assistant's behavioral instructions, interaction style, and operational constraints for the explore workflow.
## tags
skill, defines, conversational, explore, mode, openspec, cli, acts
## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-propose
## role
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
## parent
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-propose
index: .opencode/skills/openspec-propose/.pi-map.index.md
## role
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
## files
- SKILL.md | Defines an AI assistant skill that automates proposing new changes by scaffolding a directory, generating dependent artifacts (proposal, design, tasks), and tracking progress through a structured workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
## arch
Skill-definition pattern using a declarative markdown document (SKILL.md) that encodes a step-by-step procedural workflow with CLI integration conventions.
Declarative skill-definition pattern using a markdown-based manifest (SKILL.md) that encodes a step-by-step procedural workflow, CLI commands, and file-system conventions for the AI to follow.
## tags
skill, defines, assistant, automates, proposing, new, changes, scaffolding
## symbols
+4 -2
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides.
## role
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
## parent
-
## children
@@ -32,6 +32,9 @@ Root project configuration and orchestration package for a media library managem
- .pi
index: .pi/.pi-map.index.md
map: .pi/.pi-map.md
- .pi-tmp
index: .pi-tmp/.pi-map.index.md
map: .pi-tmp/.pi-map.md
- .ruff_cache
index: .ruff_cache/.pi-map.index.md
map: .ruff_cache/.pi-map.md
@@ -66,7 +69,6 @@ Root project configuration and orchestration package for a media library managem
- docker-compose.dev.yml
- docker-compose.observability.yml
- docker-compose.yml
- swap-pane
- token-usage-output.txt
## links
index: ./.pi-map.index.md
+3 -4
View File
@@ -18,13 +18,13 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides.
## role
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
- .gitignore | Configures Git to ignore Python artifacts, virtual environments, secrets, editor files, frontend builds, and tool-specific metadata from version control.
- AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across recent versions.
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across releases.
- CONTRIBUTING.md | Provides contribution guidelines and setup instructions for the Manage project's backend (FastAPI) and frontend (React) codebases. | dep: FastAPI, React, Vite, TypeScript, Ruff, pytest, Docker Compose, Tailwind CSS, TanStack Query
- LICENSE | Provides the MIT open-source software license terms for the project
- README.md | Project README documenting a media and server operations tool with Jellyfin integration, SSH file inspection, and server monitoring capabilities. | dep: FastAPI, React, TypeScript, Docker Compose, SQLite, Traefik, OIDC/Authentik, Jellyfin, Prometheus, Grafana, Alertmanager
@@ -32,10 +32,9 @@ Root project configuration and orchestration package for a media library managem
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
## arch
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
Full-stack containerized architecture using Docker Compose orchestration with a FastAPI/Uvicorn backend and Vite React frontend, Traefik reverse proxy with TLS/OIDC, and an optional observability stack (Prometheus, Grafana, Loki, Alertmanager, Alloy).
## tags
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
## symbols
+25
View File
@@ -0,0 +1,25 @@
# .pi-tmp (index)
dir: .pi-tmp
## role
Documentation and reporting workspace containing acceptance reports and change documentation for iterative feature development and refactoring efforts.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
-
## files
- followups-batch1-out.md
- four-fixes-out.md
- grafana-chart-out.md
- refine-23-out.md
- refine-4-out.md
- reusable-widgets-out.md
- widgets-out.md
## links
index: .pi-tmp/.pi-map.index.md
map: .pi-tmp/.pi-map.md
## workflows
-
## dirty
-
+25
View File
@@ -0,0 +1,25 @@
# .pi-tmp
dir: .pi-tmp
index: .pi-tmp/.pi-map.index.md
## role
Documentation and reporting workspace containing acceptance reports and change documentation for iterative feature development and refactoring efforts.
## files
- followups-batch1-out.md | This file documents a batch of fixes for a reusable widget system, including a code change summary, validation test results, and a formal acceptance report.
- four-fixes-out.md | Documentation report detailing four bug fixes across frontend and backend components, including changes made, validation results, and residual risks.
- grafana-chart-out.md | Documentation and acceptance report for replacing a Grafana iframe panel widget with a server-side chart query widget using recharts. | dep: recharts, Grafana API, Tailwind CSS, pytest, ruff, eslint
- refine-23-out.md | Documentation of a refactoring effort that moved service configuration from the ServicePage to Settings, replacing it with instance tabs.
- refine-4-out.md | Documentation of a change implementing configurable per-service widget overview tabs with backend filtering by service_id/scope, replacing stubs with a real OverviewTab component. | dep: React, TypeScript, Python/FastAPI, pytest, ruff, Vite, React Query (useWidgets hook)
- reusable-widgets-out.md | This file is an implementation report documenting the addition of reusable widget references across a full-stack application (backend CRUD/API and frontend UI/hooks).
- widgets-out.md | Documentation/acceptance report describing the implementation of two new widgets (Jellyfin now_playing and Grafana panel embed) across backend and frontend.
## arch
Flat collection of Markdown reports, each following a consistent structure of change summary, validation results, and acceptance/risk assessment across full-stack changes.
## tags
out, widget, report, documentation, widgets, fixes, reusable, backend
## symbols
-
## workflows
-
## dirty
-
+141
View File
@@ -0,0 +1,141 @@
# Grafana Chart Widget — worker output
## Files changed (10 files, ~400 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +12/-12 (panel→chart config + kind) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +70/-12 (chart query adapter replaces panel URL logic) |
| `backend/tests/test_widgets.py` | modified | +55/-20 (3 new chart tests replace 2 panel tests) |
| `backend/tests/test_services.py` | modified | +2/-2 (grafana widget-kind + API-metadata assertions) |
| `frontend/src/widgets/GrafanaChartWidget.tsx` | **new** | 100 |
| `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` | **new** | 57 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | **deleted** | -50 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | **deleted** | -72 |
| `frontend/src/integrations/registry.ts` | modified | +24/-14 (chart binding replaces panel) |
| `frontend/src/integrations/registry.test.ts` | modified | +1/-1 (panel→chart) |
| `frontend/src/widgets/index.ts` | modified | +1/-0 (export GrafanaChartWidget) |
| `frontend/package.json` + `package-lock.json` | modified | +1 dep (recharts ^3.9.2) |
**recharts version installed:** `^3.9.2`
## Grafana `/api/ds/query` request/response shape
**Request** (POST):
```json
{
"queries": [{
"datasource": {"uid": "prometheus", "type": "prometheus"},
"expr": "rate(cpu[5m])",
"format": "time_series",
"intervalMs": 30000,
"maxDataPoints": 100,
"refId": "A"
}],
"from": "now-1h",
"to": "now"
}
```
Headers: `Authorization: Bearer {api_key}`, `Content-Type: application/json`
**Response** (abbreviated):
```json
{
"results": {
"A": {
"frames": [{
"data": { "values": [[1000, 2000], [0.5, 0.8]] },
"schema": { "fields": [{"name":"Time"}, {"name":"cpu_usage"}] }
}]
}
}
}
```
## Series normalization logic
Iterates `results[*].frames[]`. For each frame with `values` having >=2 arrays (timestamps + values), extracts the series label from `schema.fields[-1].name` and zips timestamps+values into `[{t: int, v: float|null}]`. Returns `{"series": [{"label": "...", "points": [...]}]}`.
## Frontend chart rendering
`GrafanaChartWidget` fetches widget data, extracts `data.series`, merges all series by timestamp into a single recharts data array (`[{time, cpu_usage: 0.5, mem: 0.3}, ...]`), and renders a `<LineChart>` with one `<Line>` per series. Uses Tailwind CSS variables (`--chart-1` through `--chart-5`) for colors so it respects dark mode. Includes loading skeleton, error Alert, and empty-state Alert.
## Validation
```
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest → 279 passed, ruff clean
cd frontend && npm run lint && npm run build && npm run test → 127 passed, lint/build clean
```
## Deviations
1. **No deviations from spec.** The `link` widget kind is unchanged. The `panel` kind is fully replaced by `chart`.
2. **recharts `labelFormatter` type workaround.** Recharts 3.x types `labelFormatter` as `(label: ReactNode, ...) => ReactNode`, not `(number) => string`. Wrapped with `(label) => formatTime(Number(label))` to satisfy TS strict.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- The chart widget assumes the Grafana datasource is Prometheus-type (hardcoded `"type": "prometheus"` in the query body). If the user has a non-Prometheus datasource (InfluxDB, etc.), the query body format may need adjustment. The `datasource_uid` is configurable but the `type` is not.
- recharts is ~45KB gzipped added to the bundle.
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Replaces the broken iframe panel widget with a server-side chart query widget. Backend queries /api/ds/query with stored api_key; frontend renders recharts LineChart. No iframe, no browser auth, no CORS. The link widget kind is unchanged. 279 backend + 127 frontend tests pass; lint/build green both sides."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/integrations/grafana.py",
"backend/src/media_library_viewer_api/widgets/sources.py",
"backend/tests/test_widgets.py",
"backend/tests/test_services.py",
"frontend/src/widgets/GrafanaChartWidget.tsx",
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
"frontend/src/widgets/GrafanaPanelWidget.tsx (deleted)",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx (deleted)",
"frontend/src/integrations/registry.ts",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/index.ts",
"frontend/package.json"
],
"testsAddedOrUpdated": [
"backend/tests/test_widgets.py",
"backend/tests/test_services.py",
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
"frontend/src/integrations/registry.test.ts"
],
"commandsRun": [
{ "command": "cd backend && .venv/bin/ruff check .", "result": "passed", "summary": "All checks passed" },
{ "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "279 passed, 2 pre-existing warnings" },
{ "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors, 0 warnings" },
{ "command": "cd frontend && npm run build", "result": "passed", "summary": "tsc + vite build clean" },
{ "command": "cd frontend && npm run test", "result": "passed", "summary": "39 files / 127 tests passed" }
],
"validationOutput": [
"Backend ruff clean; 279 tests pass (was 278; -2 panel + 3 chart = +1 net).",
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (-3 panel + 3 chart = net 0).",
"GrafanaWidgetSource._fetch_chart POSTs to /api/ds/query with Bearer token; normalizes response to {series:[{label,points}]}",
"GrafanaChartWidget renders recharts LineChart with dark-mode CSS variable colors.",
"link widget kind unchanged; panel widget kind fully removed."
],
"residualRisks": [
"Chart query body hardcodes datasource type 'prometheus' — non-Prometheus datasources (InfluxDB etc.) may need a type field on the config.",
"recharts adds ~45KB gzipped to the frontend bundle."
],
"noStagedFiles": true,
"diffSummary": "~400 lines: replaces Grafana panel iframe widget with server-side datasource-query chart widget. Backend: /api/ds/query POST with api_key + series normalization (70 lines). Frontend: recharts LineChart component with dark-mode support (100 lines). 3 backend + 3 frontend tests. recharts ^3.9.2 installed.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "recharts labelFormatter type workaround: recharts 3.x types it as (ReactNode) => ReactNode, not (number) => string. Wrapped with Number() cast. The link widget kind is fully preserved. The panel widget kind and all its code/tests are fully deleted."
}
```
+142
View File
@@ -0,0 +1,142 @@
# New widgets: Jellyfin now_playing + Grafana panel embed
## Files changed (10 files, ~390 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/jellyfin.py` | modified | +12 (new widget kind + config model) |
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +17 (new widget kind + config model) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +12 (now_playing filter + panel embed URL) |
| `backend/tests/test_services.py` | modified | +3 (updated widget-kind assertions) |
| `backend/tests/test_widgets.py` | modified | +85 (import + 6 new tests) |
| `frontend/src/widgets/JellyfinNowPlayingWidget.tsx` | new | 41 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | new | 50 |
| `frontend/src/integrations/registry.ts` | modified | +24 (2 new widget bindings) |
| `frontend/src/integrations/registry.test.ts` | modified | +1 (updated grafana kinds) |
| `frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx` | new | 72 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | new | 72 |
## Session-filter logic for now_playing
```python
if widget_kind == "now_playing":
sessions = [
s for s in sessions
if s.get("NowPlayingItem")
and not s.get("PlayState", {}).get("IsPaused", True)
]
```
Filters raw Jellyfin sessions BEFORE `_map_sessions_to_activity_rows`. A session is "actively playing" when it has a `NowPlayingItem` (something is playing, not just idle) AND `PlayState.IsPaused` is false. The `activity` kind (default) is unchanged — shows all sessions including idle and paused.
## Embed URL format for panel
```python
embed_url = f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
```
Uses Grafana's `/d-solo/` endpoint which renders a single panel without dashboard chrome. `kiosk=tv` hides the top nav. Defaults: `from_ts="now-1h"`, `to_ts="now"`.
## Validation
```
cd backend && .venv/bin/ruff check src/ tests/ → All checks passed!
cd backend && .venv/bin/python -m pytest tests/ → 278 passed, 2 warnings (pre-existing)
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc + vite)
cd frontend && npm run test → 39 files / 127 tests passed
```
Backend: +6 new tests (definition assertions x2, grafana panel URL x2, jellyfin now_playing filter x1, jellyfin activity shows all x1).
Frontend: +6 new tests (JellyfinNowPlayingWidget x3, GrafanaPanelWidget x3).
## Deviations
1. **No deviations from spec.** Both widgets are additive — no existing behavior changed. The `activity` and `link` kinds work exactly as before.
2. **GrafanaPanelWidget pi-lens advisory** for `<Button asChild><a>` is a false positive (Radix Slot merges props, doesn't create nested `<a>`). Same pattern as GrafanaLinkWidget, ObservabilityPage, and PinnedServiceLink. Build and lint pass.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- **Grafana embedding may be blocked** by `X-Frame-Options` or CSP depending on Grafana config. The fallback "Open in Grafana" link is provided.
- **GrafanaPanelWidget iframe height is fixed at 300px** — not responsive to panel content height. A follow-up could use Grafana's panel-content-height API or a ResizeObserver.
- **now_playing filter operates on raw sessions before mapping** — if Jellyfin changes its session shape (e.g. moves `NowPlayingItem`/`PlayState`), the filter silently passes all sessions. Same fragility as the existing activity mapping.
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Implements two additive widget kinds (jellyfin now_playing + grafana panel embed) without changing any existing behavior. Backend: new widget configs + definitions + source adapter logic + 6 tests. Frontend: 2 new components + registry bindings + 6 tests. 278 backend + 127 frontend tests pass; ruff/eslint/tsc/vite all green. No staged files."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/integrations/jellyfin.py",
"backend/src/media_library_viewer_api/integrations/grafana.py",
"backend/src/media_library_viewer_api/widgets/sources.py",
"backend/tests/test_services.py",
"backend/tests/test_widgets.py",
"frontend/src/widgets/JellyfinNowPlayingWidget.tsx",
"frontend/src/widgets/GrafanaPanelWidget.tsx",
"frontend/src/integrations/registry.ts",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
],
"testsAddedOrUpdated": [
"backend/tests/test_services.py",
"backend/tests/test_widgets.py",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
],
"commandsRun": [
{
"command": "cd backend && .venv/bin/ruff check src/ tests/",
"result": "passed",
"summary": "All checks passed"
},
{
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
"result": "passed",
"summary": "278 passed, 2 warnings (pre-existing deprecation)"
},
{
"command": "cd frontend && npm run lint",
"result": "passed",
"summary": "0 errors, 0 warnings"
},
{
"command": "cd frontend && npm run build",
"result": "passed",
"summary": "tsc + vite build clean"
},
{
"command": "cd frontend && npm run test",
"result": "passed",
"summary": "39 files / 127 tests passed"
}
],
"validationOutput": [
"Backend ruff clean; 278 tests pass (+6 new).",
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (+6 new).",
"Jellyfin now_playing filters: session has NowPlayingItem + IsPaused=false.",
"Grafana panel embed URL: /d-solo/{uid}/manage?panelId={id}&from={from}&to={to}&kiosk=tv.",
"Existing activity + link widget kinds unchanged (tested)."
],
"residualRisks": [
"Grafana iframe may be blocked by X-Frame-Options/CSP; fallback link provided.",
"Iframe height fixed at 300px (not responsive to panel content).",
"now_playing filter depends on Jellyfin session shape (NowPlayingItem/PlayState)."
],
"noStagedFiles": true,
"diffSummary": "~390 lines across 11 files: 2 new backend widget kinds (jellyfin now_playing + grafana panel) with source adapter logic, 2 new frontend components, registry bindings, and 12 new tests (6 backend + 6 frontend). Purely additive — no existing behavior changed.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "The JellyfinClient mock approach uses patch on the class directly (not asyncio.to_thread) — let real asyncio handle the threading. The pi-lens nested-<a> advisory on GrafanaPanelWidget is a false positive (Button asChild uses Radix Slot)."
}
+31
View File
@@ -4,6 +4,37 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
## [Unreleased]
### **BREAKING** — Prometheus queries now route through Grafana gateway
- The `prometheus` service config changed: `base_url` is replaced by
`grafana_url` + `datasource_uid`, and the `api_key` secret is replaced by
`grafana_api_key` (a Grafana service account token or API key with read
access to the Prometheus datasource). All metric widget queries (`chart`,
`gauge`, `mean`, `metric`) now issue `POST {grafana_url}/api/ds/query`
instead of direct Prometheus HTTP calls.
- **Migration:** Reconfigure existing `prometheus` services — replace
`base_url` with `grafana_url` (your Grafana instance URL), add the
`grafana_api_key` secret, and optionally set `datasource_uid` (defaults
to `"prometheus"`).
### Added — Direct Prometheus charting
- **Prometheus is now the direct source for in-app charts.** New widget kinds
on the `prometheus` service: `chart` (multi-series line chart via recharts,
backed by `/api/v1/query_range`), `gauge` (instant scalar with configurable
threshold bands), and `mean` (client-side average over a time window).
### **BREAKING** — Grafana service type removed
- The `grafana` service type, Grafana link widget, Grafana chart widget, and
`GET /api/monitoring/grafana-status` endpoint were **removed**. Manage now
queries Prometheus directly for all chart data.
- **Migration:** Delete any existing Grafana service instances and create
Prometheus service instances instead (pointing at your Prometheus URL). Any
configured `grafana/chart` widgets must be recreated as `prometheus/chart`
widgets. Grafana link widgets are gone — use Prometheus chart/metric widgets
instead.
### Added — Observability service registry
- **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and
+1 -1
View File
@@ -2,7 +2,7 @@
dir: archive
## role
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: archive
index: archive/.pi-map.index.md
## role
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
## files
- app.py | Provides a minimal Streamlit entrypoint that adds the src directory to Python's path and delegates to the actual application in media_library_viewer.app. | dep: sys, pathlib, media_library_viewer.app
- pyproject.toml | Defines Python package metadata, dependencies, and tool configurations for a Streamlit-based Jellyfin media library browser with SSH remote file inspection. | dep: hatchling, streamlit, streamlit-aggrid, requests, paramiko, python-dotenv, pandas, ruff, pytest
- requirements.txt | Installs the current package in editable/development mode using pip | dep: pip, setuptools
## arch
Thin entrypoint pattern using a bootstrap app.py that manipulates sys.path to delegate execution to a nested media_library_viewer package, managed via standard Python packaging (pyproject.toml).
Thin bootstrap layer using path manipulation to delegate to a source module (src/), packaged with standard Python tooling (pyproject.toml) for dependency management and Streamlit deployment.
## tags
streamlit, app, python, media, library, package, pyproject, pip
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: archive/src
## role
No files provided directory appears to be empty or contents were not included, so the package's role cannot be determined.
Insufficient information — no files provided in the directory listing to determine this package's role.
## parent
index: archive/.pi-map.index.md
map: archive/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: archive/src
index: archive/src/.pi-map.index.md
## role
No files provided directory appears to be empty or contents were not included, so the package's role cannot be determined.
Insufficient information — no files provided in the directory listing to determine this package's role.
## files
## arch
Cannot be assessed due to missing file contents; please provide the file listing for analysis.
Unable to assess — empty directory or missing file contents for architectural analysis.
## tags
-
## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer
## role
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
## parent
index: archive/src/.pi-map.index.md
map: archive/src/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: archive/src/media_library_viewer
index: archive/src/media_library_viewer/.pi-map.index.md
## role
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
## files
- __init__.py | Package initialization file that defines the Media Library Viewer package metadata and exports the version string.
- app.py | Streamlit UI entrypoint for a Media Library Viewer that connects to Jellyfin and SSH backends, providing dashboard, monitoring, media browsing, and file browser tabs with cached data and path resolution between systems. | exp: func:get_jellyfin_client(base_url: str, api_key: str) → JellyfinClient, call:JellyfinClient, func:cached_users(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).users, func:get_ssh_client(host: str, username: str, port: int, key_filename: str, password: str) → RemoteSSHClient, call:RemoteSSHClient, call:client.connect, func:cached_libraries(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).libraries, func:cached_media_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).media_counts, func:cached_library_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client, call:client.libraries, call:client.library_item_counts, func:cached_active_sessions(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).active_sessions, func:cached_dir_listing(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.list_dir, call:json.loads, raise:RuntimeError, func:cached_ffprobe_preview(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.ffprobe_json, func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:apply_remote_path_prefix, func:credentials_panel(), call:load_config, call:st.header, call:st.expander, call:st.text_input, call:st.number_input, call:int, func:main(), call:st.set_page_config, call:st.title, call:st.caption, call:credentials_panel, call:st.info, call:get_jellyfin_client, call:cached_users, call:st.error, call:user.get, call:st.selectbox, call:list, call:user_options.keys, call:st.tabs, call:render_now_playing, call:st.divider, call:render_resource_dashboard, call:render_media_overview, call:cached_libraries, call:set_file_browser_path, call:resolve_remote_media_path, call:render_media_tab, call:render_file_browser, call:get_ssh_client, call:render_ssh_tools, func:set_prefixed_file_browser_path(path: str, selected_path, reset_filters) → None, call:set_file_browser_path, call:resolve_remote_media_path | dep: json, posixpath, typing, media_library_viewer.clients.jellyfin, media_library_viewer.clients.ssh, media_library_viewer.config, media_library_viewer.ui.dashboard, media_library_viewer.ui.file_browser, media_library_viewer.ui.media, media_library_viewer.ui.preview, streamlit
@@ -12,7 +12,7 @@ Streamlit-based media library viewer that provides a unified dashboard for brows
- jobs.py | Defines safe, template-based remote SSH jobs with shell-quoted parameter rendering. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:ssh.run | dep: shlex, dataclasses, typing, media_library_viewer.clients.ssh, typing.Mapping
- utils.py | Provides UI-independent formatting helpers and ffprobe output summarizers for video/audio/subtitle stream metadata. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
## arch
Layered Streamlit application using immutable dataclass configuration, template-based remote job execution, cached data access, and separated utility functions following a tab-based modular UI pattern.
Tab-based modular frontend using immutable dataclass configuration, environment-driven settings, cached data access, template-based remote job execution, and separated utility functions for metadata formatting.
## tags
client, path, media, call:, jellyfin, call:get, ssh, cached
## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/clients
## role
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
## parent
index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md
@@ -4,14 +4,14 @@ dir: archive/src/media_library_viewer/clients
index: archive/src/media_library_viewer/clients/.pi-map.index.md
## role
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
## files
- __init__.py | Package initialization file that defines external service clients module boundaries and constraints
- jellyfin.py | HTTP API client for Jellyfin/Emby media servers providing user, library, item, and session management with plain Python return types for frontend agnosticism. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → dict[str, Any], call:params.items, call:self.session.get, call:response.raise_for_status, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:isinstance, call:session.get, method:image_url(self, item_id: str, image_type) → str | dep: typing, requests
- resources.py | Manages a lightweight POSIX shell-based remote system metrics collector that samples CPU, memory, network, and disk statistics via SSH and reads the resulting JSONL data. | exp: class:ResourceMonitorPaths, func:start_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:shlex.quote, call:int, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:stop_resource_collector(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:restart_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:stop_resource_collector, call:start_resource_collector, func:resource_collector_status(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:resource_collector_debug_info(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, func:read_resource_metrics(ssh: RemoteSSHClient, max_lines, paths) → list[dict[str, Any]], call:shlex.quote, call:int, call:ssh.run, call:result.stdout.splitlines, call:line.strip, call:rows.append, call:json.loads, raise:RuntimeError, func:disk_space(ssh: RemoteSSHClient, path) → dict[str, Any], call:shlex.quote, call:ssh.run, call:result.stdout.strip, call:json.loads, raise:RuntimeError | dep: json, shlex, dataclasses, typing, media_library_viewer.clients.ssh
- ssh.py | Provides an SSH client wrapper around Paramiko for remote filesystem inspection and media analysis, ensuring POSIX shell compatibility regardless of the user's login shell. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, password, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:client.connect, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, posixpath, shlex, dataclasses, typing, paramiko
## arch
Client-wrapper pattern with each module encapsulating a specific integration concern (Jellyfin HTTP API, SSH filesystem access, remote resource monitoring), returning plain Python types for frontend agnosticism.
Modular client-per-service pattern with plain Python return types for frontend agnosticism, wrapping HTTP APIs (Jellyfin/Emby) and SSH/Paramiko connections with POSIX shell compatibility enforcement.
## tags
call:shlex.quote, resource, error, collector, call:ssh.run, raise:runtime, call:self.get, call:result.stdout.strip
## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/domain
## role
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display.
Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
## parent
index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md
@@ -4,12 +4,12 @@ dir: archive/src/media_library_viewer/domain
index: archive/src/media_library_viewer/domain/.pi-map.index.md
## role
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display.
Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
## files
- __init__.py | Serves as the package docstring for a domain-level helpers/normalization module that converts external data into stable app concepts.
- media.py | Flattens inconsistent Jellyfin API item JSON into stable, normalized dictionaries for SQLite storage and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer.utils, pandas, media_library_viewer.utils (human_size, ticks_to_minutes)
## arch
Functional transformation layer pattern mapping raw external API JSON directly into normalized flat dictionaries without intermediate ORM or complex object hierarchies.
Functional transformation layer using dictionary flattening and field mapping to decouple external API data shapes from internal storage (SQLite) and presentation (frontend) concerns.
## tags
media, call:str, date, added, item, call:item.get, streams, call:stream
## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/services
## role
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
## parent
index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md
@@ -4,12 +4,12 @@ dir: archive/src/media_library_viewer/services
index: archive/src/media_library_viewer/services/.pi-map.index.md
## role
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
## files
- __init__.py | Marks the directory as a Python package and documents it as the application services layer for coordinating clients/domain logic into reusable operations.
- media_index.py | Provides a UI-agnostic SQLite-backed media inventory service that indexes, queries, and manages Jellyfin media metadata with filtering, sorting, and pagination capabilities. | exp: class:MediaIndexStatus, class:MediaIndex, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, method:init_schema(self) → None, call:self.connect, call:conn.executescript, method:set_metadata(self, key: str, value: str | int | float) → None, call:self.init_schema, call:self.connect, call:conn.execute, call:str, method:replace_items(self, rows: Iterable[dict[str, Any]]) → int, call:self.init_schema, call:list, call:",".join, call:len, call:self.connect, call:conn.execute, call:conn.executemany, call:','.join, call:row.get, call:str, call:int, call:time.time, method:status(self) → MediaIndexStatus, call:self.db_path.exists, call:MediaIndexStatus, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone, call:str(updated_row[0]).isdigit, call:time.strftime, call:time.localtime, call:float, method:query(self, library_id, library_ids, media_types, search, hdr_filter, sort_key, sort_order, limit, offset) → tuple[list[dict[str, Any]], int], call:self.init_schema, call:where.append, call:",".join, call:len, call:params.extend, call:params.append, call:search.lower, call:" AND ".join, call:SORT_COLUMNS.get, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone, call:conn.execute( "SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?", [*params, int(limit), int(offset)], ).fetchall, call:display_media_row, call:dict, func:build_media_index(client: JellyfinClient, user_id: str, libraries: list[dict[str, Any]], index, page_size) → int, call:MediaIndex, call:time.perf_counter, call:library.get, call:client.items, call:response.get, call:normalized_rows.extend, call:normalize_media_item, call:len, call:int, call:index.replace_items, call:index.set_metadata | dep: sqlite3, time, dataclasses, pathlib, typing, media_library_viewer.clients.jellyfin, media_library_viewer.domain.media, media_library_viewer.clients.jellyfin.JellyfinClient, media_library_viewer.domain.media.display_media_row, media_library_viewer.domain.media.normalize_media_item
## arch
Service-oriented pattern with SQLite-backed indexing, query filtering, and pagination encapsulated behind a single cohesive media index service module.
Service-oriented architecture with SQLite persistence, providing filtering, sorting, and pagination capabilities abstracted away from UI concerns.
## tags
media, call:conn.execute, index, call:self.connect, schema, call:int, init, status
## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/ui
## role
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
## parent
index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md
@@ -4,7 +4,7 @@ dir: archive/src/media_library_viewer/ui
index: archive/src/media_library_viewer/ui/.pi-map.index.md
## role
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
## files
- __init__.py | Package initialization file for Streamlit UI modules that documents the architectural pattern of splitting the application into separate render modules.
- dashboard.py | Implements a Streamlit dashboard for monitoring a Jellyfin media server, displaying media library statistics, active playback sessions, and server resource metrics via SSH. | exp: func:format_rate_bytes(bytes_per_second: float | int | None) → str, call:human_size, func:rate_scale(max_value: float | int | None) → tuple[float, str], call:abs, call:float, func:scaled_rate_chart_df(chart_df: pd.DataFrame, columns: list[str], labels: list[str]) → tuple[pd.DataFrame, str], call:chart_df[columns].max(numeric_only=True).max, call:rate_scale, call:chart_df[columns].copy, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str, user_id: str) → None, call:st.subheader, call:cached_media_counts, call:st.warning, call:counts.get, call:st.columns, call:top_cols[0].metric, call:top_cols[1].metric, call:top_cols[2].metric, call:top_cols[3].metric, call:cached_library_counts, call:st.caption, call:st.markdown, call:e.get, call:st.container, call:m_cols[0].metric, call:m_cols[1].metric, func:render_now_playing(cached_active_sessions, base_url: str, api_key: str) → None, call:st.subheader, call:cached_active_sessions, call:st.warning, call:st.caption, call:session.get, call:bool, call:play_state.get, call:item.get, call:transcoding.get, call:transcode_type.append, call:rows.append, call:", ".join, call:st.dataframe, call:pd.DataFrame, func:render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed) → None, call:st.subheader, call:get_ssh_client, call:resource_collector_status, call:st.error, call:st.columns, call:control_col.caption, call:start_col.button, call:st.success, call:start_resource_collector, call:restart_col.button, call:restart_resource_collector, call:stop_col.button, call:st.info, call:stop_resource_collector, call:refresh_col.button, call:st.rerun, call:st.caption, call:read_resource_metrics, call:disk_space, call:float, call:str(space.get("used_pct", "0")).rstrip, call:space.get, call:disk_cols[0].metric, call:human_size, call:disk_cols[1].metric, call:disk_cols[2].metric, call:disk_cols[3].metric, call:st.progress, call:min, call:max, call:st.warning, call:st.expander, call:st.code, call:resource_collector_debug_info, call:pd.DataFrame, call:pd.to_numeric, call:df.dropna, call:pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert, call:time.time, call:len, call:st.write, call:raw_df['ts'].astype(float).max, call:st.dataframe, call:raw_df.tail, call:df.sort_values, call:df["cpu_pct"].mean, call:df["cpu_pct"].max, call:df["iowait_pct"].mean, call:df["iowait_pct"].max, call:df["mem_pct"].mean, call:df["mem_pct"].max, call:df["net_rx_bytes_per_sec"].mean, call:df["net_rx_bytes_per_sec"].max, call:df["net_tx_bytes_per_sec"].mean, call:df["net_tx_bytes_per_sec"].max, call:df["disk_read_bps"].mean, call:df["disk_read_bps"].max, call:df["disk_write_bps"].mean, call:df["disk_write_bps"].max, call:metric_cols[0].metric, call:metric_cols[0].caption, call:metric_cols[1].metric, call:latest.get, call:metric_cols[1].caption, call:metric_cols[2].metric, call:metric_cols[2].caption, call:metric_cols[3].metric, call:format_rate_bytes, call:metric_cols[3].caption, call:metric_cols[4].metric, call:metric_cols[4].caption, call:metric_cols[5].metric, call:metric_cols[5].caption, call:metric_cols[6].metric, call:metric_cols[6].caption, call:df.set_index, call:st.markdown, call:st.line_chart, call:scaled_rate_chart_df | dep: time, typing, media_library_viewer.clients.resources, media_library_viewer.utils, pandas, streamlit
@@ -12,7 +12,7 @@ Streamlit UI rendering layer for the media library viewer application, providing
- media.py | Renders a Streamlit UI tab for browsing and filtering a local SQLite-backed media index with ag-grid table display and automatic file browser synchronization. | exp: func:aggrid_selected_rows(response: dict[str, Any]) → list[dict[str, Any]], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_tab(client, user_id: str, libraries: list[dict[str, Any]], set_file_browser_path: Callable[[str, str | None, bool], None]) → None, call:st.subheader, call:st.caption, call:MediaIndex, call:index.status, call:st.columns, call:status_parts.append, call:format_elapsed, call:status_col.caption, call:" | ".join, call:status_col.warning, call:build_col.button, call:st.spinner, call:build_media_index, call:st.success, call:st.rerun, call:refresh_col.button, call:st.info, call:filter_col.multiselect, call:list, call:library_options.keys, call:type_col.multiselect, call:search_col.text_input, call:page_size_col.selectbox, call:page_col.number_input, call:sort_col.selectbox, call:sort_options.keys, call:order_col.selectbox, call:hdr_col.selectbox, call:index.query, call:int, call:len, call:pd.DataFrame(rows)[columns].fillna, call:st.session_state.get, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:min, call:aggrid_selected_rows, call:selected_rows[0].get, call:set_file_browser_path, call:str, call:PurePosixPath, call:st.expander, call:st.write | dep: pathlib, typing, st_aggrid, media_library_viewer.services.media_index, pandas, streamlit
- preview.py | Renders a Streamlit UI for previewing selected media file metadata via ffprobe and executing remote SSH diagnostic tools/jobs. | exp: func:render_ffprobe_sections(ffprobe_data: dict[str, Any]) → None, call:ffprobe_format_summary, call:summarize_video_streams, call:summarize_audio_streams, call:summarize_subtitle_streams, call:st.markdown, call:st.dataframe, call:pd.DataFrame, call:st.caption, func:render_selected_file_preview(ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:st.container, call:st.markdown, call:st.caption, call:is_known_video_file, call:st.columns, call:refresh_col.button, call:cached_ffprobe_preview.clear, call:st.rerun, call:st.spinner, call:status_col.error, call:status_col.success, call:render_ffprobe_sections, call:st.expander, call:st.json, func:render_ssh_tools(ssh, ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:render_selected_file_preview, call:st.subheader, call:st.tabs, call:st.button, call:ssh.ffprobe_json, call:render_ffprobe_sections, call:st.expander, call:st.dataframe, call:pd.DataFrame, call:summarize_streams, call:st.json, call:st.error, call:str, call:ssh.stat_path, call:st.code, call:st.warning, call:st.selectbox, call:list, call:JOB_TEMPLATES.keys, call:st.caption, call:JOB_TEMPLATES[job_key].render, call:run_job, call:st.write | dep: typing, media_library_viewer.jobs, media_library_viewer.utils, pandas, streamlit
## arch
Module-based render pattern where each UI tab/view is isolated in its own module, sharing session state for cross-component synchronization (e.g., file browser auto-sync) and leveraging ag-grid for interactive data tables.
Modular page-by-page rendering pattern where each module is a self-contained Streamlit view, integrated through shared session state for cross-component synchronization.
## tags
call:metric, call:grid, render, call:st.caption, col.button, browser, col.selectbox, media
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: archive/tests
## role
Legacy or archived test directory currently containing only a placeholder file with no active test code.
Empty placeholder directory retained for historical/archived test files that are no longer actively used.
## parent
index: archive/.pi-map.index.md
map: archive/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: archive/tests
index: archive/tests/.pi-map.index.md
## role
Legacy or archived test directory currently containing only a placeholder file with no active test code.
Empty placeholder directory retained for historical/archived test files that are no longer actively used.
## files
- .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
## arch
Empty placeholder structure using a `.gitkeep` file to preserve the directory in version control for potential future use.
No active code; contains only a `.gitkeep` placeholder file (with an unrelated description) to preserve the directory structure in version control.
## tags
tmux, swaps, position, two, panes, within, window, windows
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend
## role
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: backend
index: backend/.pi-map.index.md
## role
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
## files
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
## arch
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
Layered API architecture using FastAPI with Uvicorn ASGI server, containerized via Docker, configured through pyproject.toml with standardized linting and testing pipelines.
## tags
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend/src
## role
Root source directory serving as the main entry point and organizational container for the backend application.
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
## parent
index: backend/.pi-map.index.md
map: backend/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: backend/src
index: backend/src/.pi-map.index.md
## role
Root source directory serving as the main entry point and organizational container for the backend application.
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
## files
## arch
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
Cannot be fully determined as no files are listed in the directory; likely follows standard Node.js/Python backend patterns (e.g., MVC, layered architecture) depending on framework used.
## tags
-
## symbols
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api
## role
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
FastAPI backend service providing authenticated API endpoints for viewing and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
## parent
index: backend/src/.pi-map.index.md
map: backend/src/.pi-map.md
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api
index: backend/src/media_library_viewer_api/.pi-map.index.md
## role
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
FastAPI backend service providing authenticated API endpoints for viewing and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
- dependencies.py | Provides FastAPI dependency injection functions for resolving and caching service clients (Jellyfin, Jellyseerr, SSH/Local) and settings based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
- dependencies.py | Provides FastAPI dependency injection functions for resolving and instantiating Jellyfin/Jellyseerr API clients and machine-specific SSH/Local command clients. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_service_data_harness, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.service_data, FastAPI, media_library_viewer_api.services.backup_poller
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
- path_utils.py | Maps Jellyfin media paths to SSH-accessible paths using media root anchoring or fallback prefixing. | exp: func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:logger.debug, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:logger.debug, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:logger.debug, call:apply_remote_path_prefix | dep: logging, posixpath
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
## arch
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
Layered FastAPI architecture using dependency injection for client resolution, middleware-based OIDC/API-key authentication, pydantic-settings configuration, and Prometheus-based observability with structured logging.
## tags
call:, settings, call:get, request, get, client, call:str, id
call:, settings, call:get, request, get, call:str, id, client
## symbols
- Settings
- JobTemplate
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/clients
## role
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
Provides HTTP and command-line client wrappers for integrating with external media services (Jellyfin, Authentik, Jellyseerr, qBittorrent) and executing local/remote filesystem operations.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -10,15 +10,17 @@ map: backend/src/media_library_viewer_api/.pi-map.md
-
## files
- __init__.py
- authentik.py
- jellyfin.py
- jellyseerr.py
- local.py
- qbittorrent.py
- ssh.py
## links
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
map: backend/src/media_library_viewer_api/clients/.pi-map.md
## workflows
- change clients behavior
read: __init__.py, jellyfin.py, jellyseerr.py
read: __init__.py, authentik.py, jellyfin.py
## dirty
-
@@ -4,28 +4,30 @@ dir: backend/src/media_library_viewer_api/clients
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
## role
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
Provides HTTP and command-line client wrappers for integrating with external media services (Jellyfin, Authentik, Jellyseerr, qBittorrent) and executing local/remote filesystem operations.
## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- authentik.py | Provides a client wrapper around the Authentik REST API for browsing and searching the user directory with pagination. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests
- jellyfin.py | Provides a reusable, framework-agnostic HTTP client wrapper for the Jellyfin/Emby API with methods for browsing users, libraries, media items, and sessions. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests
- jellyseerr.py | HTTP client wrapper for the Jellyseerr REST API to fetch user data and enrich Jellyfin user information | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
- qbittorrent.py | Provides a minimal read-only client for the qBittorrent Web API to fetch sync/maindata using authenticated requests. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:logger.info, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
## arch
Client-wrapper pattern with framework-agnostic abstractions; parallel local/remote execution strategies via paramiko SSH and local subprocess; centralized REST API communication modules.
Adapter/gateway pattern where each client encapsulates external API or protocol communication behind a uniform interface, isolating transport-level concerns (REST, SSH, local shell) from business logic.
## tags
call:logger.info, call:logger.debug, call:self.get, call:shlex.quote, error, host, call:self.run, init
call:logger.info, error, call:logger.debug, client, call:self.get, init, call:shlex.quote, status
## symbols
- AuthentikClient
- JellyfinClient
- JellyseerrClient
- CommandResult
- LocalCommandClient
- QbittorrentClient
- RemoteSSHClient
- __init__
- get
- users
## workflows
- change clients behavior
read: __init__.py, jellyfin.py, jellyseerr.py
read: __init__.py, authentik.py, jellyfin.py
## dirty
-
@@ -0,0 +1,80 @@
"""Minimal qBittorrent Web API client (read-only: sync/maindata only).
Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s
session pattern. Authentication uses username/password login which stores an
SID cookie in the requests session. The client re-logins transparently on 403.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class QbittorrentClient:
"""Small wrapper around the qBittorrent Web API.
Only the endpoints needed by the dashboard widgets are implemented
(currently just ``/sync/maindata``). All calls share a single
:class:`requests.Session` that carries the login cookie.
"""
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
if not base_url:
raise ValueError("qBittorrent base_url is required")
if not username:
raise ValueError("qBittorrent username is required")
self.base_url = base_url.rstrip("/")
if not self.base_url.endswith("/api/v2"):
self.base_url += "/api/v2"
self._username = username
self._password = password
self.timeout = timeout
self._session = requests.Session()
self._logged_in = False
def _login(self) -> None:
"""POST username/password to ``/auth/login``; store the SID cookie.
qBittorrent returns the plain text ``"Ok."`` on success. The
``Referer`` header is required by some qBittorrent CSRF protections.
"""
resp = self._session.post(
f"{self.base_url}/auth/login",
data={"username": self._username, "password": self._password},
timeout=self.timeout,
headers={"Referer": self.base_url},
)
resp.raise_for_status()
if resp.text.strip() != "Ok.":
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
self._logged_in = True
logger.info("qBittorrent login successful for %s", self.base_url)
def _get(self, path: str, **params: Any) -> dict[str, Any]:
"""GET an endpoint with auto-login on first call and re-login on 403."""
if not self._logged_in:
self._login()
url = f"{self.base_url}{path}"
resp = self._session.get(url, params=params, timeout=self.timeout)
if resp.status_code == 403:
logger.debug("qBittorrent 403 on %s, re-logging in", path)
self._logged_in = False
self._login()
resp = self._session.get(url, params=params, timeout=self.timeout)
resp.raise_for_status()
return resp.json()
def maindata(self) -> dict[str, Any]:
"""Fetch ``/sync/maindata``.
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
``up_info_speed``, etc.) and ``torrents`` (a dict of
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
"""
return self._get("/sync/maindata")
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/domain
## role
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -4,13 +4,13 @@ dir: backend/src/media_library_viewer_api/domain
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
## role
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- dashboard.py | Provides domain helper functions for building dashboard data, specifically normalizing Jellyfin session activity rows and computing backup job summaries. | exp: func:_map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) → list[dict[str, Any]], call:session.get, call:bool, call:item.get, call:play_state.get, call:transcoding.get, call:transcode_type.append, call:results.append, call:", ".join, func:build_backup_dashboard_summary(store: SettingsStore) → BackupDashboardSummary, call:store.list_backup_jobs, call:len, call:int, call:time.time, call:store.list_backup_runs, call:recent_runs.append, call:sum, call:store.list_backup_alerts, call:failed_runs.append, call:max, call:BackupDashboardSummary, call:round | dep: time, typing, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
- media.py | Flattens inconsistent Jellyfin API JSON into normalized dictionaries for SQLite indexing and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer_api.utils, pandas
## arch
Stateless functional modules that transform inconsistent upstream API JSON into normalized dictionaries for persistence and display.
Functional utility module pattern with pure helper functions that transform external API JSON into normalized domain objects.
## tags
media, backup, call:item.get, call:str, date, added, dashboard, call:store.list
## symbols
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/integrations
## role
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -11,12 +11,13 @@ map: backend/src/media_library_viewer_api/.pi-map.md
## files
- __init__.py
- alertmanager.py
- authentik.py
- backups.py
- base.py
- grafana.py
- jellyfin.py
- jellyseerr.py
- nextcloud.py
- prometheus.py
- qbittorrent.py
- registry.py
- ssh_tasks.py
## links
@@ -24,6 +25,6 @@ index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
## workflows
- change integrations behavior
read: __init__.py, alertmanager.py, base.py
read: __init__.py, alertmanager.py, authentik.py
## dirty
-
@@ -4,33 +4,34 @@ dir: backend/src/media_library_viewer_api/integrations
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
## role
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API.
## files
- __init__.py | Defines a closed registry module for service integrations.
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
- authentik.py | Defines the service configuration model and definition for integrating Authentik as a user directory and identity provider. | exp: class:AuthentikConfig | dep: media_library_viewer_api.integrations.base
- backups.py | Defines a Backups service type with configuration and summary widget for monitoring backup jobs, run history, and alerting. | exp: class:BackupsConfig, class:BackupsSummaryWidgetConfig | dep: media_library_viewer_api.integrations.base
- base.py | Provides foundational base classes and dataclasses for defining external service integrations, including config validation and widget schema generation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
- jellyfin.py | Defines the Jellyfin media server service configuration, secret fields, and widget definitions for activity and now-playing sessions. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig | dep: media_library_viewer_api.integrations.base
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
- prometheus.py | Defines the Prometheus service configuration and widget types (metric, chart, gauge, mean) for querying and visualizing PromQL data. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig | dep: media_library_viewer_api.integrations.base
- qbittorrent.py | Declares the qBittorrent service definition including config models, secret fields, and three widget kinds (totals, active, speed). | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig | dep: media_library_viewer_api.integrations.base
- registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
## arch
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
Plugin-style registry pattern with a shared base class hierarchy; each service module independently defines config dataclasses, widget types, and summarization logic, all registered in a central closed registry for discovery and lookup.
## tags
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
config, widget, service, integrations, base, media_library_viewer_api, prometheus, definition
## symbols
- AlertmanagerConfig
- AlertmanagerAlertsWidgetConfig
- AuthentikConfig
- BackupsConfig
- BackupsSummaryWidgetConfig
- ServiceConfigBase
- WidgetConfigBase
- SecretField
- WidgetKind
- ServiceDefinition
- GrafanaConfig
## workflows
- change integrations behavior
read: __init__.py, alertmanager.py, base.py
read: __init__.py, alertmanager.py, authentik.py
## dirty
-
@@ -2,17 +2,24 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any
import requests
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
class AlertmanagerConfig(ServiceConfigBase):
"""Non-secret Alertmanager connection config."""
@@ -68,6 +75,28 @@ def summarize_alerts(
}
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""GET /api/v2/status with optional bearer auth."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
timeout = int(config.get("timeout_seconds") or 5)
headers: dict[str, str] = {}
api_key = str(secrets.get("api_key") or "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp = requests.get(f"{base_url}/api/v2/status", headers=headers, timeout=timeout)
resp.raise_for_status()
payload = resp.json()
version = str(payload.get("versionInfo", {}).get("version", "") or "connected")
return TestResult(ok=True, detail="Connected to Alertmanager.", evidence=version)
except Exception as exc:
return translate_connection_error(exc, context="Alertmanager")
DEFINITION = ServiceDefinition(
service_type="alertmanager",
name="Alertmanager",
@@ -86,4 +115,5 @@ DEFINITION = ServiceDefinition(
refresh_interval_ms=30_000,
),
],
test_callable=test_connection,
)
@@ -8,13 +8,39 @@ is unchanged -- this service type is for the directory, not SSO.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
TestResult,
translate_connection_error,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
api_token = str(secrets.get("api_token") or "")
timeout = float(config.get("timeout_seconds") or 10)
client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
result = client.users(page=1, page_size=1)
total = result.get("total", 0) if isinstance(result, dict) else 0
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{total} users")
except Exception as exc:
return translate_connection_error(exc, context="Authentik")
class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config."""
@@ -32,4 +58,5 @@ DEFINITION = ServiceDefinition(
SecretField(key="api_token", label="API token", required=True),
],
widget_kinds=[],
test_callable=test_connection,
)
@@ -1,7 +1,7 @@
"""Base classes for service integrations.
A *service definition* is a closed, compile-time description of an external service
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
* its non-secret ``config_schema`` (derived from a Pydantic model),
* the secret fields it accepts (API keys / tokens),
@@ -15,16 +15,21 @@ map. There is no runtime plugin loading.
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Annotated, Any
from typing import TYPE_CHECKING, Annotated, Any, Callable
import requests
from pydantic import BaseModel, BeforeValidator, Field
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def _validate_service_base_url(value: Any) -> str:
"""Require an absolute http(s) URL for service ``base_url`` fields.
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
because ``requests`` treats them as relative paths, so we fail fast with a
clear error instead of letting the call silently malfunction.
"""
@@ -93,6 +98,20 @@ class WidgetKind:
config_model: type[WidgetConfigBase] | None = None
@dataclass(frozen=True)
class TestResult:
"""Outcome of a credential/connectivity test for a service instance."""
ok: bool
detail: str
evidence: str | None = None
#: A test routine receives (config, secrets, store). The store is needed for
#: ssh_tasks (SSH-key resolution). Other types ignore it.
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
@dataclass(frozen=True)
class ServiceDefinition:
"""Closed description of an external service type."""
@@ -103,6 +122,7 @@ class ServiceDefinition:
config_model: type[ServiceConfigBase]
secret_fields: list[SecretField]
widget_kinds: list[WidgetKind]
test_callable: TestCallable | None = None
@property
def config_schema(self) -> dict[str, Any]:
@@ -148,3 +168,58 @@ def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
instance = model_cls.model_validate(config or {})
return instance.model_dump(exclude_none=True)
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
"""Map a common connection/auth exception to a human-friendly TestResult.
Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus
HTTP-client patterns from the widget sources. Each per-type test routine
calls this for unexpected exceptions, but handles its **type-specific**
auth failures directly (e.g., qBit ``"Fails."``).
"""
message = str(exc)
lowered = message.lower()
# Auth failures (HTTP 401/403)
if isinstance(exc, requests.HTTPError):
status_code = exc.response.status_code if exc.response is not None else 0
if status_code in (401, 403):
return TestResult(
ok=False,
detail=f"Authentication failed — the service rejected the credentials ({status_code}).",
)
if "authentication failed" in lowered or "no authentication methods available" in lowered:
return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.")
# Timeout (before OSError check, since requests.Timeout is a subclass of OSError)
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
# Connection refused / DNS / unreachable
if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)):
if (
"name or service not known" in lowered
or "nodename nor servname" in lowered
or "getaddrinfo failed" in lowered
):
return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.")
return TestResult(
ok=False,
detail="Connection refused — the service is not reachable at the configured address.",
)
# SSL / certificate errors
if "ssl" in lowered or "certificate" in lowered:
return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.")
# SSH banner (from test_machine_ssh pattern)
if "protocol banner" in lowered:
return TestResult(
ok=False,
detail="SSH banner not received — confirm the SSH service is running and the port is correct.",
)
# Fallback
prefix = f"{context}: " if context else ""
return TestResult(ok=False, detail=f"{prefix}{message[:200]}")
@@ -1,47 +0,0 @@
"""Grafana service definition."""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class GrafanaConfig(ServiceConfigBase):
"""Non-secret Grafana connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 5
class GrafanaLinkWidgetConfig(WidgetConfigBase):
"""Deep-link to a Grafana dashboard or panel."""
dashboard_uid: str
panel_id: int | None = None
DEFINITION = ServiceDefinition(
service_type="grafana",
name="Grafana",
description="Dashboards, metrics, and logs.",
config_model=GrafanaConfig,
secret_fields=[
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
],
widget_kinds=[
widget_kind(
kind="link",
name="Dashboard link",
description="Deep-link to a Grafana dashboard or panel.",
model_cls=GrafanaLinkWidgetConfig,
default_config={"dashboard_uid": ""},
refresh_interval_ms=0,
),
],
)
@@ -2,15 +2,40 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Call JellyfinClient.users() — the lightest authenticated probe."""
try:
base_url = str(config.get("base_url") or "")
api_key = str(secrets.get("api_key") or "")
timeout = int(config.get("timeout_seconds") or 10)
client = JellyfinClient(base_url, api_key, timeout=timeout)
users = client.users()
return TestResult(ok=True, detail="Connected to Jellyfin.", evidence=f"{len(users)} users")
except Exception as exc:
return translate_connection_error(exc, context="Jellyfin")
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config.
@@ -36,6 +61,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
pass
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
"""Only show sessions with active playback (not idle/paused)."""
pass
DEFINITION = ServiceDefinition(
service_type="jellyfin",
name="Jellyfin",
@@ -53,5 +84,14 @@ DEFINITION = ServiceDefinition(
default_config={},
refresh_interval_ms=30_000,
),
widget_kind(
kind="now_playing",
name="Now Playing",
description="Only sessions actively playing media.",
model_cls=JellyfinNowPlayingWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
],
test_callable=test_connection,
)
@@ -6,13 +6,39 @@ dashboard widgets yet; its service page holds connection config only.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import requests
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
TestResult,
translate_connection_error,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""GET {base_url}/status.php (unauthenticated server probe)."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
resp = requests.get(f"{base_url}/status.php", timeout=10)
resp.raise_for_status()
payload = resp.json()
version = str(payload.get("version", "") or "connected")
return TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)
except Exception as exc:
return translate_connection_error(exc, context="Nextcloud")
class NextcloudConfig(ServiceConfigBase):
"""Non-secret Nextcloud connection config."""
@@ -30,4 +56,5 @@ DEFINITION = ServiceDefinition(
SecretField(key="app_password", label="App password", required=True),
],
widget_kinds=[],
test_callable=test_connection,
)
@@ -2,20 +2,77 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import requests
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""POST {grafana_url}/api/ds/query with expr 'up' via the Grafana gateway."""
try:
grafana_url = str(config.get("grafana_url") or "").rstrip("/")
api_key = str(secrets.get("grafana_api_key") or "")
datasource_uid = str(config.get("datasource_uid") or "prometheus")
timeout = int(config.get("timeout_seconds") or 10)
if not grafana_url:
return TestResult(ok=False, detail="Grafana gateway URL is required.")
if not api_key:
return TestResult(ok=False, detail="Grafana API key is required.")
body = {
"queries": [
{
"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": "up",
"format": "time_series",
"intervalMs": 15000,
"maxDataPoints": 1,
"refId": "A",
}
],
"from": "now-1m",
"to": "now",
}
resp = requests.post(
f"{grafana_url}/api/ds/query",
json=body,
timeout=timeout,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
resp.raise_for_status()
return TestResult(
ok=True,
detail="Grafana gateway reachable.",
evidence="Gateway reachable; datasource responded.",
)
except requests.HTTPError as exc:
return translate_connection_error(exc, context="Prometheus via Grafana")
except Exception as exc:
return translate_connection_error(exc, context="Prometheus via Grafana")
class PrometheusConfig(ServiceConfigBase):
"""Non-secret Prometheus connection config."""
"""Non-secret Prometheus-via-Grafana gateway config."""
base_url: ServiceBaseUrl
grafana_url: ServiceBaseUrl
datasource_uid: str = "prometheus"
timeout_seconds: int = 10
@@ -25,13 +82,44 @@ class PrometheusMetricWidgetConfig(WidgetConfigBase):
promql: str
class PrometheusChartWidgetConfig(WidgetConfigBase):
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
"""A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111)."""
promql: str
warn_at: float | None = None
crit_at: float | None = None
min: float | None = None
max: float | None = None
unit: str | None = None
class PrometheusMeanWidgetConfig(WidgetConfigBase):
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
unit: str | None = None
DEFINITION = ServiceDefinition(
service_type="prometheus",
name="Prometheus",
description="Metrics storage and PromQL queries.",
config_model=PrometheusConfig,
secret_fields=[
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
SecretField(
key="grafana_api_key",
label="Grafana API key",
required=True,
helper="Service account token or API key for the Grafana gateway",
),
],
widget_kinds=[
widget_kind(
@@ -42,5 +130,30 @@ DEFINITION = ServiceDefinition(
default_config={"promql": ""},
refresh_interval_ms=30_000,
),
widget_kind(
kind="chart",
name="Chart",
description="Multi-series line chart from a PromQL range query.",
model_cls=PrometheusChartWidgetConfig,
default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000,
),
widget_kind(
kind="gauge",
name="Gauge",
description="Instant query rendered as a gauge with optional threshold bands.",
model_cls=PrometheusGaugeWidgetConfig,
default_config={"promql": ""},
refresh_interval_ms=30_000,
),
widget_kind(
kind="mean",
name="Mean",
description="Average value of a PromQL query over a time window.",
model_cls=PrometheusMeanWidgetConfig,
default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000,
),
],
test_callable=test_connection,
)
@@ -0,0 +1,101 @@
"""qBittorrent service definition.
Declares the config model (base URL + timeout), secret fields (username +
password), and three widget kinds (totals, active, speed). Models on
:mod:`media_library_viewer_api.integrations.prometheus`.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Login + probe maindata; surface auth failures specifically."""
try:
base_url = str(config.get("base_url") or "")
username = str(secrets.get("username") or "")
password = str(secrets.get("password") or "")
timeout = int(config.get("timeout_seconds") or 10)
client = QbittorrentClient(base_url, username, password, timeout=timeout)
data = client.maindata()
version = str(data.get("server_state", {}).get("qbittorrent_version", "") or "connected")
return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version)
except RuntimeError as exc:
lowered = str(exc).lower()
if "login failed" in lowered:
return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.")
return translate_connection_error(exc, context="qBittorrent")
except Exception as exc:
return translate_connection_error(exc, context="qBittorrent")
class QbittorrentConfig(ServiceConfigBase):
"""Non-secret qBittorrent connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 10
class QbittorrentWidgetConfig(WidgetConfigBase):
"""Per-widget config (empty — all three kinds derive from the service connection)."""
pass
DEFINITION = ServiceDefinition(
service_type="qbittorrent",
name="qBittorrent",
description="Torrent client activity, speeds, and item counts.",
config_model=QbittorrentConfig,
secret_fields=[
SecretField(key="username", label="Username", required=True),
SecretField(key="password", label="Password", required=True, helper="Stored encrypted"),
],
widget_kinds=[
widget_kind(
kind="totals",
name="Totals",
description="Count of all listed torrents, broken down by state.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
widget_kind(
kind="active",
name="Active torrents",
description="Torrents currently downloading or uploading.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=15_000,
),
widget_kind(
kind="speed",
name="Speed chart",
description="Live download/upload speed over a short window.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=5_000,
),
],
test_callable=test_connection,
)
@@ -10,18 +10,18 @@ from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALE
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
GRAFANA.service_type: GRAFANA,
PROMETHEUS.service_type: PROMETHEUS,
ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN,
NEXTCLOUD.service_type: NEXTCLOUD,
QBITTORRENT.service_type: QBITTORRENT,
SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
@@ -8,14 +8,74 @@ and shown as history on the instance's service page.
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Build an SSH client via build_ssh_client and attempt .connect().
Reuses the same error-translation patterns as test_machine_ssh (banner,
auth failed). Known-host recording is preserved.
"""
from media_library_viewer_api.services.task_runner import build_ssh_client
from media_library_viewer_api.widgets.sources import ServiceRecord
host = str(config.get("host") or "").strip()
port = int(config.get("port") or 22)
try:
service = ServiceRecord(
id="",
service_type="ssh_tasks",
name="test",
config=config,
secrets=secrets,
enabled=True,
)
client = build_ssh_client(store, service)
try:
client.connect()
except Exception as exc:
lowered = str(exc).lower()
if "protocol banner" in lowered:
return TestResult(
ok=False,
detail=f"SSH banner not received from {host}:{port}; confirm the SSH service is running.",
)
if "no authentication methods available" in lowered or "authentication failed" in lowered:
return TestResult(
ok=False,
detail=f"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.",
)
return translate_connection_error(exc, context=f"SSH {host}:{port}")
finally:
client.close()
return TestResult(
ok=True,
detail=f"SSH connection succeeded for {host}:{port}.",
evidence=f"Connected to {host}:{port}",
)
except ValueError as exc:
return TestResult(ok=False, detail=str(exc))
except Exception as exc:
return translate_connection_error(exc, context=f"SSH {host}:{port}")
class SshTasksConfig(ServiceConfigBase):
"""Non-secret SSH task runner config.
@@ -57,4 +117,5 @@ DEFINITION = ServiceDefinition(
refresh_interval_ms=0,
),
],
test_callable=test_connection,
)
@@ -37,6 +37,23 @@ from .version import get_backend_version, get_version_info
logger = logging.getLogger(__name__)
def _validate_prometheus_gateway_config() -> None:
"""Warn (not crash) about old-shape prometheus services needing migration (GM-113)."""
try:
store = get_settings_store()
for service in store.list_services("prometheus"):
config = service.get("config") or {}
if "base_url" in config and "grafana_url" not in config:
logger.warning(
"Prometheus service '%s' (id=%s) uses the old 'base_url' config shape. "
"Reconfigure with grafana_url + grafana_api_key (see CHANGELOG).",
service.get("name"),
service.get("id"),
)
except Exception: # pragma: no cover - startup best-effort
logger.exception("Failed to validate prometheus gateway config during startup")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan — startup/shutdown."""
@@ -52,6 +69,13 @@ async def lifespan(app: FastAPI):
get_settings_store().ensure_defaults()
except Exception:
logger.exception("Failed to seed default settings during startup")
try:
from media_library_viewer_api.services.service_data import get_service_data_harness
get_service_data_harness()
except Exception:
logger.exception("Failed to initialize service data harness during startup")
_validate_prometheus_gateway_config()
mail_queue = get_mail_queue()
backup_poller = get_backup_poller()
mail_queue.start()
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/models
## role
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -10,6 +10,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md
-
## files
- backups.py
- dashboards.py
- services.py
- widgets.py
## links
@@ -17,6 +18,6 @@ index: backend/src/media_library_viewer_api/models/.pi-map.index.md
map: backend/src/media_library_viewer_api/models/.pi-map.md
## workflows
- change models behavior
read: backups.py, services.py, widgets.py
read: backups.py, dashboards.py, services.py
## dirty
-
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/models
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
## role
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
## files
- backups.py | Defines Pydantic data models for backup system API requests and responses including reports, jobs, runs, alerts, and dashboard summaries. | exp: class:BackupReportRequest, class:BackupJobResponse, class:BackupRunResponse, class:BackupAlertResponse, class:BackupDashboardSummary | dep: datetime, typing, pydantic
- dashboards.py | Defines Pydantic data models for creating, updating, and representing named dashboard records in an API. | exp: class:NamedDashboardInput, class:NamedDashboard | dep: typing, pydantic
- services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
- widgets.py | Defines Pydantic models for a dashboard widget system with validation to prevent secrets/credentials in widget configuration. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
- widgets.py | Defines Pydantic models for a dashboard widget system, including input/output schemas and validation to prevent credential leakage in widget configurations. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
## arch
Pydantic-based model layer implementing data validation, serialization contracts, and custom validators enforcing security constraints (e.g., blocking credentials in non-secret configs).
Pydantic-based model layer following a schema-first design pattern with built-in validators for domain-specific business rules and data integrity.
## tags
widget, backup, instance, response, info, call:isinstance, call:, service
widget, backup, instance, dashboard, pydantic, response, info, call:isinstance
## symbols
- BackupReportRequest
- BackupJobResponse
- BackupRunResponse
- BackupAlertResponse
- BackupDashboardSummary
- NamedDashboardInput
- NamedDashboard
- ServiceInstanceInput
- ServiceInstance
- SecretFieldInfo
## workflows
- change models behavior
read: backups.py, services.py, widgets.py
read: backups.py, dashboards.py, services.py
## dirty
-
@@ -2,7 +2,7 @@
Widgets are either:
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
by that service's definition (Grafana link, Prometheus metric, Jellyfin
by that service's definition (Prometheus metric, Jellyfin
activity, SSH task output); or
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
service-less kinds (backups, static).
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/routers
## role
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -10,8 +10,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
-
## files
- __init__.py
- authentik_users.py
- backups.py
- dashboard.py
- dashboards.py
- files.py
- jobs.py
- media.py
@@ -19,14 +21,12 @@ map: backend/src/media_library_viewer_api/.pi-map.md
- services.py
- settings.py
- tasks.py
- users.py
- users_impl.py
- widgets.py
## links
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
map: backend/src/media_library_viewer_api/routers/.pi-map.md
## workflows
- change routers behavior
read: __init__.py, backups.py, dashboard.py
read: __init__.py, authentik_users.py, backups.py
## dirty
-
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/routers
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
## role
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
## files
- __init__.py | Marks the directory as a Python package for routers.
- backups.py | FastAPI router that provides REST endpoints for reporting, tracking, and alerting on backup jobs and runs. | exp: func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
- authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
- backups.py | FastAPI router for receiving backup run reports, managing backup jobs/runs, and generating/acknowledging backup alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
- dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
- media.py | FastAPI router that manages media index lifecycle (status, build, stop, query) with subprocess worker orchestration and cooperative/force cancellation. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path) → list[str], call:str, func:_start_worker(index: MediaIndex) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(client, user_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:client.libraries, call:logger.info, call:len, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index, media_library_viewer_api.workers.media_index_worker
- monitoring.py | FastAPI router providing endpoints to check the health/status of Alertmanager, Grafana, and Prometheus services and expose Prometheus scrape targets. | exp: func:_resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services, func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_grafana_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
- media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index
- monitoring.py | FastAPI router providing monitoring observability endpoints that proxy and aggregate status, alerts, and scrape targets from Alertmanager and Prometheus. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
- users.py | Re-exports all public names from the users_impl module to provide a unified public API for user-related functionality. | dep: .users_impl, users_impl
- users_impl.py | FastAPI router that fetches and merges Jellyfin users with optional Jellyseerr enrichment, plus endpoints for queueing user emails via a background mail worker. | exp: func:_safe_int(value: Any) → int, call:int, func:_permission_labels(permissions: int) → list[str], func:_role_label(permissions: int) → str, func:_account_type(user_type: Any) → str, call:_USER_TYPES.get, call:_safe_int, func:_merge_users(jellyfin_users: list[dict[str, Any]], jellyseerr_jellyfin_users: list[dict[str, Any]] | None, jellyseerr_users: list[dict[str, Any]] | None, jellyseerr_client: JellyseerrClient | None) → dict[str, Any], call:str(value or "").strip().lower, call:bool, call:_looks_like_email, call:str(value).strip, call:", ".join, call:_normalize, call:item.get, call:_lookup_keys, call:user.get, call:linked_by_jellyfin_id.get, call:(jf_link or {}).get, call:seerr_by_key.get, call:_pick_source_and_value, call:(seerr_user or {}).get, call:_first_value, call:jellyseerr_client.absolute_url, call:_safe_int, call:_role_label, call:_source_summary, call:items.append, call:_account_type, call:_permission_labels, call:logger.info, call:len, func:_normalize(value: Any) → str, call:str(value or "").strip().lower, func:_looks_like_email(value: Any) → bool, call:str(value or "").strip, call:bool, func:_pick_source_and_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:_looks_like_email, call:str(value).strip, func:_first_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:str(value or "").strip, func:_source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) → str, call:", ".join, func:_lookup_keys(item: dict[str, Any]) → list[str], call:_normalize, call:item.get, func:get_users(jellyfin, jellyseerr) → dict[str, Any], call:jellyfin.users, call:logger.info, call:len, call:jellyseerr.jellyfin_users, call:logger.exception, call:jellyseerr.users, call:_merge_users, call:bool, func:get_user_message_status() → dict[str, Any], call:mail_queue.status, func:post_user_message(recipient_ids, subject, html_body, text_body, attachments, jellyfin, jellyseerr) → dict[str, Any], call:json.loads, call:isinstance, call:str(item).strip, call:subject.strip, call:get_users, call:item.get, call:directory.get, call:users_by_id.get, call:skipped.append, call:str(item.get("email") or "").strip, call:recipients.append, call:recipient_labels.append, call:get_settings, call:validate_smtp_settings, call:mail_queue.status, call:upload.read, call:attachment_payloads.append, call:EmailAttachment, call:mail_queue.enqueue, call:str(getattr(settings, "smtp_from_address", "") or "").strip, call:getattr, call:str(getattr(settings, "smtp_username", "") or "").strip, call:logger.info, call:len, raise:HTTPException | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mailer
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances, including CRUD operations, built-in widget discovery, and data fetching through service or built-in adapters. | exp: func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException | dep: logging, time, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances and their references, including CRUD operations, data fetching, and live-link detachments. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
## arch
Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter; routers are registered by the parent application and share common dependencies for SSH orchestration, database access, and service resolution.
Modular FastAPI APIRouter pattern where each domain module exports its own router instance; routers encapsulate endpoint definitions and delegate business logic to underlying service clients, SSH utilities, and subprocess workers.
## tags
call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh, call:logger.info
call:, raise:httpexception, service, backup, get, media_library_viewer_api, ssh, call:store.get
## symbols
- MessageRequest
- RunJobRequest
- MonitoringMachineInput
- SSHKeyInput
@@ -31,9 +32,8 @@ call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh,
- ResetLocalDatabaseInput
- TaskInput
- RunTaskRequest
- _get_or_create_job
## workflows
- change routers behavior
read: __init__.py, backups.py, dashboard.py
read: __init__.py, authentik_users.py, backups.py
## dirty
-
@@ -20,8 +20,9 @@ from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mailer import validate_smtp_settings
from media_library_viewer_api.services.service_resolution import resolve_service_record
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
from media_library_viewer_api.widgets.sources import ServiceRecord
logger = logging.getLogger(__name__)
@@ -36,29 +37,6 @@ class MessageRequest(BaseModel):
html_body: str
def _resolve_service_record(
store: SettingsStore,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested authentik instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled ``authentik`` instance is configured.
"""
service_type = "authentik"
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
@@ -82,7 +60,7 @@ def get_authentik_users(
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = _resolve_service_record(store, service_id)
service = resolve_service_record(store, "authentik", service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
@@ -102,7 +80,7 @@ def get_authentik_message_status(
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
service = _resolve_service_record(store, service_id)
service = resolve_service_record(store, "authentik", service_id)
if service is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status()
@@ -116,7 +94,7 @@ def post_authentik_message(
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = _resolve_service_record(store, service_id)
service = resolve_service_record(store, "authentik", service_id)
if service is None:
return {"status": "error", "error": "Authentik service not configured"}
@@ -129,9 +129,10 @@ def post_backup_start(
@router.get("/jobs")
def get_backup_jobs(
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
jobs = store.list_backup_jobs()
jobs = store.list_backup_jobs(service_id=service_id)
return jobs
@@ -155,9 +156,10 @@ def get_backup_runs(
job_id: str | None = None,
status: str | None = None,
limit: int = 50,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[BackupRunResponse]:
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit)
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit, service_id=service_id)
return [BackupRunResponse(**run) for run in runs]
@@ -177,9 +179,15 @@ def get_backup_alerts(
job_id: str | None = None,
acknowledged: bool | None = None,
severity: str | None = None,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[BackupAlertResponse]:
alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity)
alerts = store.list_backup_alerts(
job_id=job_id,
acknowledged=acknowledged,
severity=severity,
service_id=service_id,
)
return [BackupAlertResponse(**alert) for alert in alerts]
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
}
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
return [
sys.executable,
"-m",
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
str(final_db_path),
"--staging-path",
str(staging_db_path),
"--service-id",
service_id,
]
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
staging_path = _staging_db_path(index)
staging_path.unlink(missing_ok=True)
return subprocess.Popen(
_worker_command(index.db_path, staging_path),
_worker_command(index.db_path, staging_path, service_id),
start_new_session=True,
env=os.environ.copy(),
)
@@ -131,20 +133,28 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id),
jellyfin_service_id: str | None = None,
index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]:
"""Start a media index build in a subprocess worker."""
"""Start a media index build in a subprocess worker.
The worker resolves its own Jellyfin connection from the settings store.
We do NOT use Depends(get_jellyfin_client) here because the worker runs
in a separate process and needs to resolve the client itself. Validating
the connection here would fail if Jellyfin is briefly unreachable, even
though the build just needs to start the worker process.
"""
with _build_lock:
current_status = _clean_stale_build_state(index)
if current_status.build_running and _pid_is_alive(current_status.build_pid):
logger.warning("Media build already running pid=%s", current_status.build_pid)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
libraries = client.libraries(user_id)
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
process = _start_worker(index)
logger.info(
"Starting media index build service_id=%s",
jellyfin_service_id or "<default>",
)
process = _start_worker(index, jellyfin_service_id or "")
_set_build_metadata(
index,
{
@@ -156,7 +166,7 @@ def post_build_index(
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": len(libraries),
"build_libraries_total": 0,
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
@@ -272,6 +282,7 @@ def query_media(
sort_order: str = Query("Ascending", description="Ascending or Descending"),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
jellyfin_service_id: str | None = None,
client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id),
index: MediaIndex = Depends(get_media_index),
@@ -306,6 +317,7 @@ def query_media(
sort_order=sort_order,
limit=limit,
offset=offset,
service_id=jellyfin_service_id or "",
)
logger.info("Media query returned total=%s rows=%s", total, len(rows))
@@ -1,6 +1,6 @@
"""Monitoring router — observability service status.
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
Observability components (Alertmanager, Prometheus) are resolved from
the service registry, not environment variables. The endpoints pick the first
enabled instance of a type when no ``service_id`` is given, and return graceful
"not configured" / "unreachable" payloads so the UI always renders a health card.
@@ -15,34 +15,14 @@ import requests
from fastapi import APIRouter, Body, Depends
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.service_resolution import resolve_service_record
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.targets import build_node_exporter_targets
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
from media_library_viewer_api.widgets.sources import ServiceRecord
logger = logging.getLogger(__name__)
def _resolve_service_record(
store: SettingsStore, service_type: str, service_id: str | None = None
) -> ServiceRecord | None:
"""Return the requested service instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled instance of ``service_type`` is configured.
"""
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
def _base_url(service: ServiceRecord) -> str:
return str(service.config.get("base_url") or "").rstrip("/")
@@ -104,7 +84,7 @@ def get_alertmanager_alerts(
is configured the endpoint returns an empty summary with an
``alertmanager_not_configured`` error so the UI can render a health card.
"""
service = _resolve_service_record(store, "alertmanager", service_id)
service = resolve_service_record(store, "alertmanager", service_id)
if service is None:
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
try:
@@ -149,7 +129,7 @@ def get_alertmanager_status(
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Return Alertmanager cluster/status for the UI health card."""
service = _resolve_service_record(store, "alertmanager", service_id)
service = resolve_service_record(store, "alertmanager", service_id)
if service is None:
return {
"up": False,
@@ -192,51 +172,57 @@ def get_alertmanager_status(
}
@router.get("/grafana-status")
def get_grafana_status(
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
service = _resolve_service_record(store, "grafana", service_id)
if service is None:
return _status_response(None, error="no_service_configured")
try:
response = requests.get(
f"{_base_url(service)}/api/health",
headers=_auth_headers(service),
timeout=_timeout(service, 5),
)
response.raise_for_status()
data = response.json()
except Exception:
logger.exception("Failed to fetch Grafana status")
return _status_response(service, error="grafana_unreachable")
return _status_response(service, version=data.get("version", ""))
@router.get("/prometheus-status")
def get_prometheus_status(
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Probe a Prometheus service instance's health and build info."""
service = _resolve_service_record(store, "prometheus", service_id)
"""Probe a Prometheus service's health via the Grafana gateway path (GM-110).
Issues a trivial ``up`` query through Grafana ``/api/ds/query``. Success
validates the full path: Grafana is reachable, the API key works, and the
Prometheus datasource responds.
"""
service = resolve_service_record(store, "prometheus", service_id)
if service is None:
return _status_response(None, error="no_service_configured")
base = _base_url(service)
timeout = _timeout(service, 10)
headers = _auth_headers(service)
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
api_key = str(service.secrets.get("grafana_api_key") or "")
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
timeout = int(service.config.get("timeout_seconds") or 10)
if not grafana_url or not api_key:
return _status_response(service, error="gateway_not_configured")
body = {
"queries": [
{
"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": "up",
"format": "time_series",
"intervalMs": 15_000,
"maxDataPoints": 1,
"refId": "A",
}
],
"from": "now-1m",
"to": "now",
}
try:
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
health.raise_for_status()
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
build_info.raise_for_status()
version = build_info.json().get("data", {}).get("version", "")
except Exception:
logger.exception("Failed to fetch Prometheus status")
resp = requests.post(
f"{grafana_url}/api/ds/query",
json=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
timeout=timeout,
)
resp.raise_for_status()
except requests.HTTPError as exc:
status_code = exc.response.status_code if exc.response else 0
if status_code in (401, 403):
return _status_response(service, error="auth_failed")
return _status_response(service, error="gateway_error")
except requests.RequestException:
logger.exception("Failed to fetch Prometheus status via gateway")
return _status_response(service, error="prometheus_unreachable")
return _status_response(service, version=version)
return _status_response(service, version="ok")
@router.post("/alertmanager-webhook")
@@ -12,7 +12,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config
from media_library_viewer_api.integrations.base import TestResult, validate_config
from media_library_viewer_api.integrations.registry import (
SERVICE_DEFINITIONS,
get_service_definition,
@@ -180,3 +180,30 @@ def delete_instance(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
store.delete_service(service_id)
return {"status": "deleted"}
@router.post("/test")
def test_instance(
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Test connectivity + credentials for unsaved service input.
Validates first (422 on malformed config), dispatches to the per-type
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
"""
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
definition = require_service_definition(body.service_type)
if definition.test_callable is None:
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
try:
result: TestResult = definition.test_callable(body.config, body.secrets, store)
except Exception as exc:
logger.exception("test_callable raised for type=%s", body.service_type)
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}
@@ -12,6 +12,7 @@ import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
get_service_adapter,
)
class WidgetReferenceCreate(BaseModel):
"""Payload for creating a widget reference (live-link)."""
dashboard_scope: str
widget_id: str
sort_order: int = 0
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
@@ -221,3 +231,65 @@ async def fetch_data(
error=data.get("error"),
fetched_at=int(time.time()),
).model_dump()
# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------
@router.get("/references")
def list_references(
dashboard_scope: str,
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""List widget references for a dashboard scope."""
return store.list_widget_references(dashboard_scope)
@router.post("/references", status_code=status.HTTP_201_CREATED)
def create_reference(
body: WidgetReferenceCreate,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a widget reference (live-link) on a dashboard."""
try:
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.delete("/references/{reference_id}")
def delete_reference(
reference_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Remove a widget reference from a dashboard."""
store.delete_widget_reference(reference_id)
return {"status": "deleted"}
@router.put("/references/{reference_id}")
def update_reference(
reference_id: str,
sort_order: int,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Update a widget reference's sort_order (per-dashboard reordering)."""
try:
return store.update_widget_reference(reference_id, sort_order)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/references/{reference_id}/detach")
def detach_reference(
reference_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Clone the referenced widget into a standalone instance and remove the reference."""
try:
cloned = store.detach_widget_reference(reference_id, "")
return WidgetInstance(**cloned).model_dump()
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/services
## role
Backend service layer providing business logic for media library management, backup monitoring, email notifications, SSH task execution, encryption, and persistent settings storage.
Backend service layer providing business logic for media indexing, backup monitoring, email delivery, secrets management, task execution, and persistent storage operations.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -19,7 +19,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
- mailer_impl.py
- media_index.py
- media_index_impl.py
- qbittorrent_store.py
- secrets.py
- service_data.py
- service_resolution.py
- settings_store.py
- targets.py
- task_runner.py
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ from typing import Any, Callable, Iterable
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
from media_library_viewer_api.path_utils import resolve_remote_media_path
from media_library_viewer_api.services.service_data import StorageConcern
logger = logging.getLogger(__name__)
@@ -25,6 +26,20 @@ logger = logging.getLogger(__name__)
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
MEDIA_TYPES = "Movie,Episode,Video"
# Harness concern registration: the media_index DB is scoped by service_id so
# multiple Jellyfin instances can coexist. The ALTER TABLE migration adds the
# service_id column to existing DBs; init_schema adds it for fresh installs.
# The harness run_migrations catches "duplicate column name" on re-runs.
MEDIA_INDEX_CONCERN = StorageConcern(
concern_key="media_index",
db_filename=DEFAULT_INDEX_PATH.name,
migrations=[
"ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
],
tables=["media_items"],
service_id_column="service_id",
)
# Only values from this whitelist are interpolated into ORDER BY. User-selected
# sort keys map to these known SQL snippets to avoid SQL injection.
SORT_COLUMNS = {
@@ -135,7 +150,8 @@ class MediaIndex:
date_added_ts INTEGER,
path TEXT,
library_id TEXT,
library_name TEXT
library_name TEXT,
service_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS index_metadata (
key TEXT PRIMARY KEY,
@@ -167,8 +183,13 @@ class MediaIndex:
(key, str(value)),
)
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int:
"""Atomically replace indexed media rows with a freshly built set."""
def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
"""Atomically replace indexed media rows with a freshly built set.
Scoped by ``service_id``: only rows belonging to this service are
deleted before the new batch is inserted. This means building for one
Jellyfin instance no longer wipes another instance's rows.
"""
self.init_schema()
row_list = list(rows)
columns = [
@@ -194,13 +215,14 @@ class MediaIndex:
"path",
"library_id",
"library_name",
"service_id",
]
placeholders = ",".join(["?"] * len(columns))
with self.connect() as conn:
conn.execute("DELETE FROM media_items")
conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
conn.executemany(
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
[[row.get(column) for column in columns] for row in row_list],
[[row.get(column) if column != "service_id" else service_id for column in columns] for row in row_list],
)
conn.execute(
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
@@ -287,8 +309,14 @@ class MediaIndex:
sort_order: str = "Ascending",
limit: int = 100,
offset: int = 0,
service_id: str = "",
) -> tuple[list[dict[str, Any]], int]:
"""Query indexed media with full-index filters, sorting, and pagination."""
"""Query indexed media with full-index filters, sorting, and pagination.
When ``service_id`` is non-empty, only rows matching that service are
returned. When empty (the default), all rows are returned (backward-
compatible with callers that are not multi-instance aware).
"""
self.init_schema()
where = []
params: list[Any] = []
@@ -309,6 +337,9 @@ class MediaIndex:
where.append("hdr = 1")
elif hdr_filter == "SDR/unknown only":
where.append("(hdr IS NULL OR hdr = 0)")
if service_id:
where.append("service_id = ?")
params.append(service_id)
where_sql = " WHERE " + " AND ".join(where) if where else ""
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
@@ -338,6 +369,7 @@ def build_media_index(
fallback_prefix: str = "",
progress_callback: Callable[[dict[str, Any]], None] | None = None,
should_cancel: Callable[[], bool] | None = None,
service_id: str = "",
) -> int:
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
index = index or MediaIndex()
@@ -453,7 +485,7 @@ def build_media_index(
logger.info("Media index finalizing rows=%s", len(normalized_rows))
emit("finalizing", "Writing index to disk")
ensure_not_cancelled()
count = index.replace_items(normalized_rows)
count = index.replace_items(normalized_rows, service_id=service_id)
duration = time.perf_counter() - started_at
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
processed_total = count
@@ -0,0 +1,91 @@
"""Speed-sample storage for qBittorrent widgets.
This module defines the storage concern for qBittorrent speed data and a
bespoke store with ``append``/``window`` operations. It is registered with the
:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as
the first real consumer of the harness lifecycle layer.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.services.service_data import StorageConcern
if TYPE_CHECKING:
from media_library_viewer_api.services.service_data import ServiceDataHarness
logger = logging.getLogger(__name__)
QBITTORRENT_CONCERN = StorageConcern(
concern_key="qbittorrent",
db_filename="qbittorrent.db",
migrations=[
"""
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
service_id TEXT NOT NULL,
ts INTEGER NOT NULL,
dl_speed INTEGER NOT NULL DEFAULT 0,
up_speed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
ON qbittorrent_speed_samples(service_id, ts);
"""
],
tables=["qbittorrent_speed_samples"],
)
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
MAX_SAMPLES = 120
class QbittorrentSampleStore:
"""Bespoke speed-sample store for qBittorrent widgets.
Each ``append`` inserts a new sample and prunes entries beyond
:data:`MAX_SAMPLES`, keeping only the most recent rows for the given
``service_id``.
"""
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
if harness is None:
from media_library_viewer_api.services.service_data import get_service_data_harness
harness = get_service_data_harness()
self._harness = harness
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
with self._harness.connect("qbittorrent") as conn:
conn.execute(
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
(service_id, ts, dl_speed, up_speed),
)
conn.execute(
"DELETE FROM qbittorrent_speed_samples "
"WHERE service_id = ? AND ts NOT IN ("
" SELECT ts FROM qbittorrent_speed_samples"
" WHERE service_id = ?"
" ORDER BY ts DESC LIMIT ?"
")",
(service_id, service_id, MAX_SAMPLES),
)
conn.commit()
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
"""Return all samples for a service since a timestamp (or all if ``None``)."""
with self._harness.connect("qbittorrent") as conn:
if since_ts is not None:
rows = conn.execute(
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples "
"WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
(service_id, since_ts),
).fetchall()
else:
rows = conn.execute(
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
(service_id,),
).fetchall()
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
@@ -0,0 +1,158 @@
"""Lifecycle-only storage harness for service-owned persistent data.
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
schema migrations, and cascade-delete when a service instance is removed. It
does **not** own data operations each integration implements its own Store
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
etc.). This keeps the general interface narrow (lifecycle) and the specific
interfaces rich (per-integration operations).
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
declaring its DB filename, ordered migration statements, owned tables, and the
column used for service scoping.
"""
from __future__ import annotations
import logging
import os
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class StorageConcern:
"""A per-integration storage namespace registered with the harness."""
concern_key: str # e.g. "qbittorrent", "media_index"
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
service_id_column: str = "service_id"
class ServiceDataHarness:
"""Lifecycle-only registry of per-concern storage.
Owns:
* DB provisioning (per-concern SQLite files under ``base_dir``).
* Schema migrations (run on first access via :meth:`run_migrations`).
* ``service_id`` cascade-delete when a service instance is removed.
Does **not** own:
* Data operations each store keeps bespoke append/window/query/etc.
* A generic value table or generic CRUD layer.
"""
def __init__(self, base_dir: Path | str) -> None:
self._base_dir = Path(base_dir)
self._concerns: dict[str, StorageConcern] = {}
@property
def base_dir(self) -> Path:
return self._base_dir
def register(self, concern: StorageConcern) -> None:
"""Register a storage concern. Called at startup / on first access."""
self._concerns[concern.concern_key] = concern
def db_path(self, concern_key: str) -> Path:
"""Return the absolute path to a concern's DB file."""
concern = self._concerns[concern_key]
return self._base_dir / concern.db_filename
def connect(self, concern_key: str) -> sqlite3.Connection:
"""Open a WAL-mode connection to a concern's DB."""
path = self.db_path(concern_key)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def run_migrations(self) -> None:
"""Run pending migrations for every registered concern.
Each migration string is split into individual statements (by ``;``)
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
that fail with "duplicate column name" are silently skipped, making
migrations idempotent across re-runs and fresh installs where
``init_schema`` may have already created the column.
"""
for concern in self._concerns.values():
path = self.db_path(concern.concern_key)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=30)
try:
conn.execute("PRAGMA journal_mode=WAL")
for migration_sql in concern.migrations:
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
for stmt in statements:
try:
conn.execute(stmt)
except sqlite3.OperationalError as exc:
lowered = str(exc).lower()
if "duplicate column name" in lowered or "no such table" in lowered:
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
else:
raise
conn.commit()
finally:
conn.close()
def cascade_delete(self, service_id: str) -> None:
"""Delete all rows for a ``service_id`` across every concern's tables.
Called from :meth:`SettingsStore.delete_service` after the service row
is removed. Best-effort: callers wrap in try/except so a harness
failure does not block service deletion.
"""
for concern in self._concerns.values():
col = concern.service_id_column
path = self.db_path(concern.concern_key)
if not path.exists():
continue
with sqlite3.connect(path, timeout=30) as conn:
for table in concern.tables:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if col in cols:
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_HARNESS: ServiceDataHarness | None = None
def get_service_data_harness() -> ServiceDataHarness:
"""Return the process-wide harness singleton, initializing it on first call.
Lazy registration of built-in concerns happens here (local imports avoid
circular dependencies). Migrations are run immediately after registration.
"""
global _HARNESS
if _HARNESS is None:
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
_HARNESS = ServiceDataHarness(base_dir)
# Register built-in concerns (lazy import avoids circular dependency).
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
_HARNESS.register(QBITTORRENT_CONCERN)
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
_HARNESS.register(MEDIA_INDEX_CONCERN)
_HARNESS.run_migrations()
return _HARNESS
def reset_service_data_harness() -> None:
"""Reset the singleton (for testing)."""
global _HARNESS
_HARNESS = None
@@ -0,0 +1,37 @@
"""Shared helpers for resolving service instances at request time.
Extracted from the duplicated ``_resolve_service_record`` helpers that lived
in ``routers/monitoring.py`` and ``routers/authentik_users.py``. Both routers
need the same logic: return the requested service instance (by id), or fall
back to the first enabled instance of the type. Returns ``None`` when the
instance does not exist, is the wrong type, is disabled, or when no enabled
instance of the type is configured.
"""
from __future__ import annotations
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
def resolve_service_record(
store: SettingsStore,
service_type: str,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested service instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled instance of ``service_type`` is configured.
"""
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
@@ -81,7 +81,7 @@ class SettingsStore:
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
# metrics now live in Prometheus/node_exporter. Drop the orphan
# table on startup so existing databases get a clean slate.
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
conn.execute(
@@ -165,6 +165,19 @@ class SettingsStore:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
if "widget_kind" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS widget_references (
id TEXT PRIMARY KEY,
dashboard_scope TEXT NOT NULL,
widget_id TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)")
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY,
@@ -1062,10 +1075,16 @@ class SettingsStore:
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
return self._row_to_job(row) if row else None
def list_backup_jobs(self) -> list[dict[str, Any]]:
def list_backup_jobs(self, service_id: str | None = None) -> list[dict[str, Any]]:
self.init_schema()
where = ""
params: list[Any] = []
if service_id:
where = "WHERE service_id = ?"
params.append(service_id)
sql = f"SELECT * FROM backup_jobs {where} ORDER BY created_at DESC"
with self.connect() as conn:
rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall()
rows = conn.execute(sql, params).fetchall()
return [self._row_to_job(row) for row in rows]
# ------------------------------------------------------------------
@@ -1154,6 +1173,7 @@ class SettingsStore:
job_id: str | None = None,
status: str | None = None,
limit: int = 50,
service_id: str | None = None,
) -> list[dict[str, Any]]:
self.init_schema()
clauses: list[str] = []
@@ -1164,6 +1184,9 @@ class SettingsStore:
if status:
clauses.append("status = ?")
params.append(status)
if service_id:
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
params.append(service_id)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
params.append(max(1, min(int(limit), 200)))
@@ -1244,6 +1267,7 @@ class SettingsStore:
job_id: str | None = None,
acknowledged: bool | None = None,
severity: str | None = None,
service_id: str | None = None,
) -> list[dict[str, Any]]:
self.init_schema()
clauses: list[str] = []
@@ -1257,6 +1281,9 @@ class SettingsStore:
if severity:
clauses.append("severity = ?")
params.append(severity)
if service_id:
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
params.append(service_id)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
with self.connect() as conn:
@@ -1491,6 +1518,123 @@ class SettingsStore:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,))
# ------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ------------------------------------------------------------------
def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]:
"""List widget references for a dashboard scope, joined with widget data."""
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"""
SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order,
wr.created_at AS ref_created_at
FROM widget_references wr
WHERE wr.dashboard_scope = ?
ORDER BY wr.sort_order ASC, wr.created_at ASC
""",
(dashboard_scope,),
).fetchall()
result: list[dict[str, Any]] = []
for row in rows:
widget = self.get_widget(row["widget_id"])
if not widget:
continue
result.append(
{
"id": row["ref_id"],
"dashboard_scope": row["dashboard_scope"],
"widget_id": row["widget_id"],
"sort_order": int(row["sort_order"]),
"created_at": row["ref_created_at"],
"widget": widget,
}
)
return result
def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]:
self.init_schema()
widget = self.get_widget(widget_id)
if not widget:
raise ValueError(f"Widget {widget_id} not found")
ref_id = uuid.uuid4().hex[:12]
now = int(time.time())
with self.connect() as conn:
conn.execute(
"""
INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(ref_id, dashboard_scope, widget_id, sort_order, now),
)
return {
"id": ref_id,
"dashboard_scope": dashboard_scope,
"widget_id": widget_id,
"sort_order": sort_order,
"created_at": now,
"widget": widget,
}
def delete_widget_reference(self, reference_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT * FROM widget_references WHERE id = ?",
(reference_id,),
).fetchone()
if not row:
raise ValueError(f"Reference {reference_id} not found")
conn.execute(
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
(sort_order, reference_id),
)
widget = self.get_widget(row["widget_id"])
return {
"id": row["id"],
"dashboard_scope": row["dashboard_scope"],
"widget_id": row["widget_id"],
"sort_order": sort_order,
"created_at": int(row["created_at"]),
"widget": widget,
}
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
"""Clone the referenced widget into a new standalone instance owned by the scope."""
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT widget_id FROM widget_references WHERE id = ?",
(reference_id,),
).fetchone()
if not row:
raise ValueError(f"Reference {reference_id} not found")
source = self.get_widget(row["widget_id"])
if not source:
raise ValueError(f"Source widget {row['widget_id']} not found")
# Clone: copy the widget verbatim including service_id (so service-bound
# widgets keep working), only the id/created_at change.
cloned = self.upsert_widget(
{
"service_id": source.get("service_id"),
"widget_kind": source["widget_kind"],
"title": source["title"],
"config": source["config"],
"enabled": source["enabled"],
"sort_order": source["sort_order"],
}
)
self.delete_widget_reference(reference_id)
return cloned
# ------------------------------------------------------------------
# Service registry
@@ -1610,7 +1754,7 @@ class SettingsStore:
return self.get_service(service["id"]) or service
def delete_service(self, service_id: str) -> None:
"""Delete a service and cascade-delete widgets referencing it."""
"""Delete a service and cascade-delete widgets + harness data."""
self.init_schema()
with self.connect() as conn:
# The service_id column on dashboard_widgets is added in a later
@@ -1623,6 +1767,15 @@ class SettingsStore:
)
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
# Cascade-delete harness-managed data (best-effort: the service row is
# already removed; data cleanup must not block service deletion).
try:
from media_library_viewer_api.services.service_data import get_service_data_harness
get_service_data_harness().cascade_delete(service_id)
except Exception:
logger.exception("Failed to cascade-delete harness data for service %s", service_id)
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Append a service task run history row."""
self.init_schema()
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/widgets
## role
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
Widget subsystem providing configurable dashboard widget definitions, source adapters, and data transformation helpers for the media library viewer API.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -11,12 +11,13 @@ map: backend/src/media_library_viewer_api/.pi-map.md
## files
- __init__.py
- builtin.py
- prometheus_range.py
- sources.py
## links
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
## workflows
- change widgets behavior
read: __init__.py, builtin.py, sources.py
read: __init__.py, builtin.py, prometheus_range.py
## dirty
-
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
"""Shared helpers for Prometheus range queries.
These two pieces were called out by the spec's downstream-notes as needing a
home: the step-derivation function (SC-104) and the series normalization helper
(SC-102). Keeping them in their own module makes them unit-testable in isolation
and reusable by the chart and mean widget paths (and, later, the in-service data
path of the service-storage-harness change) without ``sources.py`` growing
unbounded.
``normalize_prometheus_matrix`` is a direct extraction of the metric-label
readable-label rule that previously lived inside the Grafana datasource-proxy
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
labels.
"""
from __future__ import annotations
from typing import Any
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
WINDOW_PRESETS: dict[str, int] = {
"1h": 3_600,
"6h": 21_600,
"24h": 86_400,
"7d": 604_800,
}
#: Sentinel values Prometheus serialises for non-finite floats; map these to
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
Clamped to a minimum of 15 seconds so Prometheus does not reject
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
the resulting point count to land in the 100300 band; with
``target_points=200`` every preset yields 200 points.
"""
return max(15, round(window_seconds / target_points))
def _dedup_label(label: str, seen: dict[str, int]) -> str:
"""Apply `` (n)`` suffix on collision. Mutates and reads from ``seen`` dict."""
if label in seen:
seen[label] += 1
return f"{label} ({seen[label]})"
seen[label] = 0
return label
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
renderer consumes.
Label rule (matches the removed Grafana path so labels are stable on
migration):
1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
3. Else fall back to ``"value"``.
4. Dedup label collisions with a `` (n)`` suffix.
"""
series: list[dict[str, Any]] = []
seen: dict[str, int] = {}
for entry in result:
metric = entry.get("metric") or {}
values = entry.get("values") or []
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
label = _dedup_label(" ".join(parts) if parts else "value", seen)
points: list[dict[str, Any]] = []
for ts, raw in values:
t = _safe_int(ts)
if t is None:
# Drop samples whose timestamp is unusable rather than raising.
continue
points.append({"t": t, "v": _safe_float(raw)})
series.append({"label": label, "points": points})
return series
def normalize_grafana_frames(raw: dict[str, Any]) -> list[dict[str, Any]]:
"""Turn a Grafana ``/api/ds/query`` response into the ``{label, points}`` series shape.
Parses ``results.<refId>.frames[]`` where each frame has:
- ``data.values``: ``[[timestamps...], [values...]]``
- ``schema.fields``: ``[{name, labels?, config?: {displayName?}}, ...]``
Label rule (same as ``normalize_prometheus_matrix``, shared via ``_dedup_label``):
1. Prefer ``config.displayName`` (explicitly set in Grafana).
2. Else use Prometheus metric labels (sorted ``k=v``, excluding ``__``-prefixed).
3. Else fall back to the field name, or ``"value"``.
4. Dedup collisions with `` (n)`` suffix.
"""
series: list[dict[str, Any]] = []
seen: dict[str, int] = {}
results = raw.get("results", {})
for _ref_id, ref_data in results.items():
for frame in ref_data.get("frames", []):
values = frame.get("data", {}).get("values", [])
if len(values) < 2:
continue
timestamps = values[0]
vals = values[1]
# Derive a meaningful series label from the frame metadata.
fields = frame.get("schema", {}).get("fields", [])
value_field = fields[-1] if fields else {}
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
frame_labels = value_field.get("labels") or {}
if display_name:
label = str(display_name)
elif frame_labels:
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not str(k).startswith("__")]
label = " ".join(parts) if parts else "value"
else:
label = str(value_field.get("name", "value"))
label = _dedup_label(label, seen)
points = []
for t, v in zip(timestamps, vals):
safe_t = _safe_int(t)
if safe_t is None:
continue
points.append({"t": safe_t, "v": _safe_float(v)})
series.append({"label": label, "points": points})
return series
def _safe_float(raw: Any) -> float | None:
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
if raw in _NON_NUMERIC:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _safe_int(ts: Any) -> int | None:
"""Best-effort int conversion for a Prometheus timestamp."""
try:
return int(float(ts))
except (TypeError, ValueError):
return None
@@ -12,19 +12,28 @@ from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Protocol
import requests
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_grafana_frames,
normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5)
step_for_window,
)
logger = logging.getLogger(__name__)
@@ -96,61 +105,169 @@ class StaticWidgetSource:
# ---------------------------------------------------------------------------
class GrafanaWidgetSource:
"""Build a Grafana deep-link (no embedding)."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Grafana widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
dashboard_uid = config.get("dashboard_uid")
if not dashboard_uid:
return {"error": "dashboard_uid is required"}
url = f"{base_url}/d/{dashboard_uid}"
panel_id = config.get("panel_id")
if panel_id is not None:
url = f"{url}?viewPanel={panel_id}"
return {"url": url}
except Exception as exc:
logger.exception("grafana adapter failed")
return {"error": f"Grafana link failed: {exc}"}
class PrometheusWidgetSource:
"""Run a PromQL instant query against a Prometheus service."""
class MetricSource:
"""Run PromQL queries through a Grafana gateway (``/api/ds/query``)."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
api_key = str(service.secrets.get("grafana_api_key") or "")
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
timeout = int(service.config.get("timeout_seconds") or 10)
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
url = f"{base_url}/api/v1/query"
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
url,
params={"query": promql},
timeout=timeout,
),
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
return {"result": payload.get("data", {})}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except requests.RequestException as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"}
if widget_kind == "chart":
return await self._fetch_chart(grafana_url, api_key, datasource_uid, timeout, config)
if widget_kind == "gauge":
return await self._fetch_gauge(grafana_url, api_key, datasource_uid, timeout, config)
if widget_kind == "mean":
return await self._fetch_mean(grafana_url, api_key, datasource_uid, timeout, config)
# Default: instant-query metric path.
return await self._fetch_metric(grafana_url, api_key, datasource_uid, timeout, config)
except Exception as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"}
async def _gateway_query(
self,
grafana_url: str,
api_key: str,
datasource_uid: str,
timeout: int,
promql: str,
window_seconds: int | None = None,
max_data_points: int = 200,
) -> dict[str, Any]:
"""POST ``{grafana_url}/api/ds/query``; return raw Grafana JSON or ``{error}``.
- ``window_seconds=None`` instant mapping (``from=now-1m, maxDataPoints=1``).
- ``window_seconds=<N>`` range query (``from=now-Ns``, step derived).
"""
if not grafana_url:
return {"error": "grafana_url is required"}
if not api_key:
return {"error": "grafana_api_key is required"}
step = step_for_window(window_seconds) if window_seconds else 15
interval_ms = step * 1000
body = {
"queries": [
{
"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": promql,
"format": "time_series",
"intervalMs": interval_ms,
"maxDataPoints": 1 if window_seconds is None else max_data_points,
"refId": "A",
}
],
"from": f"now-{window_seconds or 60}s" if window_seconds else "now-1m",
"to": "now",
}
def _do_post() -> dict[str, Any]:
resp = requests.post(
f"{grafana_url}/api/ds/query",
json=body,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
timeout=timeout,
)
resp.raise_for_status()
return resp.json()
try:
return await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
except asyncio.TimeoutError:
return {"error": "Grafana query timed out"}
except requests.RequestException as exc:
logger.exception("grafana gateway query failed")
return {"error": f"Grafana query failed: {exc}"}
async def _fetch_chart(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Range query → ``{series}`` for the chart widget (GM-106)."""
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
if "error" in raw:
return raw
return {"series": normalize_grafana_frames(raw)}
async def _fetch_gauge(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Instant query → scalar for the gauge widget (GM-107).
Scalar-only: a multi-series query returns an error. Threshold config is
passed through for the frontend renderer.
"""
promql = config.get("promql") or ""
if not promql:
return {"error": "promql is required"}
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
if "error" in raw:
return raw
series = normalize_grafana_frames(raw)
if len(series) != 1:
return {"error": "Gauge requires a single-series query; refine your PromQL"}
points = series[0]["points"]
if not points:
return {"error": "Gauge query returned no scalar value"}
value = points[-1]["v"]
if value is None:
return {"error": "Gauge query returned no scalar value"}
return {
"value": value,
"warn_at": config.get("warn_at"),
"crit_at": config.get("crit_at"),
"min": config.get("min"),
"max": config.get("max"),
"unit": config.get("unit"),
}
async def _fetch_mean(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Range query → client-side mean for the mean widget (GM-108).
Runs a gateway range query over the configured window preset, averages
all non-null numeric samples of the single series, and returns a scalar.
Scalar-only: a multi-series query returns an error.
"""
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
if "error" in raw:
return raw
series = normalize_grafana_frames(raw)
if len(series) != 1:
return {"error": "Mean requires a single-series query; refine your PromQL"}
nums = [p["v"] for p in series[0]["points"] if p["v"] is not None]
if not nums:
return {"error": "Mean query returned no numeric samples in the window"}
return {"value": sum(nums) / len(nums), "unit": config.get("unit")}
async def _fetch_metric(
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
) -> dict[str, Any]:
"""Instant query → ``{result}`` for the metric widget (GM-109).
Returns ``{result: [{label, points}]}`` the normalized series shape.
The frontend ``PrometheusMetricWidget`` renders the last point of each
series.
"""
promql = config.get("promql") or ""
if not promql:
return {"error": "promql is required"}
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
if "error" in raw:
return raw
return {"result": normalize_grafana_frames(raw)}
class AlertmanagerWidgetSource:
"""Fetch firing alerts from an Alertmanager service and summarize them."""
@@ -208,6 +325,10 @@ class JellyfinWidgetSource:
asyncio.to_thread(client.sessions),
timeout=timeout,
)
if widget_kind == "now_playing":
sessions = [
s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True)
]
rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows}
except asyncio.TimeoutError:
@@ -265,13 +386,75 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
logger.exception("failed to record ssh task timeout")
class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "qBittorrent widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
timeout = int(service.config.get("timeout_seconds") or 10)
if not base_url or not username or not password:
return {"error": "qBittorrent service is missing base_url, username, or password"}
client = QbittorrentClient(base_url, username, password, timeout)
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
server_state = data.get("server_state", {})
torrents = data.get("torrents", {})
if widget_kind == "totals":
by_state: dict[str, int] = {}
for t in torrents.values():
state = str(t.get("state", "unknown"))
by_state[state] = by_state.get(state, 0) + 1
return {"total": len(torrents), "by_state": by_state}
if widget_kind == "active":
active = [
{
"name": t.get("name"),
"state": t.get("state"),
"size": t.get("size"),
"progress": t.get("progress"),
"dl_speed": t.get("dlspeed"),
"up_speed": t.get("upspeed"),
}
for t in torrents.values()
if str(t.get("state", "")) in {"downloading", "uploading"}
]
return {"torrents": active}
if widget_kind == "speed":
dl = int(server_state.get("dl_info_speed", 0))
up = int(server_state.get("up_info_speed", 0))
ts = int(time.time())
store = QbittorrentSampleStore()
store.append(service.id, ts, dl, up)
samples = store.window(service.id)
series = [
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
]
return {"series": series}
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
except asyncio.TimeoutError:
return {"error": "qBittorrent data fetch timed out"}
except Exception as exc:
logger.exception("qbittorrent adapter failed")
return {"error": f"qBittorrent fetch failed: {exc}"}
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"grafana": GrafanaWidgetSource(),
"prometheus": PrometheusWidgetSource(),
"prometheus": MetricSource(),
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/workers
## role
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
Provides background worker subprocesses for asynchronously building and updating media indexes from external servers.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api/workers
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
## role
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
Provides background worker subprocesses for asynchronously building and updating media indexes from external servers.
## files
- __init__.py | Marks the directory as a Python package for worker entrypoints used in background task processing.
- media_index_worker.py | Subprocess worker that builds a media index from Jellyfin libraries with progress tracking and cooperative cancellation support. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:run_build(final_index_path: str | Path, staging_index_path: str | Path) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:get_jellyfin_client, call:get_user_id, call:client.libraries, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index
- media_index_worker.py | This file acts as a standalone subprocess worker that asynchronously builds and updates a media index from a Jellyfin server, allowing the main API to remain responsive. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:_resolve_jellyfin(service_id: str) → tuple[Any, str], call:get_settings_store, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:int, call:JellyfinClient, call:client.users, call:client.libraries, call:next, call:u.get, call:logger.info, call:logger.warning, raise:RuntimeError, func:run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:_resolve_jellyfin, call:client.libraries, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies
## arch
Subprocess worker pattern with cooperative cancellation, designed to be spawned and monitored by a parent process for long-running media indexing tasks.
Standalone subprocess worker pattern that decouples long-running data synchronization tasks from the main API process.
## tags
call:, metadata, set, index, call:get, media, progress, worker
call:, metadata, set, index, jellyfin, settings, media, media_library_viewer_api
## symbols
- _set_build_metadata
- _cancel_requested
- _start_state
- _progress_callback
- _resolve_jellyfin
- run_build
- main
- call:state.items
- call:index.set_metadata
## workflows
- change workers behavior
read: __init__.py, media_index_worker.py
@@ -14,7 +14,6 @@ from pathlib import Path
from typing import Any
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.services.media_index import (
MediaIndex,
@@ -88,15 +87,70 @@ def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> No
)
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
"""Resolve the Jellyfin client + user_id from the settings store.
In a subprocess we cannot use the FastAPI dependency layer (no request),
so we query the settings store directly. When ``service_id`` is given,
resolve that specific instance; otherwise fall back to first-enabled.
"""
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.dependencies import _service_record, get_settings_store
store = get_settings_store()
service = _service_record(store, "jellyfin", service_id or None)
if service is None:
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
client = JellyfinClient(base_url, api_key, timeout)
user_id = str(service.get("config", {}).get("user_id") or "")
if not user_id:
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
user_id = users[0]["Id"]
else:
# Try the configured user_id directly. It might be the internal
# Jellyfin Id (a long hash) — in that case libraries() succeeds
# without an extra users() round-trip. Only if it fails do we
# resolve it via the users API (the config field accepts usernames
# like 'admin' too, but Jellyfin's API rejects them on /Users/<id>).
try:
client.libraries(user_id)
except Exception:
users = client.users()
match = next((u for u in users if str(u.get("Name", "")) == user_id), None)
if match:
resolved = match["Id"]
logger.info(
"Resolved username '%s' to Jellyfin Id '%s'",
user_id,
resolved,
)
user_id = resolved
elif users:
user_id = users[0]["Id"]
logger.warning(
"user_id '%s' not found; falling back to first user",
service.get("config", {}).get("user_id"),
)
return client, user_id
def run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id: str = "") -> int:
"""Run the media index build in a subprocess."""
settings = get_settings()
configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client()
user_id = get_user_id()
libraries = client.libraries(user_id)
# Create the indexes BEFORE the try block so the except handler can write
# error state to the DB. If _resolve_jellyfin or client.libraries fails,
# the worker needs to record the error — otherwise the status stays
# "queued" forever with no feedback.
final_index = MediaIndex(final_index_path)
staging_index = MediaIndex(staging_index_path)
pid = os.getpid()
@@ -104,10 +158,13 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
staging_path = Path(staging_index.db_path)
staging_path.unlink(missing_ok=True)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
try:
client, user_id = _resolve_jellyfin(service_id)
libraries = client.libraries(user_id)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
count = build_media_index(
client,
user_id,
@@ -117,6 +174,7 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
fallback_prefix=settings.path_prefix,
progress_callback=lambda state: _progress_callback(final_index, pid, state),
should_cancel=lambda: _cancel_requested(final_index),
service_id=service_id,
)
# Swap the staging database into place atomically.
os.replace(staging_index.db_path, final_index.db_path)
@@ -192,8 +250,9 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-path", required=True)
parser.add_argument("--service-id", default="", help="Jellyfin service instance id")
args = parser.parse_args()
return run_build(args.index_path, args.staging_path)
return run_build(args.index_path, args.staging_path, args.service_id)
if __name__ == "__main__": # pragma: no cover
+8 -3
View File
@@ -2,7 +2,7 @@
dir: backend/tests
## role
Test suite providing unit and integration tests that validate API endpoints, configuration, external clients, utilities, and service logic across the backend.
Comprehensive test suite providing unit and integration tests that validate the backend's API endpoints, external service clients, data persistence, and utility functions of a media library management dashboard.
## parent
index: backend/.pi-map.index.md
map: backend/.pi-map.md
@@ -11,8 +11,10 @@ map: backend/.pi-map.md
## files
- __init__.py
- test_api.py
- test_authentik_client.py
- test_backups.py
- test_config.py
- test_dashboards.py
- test_domain_media.py
- test_jellyseerr_client.py
- test_jobs.py
@@ -20,6 +22,9 @@ map: backend/.pi-map.md
- test_media_index.py
- test_observability.py
- test_path_utils.py
- test_prometheus_range.py
- test_qbittorrent_client.py
- test_service_data.py
- test_services.py
- test_ssh_client.py
- test_targets.py
@@ -30,9 +35,9 @@ index: backend/tests/.pi-map.index.md
map: backend/tests/.pi-map.md
## workflows
- change tests behavior
read: __init__.py, test_api.py, test_backups.py
read: __init__.py, test_api.py, test_authentik_client.py
- change tests CLI
read: test_jellyseerr_client.py, test_ssh_client.py
read: test_authentik_client.py, test_jellyseerr_client.py, test_qbittorrent_client.py
- change tests config
read: test_config.py
## dirty
File diff suppressed because one or more lines are too long
+206 -75
View File
@@ -5,10 +5,12 @@ without requiring real remote connections.
"""
import json
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import requests
from fastapi.testclient import TestClient
from media_library_viewer_api.clients.ssh import CommandResult
@@ -26,6 +28,7 @@ from media_library_viewer_api.widgets.sources import ServiceRecord
# Short alias for the monitoring router module under test.
_MON = "media_library_viewer_api.routers.monitoring"
_SVC = "media_library_viewer_api.services.service_resolution"
# --- Fixtures ---
@@ -362,7 +365,7 @@ class TestMediaIndexApi:
assert data["status"] == "started"
assert data["build_running"] is True
assert data["build_stage"] == "queued"
assert data["build_libraries_total"] == len(mock_jellyfin.libraries.return_value)
assert data["build_libraries_total"] == 0
assert data["build_pid"] == 4321
start_worker.assert_called_once()
finally:
@@ -496,7 +499,7 @@ class TestMonitoring:
class TestResolveServiceRecord:
"""Unit tests for _resolve_service_record (service_id + first-enabled paths)."""
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
def _store(self, rows):
store = MagicMock()
@@ -509,31 +512,31 @@ class TestResolveServiceRecord:
return store
def test_service_id_match_returns_record(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record
from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
store = self._store([row])
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
result = _resolve_service_record(store, "alertmanager", "am1")
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
result = resolve_service_record(store, "alertmanager", "am1")
assert result == "RECORD"
mock_build.assert_called_once_with(store, row)
def test_service_id_type_mismatch_returns_none(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record
from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
store = self._store([row])
assert _resolve_service_record(store, "alertmanager", "x1") is None
assert resolve_service_record(store, "alertmanager", "x1") is None
def test_service_id_disabled_returns_none(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record
from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
store = self._store([row])
assert _resolve_service_record(store, "alertmanager", "am1") is None
assert resolve_service_record(store, "alertmanager", "am1") is None
def test_no_service_id_returns_first_enabled(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record
from media_library_viewer_api.services.service_resolution import resolve_service_record
rows = [
{
@@ -554,16 +557,16 @@ class TestResolveServiceRecord:
},
]
store = self._store(rows)
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
result = _resolve_service_record(store, "alertmanager", None)
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
result = resolve_service_record(store, "alertmanager", None)
assert result == "RECORD"
mock_build.assert_called_once_with(store, rows[1])
def test_no_service_id_and_none_enabled_returns_none(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record
from media_library_viewer_api.services.service_resolution import resolve_service_record
store = self._store([])
assert _resolve_service_record(store, "alertmanager", None) is None
assert resolve_service_record(store, "alertmanager", None) is None
class TestSettingsMachines:
@@ -624,7 +627,7 @@ class TestAlertmanager:
def test_alerts_endpoint_when_unreachable(self, test_client):
service = _am_service()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
):
response = test_client.get("/api/monitoring/alerts")
@@ -651,7 +654,7 @@ class TestAlertmanager:
}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp),
):
response = test_client.get("/api/monitoring/alerts")
@@ -669,7 +672,7 @@ class TestAlertmanager:
resp.json.return_value = {"status": "success", "data": []}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
):
test_client.get("/api/monitoring/alerts")
@@ -687,7 +690,7 @@ class TestAlertmanager:
def test_alertmanager_status_when_unreachable(self, test_client):
service = _am_service()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
):
response = test_client.get("/api/monitoring/alertmanager-status")
@@ -707,7 +710,7 @@ class TestAlertmanager:
}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp),
):
response = test_client.get("/api/monitoring/alertmanager-status")
@@ -740,48 +743,6 @@ class TestAlertmanagerWebhook:
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
class TestGrafanaStatus:
def test_grafana_status_when_not_configured(self, test_client):
response = test_client.get("/api/monitoring/grafana-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "no_service_configured"
def test_grafana_status_when_unreachable(self, test_client):
service = ServiceRecord(
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
)
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
):
response = test_client.get("/api/monitoring/grafana-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "grafana_unreachable"
assert data["name"] == "Grafana"
def test_grafana_status_returns_version(self, test_client):
service = ServiceRecord(
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
)
resp = MagicMock()
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp),
):
response = test_client.get("/api/monitoring/grafana-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is True
assert data["version"] == "11.3.1"
assert data["service_id"] == "g1"
class TestPrometheusStatus:
def test_prometheus_status_when_not_configured(self, test_client):
response = test_client.get("/api/monitoring/prometheus-status")
@@ -792,11 +753,15 @@ class TestPrometheusStatus:
def test_prometheus_status_when_unreachable(self, test_client):
service = ServiceRecord(
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
id="p1",
service_type="prometheus",
name="Prometheus",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.post", side_effect=__import__("requests").ConnectionError("refused")),
):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
@@ -804,22 +769,188 @@ class TestPrometheusStatus:
assert data["up"] is False
assert data["error"] == "prometheus_unreachable"
def test_prometheus_status_returns_version(self, test_client):
def test_prometheus_status_returns_ok(self, test_client):
service = ServiceRecord(
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
id="p1",
service_type="prometheus",
name="Prometheus",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "key"},
)
health = MagicMock()
health.raise_for_status = MagicMock()
build_info = MagicMock()
build_info.raise_for_status = MagicMock()
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
gateway_resp = MagicMock()
gateway_resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.post", return_value=gateway_resp),
):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is True
assert data["version"] == "2.55.1"
assert data["version"] == "ok"
assert data["service_id"] == "p1"
@pytest.mark.parametrize("status_code", [401, 403])
def test_prometheus_status_returns_auth_failure_message(self, test_client, status_code):
# GM-110: a 401/403 from the Grafana gateway must surface as an
# auth-related error, not a crash and not a generic gateway error.
service = ServiceRecord(
id="p1",
service_type="prometheus",
name="Prometheus",
config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
secrets={"grafana_api_key": "bad-key"},
)
auth_error = requests.HTTPError(
f"{status_code} Client Error",
response=MagicMock(status_code=status_code),
)
with (
patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.post", side_effect=auth_error),
):
response = test_client.get("/api/monitoring/prometheus-status")
assert response.status_code == 200
data = response.json()
assert not data["up"]
assert data["error"] == "auth_failed"
assert data["service_id"] == "p1"
class TestPrometheusStartupValidation:
"""GM-113: startup warns (never crashes) about old-shape prometheus services."""
def test_old_shape_prometheus_service_logs_migration_warning(self, tmp_path, caplog):
from media_library_viewer_api.main import _validate_prometheus_gateway_config
# Seed a prometheus service persisted with the OLD config shape: a
# ``base_url`` and no ``grafana_url`` (pre-gateway migration).
store = SettingsStore(tmp_path / "settings.sqlite")
store.upsert_service(
{
"service_type": "prometheus",
"name": "Legacy Prometheus",
"config": {"base_url": "https://prometheus.example.com"},
"enabled": True,
}
)
with (
patch("media_library_viewer_api.main.get_settings_store", return_value=store),
caplog.at_level(logging.WARNING, logger="media_library_viewer_api.main"),
):
# Must not raise even though the service uses the deprecated shape.
_validate_prometheus_gateway_config()
# Best-effort validator logs a migration hint referencing grafana_url.
assert "grafana_url" in caplog.text
assert any(record.levelno == logging.WARNING for record in caplog.records)
# --- Service credential tester endpoint (CT-101..CT-113) ---
class TestServiceTestEndpoint:
"""Tests for POST /api/services/test — dispatch, validation-first, no-persistence, no-secret-logs."""
def test_backups_returns_no_test_needed(self, test_client: TestClient) -> None:
"""backups has test_callable=None → returns ok=true with 'No test' detail."""
response = test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
assert response.status_code == 200
body = response.json()
assert body["ok"] is True
assert "No" in body["detail"]
def test_validation_first_rejects_malformed_config(self, test_client: TestClient) -> None:
"""Malformed config (schema-less base_url) → 422, no test_callable called."""
response = test_client.post(
"/api/services/test",
json={
"service_type": "qbittorrent",
"name": "test",
"config": {"base_url": "localhost:8080"}, # missing http://
"secrets": {"username": "u", "password": "p"},
"enabled": True,
},
)
assert response.status_code == 422
def test_no_persistence_after_test(self, test_client: TestClient, tmp_path) -> None:
"""Calling /test does not create a service row."""
store = app.dependency_overrides[get_settings_store]()
before = len(store.list_services())
test_client.post(
"/api/services/test",
json={
"service_type": "backups",
"name": "test",
"config": {"ingestion_label": "default"},
"secrets": {},
"enabled": True,
},
)
after = len(store.list_services())
assert before == after
def test_secrets_not_logged(self, test_client: TestClient, caplog) -> None:
"""Secret values in the request body never reach any log line.
Unlike the trivial empty-secrets case, this drives the full endpoint
path (validate -> dispatch to the real test_callable -> success log)
with real-looking secret payloads. The per-type test_callables are
mocked at the network boundary so they succeed, proving the endpoint
does not log the secret values even though they are in the request body.
"""
api_key_secret = "glc_somethingverysecret"
password_secret = "SUPER-SECRET-PW-12345"
prom_response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
qbit_client = MagicMock()
qbit_client.maindata.return_value = {"server_state": {"qbittorrent_version": "v4.6.0"}}
with (
caplog.at_level(logging.DEBUG),
patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=prom_response),
patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=qbit_client),
):
prom_resp = test_client.post(
"/api/services/test",
json={
"service_type": "prometheus",
"name": "test",
"config": {"grafana_url": "http://grafana:3000"},
"secrets": {"grafana_api_key": api_key_secret},
"enabled": True,
},
)
qbit_resp = test_client.post(
"/api/services/test",
json={
"service_type": "qbittorrent",
"name": "test",
"config": {"base_url": "http://qb:8080"},
"secrets": {"username": "u", "password": password_secret},
"enabled": True,
},
)
# Both requests must run the endpoint fully (validate + dispatch + success).
assert prom_resp.status_code == 200
assert prom_resp.json()["ok"] is True
assert qbit_resp.status_code == 200
assert qbit_resp.json()["ok"] is True
# Neither the full secret values nor meaningful fragments may leak into logs.
leaked = [
fragment for fragment in (api_key_secret, password_secret, "verysecret", "SUPER") if fragment in caplog.text
]
assert not leaked, f"secret fragments leaked into logs: {leaked!r}"
+79
View File
@@ -1,6 +1,7 @@
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from media_library_viewer_api.main import app
@@ -33,6 +34,84 @@ def test_dashboard_backups():
auth_module._API_KEY = None
# ---------------------------------------------------------------------------
# Per-instance service scoping (PI-110, PI-111, PI-119)
# ---------------------------------------------------------------------------
@pytest.fixture
def store(tmp_path):
"""Fresh SettingsStore with schema initialized."""
db_path = tmp_path / "test_settings.sqlite"
s = SettingsStore(db_path)
s.init_schema()
return s
class TestBackupServiceScoping:
"""Verify service_id filtering on list_backup_jobs/runs/alerts."""
def test_list_backup_jobs_filtered_by_service(self, store: SettingsStore):
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
assert len(store.list_backup_jobs(service_id="svc-a")) == 1
assert len(store.list_backup_jobs(service_id="svc-b")) == 1
def test_list_backup_jobs_unfiltered_returns_all(self, store: SettingsStore):
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
assert len(store.list_backup_jobs()) == 2
assert len(store.list_backup_jobs(service_id="")) == 2
def test_list_backup_runs_filtered_by_service(self, store: SettingsStore):
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
assert len(store.list_backup_runs(service_id="svc-a")) == 1
assert len(store.list_backup_runs(service_id="svc-b")) == 1
def test_list_backup_alerts_filtered_by_service(self, store: SettingsStore):
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
store.create_backup_alert({"job_id": job_a["id"], "alert_type": "test", "severity": "warning"})
store.create_backup_alert({"job_id": job_b["id"], "alert_type": "test", "severity": "warning"})
assert len(store.list_backup_alerts(service_id="svc-a")) == 1
assert len(store.list_backup_alerts(service_id="svc-b")) == 1
def test_list_backup_runs_unfiltered_returns_all(self, store: SettingsStore):
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
assert len(store.list_backup_runs()) == 2
def test_endpoint_threads_service_id_to_store(self, store: SettingsStore):
"""GET /api/backups/jobs?service_id=svc-a filters via the endpoint."""
import media_library_viewer_api.auth as auth_module
from media_library_viewer_api.services import settings_store
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
original_store = settings_store._store
settings_store._store = store
auth_module._API_KEY = None
try:
client = TestClient(app)
resp = client.get("/api/backups/jobs", params={"service_id": "svc-a"})
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "job-a"
finally:
settings_store._store = original_store
auth_module._API_KEY = None
def test_post_backup_report():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_settings.sqlite"
+260
View File
@@ -0,0 +1,260 @@
"""Tests for the service credential tester (CT-101..CT-113, CT-119)."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import requests
from media_library_viewer_api.integrations.alertmanager import test_connection as am_test
from media_library_viewer_api.integrations.authentik import test_connection as ak_test
from media_library_viewer_api.integrations.base import translate_connection_error
from media_library_viewer_api.integrations.jellyfin import test_connection as jf_test
from media_library_viewer_api.integrations.nextcloud import test_connection as nc_test
from media_library_viewer_api.integrations.prometheus import test_connection as prom_test
from media_library_viewer_api.integrations.qbittorrent import test_connection as qbit_test
from media_library_viewer_api.integrations.ssh_tasks import test_connection as ssh_test
# ---------------------------------------------------------------------------
# translate_connection_error (CT-119)
# ---------------------------------------------------------------------------
class TestTranslateConnectionError:
def test_http_401_maps_to_auth_message(self) -> None:
resp = SimpleNamespace(status_code=401)
exc = requests.HTTPError(response=resp)
result = translate_connection_error(exc)
assert result.ok is False
assert "Authentication failed" in result.detail
def test_http_403_maps_to_auth_message(self) -> None:
resp = SimpleNamespace(status_code=403)
exc = requests.HTTPError(response=resp)
result = translate_connection_error(exc)
assert result.ok is False
assert "Authentication failed" in result.detail
def test_connection_error_dns_maps_to_host_not_found(self) -> None:
exc = requests.ConnectionError("getaddrinfo failed")
result = translate_connection_error(exc)
assert result.ok is False
assert "Host not found" in result.detail
def test_timeout_maps_to_timed_out(self) -> None:
exc = requests.Timeout("timed out")
result = translate_connection_error(exc)
assert result.ok is False
assert "timed out" in result.detail.lower()
def test_generic_fallback_includes_context(self) -> None:
exc = ValueError("something weird happened")
result = translate_connection_error(exc, context="qBittorrent")
assert result.ok is False
assert "qBittorrent" in result.detail
assert "something weird happened" in result.detail
# ---------------------------------------------------------------------------
# qbittorrent (CT-104)
# ---------------------------------------------------------------------------
class TestQbittorrentTestConnection:
def test_success_returns_version(self) -> None:
mock_client = MagicMock()
mock_client.maindata.return_value = {"server_state": {"qbittorrent_version": "v4.6.0"}}
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
result = qbit_test(
{"base_url": "http://qb:8080", "timeout_seconds": 5},
{"username": "u", "password": "p"},
MagicMock(),
)
assert result.ok is True
assert result.evidence == "v4.6.0"
def test_login_failed_translates_to_auth_message(self) -> None:
mock_client = MagicMock()
mock_client.maindata.side_effect = RuntimeError("qBittorrent login failed: Fails.")
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
assert result.ok is False
assert "Authentication failed" in result.detail
def test_connection_error_translates(self) -> None:
mock_client = MagicMock()
mock_client.maindata.side_effect = requests.ConnectionError("Connection refused")
with patch("media_library_viewer_api.integrations.qbittorrent.QbittorrentClient", return_value=mock_client):
result = qbit_test({"base_url": "http://qb:8080"}, {"username": "u", "password": "p"}, MagicMock())
assert result.ok is False
assert "Connection refused" in result.detail
# ---------------------------------------------------------------------------
# prometheus (CT-105)
# ---------------------------------------------------------------------------
class TestPrometheusTestConnection:
def test_success_returns_gateway_evidence(self) -> None:
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"results": {}})
with patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=payload):
result = prom_test(
{"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
{"grafana_api_key": "tok"},
MagicMock(),
)
assert result.ok is True
assert "Gateway" in (result.evidence or "")
def test_missing_url_returns_error_without_network(self) -> None:
result = prom_test({}, {"grafana_api_key": "tok"}, MagicMock())
assert result.ok is False
assert "URL" in result.detail
def test_missing_api_key_returns_error_without_network(self) -> None:
result = prom_test({"grafana_url": "http://grafana:3000"}, {}, MagicMock())
assert result.ok is False
assert "API key" in result.detail
def test_http_401_translates_to_auth(self) -> None:
exc = requests.HTTPError(response=SimpleNamespace(status_code=401))
payload = SimpleNamespace(raise_for_status=MagicMock(side_effect=exc))
with patch("media_library_viewer_api.integrations.prometheus.requests.post", return_value=payload):
result = prom_test(
{"grafana_url": "http://grafana:3000"},
{"grafana_api_key": "wrong"},
MagicMock(),
)
assert result.ok is False
assert "Authentication failed" in result.detail
# ---------------------------------------------------------------------------
# alertmanager (CT-106)
# ---------------------------------------------------------------------------
class TestAlertmanagerTestConnection:
def test_success_returns_version(self) -> None:
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"versionInfo": {"version": "0.27.0"}},
)
with patch("media_library_viewer_api.integrations.alertmanager.requests.get", return_value=payload):
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
assert result.ok is True
assert result.evidence == "0.27.0"
def test_connection_refused_translates(self) -> None:
with patch(
"media_library_viewer_api.integrations.alertmanager.requests.get",
side_effect=requests.ConnectionError("refused"),
):
result = am_test({"base_url": "http://am:9093"}, {}, MagicMock())
assert result.ok is False
assert "Connection refused" in result.detail
# ---------------------------------------------------------------------------
# jellyfin (CT-107)
# ---------------------------------------------------------------------------
class TestJellyfinTestConnection:
def test_success_returns_user_count(self) -> None:
mock_client = MagicMock()
mock_client.users.return_value = [{"Name": "a"}, {"Name": "b"}]
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "k"}, MagicMock())
assert result.ok is True
assert "2 users" == result.evidence
def test_http_401_translates_to_auth(self) -> None:
mock_client = MagicMock()
mock_client.users.side_effect = requests.HTTPError(response=SimpleNamespace(status_code=401))
with patch("media_library_viewer_api.integrations.jellyfin.JellyfinClient", return_value=mock_client):
result = jf_test({"base_url": "http://jf:8096"}, {"api_key": "wrong"}, MagicMock())
assert result.ok is False
assert "Authentication failed" in result.detail
# ---------------------------------------------------------------------------
# authentik (CT-108)
# ---------------------------------------------------------------------------
class TestAuthentikTestConnection:
def test_success_returns_user_count(self) -> None:
mock_client = MagicMock()
mock_client.users.return_value = {"total": 5, "items": []}
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
assert result.ok is True
assert "5 users" == result.evidence
def test_connection_error_translates(self) -> None:
mock_client = MagicMock()
mock_client.users.side_effect = requests.ConnectionError("refused")
with patch("media_library_viewer_api.integrations.authentik.AuthentikClient", return_value=mock_client):
result = ak_test({"base_url": "http://ak:9000"}, {"api_token": "tok"}, MagicMock())
assert result.ok is False
assert "Connection refused" in result.detail
# ---------------------------------------------------------------------------
# ssh_tasks (CT-109)
# ---------------------------------------------------------------------------
class TestSshTasksTestConnection:
def test_success_returns_connected_evidence(self) -> None:
mock_client = MagicMock()
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {"passphrase": ""}, MagicMock())
assert result.ok is True
assert "Connected to srv:22" == result.evidence
def test_auth_failed_translates_to_ssh_auth_message(self) -> None:
mock_client = MagicMock()
mock_client.connect.side_effect = Exception("SSH authentication failed")
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
assert result.ok is False
assert "SSH authentication failed" in result.detail
def test_protocol_banner_translates(self) -> None:
mock_client = MagicMock()
mock_client.connect.side_effect = Exception("protocol banner error")
with patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=mock_client):
result = ssh_test({"host": "srv", "port": 22, "username": "u"}, {}, MagicMock())
assert result.ok is False
assert "SSH banner" in result.detail
def test_missing_host_returns_value_error(self) -> None:
result = ssh_test({"host": "", "username": "u"}, {}, MagicMock())
assert result.ok is False
# ---------------------------------------------------------------------------
# nextcloud (CT-110)
# ---------------------------------------------------------------------------
class TestNextcloudTestConnection:
def test_success_returns_version(self) -> None:
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"version": "29.0.0"})
with patch("media_library_viewer_api.integrations.nextcloud.requests.get", return_value=payload):
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
assert result.ok is True
assert result.evidence == "29.0.0"
def test_connection_error_translates(self) -> None:
with patch(
"media_library_viewer_api.integrations.nextcloud.requests.get",
side_effect=requests.ConnectionError("refused"),
):
result = nc_test({"base_url": "http://nc:80"}, {}, MagicMock())
assert result.ok is False
assert "Connection refused" in result.detail
+62 -19
View File
@@ -228,19 +228,70 @@ class TestMediaIndexQuery:
class TestMediaIndexReplace:
def test_replace_clears_old(self, index):
index.replace_items([
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
])
index.replace_items(
[
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]
)
status = index.status()
assert status.item_count == 1
index.replace_items([
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
])
index.replace_items(
[
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]
)
status = index.status()
assert status.item_count == 2
def test_replace_scoped_by_service_id_preserves_other_services(self, index):
"""Regression test: building for one Jellyfin must not wipe another's rows.
Before the migration, ``replace_items`` did ``DELETE FROM media_items``
(global clear). This test locks in the fix: a scoped replace preserves
rows belonging to a different service_id.
"""
rows_a = [
{"id": "a1", "title": "Alpha Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
{"id": "a2", "title": "Alpha Show", "type": "Episode", "library_id": "l2", "library_name": "L2"},
]
rows_b = [
{"id": "b1", "title": "Beta Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]
index.replace_items(rows_a, service_id="svc-a")
assert index.status().item_count == 2
# Replacing for svc-b must NOT wipe svc-a's rows.
index.replace_items(rows_b, service_id="svc-b")
assert index.status().item_count == 3 # 2 from svc-a + 1 from svc-b
# Querying svc-a returns only its rows.
rows_a_result, total_a = index.query(
library_ids=["l1", "l2"],
media_types=["Movie", "Episode"],
service_id="svc-a",
)
assert total_a == 2
assert {r["id"] for r in rows_a_result} == {"a1", "a2"}
# Querying svc-b returns only its rows.
rows_b_result, total_b = index.query(
library_ids=["l1"],
media_types=["Movie"],
service_id="svc-b",
)
assert total_b == 1
assert rows_b_result[0]["id"] == "b1"
# Querying with no service_id returns all rows (backward-compat).
_, total_all = index.query(
library_ids=["l1", "l2"],
media_types=["Movie", "Episode"],
)
assert total_all == 3
class TestMediaIndexMetadata:
def test_set_and_read_metadata(self, index):
@@ -288,15 +339,11 @@ class TestMediaIndexBuildPaths:
self.calls.append(kwargs.get("start_index", 0))
if kwargs.get("start_index", 0) == 0:
return {
"Items": [
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
],
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
"TotalRecordCount": 2,
}
return {
"Items": [
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
],
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
"TotalRecordCount": 2,
}
@@ -332,15 +379,11 @@ class TestMediaIndexBuildPaths:
self.calls.append(kwargs.get("start_index", 0))
if kwargs.get("start_index", 0) == 0:
return {
"Items": [
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
],
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
"TotalRecordCount": 2,
}
return {
"Items": [
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
],
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
"TotalRecordCount": 2,
}
+257
View File
@@ -0,0 +1,257 @@
"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104)."""
from __future__ import annotations
import pytest
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
_dedup_label,
normalize_grafana_frames,
normalize_prometheus_matrix,
step_for_window,
)
class TestStepForWindow:
"""SC-104: every preset must yield 100300 points."""
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
window = WINDOW_PRESETS[preset]
step = step_for_window(window)
# Clamped minimum.
assert step >= 15
point_count = window // step
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
def test_floor_of_fifteen_seconds(self) -> None:
# A tiny window that would otherwise produce a sub-15s step is clamped.
assert step_for_window(60) == 15
def test_custom_target_points(self) -> None:
# Targeting 100 points for 1h yields step 36 (3600/100).
assert step_for_window(3_600, target_points=100) == 36
class TestNormalizePrometheusMatrix:
"""SC-102: label rule + null handling + dedup."""
def test_empty_matrix(self) -> None:
assert normalize_prometheus_matrix([]) == []
def test_drops_dunder_labels_and_joins(self) -> None:
result = [
{
"metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"},
"values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]],
}
]
out = normalize_prometheus_matrix(result)
assert len(out) == 1
assert out[0]["label"] == "instance=host:9100 mode=idle"
assert out[0]["points"] == [
{"t": 1_700_000_000, "v": 12.5},
{"t": 1_700_000_030, "v": 13.0},
]
def test_falls_back_to_value_when_no_labels(self) -> None:
result = [{"metric": {}, "values": [[100, "1"]]}]
out = normalize_prometheus_matrix(result)
assert out[0]["label"] == "value"
def test_dedup_collisions_with_suffix(self) -> None:
# Two series with identical visible labels get a "(1)" suffix on the 2nd.
result = [
{"metric": {"job": "x"}, "values": [[1, "1"]]},
{"metric": {"job": "x"}, "values": [[1, "2"]]},
]
out = normalize_prometheus_matrix(result)
labels = [s["label"] for s in out]
assert labels == ["job=x", "job=x (1)"]
def test_non_numeric_sentinels_become_none(self) -> None:
result = [
{
"metric": {"job": "x"},
"values": [
[1, "NaN"],
[2, "+Inf"],
[3, "-Inf"],
[4, "3.5"],
],
}
]
out = normalize_prometheus_matrix(result)
assert out[0]["points"] == [
{"t": 1, "v": None},
{"t": 2, "v": None},
{"t": 3, "v": None},
{"t": 4, "v": 3.5},
]
def test_malformed_values_are_ignored_not_raised(self) -> None:
result = [
{
"metric": {"job": "x"},
"values": [
[1, "3.5"],
["not-a-ts", "9"], # unusable timestamp → dropped
[3, "junk-value"], # unparseable value → v: None
],
}
]
out = normalize_prometheus_matrix(result)
assert out[0]["points"] == [
{"t": 1, "v": 3.5},
{"t": 3, "v": None},
]
class TestDedupLabel:
"""The shared label-dedup helper used by both normalizers (GM-104)."""
def test_first_use_returns_label_unchanged(self) -> None:
seen: dict[str, int] = {}
assert _dedup_label("value", seen) == "value"
assert seen == {"value": 0}
def test_collision_appends_suffix(self) -> None:
seen: dict[str, int] = {}
assert _dedup_label("job=x", seen) == "job=x"
assert _dedup_label("job=x", seen) == "job=x (1)"
assert _dedup_label("job=x", seen) == "job=x (2)"
def test_different_labels_dont_collide(self) -> None:
seen: dict[str, int] = {}
assert _dedup_label("a", seen) == "a"
assert _dedup_label("b", seen) == "b"
class TestNormalizeGrafanaFrames:
"""GM-104: frames normalizer recovered from 65bae95 + shared dedup."""
def test_empty_response(self) -> None:
assert normalize_grafana_frames({"results": {}}) == []
assert normalize_grafana_frames({}) == []
def test_single_frame_with_values(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[1000, 2000], [1.5, 2.5]]},
"schema": {"fields": [{"name": "Time"}, {"name": "Value"}]},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert len(out) == 1
assert out[0]["label"] == "Value"
assert out[0]["points"] == [
{"t": 1000, "v": 1.5},
{"t": 2000, "v": 2.5},
]
def test_display_name_takes_priority(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100, 200], [0.75, 0.80]]},
"schema": {
"fields": [
{"name": "Time"},
{
"name": "Value",
"labels": {"instance": "host:9100"},
"config": {"displayName": "CPU Usage"},
},
]
},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert out[0]["label"] == "CPU Usage"
def test_labels_fallback_when_no_display_name(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100], [1.0]]},
"schema": {
"fields": [
{"name": "Time"},
{
"name": "Value",
"labels": {"__name__": "up", "instance": "h:9100"},
},
]
},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert out[0]["label"] == "instance=h:9100"
def test_falls_back_to_value_when_no_metadata(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100], [1.0]]},
"schema": {"fields": [{"name": "Time"}, {}]},
}
]
}
}
}
out = normalize_grafana_frames(raw)
assert out[0]["label"] == "value"
def test_dedup_collisions(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{
"data": {"values": [[100], [1.0]]},
"schema": {"fields": [{}, {"name": "Value"}]},
},
{
"data": {"values": [[100], [2.0]]},
"schema": {"fields": [{}, {"name": "Value"}]},
},
]
}
}
}
out = normalize_grafana_frames(raw)
labels = [s["label"] for s in out]
assert labels == ["Value", "Value (1)"]
def test_skips_frames_with_insufficient_values(self) -> None:
raw = {
"results": {
"A": {
"frames": [
{"data": {"values": [[100]]}, "schema": {"fields": []}},
{"data": {"values": [[100], [1.0]]}, "schema": {"fields": [{}, {}]}},
]
}
}
}
out = normalize_grafana_frames(raw)
assert len(out) == 1
+134
View File
@@ -0,0 +1,134 @@
"""Unit tests for the QbittorrentClient."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
import requests
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
class QbittorrentClientTests(unittest.TestCase):
def setUp(self) -> None:
self.client = QbittorrentClient("https://qb.example.com", "admin", "secret", timeout=5)
self.session = MagicMock()
self.client._session = self.session
def _login_response(self, text: str = "Ok.") -> MagicMock:
resp = MagicMock()
resp.text = text
resp.raise_for_status.return_value = None
resp.status_code = 200
return resp
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
resp = MagicMock()
resp.json.return_value = json_data
resp.raise_for_status.return_value = None
resp.status_code = status_code
resp.text = ""
return resp
def test_base_url_appends_api_v2(self) -> None:
c = QbittorrentClient("https://qb.example.com", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_base_url_keeps_existing_api_v2(self) -> None:
c = QbittorrentClient("https://qb.example.com/api/v2", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_base_url_strips_trailing_slash(self) -> None:
c = QbittorrentClient("https://qb.example.com/", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_empty_base_url_raises(self) -> None:
with self.assertRaises(ValueError):
QbittorrentClient("", "u", "p")
def test_empty_username_raises(self) -> None:
with self.assertRaises(ValueError):
QbittorrentClient("https://qb.example.com", "", "p")
def test_login_posts_credentials(self) -> None:
self.session.post.return_value = self._login_response("Ok.")
self.client._login()
self.session.post.assert_called_once()
call_args = self.session.post.call_args
self.assertIn("/auth/login", call_args.args[0])
self.assertEqual(call_args.kwargs["data"], {"username": "admin", "password": "secret"})
self.assertTrue(self.client._logged_in)
def test_login_failure_raises_runtime_error(self) -> None:
self.session.post.return_value = self._login_response("Fails.")
with self.assertRaises(RuntimeError):
self.client._login()
def test_get_auto_logs_in_on_first_call(self) -> None:
"""First _get triggers login, then fetches data."""
self.session.post.return_value = self._login_response("Ok.")
self.session.get.return_value = self._get_response({"server_state": {}, "torrents": {}})
result = self.client._get("/sync/maindata")
self.session.post.assert_called_once() # login happened
self.assertEqual(result, {"server_state": {}, "torrents": {}})
def test_cookie_reuse_does_not_re_login(self) -> None:
"""After login, subsequent _get calls do NOT re-login."""
self.client._logged_in = True # simulate already logged in
self.session.get.return_value = self._get_response({"data": 1})
self.client._get("/some/path")
self.session.post.assert_not_called() # no re-login
def test_403_triggers_re_login(self) -> None:
"""A 403 response triggers re-login and retries the GET."""
self.client._logged_in = True # already logged in from a prior call
forbidden = MagicMock()
forbidden.status_code = 403
ok = self._get_response({"server_state": {}, "torrents": {}})
self.session.get.side_effect = [forbidden, ok]
self.session.post.return_value = self._login_response("Ok.")
result = self.client._get("/sync/maindata")
self.assertEqual(self.session.get.call_count, 2) # initial + retry
self.session.post.assert_called_once() # re-login happened
self.assertEqual(result, {"server_state": {}, "torrents": {}})
def test_maindata_returns_full_payload(self) -> None:
self.client._logged_in = True
payload = {
"server_state": {"dl_info_speed": 12345, "up_info_speed": 6789},
"torrents": {
"abc": {"name": "Movie.mkv", "state": "downloading", "progress": 0.5},
"def": {"name": "Show.mkv", "state": "uploading", "progress": 1.0},
},
}
self.session.get.return_value = self._get_response(payload)
result = self.client.maindata()
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
self.assertEqual(len(result["torrents"]), 2)
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
"""A network error during login propagates as requests exception."""
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
bad_resp = MagicMock()
bad_resp.raise_for_status.side_effect = requests.ConnectionError("refused")
bad_resp.text = ""
mock_session.post.return_value = bad_resp
client = QbittorrentClient("https://qb.example.com", "u", "p")
with self.assertRaises(requests.ConnectionError):
client._login()
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More