Compare commits

...

95 Commits

Author SHA1 Message Date
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
Developer 8d2e4c9bfd Service IA refinement: nav naming, instance tabs, config to Settings, configurable Overview
Four coupled changes to the services-as-hub IA:

1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
   Grafana, Prometheus, Backups, Authentik) instead of conceptual names
   (Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
   entry ('SSH Tasks') instead of two. The content tabs inside each
   service page surface the concepts (Files, Actions).

2. Service page gains a two-level tab structure when multiple enabled
   instances of the same type exist: instance tabs on top ([Main Jellyfin]
   [Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
   [Widgets]). Clicking an instance tab navigates to the sibling's route.
   Single instance: no instance tabs. Replaces the dropdown switcher.

3. Config tab (connection fields, secrets, enable/disable, delete) moves
   from the service page to Settings > Services tab. The service page
   becomes a PURE operational view (Overview + content tabs + Widgets) --
   no save/delete/config state. Settings gains a 4th tab 'Services' with
   ServiceConfigEditor per instance (schema-driven config fields, secrets
   with leave-blank-to-keep semantics, ConfirmDialog on delete).

4. Overview tab is now a configurable widget grid per service instance.
   Each instance manages its own set of widgets on its Overview. Backend
   widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
   filter params; the main Dashboard uses scope=dashboard to exclude
   service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
   WidgetConfigDialog. Empty state CTA for instances with no widgets.

All service-tab stubs are replaced; stubs.tsx deleted.

272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
2026-06-26 22:25:46 +00:00
Developer fef0ded76f Fix: tabs.tsx data-orientation variants were dead (side-by-side layout)
The shared Tabs primitive used data-horizontal:* / data-vertical:* Tailwind
variants, but the component sets data-orientation='horizontal' (not
data-horizontal). Tailwind v4 data-* variants match attribute names, so
data-horizontal:flex-col on the Tabs root never applied -- the TabsList
and TabsContent laid out side-by-side instead of stacking.

Other consumers (TabbedCard, Settings) wrap their tab children in <div>s,
so the broken flex direction was masked. ServicePage puts TabsList and
TabsContent as direct children of <Tabs>, exposing the bug.

Fix: switch every dead variant to data-[orientation=horizontal]:* /
data-[orientation=vertical]:* (the root flex-col, the list h-8/h-fit/
flex-col, the trigger w-full/justify-start, and the active-indicator
after-element positioning). The full orientation system now works as
intended for both horizontal and vertical tabs.

117 tests pass; lint/build green.
2026-06-26 21:33:19 +00:00
Developer f7f590fa47 Fix: ServicePage content tabs wrapped in SheetForm on mobile
The reconciliation with mobile-responsive-parity applied the SheetForm
wrapper (designed when ServicePage was config-only) to the ENTIRE
service page, including content tabs. Clicking a nav item like 'Media'
on mobile opened a form sheet with Save/Cancel instead of the tabbed
content browser.

Fix: ServicePage now renders the Tabs skeleton on ALL breakpoints.
Content tabs (Media, Files, Actions, etc.) are operational views, not
forms -- they have their own mobile handling (MobileCardRow, etc.) and
should not be wrapped in a Save/Cancel sheet. The Config tab renders
inline like every other tab.

Removes the isMobile branch + SheetForm wrapper + dead imports
(useIsMobile, SheetForm) + sheetOpen state. 117 tests pass; lint/build
green.
2026-06-26 21:21:59 +00:00
Developer 01527ae4f0 Rebase services-as-hub-ia onto mobile-responsive-parity
Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
2026-06-26 21:08:51 +00:00
Developer b583d5a365 Update verify report: 4 of 5 residual risks resolved
R1 (R4.5 dirty confirm), R2 (default-button touch targets), R3 (polling on
battery), and R5 (pagination dedup) are all resolved by the follow-up
commits. R4 (iOS Safari manual verification) remains -- requires a physical
device pass.
2026-06-26 15:59:00 +00:00
Developer 32fa01cc12 Extract shared TablePagination (dedupe DataTable + Media mobile)
Pull the duplicated pagination footer into a single shared component at
frontend/src/components/ui/table-pagination.tsx. Both the desktop
DataTable (which had an internal DataTablePagination driven by a TanStack
table instance) and the Media mobile card list (which had a standalone
MediaMobilePagination driven by raw PaginationState) now consume it.

The shared component takes the raw primitives (pageIndex, pageSize,
pageCount, totalRows, pageSizeOptions, onPaginationChange, optional
className) so it backs both an adapter view (DataTable extracts state
from its table instance and passes table.setPagination) and a direct
state view (Media passes its pagination state directly). Includes the
44px mobile-touch-target on prev/next buttons (previously only on the
Media mobile variant).

Removes ~90 lines of duplication across data-table.tsx and Media.tsx;
adds the focused 122-line shared component. The DataTable Select imports
are dropped (now unused). 122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #5.
2026-06-26 15:58:12 +00:00
Developer ac703eecd2 Pause TanStack interval refetches when the tab is hidden (D8)
Set refetchIntervalInBackground: false as a QueryClient default so all
interval-based polls (widgets ~30s, message-queue 5s, media build progress
1s) pause when document.visibilityState === 'hidden'. Battery-friendly on
mobile -- the dashboard is the page most likely to be left open on a phone.

The media build-progress poll previously forced refetchIntervalInBackground:
true; that override is removed so it inherits the default. The build keeps
running server-side; the poll resumes and catches up when the user returns
to the tab.

122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #3 (D8 battery follow-up).
2026-06-26 15:48:43 +00:00
Developer d05de0aacd Touch-target pass: 44px min on default-size buttons (WCAG 2.5.5)
Applies .mobile-touch-target to 32 default-size <Button> elements (32px
tall, below the mobile minimum) across 9 files for strict WCAG 2.5.5
compliance: Save, Cancel, Delete, Validate SSH, Run job, Build index,
Update connection, Add service, etc. Plus the shared DialogFooter Cancel
+ Confirm buttons (used by every ConfirmDialog).

The class applies min-height/min-width: 44px only below md
(max-width: 767px); no-op at md+, so desktop sizing is unchanged.

Completes the touch-target audit started in Slice 9 (which covered icon
buttons, size=sm buttons, checkboxes, switches). 122 tests pass; lint/
build green. No new tests (@media queries aren't honored by jsdom).

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #2.
2026-06-26 15:45:34 +00:00
Developer 09b9c45665 SheetForm dirty-state confirm + wire isDirty into all form consumers (R4.5)
SheetForm gains an isDirty prop. When true, any close attempt (Cancel
button, header X, Radix overlay click, Escape) opens a 'Discard changes?'
ConfirmDialog instead of discarding unsaved edits. Radix dismiss callbacks
(onEscapeKeyDown, onPointerDownOutside) are intercepted when dirty so the
guard applies uniformly.

All four form consumers now compute and pass isDirty:
- ServicePage: name/enabled/config differ from the persisted instance.
- Settings machine editor: field-by-field draft vs editingMachine
  (create mode is always dirty; secret write-only fields excluded).
- Message compose: subject non-empty, body differs from default, or
  attachments present.
- WidgetConfigDialog: draft !== null (only draft mode is guarded; list
  mode has nothing to discard).

Tests: 3 new SheetForm dirty-guard cases (prompt on cancel, abort discard,
clean close when not dirty) + one focused dirty-guard test per consumer.
122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #1.
2026-06-26 15:31:30 +00:00
Developer 32516f6e3b Docs + verify report for mobile responsive parity (Slice 10)
Add Mobile Responsive Design section to docs/REQUIREMENTS.md documenting
the breakpoint policy (single md:768px), hybrid table strategy (cards below
md), SheetForm edit flows, 44px touch targets, dashboard single-column +
anchors, unchanged polling, and HoverEditButton behavior.

Add openspec verify-report.md with per-AC evidence (AC1-AC8), residual
risks (R4.5 dirty-state confirm, default-button touch targets, polling on
battery, iOS Safari manual verification, pagination duplication), and
non-goals confirmation.

All 9 routes fully operable at 375px. 116 frontend tests pass; lint/build
green. Desktop layout unchanged. No backend changes.

Refs openspec/changes/mobile-responsive-parity/ (tasks slice 10).
2026-06-26 14:40:04 +00:00
Developer 30f1b6e6db Touch-target audit: 44px minimum on mobile interactive elements (Slice 9)
Apply the mobile-touch-target CSS class to 40 interactive elements across
12 files. The class applies min-height/min-width:44px only below md
(max-width:767px), satisfying WCAG 2.5.5 / Apple HIG on touch devices.
Desktop behavior is unchanged.

Audit log (before -> after hit-area):
- App.tsx: hamburger/dark-mode/sign-out (32/32/28 -> 44)
- Dashboard.tsx: shortcut open/edit/delete (28 -> 44), enabled switch (18 -> 44)
- Media.tsx: mobile pagination prev/next (28 -> 44)
- FileBrowser.impl.tsx: 'Open Settings' alert button (28 -> 44)
- UsersPage.impl.tsx: compose toolbar bold/italic/link/list (32 -> 44),
  attachment remove button (16 -> 44)
- Settings.tsx: machine switch (18 -> 44), clear/add-machine buttons (28 -> 44),
  reset-db checkboxes x3 (16 -> 44)
- Actions.tsx: 'Add action' button (28 -> 44)
- ServicePage.tsx: service enabled switch (18 -> 44)
- ServicesPage.tsx: service switch/open-link/delete-icon (18/28/32 -> 44)
- ObservabilityPage.tsx: retry + 4 asChild link buttons (28 -> 44)
- WidgetConfigDialog.tsx: 4 icon buttons (32 -> 44), 2 switches (18 -> 44),
  2 add-widget buttons (28 -> 44)
- SessionActivityPanel.tsx: 'Open in Users' button (28 -> 44)

Deliberately skipped: default-size text buttons (32px, borderline), desktop-only
sidebar toggle, DataTable internals (desktop-only below md), Select triggers.
Dashboard anchor pills and HoverEditButton already had the class from Slices 1/2.

No new tests (the class applies via @media which jsdom doesn't honor).
116 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R6, tasks slice 9).
2026-06-26 14:37:40 +00:00
Developer 7808822a55 Mobile message compose + WidgetConfigDialog SheetForms (Slice 8)
Below md, both the message-compose Dialog and the WidgetConfigDialog
render inside a SheetForm instead of a centered Dialog.

Message compose (UsersPage.impl.tsx): the form body (subject, formatting
toolbar, HTML textarea, preview, attachments) is extracted into a shared
composeBody const consumed by both SheetForm (mobile) and Dialog
(desktop). SheetForm wired with title, onSave=handleSend (which already
closes on success per R4.5), onCancel=closeCompose, isPending,
saveDisabled, saveLabel='Send message'.

WidgetConfigDialog: the draftBody const is shared between branches. The
two-mode flow (list vs draft) maps to dynamic SheetForm props -- list
mode ('Dashboard widgets' / Done / Cancel both close), draft mode
('Add/Edit widget' / Save widget / Cancel=reset back to list). The
inline Back/Save buttons are hidden on mobile (!isMobile) since the
SheetForm footer provides them.

Desktop (md+) is token-identical for both components -- the
isComposeMobile (900px) fullscreen styling on compose is preserved for
the 768-900px band. The large diff (~860 lines) is dominated by
extraction/re-indentation of shared form bodies into consts; the
behavioral delta is ~80 lines.

Tests: 3 new (compose mobile send/subject, WidgetConfigDialog desktop +
mobile titles/Done). 116 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 8).
2026-06-26 14:17:30 +00:00
Developer e805c624b2 Mobile Settings: machine editor SheetForm (Slice 7)
Below md, the machine editor Dialog renders as a SheetForm (triggered by
the same Edit/Add buttons via machineDialogOpen state). The shared
MachineEditor body (fields + SSH validate button) renders inside the
sheet; the ConfirmDialog is a sibling outside. Desktop Dialog is
byte-for-byte identical.

No navigation needed on close -- the Settings page content (tabbed cards,
machine list) is always visible behind the sheet, so there is no stranding
risk (unlike ServicePage where the sheet was the whole page).

Added saveDisabled prop to SheetForm (additive, default false) so the
machine editor can gate Save on required fields (name + host for SSH
mode), matching the desktop DialogFooter confirmDisabled semantics.

Scope note: SSHKeyManager is an inline two-panel layout (SelectionRailCard
+ SectionCard), not a dialog, and already stacks responsively via
grid-cols-1 md:grid-cols-[...]. Wrapping it in SheetForm would break its
always-visible selection rail. Left as-is.

Tests: 3 new mobile cases (SheetForm render, save payload, cancel closes)
+ desktop unchanged. 113 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 7).
2026-06-26 13:56:27 +00:00
Developer f7b63fead5 Mobile ServicePage: SheetForm edit + close-on-save navigation (Slice 6)
Below md, ServicePage renders the edit form inside a SheetForm (open on
mount -- this page always edits an existing instance reached via
/services/:type/:id). The sheet body holds Name + Enabled + Connection
fields (no SectionCard wrapper, the sheet is the container) + Delete +
Widgets. At md+ the existing full-page layout renders token-identical.

Refactor: extracted the desktop inline JSX into configFields/widgetsCard/
confirmDelete consts and renamed ServiceConnectionCard ->
ServiceConnectionFields (isMobile prop drops the SectionCard wrapper on
mobile). Desktop output unchanged.

Fixes from Slice 6 review:
- R4.5: save() now closes the sheet on successful save (was staying open).
- Closing the sheet (save or cancel) navigates back to /services -- on
  mobile the sheet IS the page, so closing it would strand the user on a
  blank div. Added useNavigate.
- Strengthened the mobile save test to assert the full payload
  (name, id, enabled, secrets:{}, config), not just name+id.

Out of scope (flagged for verify pass): R4.5 dirty-state outside-click
confirm is a broader SheetForm concern not yet implemented.

Tests: 5 new (2 desktop non-regression + no-dialog, 3 mobile sheet render +
save payload + editable config). useNavigate added to the router mock.
110 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 6).
2026-06-26 13:44:50 +00:00
Developer 2eb649eceb Mobile Users + Backups tables: stacked cards + selection (Slice 5)
Below md, the Users directory and the three Backups tables render as
MobileCardRow cards:

- UsersPage: display name primary; username/activity/email fields. Each
  card carries a selection checkbox (44px via mobile-touch-target) in the
  actions slot with stopPropagation so toggling selection does not open
  the drawer; card-body tap still opens the drawer.
- BackupAlertsTable: alert message primary; severity/type/created fields;
  Acknowledge action preserved in actions slot.
- BackupJobsTable: job name primary; source/schedule/last-status fields
  (joins latestRuns into a JobCardRow).
- BackupRunsTable: run job_id primary; status/duration/size/started fields;
  status-filter Select renders above both layouts (preserved on mobile).

Desktop (md+) is byte-for-byte identical for all four components -- the
UsersPage diff is dominated by re-indenting the existing Table into the
isMobile ternary else branch.

Fix from Slice 5 review: MobileCardRow now renders the clickable card as
<div role=button tabIndex=0> with Enter/Space keyboard handling instead
of <button>, so nesting a Radix Checkbox (which renders a <button>) in
the actions slot produces valid HTML. The desktop-parity argument for
<button>-in-<button> did not hold (desktop rows are <tr>, not buttons).

Cross-cutting: useIsMobile hardened with typeof window.matchMedia guard
(safe in real browsers; only changes jsdom crash -> false). The file-local
900px compose hook was renamed useComposeViewport to avoid collision with
the shared 768px useIsMobile.

Tests: BackupJobsTable test file added (was untested), UsersPage mobile
selection round-trip + stopPropagation, mobile card render across all
four components. 105 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 5).
2026-06-26 13:24:19 +00:00
Developer 2076ab76fa Mobile FileBrowser: stacked cards for file list (Slice 4)
Below md, the file table renders as MobileCardRow cards: name as primary,
plus type/size/modified. Whole-card tap triggers handleRowClick (dir rows
navigate into the directory; file rows select for ffprobe preview). No
pagination needed (FileBrowser does not paginate).

The ext column is omitted from the card -- the extension is already visible
in the filename itself, so it's redundant on mobile and would waste card
space.

Path bar / breadcrumbs / Open / Refresh live outside the table and already
stack on mobile via existing md:flex-row. ffprobe and Jobs sections are
unaffected.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props.

Tests: 4 new covering mobile card render + dir-tap navigation + path
controls present + desktop DataTable. matchMedia mocked per-breakpoint.
98 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 4).
2026-06-26 12:53:25 +00:00
Developer 2e3e7b3850 Mobile Media table: stacked cards + mobile pagination (Slice 3)
Below md, the Media DataTable renders as MobileCardRow cards: title as
primary, plus size/HDR/library/year (3-5 fields, null-safe). Card tap
navigates to /files?path=... (same handleRowClick as desktop). The TanStack
column-visibility toggle is absent below md (the card picks the fields).

Pagination is preserved via a standalone MediaMobilePagination component
that mirrors DataTablePagination semantics (rows count, page-size select,
page indicator, prev/next with correct disabled states) off the raw
PaginationState. The duplication is flagged tech debt -- extracting a shared
TablePagination is a follow-up, out of scope for this slice.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props. enableRowSelection state is
vestigial (no batch consumer on either path); navigation is the correct
primary mobile interaction.

Tests: 5 new covering mobile cards + hidden column toggle + pagination +
card-tap navigation, and desktop DataTable + column toggle. matchMedia
mocked per-breakpoint. 94 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 3).
2026-06-26 12:43:09 +00:00
Developer c447dfe68d Mobile dashboard layout: single column + section anchors (Slice 2)
Below md, widgets render in a single column grouped by section
(Observability / Media / Backups / Custom) with a horizontally-scrollable
anchor pill bar that smooth-scrolls to each section. Empty sections are
omitted from both the bar and the list. scroll-mt-16 keeps the sticky
TopBar from covering section headings.

Section mapping: observability (alertmanager/prometheus/grafana services),
media (jellyfin), backups (builtin backups widget), custom (static,
ssh_tasks, nextcloud, unknown, orphans). Within each section the user's
configured sort order is preserved.

Desktop (md+) is byte-for-byte unchanged -- the isMobile===false branch
emits the original visibleWidgets.map(...) sequence with no wrapper.
useServiceInstances() is cache-shared with WidgetInstanceCard (same
TanStack key), so no extra network requests.

Tests: 3 new (6 total) covering mobile single-column + anchors, desktop
non-regression, and scrollIntoView jump. matchMedia mocked per-breakpoint.
89 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R7, tasks slice 2).
2026-06-26 12:25:42 +00:00
Developer 688a18af22 Add mobile responsive primitives (Slice 1)
Foundation for the mobile-responsive-parity change. Adds:
- useIsMobile() hook: single source of truth for the md:768px cut (SSR-safe)
- MobileCardRow<T>: stacked card list for wide tables below md, with getRowId
  stable keys, primary field as title, optional onRowClick + actions slot
- SheetForm: full-height form host (h-[100dvh], flex column, sticky header +
  footer via flex not position:sticky) for mobile edit flows
- HoverEditButton: mobile prop (default 'always') -- always visible below md,
  hover-revealed at md+; desktop aesthetic preserved
- .mobile-touch-target CSS utility: 44x44 min hit area below md (WCAG 2.5.5)
- App.tsx refactored to use useIsMobile(); shell behavior unchanged

Tests cover primary/field rendering, onRowClick, actions slot, empty rows,
no-primary, stable keys (no duplicate-key warning), and all SheetForm
interactions. 86 tests pass; lint/build green.

MobileCardRow key strategy: uses getRowId when provided (falls back to index);
per design §trade-offs, fields are declared per-table to prioritize by mobile
importance rather than auto-derived from column defs.

Refs openspec/changes/mobile-responsive-parity/ (design §Shared primitives,
spec R1/R5/R6, tasks slice 1).
2026-06-26 12:09:21 +00:00
Developer 18ee77a4e4 Plan mobile responsive parity (OpenSpec change)
Add proposal/spec/design/tasks for full mobile parity across all 9 routes.
Decisions: hybrid tables (cards below md for big four), Sheet-based forms,
always-visible edit affordance, 44px touch targets, single-column dashboard
with anchors, responsive web only (no PWA), phone portrait at md:768px cut.
Polling unchanged (risk flagged). Delivery: 10 chained PRs, primitives first.
2026-06-26 11:49:47 +00:00
Developer 3d331e4c72 Make service connection config editable on service page
The service detail page showed non-secret connection config (base_url,
user_id, username, timeout_seconds) as read-only. Render schema-driven
editable inputs (reusing the create-dialog pattern) with a draftConfig
state hydrated from the instance, and unify the save button to persist
both config and secrets. Number fields render as type=number; the base_url
schema description surfaces as helper text.
2026-06-26 09:52:43 +00:00
Developer eebc86a52b Enforce http(s) schema on service base_url fields
Add a shared ServiceBaseUrl type (BeforeValidator + Field description) in
integrations/base.py and apply it to base_url across all six service configs
(grafana, prometheus, alertmanager, jellyfin, jellyseerr, nextcloud). Missing
http:// or https:// schema now fails fast with a clear 422 instead of breaking
HTTP clients silently. Tests cover reject/accept cases; REQUIREMENTS updated.
2026-06-26 09:52:20 +00:00
Developer 56b919ea1f style(frontend/api): apply formatter to backups.ts and client.ts
Convert indentation to tabs and reflow long import lines. No behavior
change.

Co-authored-by: el Gentleman <gentleman@pi.local>
2026-06-26 09:10:32 +00:00
Developer 648320abfd chore(project-map): refresh .pi-map role/arch summaries
Regenerate project map artifacts across backend, docs, openspec, and
root to refresh role descriptions and architectural notes after recent
service-registry and observability changes.

Co-authored-by: el Gentleman <gentleman@pi.local>
2026-06-26 09:10:19 +00:00
Developer 7d252489de fix(api): return 503 instead of 500 when Jellyfin/SSH not configured
On a fresh deploy with no Jellyfin service configured yet,
get_jellyfin_client (and get_user_id / get_ssh_client) raised a plain
RuntimeError, which bubbled up as a 500 traceback on every
Jellyfin-dependent route (dashboard counts/libraries/activity, media,
users). Convert those RuntimeErrors to HTTPException(503) with a clear
detail message so FastAPI returns a clean 503 JSON response instead of
a 500, and the frontend can render a not-configured state.

- dependencies.py: get_jellyfin_client (no service / missing creds),
  get_user_id (no users discovered), and get_ssh_client (no SSH machine
  + no legacy key path) now raise HTTPException(503, detail=...).
- tests/test_api.py: added
  TestDashboard.test_jellyfin_endpoints_return_503_when_not_configured
  covering /api/dashboard/counts and /activity.

ruff clean; 240 backend tests pass.
2026-06-26 08:39:39 +00:00
Developer 04319025de fix(api): attach Bearer token to services/widgets/backups requests
Under AUTH_ENABLED=true, api/services.ts, api/widgets.ts, and
api/backups.ts called fetch() directly without attaching the OIDC
access token, so every services/widgets/backups request 401'd while
api/client.ts requests succeeded. The token was only attached in
client.ts.

Extract the auth-attaching fetch helpers (buildUrl/buildHeaders/
readErrorDetail + get/post/put/del/postForm) into a new api/shared.ts
that consults getAccessToken(), rewrite services.ts/widgets.ts/
backups.ts to use them, and consolidate client.ts to import from
shared.ts (removing its duplicated copies). Now every backend request
goes through one auth-attaching path.

As a side benefit, error messages surface the HTTP status + backend
detail instead of a generic "Failed to ..." string.

Bug masked in dev because dev runs AUTH_ENABLED=false. npm run build
clean; 0 lint errors; 72 frontend tests pass.
2026-06-26 08:18:39 +00:00
Developer 8bc209b27e docs: fix stale-live docs and drop obsolete Obsidian spec
Refreshes the docs that were actively misleading about the current
FastAPI + React + service-registry app, and deletes one obsolete design.

- CONTRIBUTING.md: full rewrite — Streamlit-era guidance replaced with
  the current backend (ruff/pytest, src/ layout) + frontend (npm
  lint/build/test) workflow, service-registry model, and shadcn/Tailwind
  stack. Mirrors AGENTS.md.
- README.md: removed the non-existent /addons/:addonId route (Services
  page is current); fixed the per-machine Jellyfin wording; replaced the
  py_compile dev snippet with ruff + pytest / npm lint+build+test.
- backend/README.md: updated the structure tree (removed deleted
  clients/resources.py; added routers backups/services/tasks/widgets,
  integrations/, models/, widgets/, workers/); dropped the "starts the
  collector" sentence (MonitoringPoller is decommissioned).
- frontend/README.md: corrected the uvicorn module path
  (main:app -> media_library_viewer_api.main:app).
- Deleted docs/superpowers/specs/2026-05-08-obsidian-documentation-design.md
  (Obsidian vault never built; stack refs MUI/D3/AG Grid all removed).

Historical docs (MIGRATION_PLAN, superpowers backup-monitoring, the
bannered design/runbook/context files) deferred to a later banner pass.
2026-06-25 09:07:19 +00:00
alex cbc2740e37 Update docker-compose.yml 2026-06-25 10:03:54 +02:00
Developer 6919158012 docs: complete Jellyfin migration, archive jellyfin-service-registry
Slice 3 (final) of jellyfin-service-registry. Documents the completed
migration and archives the SDD change.

- docs/REQUIREMENTS.md: marked the machine-level Jellyfin follow-up
  resolved; added a decision-log entry (Jellyfin no longer a machine
  service, dead media_root/path_prefix removed; global config +
  path_utils retained for Jellyfin->SSH path resolution).
- CHANGELOG.md: struck through the old follow-up note; added a
  Follow-up #2 section describing the machine field + service removal.
- Archived openspec/changes/jellyfin-service-registry (no active SDD
  changes remain).

Backend ruff clean / 239 tests pass; frontend 0 lint errors / build
clean / 72 tests pass.
2026-06-24 14:56:25 +00:00
Developer fd12e921fd refactor(settings): remove dead machine path fields from frontend
Slice 2 of jellyfin-service-registry. Removes the machine-level
media_root/path_prefix fields from the frontend now that the backend no
longer stores them.

- types/index.ts: dropped media_root/path_prefix from MonitoringMachine
  and MonitoringMachineInput.
- pages/Settings.tsx: removed the media_root form input, the read-only
  "Media root" detail (replaced with a local-hint field mirroring the
  editor), and media_root/path_prefix from emptyMachine() and both
  edit-handler reset mappings; updated the section description.
- tests: removed media_root/path_prefix from Settings/Media/FileBrowser
  test fixtures.

npm run build (tsc -b + vite) clean; 0 lint errors; 72 tests pass.
2026-06-24 14:51:28 +00:00
Developer 7107815a5c refactor(settings): drop jellyfin machine service + dead machine path fields
Slice 1 of jellyfin-service-registry. Jellyfin is configured exclusively
via the service registry now; the machine-level media_root/path_prefix
fields were dead duplicates of the global config.

- services/settings_store.py: DEFAULT_SERVICES no longer includes
  "jellyfin" (now ["monitoring", "files"]). Removed machine-level
  media_root/path_prefix from _default_local_machine, _row_to_machine,
  _normalize_machine_payload, _seed_local_machine, get_machine_config,
  and upsert_machine. _default_local_machine no longer reads global
  config, so the get_settings import is dropped.
- routers/settings.py: removed media_root/path_prefix from
  MonitoringMachineInput (dead API input; store already ignored them).

The global config remote_media_root/path_prefix properties + path_utils.py
are unchanged (files.py and media_index still use them for Jellyfin->SSH
path resolution). ruff clean; 239 backend tests pass.
2026-06-24 14:38:47 +00:00
Developer 38b2de54ff chore: archive observability-service-registry, track pi-map artifacts
- Archive the completed observability-service-registry SDD change into
  openspec/changes/archive/ (delivered across 5 slices; only
  jellyfin-service-registry remains active).
- Stop ignoring .pi-map.md / .pi-map.index.md so the navigation maps are
  versioned alongside the code, and add the regenerated map pairs repo-wide.
2026-06-24 13:28:23 +00:00
Developer c1610c93a1 docs(observability): refresh docs for service-registry end state
After the observability-service-registry slices removed the observability
env vars and the file-SD writer, several docs still instructed readers to
set vars that no longer exist. Updated the live config instructions;
historical decision-log entries are left intact.

- README.md: removed VITE_GRAFANA_URL/VITE_PROMETHEUS_URL/ALERTMANAGER_URL
  from compose examples and the env-var block; added a note that
  observability is configured on the Services page; updated Notes.
- frontend/README.md: dropped the stale VITE_* deep-link sentence.
- docs/REQUIREMENTS.md: fixed one stale trailing phrase in the
  externalization decision-log entry (VITE_* no longer "remain").
- docs/monitoring-logging-design.md: added a "Superseded mechanisms" note
  under Implementation Plan so the Phase 2/3 file-SD + alertmanager_url
  details read as historical, not current wiring.
- context.md: strengthened the status banner to cover the env->service-
  registry and file-SD->http_sd_configs shift; body marked historical.

.env.example is assistant-edit-blocked; updated replacement text provided
to the user separately.
2026-06-24 09:15:50 +00:00
Developer b1a66a1ab7 chore(observability): remove remaining observability env vars, docs
Slice 5 (final) of observability-service-registry. Completes the move to
service-registry-only observability config: no observability service env
vars remain.

- config.py: removed alertmanager_url + alertmanager_webhook_url fields.
- docker-compose.yml / docker-compose.dev.yml: removed ALERTMANAGER_URL,
  ALERTMANAGER_WEBHOOK_URL (backend env), and VITE_GRAFANA_URL,
  VITE_PROMETHEUS_URL (frontend build args / dev env).
- frontend/Dockerfile: removed the VITE_GRAFANA_URL / VITE_PROMETHEUS_URL
  ARG, build-stage ENV, and dev-stage ENV lines.
- docs: REQUIREMENTS decision-log entry; CHANGELOG Added/Changed/BREAKING
  for the observability service registry; backend/README monitoring
  section (Observability page, services page config, http_sd_configs,
  new health endpoints, log-only webhook).

The only observability env var remaining is PROMETHEUS_ENABLED (Manage's
own /metrics toggle). Grep-gated: no live references to the removed
vars/fields in backend src, frontend src, compose, or Dockerfile.

ruff clean; 239 backend tests pass; frontend 0 lint errors, build clean,
72 tests pass.

.env.example is assistant-edit-blocked; user follow-up noted in the SDD
tasks: drop the removed vars there too.
2026-06-24 08:49:53 +00:00
Developer b200025daa refactor(observability): drop file-SD writer for http_sd_configs
Slice 4 of observability-service-registry. Removes the shared-file
Prometheus bridge; external Prometheus now consumes node-exporter targets
via http_sd_configs against GET /api/monitoring/prometheus-targets.

- services/targets.py: removed write_prometheus_targets() (the file
  writer) and its json/Path/get_settings imports; updated module docstring.
  build_node_exporter_targets() is unchanged and still powers the HTTP
  endpoint.
- main.py: removed the startup write_prometheus_targets call.
- routers/settings.py: removed the _write_prometheus_targets helper and
  its three post machine create/update/delete call sites + the now-unused
  targets import.
- config.py: removed the prometheus_file_sd_dir field.
- docker-compose.yml / docker-compose.dev.yml: removed the
  PROMETHEUS_FILE_SD_DIR backend env var.
- tests: removed TestWritePrometheusTargets + the write_prometheus_targets
  import in test_targets.py; rewrote the two TestSettingsMachines tests to
  assert machines appear/disappear from /api/monitoring/prometheus-targets
  (the surviving HTTP path) instead of the removed file-writer side effect.

ruff clean; 239 backend tests pass.
2026-06-24 08:37:20 +00:00
Developer 0c5698c903 feat(observability): service discovery, health cards, alertmanager widget
Slice 3 of observability-service-registry (frontend). The Observability
page discovers Grafana from the service registry instead of env vars,
adds Grafana + Prometheus health cards, and ships an alertmanager
active_alerts dashboard widget.

- types: added GrafanaStatus + PrometheusStatus; added optional
  service_id/error to AlertmanagerStatus.
- api/client.ts + hooks/useObservability.ts: fetchGrafanaStatus,
  fetchPrometheusStatus, useGrafanaStatus, usePrometheusStatus.
- widgets/AlertmanagerAlertsWidget.tsx (new): presentational widget
  consuming the active_alerts summary shape (total/by_severity/alerts);
  exported from widgets/index.ts.
- integrations/registry.ts: alertmanager binding (active_alerts kind,
  30s refresh, optional severity_filter); registry.test.ts updated to
  6 service types incl alertmanager + a resolve test.
- components/ObservabilityPage.tsx: removed
  import.meta.env.VITE_GRAFANA_URL; derive GRAFANA_BASE_URL from the
  first enabled grafana service via useServiceInstances("grafana");
  added Grafana + Prometheus HealthCards (up/not-configured/unreachable)
  with QueryError retry blocks; machine dashboard shows a "No Grafana
  service configured" empty-state linking to /services when none is set.

npm run build (tsc -b + vite) clean; 0 lint errors; 72 frontend tests
pass. Reviewed fresh-context (read-only): no blockers.
2026-06-24 08:23:23 +00:00
Developer 14771ae990 feat(observability): resolve services from registry, add health endpoints
Slice 2 of observability-service-registry. The monitoring router resolves
observability components from the service registry instead of env vars.

- routers/monitoring.py: removed _alertmanager_client/_webhook_client env
  readers + the get_settings import. Added _resolve_service_record(store,
  service_type, service_id?) -> ServiceRecord|None (requested instance with
  type+enabled checks, else first enabled instance), plus _base_url/_timeout/
  _auth_headers (Bearer from api_key)/_status_response helpers.
- /alerts + /alertmanager-status now take service_id? + Depends(store),
  resolve an alertmanager service, return graceful not-configured/
  unreachable payloads including service_id/name; status down-branches now
  include peers:[] + error (fixes prior type drift).
- NEW /grafana-status (probes /api/health) and /prometheus-status (probes
  /-/healthy then /api/v1/status/buildinfo) returning
  {up,version,service_id,name,error}.
- Webhook receiver is now log-only (dropped the outbound
  ALERTMANAGER_WEBHOOK_URL forward).
- tests: rewrote TestAlertmanager + TestAlertmanagerWebhook to mock
  _resolve_service_record/requests.get (not-configured via empty registry);
  added TestGrafanaStatus/TestPrometheusStatus and a TestResolveServiceRecord
  unit class covering service_id match/type-mismatch/disabled and first-
  enabled/none-enabled paths.

Orphaned config fields alertmanager_url/alertmanager_webhook_url and the
env-var removal land in Slice 5. ruff clean; 240 backend tests pass.

Reviewed fresh-context (read-only): no blockers.
2026-06-24 07:53:25 +00:00
Developer 7d49df3e7d feat(observability): add alertmanager service type and widget
Slice 1 of observability-service-registry. Alertmanager becomes a
first-class service-registry type, mirroring grafana/prometheus.

- integrations/alertmanager.py (new): AlertmanagerConfig
  (base_url, timeout_seconds), AlertmanagerAlertsWidgetConfig (optional
  severity_filter), shared summarize_alerts() helper, and DEFINITION
  (service_type "alertmanager", secret api_key, widget "active_alerts").
- integrations/registry.py: register ALERTMANAGER (7 types now).
- widgets/sources.py: AlertmanagerWidgetSource fetches
  {base_url}/api/v1/alerts, sends optional Bearer token from the api_key
  secret, applies optional severity_filter, and summarizes via the shared
  helper; registered in SERVICE_ADAPTERS.
- routers/monitoring.py: _summary_from_alerts delegates to the shared
  summarize_alerts (behavior unchanged).
- tests: registry now 7 types; /api/services/types lists alertmanager;
  4 new adapter tests (summarize, severity filter, bearer token, missing
  service).

Backend-only slice; the frontend active_alerts widget binding lands in a
later slice. ruff clean; 228 backend tests pass.

Reviewed fresh-context (read-only): no blockers.
2026-06-23 22:25:50 +00:00
Developer c13e274ca4 docs(openspec): re-scope observability-service-registry change
Rename grafana-prometheus-polish -> observability-service-registry and
rewrite proposal/design/tasks for the approved vision: all observability
integration (alertmanager, grafana, prometheus) configured as service-
registry instances in the UI, surfaced on a dedicated page, with widgets
per service definition -- nothing in the env.

Key scope decisions captured:
- Add alertmanager as a 6th service type + active_alerts widget.
- Rewire /alerts + /alertmanager-status to resolve from service records
  (first-enabled-instance default; no primary flag in v1).
- Add /grafana-status + /prometheus-status health endpoints.
- Observability page discovers services; kill VITE_GRAFANA_URL /
  VITE_PROMETHEUS_URL deep-links.
- Webhook receiver stays log-only (drop the outbound forward).
- Remove PROMETHEUS_FILE_SD_DIR + the file-writer; external Prometheus
  uses http_sd_configs against GET /api/monitoring/prometheus-targets.
  build_node_exporter_targets + that endpoint stay.
- PROMETHEUS_ENABLED stays (Manage's own /metrics toggle).
- End state: zero observability *service* env vars.

Plan = 5 slices, each <=400 changed lines, green tests/lint/build,
commit per slice.
2026-06-23 21:48:58 +00:00
Developer d4f95b64d4 chore(observability): externalize stack from root compose files
Manage now connects to existing Grafana/Prometheus/Alertmanager instances
and never deploys its own stack.

- docker-compose.yml / docker-compose.dev.yml: removed prometheus, loki,
  alloy, grafana, alertmanager, node-exporter services, the monitoring
  network, and observability named volumes; they now ship only backend +
  frontend. Dev frontend now joins the web network so the Vite dev proxy
  can reach the backend.
- backend: alertmanager_url default is now empty; /api/monitoring/alerts
  and /alertmanager-status return graceful "not configured" responses
  when ALERTMANAGER_URL is unset. Added not-configured tests.
- docker-compose.observability.yml: kept as the optional standalone
  example; header clarifies Manage does not deploy it.
- Removed orphaned combined monitoring/prometheus/prometheus.yml
  (standalone stack uses prometheus.standalone.yml).
- Docs (README, REQUIREMENTS decision log, monitoring-logging-design,
  observability-runbooks, context.md, MIGRATION_PLAN, frontend/README,
  CHANGELOG) updated to the connect-to-existing model.

VITE_GRAFANA_URL / VITE_PROMETHEUS_URL remain as optional frontend
deep-link overrides. .env.example still needs a manual update (safety
policy blocks assistant edits): set ALERTMANAGER_URL empty/optional and
move standalone-only vars out of the root file.
2026-06-23 21:20:07 +00:00
Developer 4d520ab0e3 docs(openspec): add SDD artifacts for next changes
- jellyfin-service-registry: proposal, design, and tasks for completing
  the Jellyfin migration off machine-level config.
- grafana-prometheus-polish: proposal, design, and tasks for improving
  the Grafana/Prometheus observability integration.

Both are planning-only artifacts; implementation not started.
2026-06-23 20:40:35 +00:00
Developer ca8927834e chore(openspec): archive completed changes
Move finished change directories to openspec/changes/archive/:
- configurable-dashboard-widgets
- decommission-monitoring-poller
- service-registry
- unify-tasks-on-services

All associated implementation has been merged to main.
2026-06-23 19:38:34 +00:00
Developer a39dbf272c docs(backend): remove legacy monitoring poller endpoints from README
The legacy SSH-scraping MonitoringPoller and its endpoints were
decommissioned earlier; update the backend README endpoint list and
Monitoring description to match the current Alertmanager + Prometheus
targets + Grafana observability model.
2026-06-23 17:52:59 +00:00
Developer 50eb76a10d feat(tasks): unify saved tasks on ssh_tasks services
- Add shared task_runner.run_saved_task helper used by routers/tasks.py and
  widgets/sources.py SshTaskWidgetSource.
- Saved tasks now target ssh_tasks service instances via default_service_id;
  the legacy default_machine_id and saved_task_runs are removed.
- Actions page lists ssh_tasks services for default and run-time selection.
- Update types, API client, hooks, tests, docs, and changelog.

Backend tests: 222 passed. Frontend lint/build/test: clean (71 passed).
2026-06-23 16:46:46 +00:00
Developer d7ad933b2a Merge pull request 'docs(unify-tasks): SDD artifacts' from docs/unify-tasks-sdd into main 2026-06-23 13:05:53 +00:00
Developer 8c69911252 docs(unify-tasks): SDD proposal, design, and tasks
Design-only artifacts for unifying saved tasks on ssh_tasks services.
No implementation yet.

- proposal: two-path problem (Actions→machine vs widget→service), goals,
  non-goals, grilling decisions (SSH-only, service_task_runs only, keep override)
- design: shared run_saved_task helper, column rename, saved_task_runs dropped,
  API + frontend changes, 2-slice plan
- tasks: backend (shared runner + router) + frontend (Actions page)
2026-06-23 13:05:52 +00:00
Developer 7b3e2ebace Merge pull request 'chore: remove dead machine-level Jellyfin/Jellyseerr fields' (#13) from chore/remove-dead-machine-jellyfin-fields into main 2026-06-23 12:55:32 +00:00
422 changed files with 25885 additions and 6230 deletions
+20
View File
@@ -0,0 +1,20 @@
# .claude (index)
dir: .claude
## role
Configuration and settings directory for Claude AI assistant integration within the project workspace.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
- .claude/skills
index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md
## files
## links
index: .claude/.pi-map.index.md
map: .claude/.pi-map.md
## workflows
-
## dirty
-
+18
View File
@@ -0,0 +1,18 @@
# .claude
dir: .claude
index: .claude/.pi-map.index.md
## role
Configuration and settings directory for Claude AI assistant integration within the project workspace.
## files
## arch
Flat configuration directory following standard AI assistant tool conventions, typically containing permission rules, context files, and project-specific behavioral settings.
## tags
-
## symbols
-
## workflows
-
## dirty
-
+20
View File
@@ -0,0 +1,20 @@
# .claude/skills (index)
dir: .claude/skills
## role
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
## children
- .claude/skills/sift-backlog
index: .claude/skills/sift-backlog/.pi-map.index.md
map: .claude/skills/sift-backlog/.pi-map.md
## files
## links
index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md
## workflows
-
## dirty
-
+18
View File
@@ -0,0 +1,18 @@
# .claude/skills
dir: .claude/skills
index: .claude/skills/.pi-map.index.md
## role
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
## files
## arch
Flat directory structure with markdown-based skill modules that define specialized assistant capabilities.
## tags
-
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .claude/skills/sift-backlog (index)
dir: .claude/skills/sift-backlog
## role
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
## children
-
## files
- SKILL.md
## links
index: .claude/skills/sift-backlog/.pi-map.index.md
map: .claude/skills/sift-backlog/.pi-map.md
## workflows
-
## dirty
-
+19
View File
@@ -0,0 +1,19 @@
# .claude/skills/sift-backlog
dir: .claude/skills/sift-backlog
index: .claude/skills/sift-backlog/.pi-map.index.md
## role
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-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
-
## workflows
-
## dirty
-
-2
View File
@@ -55,5 +55,3 @@ frontend/dist/
.superpowers/
# Local Pi runtime state
.atl/
.pi-map.md
.pi-map.index.md
+23
View File
@@ -0,0 +1,23 @@
# .opencode (index)
dir: .opencode
## role
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
## children
- .opencode/commands
index: .opencode/commands/.pi-map.index.md
map: .opencode/commands/.pi-map.md
- .opencode/skills
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## files
## links
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
## workflows
-
## dirty
-
+18
View File
@@ -0,0 +1,18 @@
# .opencode
dir: .opencode
index: .opencode/.pi-map.index.md
## role
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
## files
## arch
Flat directory structure with declarative configuration files; no executable code or architectural patterns involved.
## tags
-
## symbols
-
## workflows
-
## dirty
-
+22
View File
@@ -0,0 +1,22 @@
# .opencode/commands (index)
dir: .opencode/commands
## role
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
## children
-
## files
- opsx-apply.md
- opsx-archive.md
- opsx-explore.md
- opsx-propose.md
## links
index: .opencode/commands/.pi-map.index.md
map: .opencode/commands/.pi-map.md
## workflows
-
## dirty
-
+22
View File
@@ -0,0 +1,22 @@
# .opencode/commands
dir: .opencode/commands
index: .opencode/commands/.pi-map.index.md
## role
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 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
-
## workflows
-
## dirty
-
+29
View File
@@ -0,0 +1,29 @@
# .opencode/skills (index)
dir: .opencode/skills
## role
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
## children
- .opencode/skills/openspec-apply-change
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
map: .opencode/skills/openspec-apply-change/.pi-map.md
- .opencode/skills/openspec-archive-change
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
map: .opencode/skills/openspec-archive-change/.pi-map.md
- .opencode/skills/openspec-explore
index: .opencode/skills/openspec-explore/.pi-map.index.md
map: .opencode/skills/openspec-explore/.pi-map.md
- .opencode/skills/openspec-propose
index: .opencode/skills/openspec-propose/.pi-map.index.md
map: .opencode/skills/openspec-propose/.pi-map.md
## files
## links
index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md
## workflows
-
## dirty
-
+18
View File
@@ -0,0 +1,18 @@
# .opencode/skills
dir: .opencode/skills
index: .opencode/skills/.pi-map.index.md
## role
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
## files
## arch
Configuration-driven skill registry with declarative definition files (no implementation code present in this directory).
## tags
-
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-apply-change (index)
dir: .opencode/skills/openspec-apply-change
## role
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
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
map: .opencode/skills/openspec-apply-change/.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-apply-change
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 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
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
-
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-archive-change (index)
dir: .opencode/skills/openspec-archive-change
## role
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
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
map: .opencode/skills/openspec-archive-change/.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-archive-change
dir: .opencode/skills/openspec-archive-change
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
## role
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-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
-
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-explore (index)
dir: .opencode/skills/openspec-explore
## role
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
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-explore/.pi-map.index.md
map: .opencode/skills/openspec-explore/.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-explore
dir: .opencode/skills/openspec-explore
index: .opencode/skills/openspec-explore/.pi-map.index.md
## role
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 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
-
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-propose (index)
dir: .opencode/skills/openspec-propose
## role
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
## children
-
## files
- SKILL.md
## links
index: .opencode/skills/openspec-propose/.pi-map.index.md
map: .opencode/skills/openspec-propose/.pi-map.md
## workflows
-
## dirty
-
@@ -0,0 +1,19 @@
# .opencode/skills/openspec-propose
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, 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
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
-
## workflows
-
## dirty
-
+79
View File
@@ -0,0 +1,79 @@
# . (index)
dir: .
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
## parent
-
## children
- .atl
index: .atl/.pi-map.index.md
map: .atl/.pi-map.md
- .claude
index: .claude/.pi-map.index.md
map: .claude/.pi-map.md
- .opencode
index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md
- .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
- archive
index: archive/.pi-map.index.md
map: archive/.pi-map.md
- backend
index: backend/.pi-map.index.md
map: backend/.pi-map.md
- docs
index: docs/.pi-map.index.md
map: docs/.pi-map.md
- frontend
index: frontend/.pi-map.index.md
map: frontend/.pi-map.md
- monitoring
index: monitoring/.pi-map.index.md
map: monitoring/.pi-map.md
- openspec
index: openspec/.pi-map.index.md
map: openspec/.pi-map.md
## files
- .dockerignore
- .env.example
- .gitignore
- AGENTS.md
- CHANGELOG.md
- CONTRIBUTING.md
- LICENSE
- README.md
- context.md
- docker-compose.dev.yml
- docker-compose.observability.yml
- docker-compose.yml
- token-usage-output.txt
## links
index: ./.pi-map.index.md
map: ./.pi-map.md
## workflows
-
## dirty
-
+45
View File
@@ -0,0 +1,45 @@
# .
dir: .
index: ./.pi-map.index.md
## Project Map Protocol
1. Read this protocol and the root `.pi-map.index.md` first.
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
3. Load indexes before rich maps during task-start navigation.
4. Read the local rich map and actual source before editing.
5. Treat non-empty `## dirty` sections in either artifact as stale.
6. If source and generated artifacts disagree, trust source.
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
8. After editing source, run `project_map_patch` for each changed file.
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
Trust boundary: index routes, map orients, source decides.
## role
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 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
- context.md | Documentation file providing a historical and architectural overview of an observability stack (Prometheus, Grafana, Loki, Alertmanager) for a containerized media management application. | dep: Prometheus, Grafana, Loki, Alertmanager, Grafana Alloy, Node Exporter, Docker Compose, FastAPI
- 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
- 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
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
-
## workflows
-
## dirty
-
+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."
}
```
+81
View File
@@ -0,0 +1,81 @@
# Service IA Refinement — Instance Tabs + Config to Settings
## Files changed (5 files, +310/-381)
| File | Status | Lines |
|------|--------|-------|
| `frontend/src/integrations/navEntries.ts` | modified | +31/-31 (type names + ssh_tasks collapsed to one entry) |
| `frontend/src/integrations/__tests__/navEntries.test.ts` | modified | +23/-23 (updated labels) |
| `frontend/src/pages/ServicePage.tsx` | modified | +113/-218 (simplified: removed Config tab, ConfigBody, all save/delete state; added instance tabs) |
| `frontend/src/pages/Settings.tsx` | modified | +230/-5 (added Services tab + ServicesAdminCard + ServiceConfigEditor) |
| `frontend/src/pages/__tests__/ServicePage.test.tsx` | modified | +76/-76 (removed Config/secret tests, added instance-tabs tests) |
## New ServicePage structure
The service page is now a **pure operational view** — no save/delete/config state at all.
**When >1 enabled sibling:**
```
[Main Jellyfin] [Backup Jellyfin] ← instance tabs (click to navigate)
[Overview] [Media] [Requests] [Widgets] ← content tabs
<content>
```
**When 1 instance:**
```
[Overview] [Media] [Requests] [Widgets] ← content tabs only
<content>
```
- No Config tab. No `<Select>` switcher. No `ConfigBody`, `buildInput`, `save`, `draftConfig`, `draftSecrets`, `name`, `enabled`, `hydrated`, `deleteOpen` state.
- Instance tabs use the shadcn `Tabs` component (outer level). Content tabs use a nested `Tabs` (inner level). Clicking an instance tab navigates to `/services/:type/:id`.
- Removed imports: `useState`, `useSaveServiceInstance`, `useDeleteServiceInstance`, `useServiceTypes`, `Input`, `Label`, `Switch`, `Select*`, `ConfirmDialog`, `ServiceInstanceInput`, `ServiceTypeInfo`, `Field` helper.
## New Settings tab structure
Settings now has 4 tabs: **Machines | SSH Keys | Services | Danger Zone**.
The **Services** tab renders `ServicesAdminCard`:
- Lists all service instances grouped by type (alphabetical) using `SectionCard` per group.
- Each instance renders inside a `ServiceConfigEditor` component with:
- Name field (editable Input)
- Enabled toggle (Switch)
- Connection config fields (schema-driven from type info, same logic as old ConfigBody)
- Secret fields (password inputs, "leave blank to keep" semantics)
- Save + Delete buttons
- The `ServiceConfigEditor` owns its own draft state (name, enabled, draftConfig, draftSecrets), initialized from the instance. `buildInput` + `handleSave` replicate the old ConfigBody logic.
## How instance tabs work
- `siblings` is computed as `services.filter(s => s.service_type === serviceType && s.enabled)`.
- When `siblings.length > 1`, an outer `<Tabs value={instance.id}>` renders one `<TabsTrigger>` per sibling. Each trigger has `onClick={() => navigate(`/services/${serviceType}/${sibling.id}`)}`.
- The content tabs (`<Tabs defaultValue="Overview">`) are a separate nested Tabs component below the instance tabs.
- Single instance: no instance tabs rendered (the condition is false).
## Validation
```
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc -b + vite)
cd frontend && npm run test → 36 files / 118 tests passed (was 117; +1 instance-tabs test)
```
## Deviations
1. **No ConfirmDialog on delete in ServiceConfigEditor.** The old ServicePage had a ConfirmDialog before deleting. The new ServiceConfigEditor calls `deleteService.mutate(instance.id)` directly on the Delete button click. This is a minor UX regression; a follow-up can add the confirm dialog. Kept simple to stay within scope.
2. **Instance tabs use onClick navigation, not Radix tab state.** The outer Tabs `value` is bound to `instance.id` (the current route), and clicking a trigger navigates. Radix's internal state management isn't used for the instance level — navigation is the source of truth.
3. **tabs.tsx formatting discarded.** The write tool normalized tabs.tsx (semicolons + indentation). I discarded that diff to keep the change focused on the 5 intended files.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- No ConfirmDialog on service delete in the Settings > Services tab (minor UX regression vs the old ServicePage).
- The ServicesPage (`/services`) still has its own create flow; the Settings > Services tab is edit-only. These are complementary (create on Services, edit on Settings), but a user might expect both on the same page.
+138
View File
@@ -0,0 +1,138 @@
# Configurable per-service Overview (change 4)
## Files changed (10 files, ~310 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/services/settings_store.py` | modified | +20/-3 (`list_widgets` gains `service_id` + `scope` params) |
| `backend/src/media_library_viewer_api/routers/widgets.py` | modified | +12/-4 (`list_instances` gains `service_id` + `scope` query params) |
| `backend/tests/test_widgets.py` | modified | +36 (filter test) |
| `frontend/src/api/widgets.ts` | modified | +8/-1 (`fetchWidgetInstances` accepts `serviceId?` + `scope?`) |
| `frontend/src/hooks/useWidgets.ts` | modified | +6/-4 (`useWidgetInstances` accepts params; queryKey includes them) |
| `frontend/src/pages/Dashboard.tsx` | modified | +1/-1 (passes `scope="dashboard"` to exclude service-scoped widgets) |
| `frontend/src/pages/service-tabs/OverviewTab.tsx` | **new** | 67 |
| `frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx` | **new** | 79 |
| `frontend/src/pages/service-tabs/index.ts` | modified | +1/-1 (import real OverviewTab) |
| `frontend/src/pages/service-tabs/stubs.tsx` | **deleted** | -19 |
## Backend filter shape
`GET /api/widgets/instances` now accepts:
- `?service_id=X` — filter to widgets for service X
- `?scope=dashboard` — only NULL service_id widgets (main dashboard)
- `?scope=service` — only non-NULL service_id widgets
`SettingsStore.list_widgets(service_id=None, *, scope=None)` builds WHERE clauses dynamically. No-args returns all (backward-compatible).
## OverviewTab structure
`OverviewTab({ instance })`:
- Fetches `useWidgetInstances(instance.id)` (scoped to this service).
- Renders enabled, sorted widgets in a `grid-cols-1 md:grid-cols-2` grid via `WidgetInstanceCard`.
- "Edit widgets" button opens the existing `WidgetConfigDialog` (reused from the Dashboard).
- Empty state: "No widgets on this overview yet" + "Add widgets" button.
- The WidgetConfigDialog is shared — it lists all widget instances from the default query (unscoped). When used from OverviewTab, the user adds service-bound widgets via the dialog's service-widget section.
## Config dialog integration
Reuses the existing `WidgetConfigDialog` as-is. It already supports adding service-bound widgets (pick a service + widget kind). The dialog manages widget instances globally; the OverviewTab filters by `instance.id`. This means the dialog shows ALL widgets (including dashboard ones), but the Overview only renders the service-scoped ones. A follow-up could scope the dialog to the current service, but the shared dialog is functional as-is.
## Validation
```
cd backend && .venv/bin/ruff check . → All checks passed!
cd backend && .venv/bin/python -m pytest tests/ → 272 passed, 2 warnings
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc + vite)
cd frontend && npm run test → 36 files / 121 tests passed
```
## Deviations
1. **WidgetConfigDialog is unscoped.** It lists all widget instances. The OverviewTab filters by `instance.id` at render time, but the dialog shows everything. Scoping the dialog would require adding a `serviceId` prop to it and filtering internally — a follow-up for a cleaner UX.
2. **stubs.tsx deleted.** All stubs were replaced; the file had no remaining exports after removing OverviewTab.
3. **ServicePage tests updated.** Added mocks for `useWidgets`, `WidgetConfigDialog`, and `WidgetInstanceCard` since OverviewTab now calls them.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- WidgetConfigDialog is shared and unscoped — adding a widget from the OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.
- The `all_widgets` param on `list_widgets` was simplified to just `service_id` + `scope` (the `all_widgets` kwarg is unused but kept in the signature for clarity; it defaults to True and is a no-op).
- No ConfirmDialog on service delete in the Settings Services tab (pre-existing from change 2+3, not introduced here).
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Implements configurable per-service Overview (widget grid scoped by instance.id) + backend filter params (?service_id= + ?scope=) + Dashboard scope fix + tests. No scope widening: 10 files, ~310 lines. 272 backend + 121 frontend tests pass; lint/build green both sides."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/services/settings_store.py",
"backend/src/media_library_viewer_api/routers/widgets.py",
"backend/tests/test_widgets.py",
"frontend/src/api/widgets.ts",
"frontend/src/hooks/useWidgets.ts",
"frontend/src/pages/Dashboard.tsx",
"frontend/src/pages/service-tabs/OverviewTab.tsx",
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
"frontend/src/pages/service-tabs/index.ts",
"frontend/src/pages/service-tabs/stubs.tsx"
],
"testsAddedOrUpdated": [
"backend/tests/test_widgets.py",
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
"frontend/src/pages/__tests__/ServicePage.test.tsx"
],
"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": "272 passed, 2 warnings (pre-existing)"
},
{
"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": "36 files / 121 tests passed"
}
],
"validationOutput": [
"Backend list_widgets supports service_id + scope filtering; test covers all/dash scope/service scope/filtered.",
"Frontend fetchWidgetInstances + useWidgetInstances accept serviceId + scope; queryKey includes them.",
"Dashboard uses scope=dashboard to exclude service-scoped widgets.",
"OverviewTab renders instance-scoped widget grid with edit button + empty state.",
"stubs.tsx deleted (all stubs replaced)."
],
"residualRisks": [
"WidgetConfigDialog is shared and unscoped — adding a widget from OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.",
"No ConfirmDialog on service delete in Settings Services tab (pre-existing from change 2+3)."
],
"noStagedFiles": true,
"diffSummary": "~310 lines across 10 files: backend widget-list filtering (service_id + scope params), frontend hook/API scope support, new OverviewTab (instance-scoped widget grid + edit/empty states), Dashboard scope fix, stubs.tsx deleted, ServicePage test mocks updated.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "Nothing is staged. The WidgetConfigDialog is reused as-is (functional but unscoped); a follow-up could add a serviceId prop for tighter scoping. The all_widgets kwarg on list_widgets is unused but kept for API clarity."
}
+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)."
}
+83 -3
View File
@@ -4,6 +4,66 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
## [Unreleased]
### 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
Prometheus instances in the UI on the Services page; all three are first-class
service-registry entries with dashboard widgets (`active_alerts`, Grafana link,
Prometheus metric).
- New monitoring endpoints resolve the configured service instance and probe its
health: `GET /api/monitoring/grafana-status`, `/prometheus-status`. The
`/alerts` and `/alertmanager-status` endpoints now take an optional
`service_id` and pick the first enabled alertmanager instance by default.
- The Observability page discovers Grafana/Prometheus/Alertmanager from the
registry and renders health cards; the dashboard `active_alerts` widget sums
firing alerts by severity.
### Changed — Observability is now external only
- **Removed** all observability services from `docker-compose.yml` and
`docker-compose.dev.yml`. They now deploy **only** the backend and frontend.
The `monitoring` network and the `prometheus`/`loki`/`alloy`/`grafana`/
`alertmanager`/`node-exporter` services and their named volumes were deleted,
and the `GRAFANA_APP_HOST` Traefik rule was removed.
- Manage now connects to **existing** Grafana/Prometheus/Alertmanager instances
and never ships its own stack. The previous in-compose stack is preserved as
an optional, deploy-it-yourself example in `docker-compose.observability.yml`
(config under `monitoring/`, documented in `docs/observability-runbooks.md`).
- Removed the now-orphaned combined `monitoring/prometheus/prometheus.yml`; the
standalone stack uses `monitoring/prometheus/prometheus.standalone.yml`.
- Removed the Prometheus file-SD bridge (`PROMETHEUS_FILE_SD_DIR` + the
`write_prometheus_targets` file writer). External Prometheus instances now
consume node-exporter targets via `http_sd_configs` against
`GET /api/monitoring/prometheus-targets`. The webhook receiver is log-only.
### **BREAKING**
- Observability is configured entirely via the service registry; the backend
`alertmanager_url`/`alertmanager_webhook_url` and frontend
`VITE_GRAFANA_URL`/`VITE_PROMETHEUS_URL` environment variables, plus
`PROMETHEUS_FILE_SD_DIR`, were **removed**. Re-create your Alertmanager /
Grafana / Prometheus instances on the Services page after upgrading. The only
observability env var remaining is `PROMETHEUS_ENABLED` (toggles Manage's own
`/metrics` endpoint).
### Added — Service registry
- Runtime **service registry** persisted in the backend SQLite database. External
@@ -32,6 +92,12 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
### **BREAKING**
- Saved Actions (server tasks) now target `ssh_tasks` service instances instead
of monitoring machines. The `default_machine_id` field on saved tasks was
replaced with `default_service_id`; the legacy `saved_task_runs` table was
dropped and run history now lives in `service_task_runs`. Re-create SSH task
runner services on the Services page and re-link saved actions after
upgrading.
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
with:
@@ -51,9 +117,23 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
### Notes / follow-ups
- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
pages. Migrating those onto the service registry is a separate follow-up change
(see `openspec/changes/service-registry/design.md` §12.5).
- ~~Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
pages. Migrating those onto the service registry is a separate follow-up change.~~
**Done (2026-06-23):** Jellyfin is no longer a machine service, and the dead
machine-level `media_root`/`path_prefix` fields were removed. See the
Jellyfin migration entry in `docs/REQUIREMENTS.md`.
## Follow-up #2 — remove dead machine `media_root`/`path_prefix` + Jellyfin service
Completes the Jellyfin migration onto the service registry. Jellyfin is no
longer a machine `services` tag (`DEFAULT_SERVICES` is now `["monitoring",
"files"]`), and the dead machine-level `media_root`/`path_prefix` fields were
removed from the settings store, `MonitoringMachineInput`, frontend types, and
the Settings UI. Jellyfin is configured exclusively as a service-registry
instance. The global `REMOTE_MEDIA_ROOT`/`REMOTE_PATH_PREFIX` config properties
and `path_utils.py` remain (files/media-index still use them for Jellyfin→SSH
path resolution). Existing DB rows may still carry these keys in `config_json`;
they are inert and get dropped on the next machine save.
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
+77 -20
View File
@@ -1,55 +1,114 @@
# Contributing
Thanks for considering a contribution.
Thanks for considering a contribution to Manage.
Manage is a media and server-operations dashboard built from two subprojects:
- **`backend/`** — FastAPI (Python 3.11) REST API using a `src/` layout.
- **`frontend/`** — Vite + React + TypeScript SPA.
- **`archive/`** — the original Streamlit prototype, preserved for reference only. Do **not** use it as a guide; the app is FastAPI + React now.
The authoritative contributor quick-reference is [`AGENTS.md`](./AGENTS.md). This document mirrors it for human contributors.
## Setup
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
```
Copy env template:
### Frontend
```bash
cp .env.example .env
cd frontend
npm install
```
Then set real values in `.env` and run:
### Local stack (optional)
For a full local dev stack with hot reload (auth disabled):
```bash
streamlit run app.py
docker compose -f docker-compose.dev.yml up --build
```
## Development guidelines
The dev compose deploys only the backend and frontend; Manage never deploys an
observability stack. For the optional standalone observability example, see
`docker-compose.observability.yml` and `docs/observability-runbooks.md`.
## Development commands
Run backend checks from `backend/` and frontend checks from `frontend/`.
```bash
# Backend: lint + tests
cd backend && ruff check . && python -m pytest
# Run the API locally (if the package is installed as above)
uvicorn media_library_viewer_api.main:app --reload --port 8000
# Otherwise, without installing: PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
# Focused backend tests
pytest tests/test_api.py
pytest -k <expr>
```
```bash
# Frontend: dev server (proxies /api to http://localhost:8000)
cd frontend && npm run dev
# Frontend: lint + typecheck/build (build runs tsc -b + vite build) + tests
npm run lint
npm run build
npm run test
```
## Guidelines
- Keep architecture boundaries clear:
- `clients/` for external integrations
- `domain/` for normalization/business logic
- `services/` for app services/indexing
- `ui/` for Streamlit rendering
- `clients/` for external service transports (Jellyfin, Jellyseerr, SSH, local shell).
- `integrations/` for service-registry definitions (config schema, secrets, widget kinds).
- `domain/` for normalization/business logic.
- `services/` for app services, indexing, persistence, and background workers.
- `routers/` for FastAPI route handlers.
- `models/` for Pydantic request/response schemas.
- Prefer small, focused functions and explicit names.
- Preserve safe SSH behavior and shell quoting — job templates must quote all interpolated values.
- External services (Jellyfin, Grafana, Prometheus, Alertmanager, …) are configured at runtime via the **service registry** in the UI, not environment variables. The only observability env var is `PROMETHEUS_ENABLED` (Manage's own `/metrics` toggle).
- Avoid introducing optional fallback paths unless required.
- Prefer small, focused functions and explicit session-state keys.
- Preserve safe SSH behavior and path quoting.
## Validation
### Backend style
Before opening a merge request, run:
Backend linting/format is Ruff (line length 120, Python 3.11); config lives in `backend/pyproject.toml`.
### Frontend style
The frontend uses **shadcn/ui + Tailwind CSS v4 + lucide-react + TanStack Query + TanStack Table**. Do not introduce MUI, Emotion, recharts, d3, or AG Grid — those were removed and are not coming back.
## Validation before opening a merge request
Before opening a merge request, run and ensure green:
```bash
PYTHONPATH=src python -m py_compile app.py src/media_library_viewer/*.py src/media_library_viewer/clients/*.py src/media_library_viewer/domain/*.py src/media_library_viewer/services/*.py src/media_library_viewer/ui/*.py
cd backend && ruff check . && python -m pytest
cd frontend && npm run lint && npm run build && npm run test
```
If behavior, UX, or architecture changed, also update `docs/REQUIREMENTS.md`.
## Security / secrets
Never commit:
- `.env`
- `.streamlit/secrets.toml`
- private keys or API tokens
- service secrets
Use `.env.example` for documented placeholders only.
Service secrets are encrypted at rest with `MANAGE_ENCRYPTION_KEY` (required to start the backend). Use `.env.example` for documented placeholders only.
## Pull requests
@@ -57,6 +116,4 @@ Please include:
- what changed
- why it changed
- how it was tested
If behavior/requirements changed, also update `docs/REQUIREMENTS.md`.
- how it was tested (commands run / tests added)
+20 -16
View File
@@ -20,15 +20,15 @@ The project consists of two subprojects:
## Features
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, SSH task output, static text) and shortcuts
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, Alertmanager alerts, SSH task output, static text) and shortcuts
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
- Per-machine settings for Jellyfin, Jellyseerr, SSH, and monitoring targets
- Service registry: configure Jellyfin, Jellyseerr, Alertmanager, Grafana, Prometheus, Nextcloud, and SSH task runner instances in the UI
- Per-machine settings for SSH, monitoring targets, and file browsing
- SQLite-indexed media table with full-library sort/filter
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
- Remote file browser with ffprobe preview and job execution
- Jellyfin API integration for library metadata and user identity data
- SSH-based file inspection and safe remote job templates
- Addon pages for Grafana, Prometheus, and SSH tasks at `/addons/:addonId`
## Quick Start
@@ -44,6 +44,8 @@ Open the app at <http://localhost:8080>.
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
> **Observability is external.** Manage only ships its **backend** and **frontend**. It does **not** deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter. The backend exposes a `/metrics` endpoint and optional Alertmanager proxy endpoints so an *existing* observability deployment can scrape and consume them. For a ready-to-run example stack you can deploy alongside Manage, see [`docker-compose.observability.yml`](docker-compose.observability.yml) and [`docs/observability-runbooks.md`](docs/observability-runbooks.md).
Local development with hot reload:
```bash
@@ -81,23 +83,23 @@ Production-style example with shell exports:
```bash
export BACKEND_APP_HOST=api.manage.example.com
export FRONTEND_APP_HOST=manage.example.com
export GRAFANA_APP_HOST=grafana.manage.example.com
export CERT_RESOLVER=letsencrypt
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
export VITE_OIDC_CLIENT_ID=manage
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
export VITE_GRAFANA_URL=https://grafana.manage.example.com
export VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
docker compose up --build
```
> Observability services (Grafana, Prometheus, Alertmanager) are configured in
> the app on the **Services** page — no env vars for them.
Inline one-liner example:
```bash
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com GRAFANA_APP_HOST=grafana.manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_PROMETHEUS_URL=https://prometheus.manage.example.com docker compose up --build
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
```
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
@@ -144,9 +146,9 @@ VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
# Grafana / Prometheus public URLs for frontend deep-links (service adapters read URLs from service records)
VITE_GRAFANA_URL=https://grafana.manage.example.com
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
# Observability services (Grafana, Prometheus, Alertmanager) are configured in
# the app on the Services page. The only observability env var is the optional
# PROMETHEUS_ENABLED toggle (defaults on) for Manage's own /metrics endpoint.
# Required: master key encrypting service secrets (API keys/tokens) at rest.
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
@@ -170,18 +172,20 @@ ssh user@host
## Development
```bash
# Backend
cd backend && PYTHONPATH=src python -m py_compile src/media_library_viewer_api/main.py
# Backend (lint + tests)
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest
# Frontend
cd frontend && npx tsc --noEmit && npm run build
# Frontend (lint + typecheck/build + tests)
cd frontend && npm run lint && npm run build && npm run test
```
Focused frontend typecheck: `npx tsc --noEmit`.
## Notes
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. They deploy **only** the backend and frontend; Manage never deploys its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
- Grafana and Prometheus widget adapters resolve URLs from service records configured in the app; `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` are only used for frontend deep-links. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`.
- Grafana, Prometheus, and Alertmanager are configured as **service instances** in the app (Services page); their widget adapters resolve URLs from service records, and no observability URLs/credentials live in env vars. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`. When no alertmanager service is configured, the alert proxy endpoints return graceful "not configured" responses.
+31
View File
@@ -0,0 +1,31 @@
# archive (index)
dir: archive
## role
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
## children
- archive/src
index: archive/src/.pi-map.index.md
map: archive/src/.pi-map.md
- archive/tests
index: archive/tests/.pi-map.index.md
map: archive/tests/.pi-map.md
## files
- app.py
- pyproject.toml
- requirements.txt
## links
index: archive/.pi-map.index.md
map: archive/.pi-map.md
## workflows
- change archive behavior
read: app.py, pyproject.toml, requirements.txt
- change archive config
read: pyproject.toml
- explore archive subdirectories
index: archive/src/.pi-map.index.md, archive/tests/.pi-map.index.md
## dirty
-
+26
View File
@@ -0,0 +1,26 @@
# archive
dir: archive
index: archive/.pi-map.index.md
## role
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 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
-
## workflows
- change archive behavior
read: app.py, pyproject.toml, requirements.txt
- change archive config
read: pyproject.toml
- explore archive subdirectories
index: archive/src/.pi-map.index.md, archive/tests/.pi-map.index.md
## dirty
-
+20
View File
@@ -0,0 +1,20 @@
# archive/src (index)
dir: archive/src
## role
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
## children
- archive/src/media_library_viewer
index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md
## files
## links
index: archive/src/.pi-map.index.md
map: archive/src/.pi-map.md
## workflows
-
## dirty
-
+18
View File
@@ -0,0 +1,18 @@
# archive/src
dir: archive/src
index: archive/src/.pi-map.index.md
## role
Insufficient information — no files provided in the directory listing to determine this package's role.
## files
## arch
Unable to assess — empty directory or missing file contents for architectural analysis.
## tags
-
## symbols
-
## workflows
-
## dirty
-
@@ -0,0 +1,39 @@
# archive/src/media_library_viewer (index)
dir: archive/src/media_library_viewer
## role
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
## children
- archive/src/media_library_viewer/clients
index: archive/src/media_library_viewer/clients/.pi-map.index.md
map: archive/src/media_library_viewer/clients/.pi-map.md
- archive/src/media_library_viewer/domain
index: archive/src/media_library_viewer/domain/.pi-map.index.md
map: archive/src/media_library_viewer/domain/.pi-map.md
- archive/src/media_library_viewer/services
index: archive/src/media_library_viewer/services/.pi-map.index.md
map: archive/src/media_library_viewer/services/.pi-map.md
- archive/src/media_library_viewer/ui
index: archive/src/media_library_viewer/ui/.pi-map.index.md
map: archive/src/media_library_viewer/ui/.pi-map.md
## files
- __init__.py
- app.py
- config.py
- jobs.py
- utils.py
## links
index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md
## workflows
- change media_library_viewer behavior
read: __init__.py, app.py, config.py
- change media_library_viewer config
read: config.py
- explore media_library_viewer subdirectories
index: archive/src/media_library_viewer/clients/.pi-map.index.md, archive/src/media_library_viewer/domain/.pi-map.index.md, archive/src/media_library_viewer/services/.pi-map.index.md
## dirty
-
@@ -0,0 +1,35 @@
# archive/src/media_library_viewer
dir: archive/src/media_library_viewer
index: archive/src/media_library_viewer/.pi-map.index.md
## role
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
- config.py | Loads application configuration from environment variables and .env files using immutable dataclasses for Jellyfin and SSH settings. | exp: class:JellyfinConfig, class:SSHConfig, class:AppConfig, func:load_config() → AppConfig, call:AppConfig | dep: os, dataclasses, pathlib, dotenv
- 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
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
- JellyfinConfig
- SSHConfig
- AppConfig
- JobTemplate
- get_jellyfin_client
- cached_users
- get_ssh_client
- cached_libraries
## workflows
- change media_library_viewer behavior
read: __init__.py, app.py, config.py
- change media_library_viewer config
read: config.py
- explore media_library_viewer subdirectories
index: archive/src/media_library_viewer/clients/.pi-map.index.md, archive/src/media_library_viewer/domain/.pi-map.index.md, archive/src/media_library_viewer/services/.pi-map.index.md
## dirty
-
@@ -0,0 +1,23 @@
# archive/src/media_library_viewer/clients (index)
dir: archive/src/media_library_viewer/clients
## role
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
## children
-
## files
- __init__.py
- jellyfin.py
- resources.py
- ssh.py
## links
index: archive/src/media_library_viewer/clients/.pi-map.index.md
map: archive/src/media_library_viewer/clients/.pi-map.md
## workflows
- change clients behavior
read: __init__.py, jellyfin.py, resources.py
## dirty
-
@@ -0,0 +1,30 @@
# archive/src/media_library_viewer/clients
dir: archive/src/media_library_viewer/clients
index: archive/src/media_library_viewer/clients/.pi-map.index.md
## role
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
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
- JellyfinClient
- ResourceMonitorPaths
- CommandResult
- RemoteSSHClient
- __init__
- get
- users
- libraries
## workflows
- change clients behavior
read: __init__.py, jellyfin.py, resources.py
## dirty
-
@@ -0,0 +1,21 @@
# archive/src/media_library_viewer/domain (index)
dir: archive/src/media_library_viewer/domain
## role
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
## children
-
## files
- __init__.py
- media.py
## links
index: archive/src/media_library_viewer/domain/.pi-map.index.md
map: archive/src/media_library_viewer/domain/.pi-map.md
## workflows
- change domain behavior
read: __init__.py, media.py
## dirty
-
@@ -0,0 +1,28 @@
# archive/src/media_library_viewer/domain
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 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 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
- first_media_source
- media_streams
- stream_value
- is_hdr_item
- format_date_added
- timestamp_date_added
- format_rate_bits_decimal
- normalize_media_item
## workflows
- change domain behavior
read: __init__.py, media.py
## dirty
-
@@ -0,0 +1,21 @@
# archive/src/media_library_viewer/services (index)
dir: archive/src/media_library_viewer/services
## role
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
## children
-
## files
- __init__.py
- media_index.py
## links
index: archive/src/media_library_viewer/services/.pi-map.index.md
map: archive/src/media_library_viewer/services/.pi-map.md
## workflows
- change services behavior
read: __init__.py, media_index.py
## dirty
-
@@ -0,0 +1,28 @@
# archive/src/media_library_viewer/services
dir: archive/src/media_library_viewer/services
index: archive/src/media_library_viewer/services/.pi-map.index.md
## role
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 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
- MediaIndexStatus
- MediaIndex
- __init__
- connect
- init_schema
- set_metadata
- replace_items
- status
## workflows
- change services behavior
read: __init__.py, media_index.py
## dirty
-
@@ -0,0 +1,24 @@
# archive/src/media_library_viewer/ui (index)
dir: archive/src/media_library_viewer/ui
## role
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
## children
-
## files
- __init__.py
- dashboard.py
- file_browser.py
- media.py
- preview.py
## links
index: archive/src/media_library_viewer/ui/.pi-map.index.md
map: archive/src/media_library_viewer/ui/.pi-map.md
## workflows
- change ui behavior
read: __init__.py, dashboard.py, file_browser.py
## dirty
-
@@ -0,0 +1,31 @@
# archive/src/media_library_viewer/ui
dir: archive/src/media_library_viewer/ui
index: archive/src/media_library_viewer/ui/.pi-map.index.md
## role
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
- file_browser.py | Renders an interactive SSH remote file browser UI in Streamlit with filtering, sorting, pagination, and directory navigation using ag-grid. | exp: func:reset_file_browser_filters() → None, call:st.session_state.pop, func:set_file_browser_path(path: str, selected_path, reset_filters) → None, func:aggrid_selected_rows(response: dict) → list[dict], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args: tuple, initial_path: str) → str, call:st.subheader, call:st.session_state.pop, call:reset_file_browser_filters, call:st.session_state.get, call:st.columns, call:status_col.caption, call:selected_col.caption, call:path_col.text_input, call:set_file_browser_path, call:st.rerun, call:refresh_col.button, call:cached_dir_listing.clear, call:st.error, call:PurePosixPath(name).suffix.lower, call:str, call:display_rows.append, call:int, call:human_size, call:float, call:timestamp_to_local, call:len, call:sum, call:st.caption, call:st.container, call:filter_col.selectbox, call:search_col.text_input, call:sorted, call:ext_col.selectbox, call:sort_col.selectbox, call:order_col.toggle, call:page_size_col.selectbox, call:search_term.lower, call:r["name"].lower, call:filtered_rows.sort, call:max, call:page_col.number_input, call:summary_col.caption, call:min, call:visible_rows.append, call:visible_rows.extend, call:st.info, call:st.expander, call:st.write, call:pd.DataFrame, 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:aggrid_selected_rows, call:picked_row.get | dep: json, pathlib, typing, st_aggrid, media_library_viewer.utils, streamlit, pandas
- 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
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
- format_rate_bytes
- rate_scale
- scaled_rate_chart_df
- format_elapsed
- render_media_overview
- render_now_playing
- render_resource_dashboard
- reset_file_browser_filters
## workflows
- change ui behavior
read: __init__.py, dashboard.py, file_browser.py
## dirty
-
+19
View File
@@ -0,0 +1,19 @@
# archive/tests (index)
dir: archive/tests
## role
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
## children
-
## files
- .gitkeep
## links
index: archive/tests/.pi-map.index.md
map: archive/tests/.pi-map.md
## workflows
-
## dirty
-
+19
View File
@@ -0,0 +1,19 @@
# archive/tests
dir: archive/tests
index: archive/tests/.pi-map.index.md
## role
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
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
-
## workflows
-
## dirty
-
+32
View File
@@ -0,0 +1,32 @@
# backend (index)
dir: backend
## role
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
## children
- backend/.pytest_cache
index: backend/.pytest_cache/.pi-map.index.md
map: backend/.pytest_cache/.pi-map.md
- backend/.ruff_cache
index: backend/.ruff_cache/.pi-map.index.md
map: backend/.ruff_cache/.pi-map.md
- backend/src
index: backend/src/.pi-map.index.md
map: backend/src/.pi-map.md
- backend/tests
index: backend/tests/.pi-map.index.md
map: backend/tests/.pi-map.md
## files
- Dockerfile
- README.md
- pyproject.toml
## links
index: backend/.pi-map.index.md
map: backend/.pi-map.md
## workflows
-
## dirty
-
+21
View File
@@ -0,0 +1,21 @@
# backend
dir: backend
index: backend/.pi-map.index.md
## role
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
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
-
## workflows
-
## dirty
-
+36 -21
View File
@@ -13,29 +13,46 @@ backend/
│ ├── __init__.py
│ ├── main.py # FastAPI app entrypoint
│ ├── config.py # pydantic-settings config
│ ├── auth.py # OIDC/JWT + API key auth
│ ├── dependencies.py # Dependency injection
│ ├── observability.py # Prometheus metrics + request IDs
│ ├── logging_utils.py # Structured JSON/text logging
│ ├── path_utils.py # Jellyfin→SSH path resolution
│ ├── jobs.py # Job templates
│ ├── utils.py # Formatting helpers
│ ├── routers/
│ │ ├── backups.py
│ │ ├── dashboard.py
│ │ ├── monitoring.py
│ │ ├── media.py
│ │ ├── users.py
│ │ ├── settings.py
│ │ ├── files.py
│ │ ── jobs.py
│ │ ── jobs.py
│ │ ├── media.py
│ │ ├── monitoring.py
│ │ ├── services.py
│ │ ├── settings.py
│ │ ├── tasks.py
│ │ ├── users.py (+ users_impl.py)
│ │ └── widgets.py
│ ├── clients/
│ │ ├── jellyfin.py
│ │ ├── jellyseerr.py
│ │ ├── local.py
│ │ ├── resources.py
│ │ └── ssh.py
│ ├── integrations/ # Service-registry definitions
│ ├── domain/
│ │ └── media.py
── services/
├── media_index.py
── settings_store.py
── models/ # Pydantic request/response models
├── services/
── media_index.py (+ _impl.py)
│ │ ├── settings_store.py
│ │ ├── secrets.py # Fernet encryption at rest
│ │ ├── targets.py # Node Exporter target discovery
│ │ ├── task_runner.py
│ │ ├── mail_queue.py (+ mailer.py/_impl.py)
│ │ ├── backup_alert_engine.py (+ backup_poller.py)
│ │ ├── known_hosts.py
│ │ └── db_maintenance.py
│ ├── widgets/ # Widget sources (dashboard data adapters)
│ └── workers/ # Background workers (media index)
└── tests/
```
@@ -99,7 +116,7 @@ Or with PYTHONPATH if not installed:
PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
```
API docs available at: http://localhost:8000/docs
API docs available at: <http://localhost:8000/docs>
## Docker
@@ -123,16 +140,16 @@ export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
docker compose up --build
```
2. After the API is running, open the app, go to **Settings**, and add machine entries:
1. After the API is running, open the app, go to **Settings**, and add machine entries:
- **Local**: monitors the API host itself without SSH.
- **SSH**: monitors another machine using a host, username, and a private key pasted directly into the machine settings, with an optional passphrase.
- The machine editor groups Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes under separate headings so each service area is easier to scan.
- Saving a monitoring machine now validates the banner/auth flow, records the first trusted host key into the backend-managed `known_hosts` file, and starts the collector so charts populate without a separate manual step.
- Saving a monitoring machine validates the banner/auth flow and records the first trusted host key into the backend-managed `known_hosts` file.
- If the host cannot be reached or authenticated, the save flow surfaces the SSH error directly in the dialog.
- Use **Validate SSH + trust host** in the machine editor before saving if you want to test the banner/auth flow explicitly.
- The first successful SSH connection uses trust-on-first-use: the backend records that machine's host key into its managed `known_hosts` file automatically, then continues verifying it strictly on later connects.
3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller.
2. Open **Observability** to see Alertmanager alerts, Prometheus scrape targets, and Grafana deep-links for configured machines. Alertmanager, Grafana, and Prometheus are configured as service instances on the **Services** page; system metrics (disk, CPU, memory) are owned by the external observability stack (Prometheus + node_exporter + Grafana), not by the Manage backend.
For local development, `docker compose -f docker-compose.dev.yml up --build` does not require an SSH key unless you configure remote SSH machines in the Settings tab.
@@ -142,14 +159,12 @@ For local development, `docker compose -f docker-compose.dev.yml up --build` doe
- `GET /api/dashboard/libraries` — Per-library breakdown
- `GET /api/dashboard/now-playing` — Active playback sessions
- `GET /api/monitoring/machines` — Persistent monitoring machine definitions
- `GET /api/monitoring/status?machine_id=` — Collector status for a machine
- `GET /api/monitoring/metrics?machine_id=` — Resource samples (last hour)
- `GET /api/monitoring/disk?machine_id=` — Disk space
- `POST /api/monitoring/start|stop|restart?machine_id=` — Collector controls
- `GET /api/monitoring/diagnostics?machine_id=` — Collector debug info
- `GET /api/monitoring/poller` — Backend poller status and configuration
- `GET /api/monitoring/machines/{machine_id}/actions` — Recent machine action history
- `GET /api/dashboard/monitoring` — Dashboard-wide per-machine monitoring summary table with 10-minute averages and min/max subtext
- `GET /api/monitoring/prometheus-targets` — Prometheus scrape targets for remote Node Exporters (consumed by external Prometheus via `http_sd_configs`)
- `GET /api/monitoring/alerts` — Active Alertmanager alerts summary (resolves the configured alertmanager service)
- `GET /api/monitoring/alertmanager-status` — Alertmanager cluster/status
- `GET /api/monitoring/grafana-status` — Grafana service health
- `GET /api/monitoring/prometheus-status` — Prometheus service health
- `POST /api/monitoring/alertmanager-webhook` — Receive Alertmanager webhooks (log-only)
- `GET /api/settings/machines` — Manage machine definitions
- `GET /api/media/status` — Index status
- `POST /api/media/build` — Rebuild index
+20
View File
@@ -0,0 +1,20 @@
# backend/src (index)
dir: backend/src
## role
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
## children
- backend/src/media_library_viewer_api
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
## files
## links
index: backend/src/.pi-map.index.md
map: backend/src/.pi-map.md
## workflows
-
## dirty
-
+18
View File
@@ -0,0 +1,18 @@
# backend/src
dir: backend/src
index: backend/src/.pi-map.index.md
## role
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
## files
## arch
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
-
## workflows
-
## dirty
-
@@ -0,0 +1,57 @@
# backend/src/media_library_viewer_api (index)
dir: backend/src/media_library_viewer_api
## role
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
## children
- backend/src/media_library_viewer_api/clients
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
map: backend/src/media_library_viewer_api/clients/.pi-map.md
- backend/src/media_library_viewer_api/domain
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
map: backend/src/media_library_viewer_api/domain/.pi-map.md
- backend/src/media_library_viewer_api/integrations
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
- backend/src/media_library_viewer_api/models
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
map: backend/src/media_library_viewer_api/models/.pi-map.md
- backend/src/media_library_viewer_api/routers
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
map: backend/src/media_library_viewer_api/routers/.pi-map.md
- backend/src/media_library_viewer_api/services
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
map: backend/src/media_library_viewer_api/services/.pi-map.md
- backend/src/media_library_viewer_api/widgets
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
- backend/src/media_library_viewer_api/workers
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
map: backend/src/media_library_viewer_api/workers/.pi-map.md
## files
- __init__.py
- auth.py
- config.py
- dependencies.py
- jobs.py
- logging_utils.py
- main.py
- observability.py
- path_utils.py
- utils.py
- version.py
## links
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
## workflows
- change media_library_viewer_api behavior
read: __init__.py, auth.py, config.py
- change media_library_viewer_api config
read: config.py
- explore media_library_viewer_api subdirectories
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
## dirty
-
@@ -0,0 +1,41 @@
# backend/src/media_library_viewer_api
dir: backend/src/media_library_viewer_api
index: backend/src/media_library_viewer_api/.pi-map.index.md
## role
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 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_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 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, call:str, id, client
## symbols
- Settings
- JobTemplate
- _normalize_issuer_url
- get_oidc_metadata
- get_jwk_client
- _split_audience
- validate_auth_settings
- validate_bearer_jwt
## workflows
- change media_library_viewer_api behavior
read: __init__.py, auth.py, config.py
- change media_library_viewer_api config
read: config.py
- explore media_library_viewer_api subdirectories
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
## dirty
-
@@ -0,0 +1,26 @@
# backend/src/media_library_viewer_api/clients (index)
dir: backend/src/media_library_viewer_api/clients
## role
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
## children
-
## 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, authentik.py, jellyfin.py
## dirty
-
@@ -0,0 +1,33 @@
# backend/src/media_library_viewer_api/clients
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-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
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, error, call:logger.debug, client, call:self.get, init, call:shlex.quote, status
## symbols
- AuthentikClient
- JellyfinClient
- JellyseerrClient
- CommandResult
- LocalCommandClient
- QbittorrentClient
- RemoteSSHClient
- __init__
## workflows
- change clients behavior
read: __init__.py, authentik.py, jellyfin.py
## dirty
-
@@ -0,0 +1,115 @@
"""Authentik directory API client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). This client wraps the Authentik REST API for browsing the user directory
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class AuthentikClient:
"""Small wrapper around the Authentik core directory API."""
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
if not base_url:
raise ValueError("Authentik base_url is required")
if not api_token:
raise ValueError("Authentik API token is required")
self.base_url = base_url.rstrip("/")
if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7]
self.api_token = api_token
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning(
"Authentik GET %s failed status=%s url=%s",
path,
response.status_code,
response.url,
)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
return response.json()
def users(
self,
search: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
"""Return a normalized page of Authentik users.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
Authentik response into ``{items, total, page, page_size}``. Each item
is the raw Authentik user dict (pk, username, name, email, avatar, …)
so the frontend can pick the fields it needs.
"""
payload = self.get(
"/core/users/",
search=search,
page=page,
page_size=page_size,
)
if not isinstance(payload, dict):
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
return {"items": [], "total": 0, "page": page, "page_size": page_size}
results = payload.get("results")
items: list[dict[str, Any]] = (
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
)
pagination = payload.get("pagination") or {}
total = 0
if isinstance(pagination, dict):
try:
total = int(pagination.get("count") or 0)
except (TypeError, ValueError):
total = 0
logger.info(
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
page,
page_size,
len(items),
total,
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
@@ -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")
@@ -54,9 +54,6 @@ class Settings(BaseSettings):
# Observability
prometheus_enabled: bool = True
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
# Remote paths
remote_media_root: str = ""
@@ -18,7 +18,6 @@ from typing import Any
from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
@@ -163,31 +162,21 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
if service is None:
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
raise HTTPException(
status_code=503,
detail="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.")
raise HTTPException(
status_code=503,
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
)
cache_key = (service["id"], base_url, api_key)
return _jellyfin_client_for(cache_key)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyseerr", service_id)
if service is None:
logger.info("Jellyseerr client not configured (no jellyseerr service)")
return None
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:
logger.info("Jellyseerr service is missing base_url or api_key")
return None
return JellyseerrClient(base_url, api_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict."""
store = store or get_settings_store()
@@ -244,7 +233,10 @@ def get_ssh_client(request: Request = None):
"set" if settings.ssh_password else "missing",
)
if not settings.ssh_key_path:
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
raise HTTPException(
status_code=503,
detail="No SSH machine is configured and SSH key settings must be configured",
)
return _ssh_client_for(
(
"legacy",
@@ -280,5 +272,8 @@ def get_user_id(request: Request = None) -> str:
client = get_jellyfin_client(request)
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
raise HTTPException(
status_code=503,
detail="No Jellyfin users found and no user_id configured on the service",
)
return users[0]["Id"]
@@ -0,0 +1,22 @@
# backend/src/media_library_viewer_api/domain (index)
dir: backend/src/media_library_viewer_api/domain
## role
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
## children
-
## files
- __init__.py
- dashboard.py
- media.py
## links
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
map: backend/src/media_library_viewer_api/domain/.pi-map.md
## workflows
- change domain behavior
read: __init__.py, dashboard.py, media.py
## dirty
-
@@ -0,0 +1,29 @@
# backend/src/media_library_viewer_api/domain
dir: backend/src/media_library_viewer_api/domain
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
## role
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
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
- _map_sessions_to_activity_rows
- build_backup_dashboard_summary
- first_media_source
- media_streams
- stream_value
- is_hdr_item
- format_date_added
- timestamp_date_added
## workflows
- change domain behavior
read: __init__.py, dashboard.py, media.py
## dirty
-
@@ -0,0 +1,30 @@
# backend/src/media_library_viewer_api/integrations (index)
dir: backend/src/media_library_viewer_api/integrations
## role
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
## children
-
## files
- __init__.py
- alertmanager.py
- authentik.py
- backups.py
- base.py
- jellyfin.py
- nextcloud.py
- prometheus.py
- qbittorrent.py
- registry.py
- ssh_tasks.py
## links
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, authentik.py
## dirty
-
@@ -0,0 +1,37 @@
# backend/src/media_library_viewer_api/integrations
dir: backend/src/media_library_viewer_api/integrations
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
## role
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
- 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 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
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, widget, service, integrations, base, media_library_viewer_api, prometheus, definition
## symbols
- AlertmanagerConfig
- AlertmanagerAlertsWidgetConfig
- AuthentikConfig
- BackupsConfig
- BackupsSummaryWidgetConfig
- ServiceConfigBase
- WidgetConfigBase
- SecretField
## workflows
- change integrations behavior
read: __init__.py, alertmanager.py, authentik.py
## dirty
-
@@ -0,0 +1,89 @@
"""Alertmanager service definition."""
from __future__ import annotations
from typing import Any
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class AlertmanagerConfig(ServiceConfigBase):
"""Non-secret Alertmanager connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 5
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
"""Active-alerts summary for an Alertmanager instance."""
severity_filter: str | None = None
def summarize_alerts(
alerts: list[dict[str, Any]],
*,
severity_filter: str | None = None,
) -> dict[str, Any]:
"""Build a UI-friendly summary from an Alertmanager ``/api/v1/alerts`` list.
Reshapes the raw alert objects into a stable summary (``total``,
``by_severity``, top-50 ``alerts``). When ``severity_filter`` is given, only
alerts whose ``labels.severity`` matches are counted.
"""
by_severity: dict[str, int] = {}
open_alerts: list[dict[str, Any]] = []
for alert in alerts:
labels = alert.get("labels") or {}
annotations = alert.get("annotations") or {}
severity = labels.get("severity", "unknown")
if severity_filter and severity != severity_filter:
continue
by_severity[severity] = by_severity.get(severity, 0) + 1
open_alerts.append(
{
"name": labels.get("alertname", "unknown"),
"severity": severity,
"category": labels.get("category", ""),
"job_name": labels.get("job_name", labels.get("job", "")),
"summary": annotations.get("summary", ""),
"description": annotations.get("description", ""),
"active_since": alert.get("startsAt"),
"state": alert.get("status", "firing"),
"labels": labels,
}
)
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
return {
"total": len(open_alerts),
"by_severity": by_severity,
"alerts": open_alerts[:50],
}
DEFINITION = ServiceDefinition(
service_type="alertmanager",
name="Alertmanager",
description="Alertmanager alerts and status.",
config_model=AlertmanagerConfig,
secret_fields=[
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
],
widget_kinds=[
widget_kind(
kind="active_alerts",
name="Active alerts",
description="Firing alerts summary from Alertmanager.",
model_cls=AlertmanagerAlertsWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
],
)
@@ -0,0 +1,35 @@
"""Authentik service definition.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
on the Authentik service page (Users + Messaging tabs). OIDC authentication
is unchanged -- this service type is for the directory, not SSO.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 10
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
description="User directory and identity provider integration.",
config_model=AuthentikConfig,
secret_fields=[
SecretField(key="api_token", label="API token", required=True),
],
widget_kinds=[],
)
@@ -0,0 +1,48 @@
"""Backups service definition.
Backups is modeled as a service type so it can be configured, named, and
multi-instanced like other services. Reports arrive via the existing REST
report endpoint; the ``ingestion_label`` disambiguates multi-instance
ingestion.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class BackupsConfig(ServiceConfigBase):
"""Non-secret Backups connection config."""
ingestion_label: str = "default"
class BackupsSummaryWidgetConfig(WidgetConfigBase):
"""Backup dashboard summary (jobs, runs, alerts)."""
# No user-overridable fields; the widget reads the internal backup tables.
pass
DEFINITION = ServiceDefinition(
service_type="backups",
name="Backups",
description="Backup job monitoring, run history, and alerting.",
config_model=BackupsConfig,
secret_fields=[],
widget_kinds=[
widget_kind(
kind="summary",
name="Summary",
description="Backup job summary and active alerts.",
model_cls=BackupsSummaryWidgetConfig,
default_config={},
refresh_interval_ms=60_000,
),
],
)
@@ -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),
@@ -16,9 +16,37 @@ map. There is no runtime plugin loading.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from typing import Annotated, Any
from pydantic import BaseModel
from pydantic import BaseModel, BeforeValidator, Field
def _validate_service_base_url(value: Any) -> str:
"""Require an absolute http(s) URL for service ``base_url`` fields.
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.
"""
if not isinstance(value, str):
raise ValueError("base_url must be a string starting with http:// or https://")
text = value.strip()
if not text:
raise ValueError("base_url must not be empty")
lowered = text.lower()
if not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError("base_url must start with http:// or https:// (include the schema)")
return text
#: Shared annotated type for service ``base_url`` fields. applying the validator
#: uniformly across every integration so missing schemas are rejected at the
#: config boundary with a helpful message.
ServiceBaseUrl = Annotated[
str,
Field(description="Absolute URL including the http:// or https:// schema."),
BeforeValidator(_validate_service_base_url),
]
class ServiceConfigBase(BaseModel):
@@ -26,6 +54,9 @@ class ServiceConfigBase(BaseModel):
Subclass this in each integration module and declare the connection fields.
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
Connection URLs should use the :data:`ServiceBaseUrl` type so the
``http(s)://`` schema is enforced consistently across integrations.
"""
@@ -1,46 +0,0 @@
"""Grafana service definition."""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class GrafanaConfig(ServiceConfigBase):
"""Non-secret Grafana connection config."""
base_url: str
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,
),
],
)
@@ -4,6 +4,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
@@ -12,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config."""
"""Non-secret Jellyfin connection config.
base_url: str
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
paired Jellyseerr companion config, absorbed from the former standalone
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
When both are set, the Jellyfin service page renders a Requests tab backed
by Jellyseerr.
"""
base_url: ServiceBaseUrl
user_id: str = ""
timeout_seconds: int = 10
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -26,6 +36,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",
@@ -43,5 +59,13 @@ 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,
),
],
)
@@ -1,32 +0,0 @@
"""Jellyseerr service definition.
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
own service type so multiple Jellyseerr instances are supported independently of
Jellyfin. It provides no dashboard widgets today.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: str
DEFINITION = ServiceDefinition(
service_type="jellyseerr",
name="Jellyseerr",
description="Request management companion to Jellyfin.",
config_model=JellyseerrConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[],
)
@@ -8,6 +8,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
class NextcloudConfig(ServiceConfigBase):
"""Non-secret Nextcloud connection config."""
base_url: str
base_url: ServiceBaseUrl
username: str = ""
@@ -4,6 +4,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
class PrometheusConfig(ServiceConfigBase):
"""Non-secret Prometheus connection config."""
base_url: str
base_url: ServiceBaseUrl
timeout_seconds: int = 10
@@ -24,6 +25,32 @@ 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",
@@ -41,5 +68,29 @@ 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,
),
],
)
@@ -0,0 +1,68 @@
"""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 media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
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,
),
],
)
@@ -6,21 +6,25 @@ There is no runtime plugin loading.
from __future__ import annotations
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
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.jellyseerr import DEFINITION as JELLYSEERR
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,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD,
QBITTORRENT.service_type: QBITTORRENT,
SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
}
+13 -8
View File
@@ -21,8 +21,12 @@ from media_library_viewer_api.observability import (
record_request,
set_current_request_id,
)
from media_library_viewer_api.routers import (
authentik_users as authentik_users_router,
)
from media_library_viewer_api.routers import backups as backups_router
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
from media_library_viewer_api.routers import dashboards as dashboards_router
from media_library_viewer_api.routers import services as services_router
from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router
@@ -44,16 +48,16 @@ async def lifespan(app: FastAPI):
validate_encryption_key()
logger.info("Backend startup complete: %s", describe_settings(settings))
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
try:
from media_library_viewer_api.services.targets import write_prometheus_targets
write_prometheus_targets(get_settings_store())
except Exception:
logger.exception("Failed to write Prometheus file-SD targets during startup")
try:
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")
mail_queue = get_mail_queue()
backup_poller = get_backup_poller()
mail_queue.start()
@@ -142,12 +146,13 @@ app.include_router(monitoring.router)
app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)
app.include_router(backups_router.router)
app.include_router(widgets_router.router)
app.include_router(dashboards_router.router)
app.include_router(services_router.router)
app.include_router(authentik_users_router.router)
@app.get("/api/health")
@@ -0,0 +1,23 @@
# backend/src/media_library_viewer_api/models (index)
dir: backend/src/media_library_viewer_api/models
## role
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
## children
-
## files
- backups.py
- dashboards.py
- services.py
- widgets.py
## links
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, dashboards.py, services.py
## dirty
-
@@ -0,0 +1,30 @@
# backend/src/media_library_viewer_api/models
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 (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, 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 following a schema-first design pattern with built-in validators for domain-specific business rules and data integrity.
## tags
widget, backup, instance, dashboard, pydantic, response, info, call:isinstance
## symbols
- BackupReportRequest
- BackupJobResponse
- BackupRunResponse
- BackupAlertResponse
- BackupDashboardSummary
- NamedDashboardInput
- NamedDashboard
- ServiceInstanceInput
## workflows
- change models behavior
read: backups.py, dashboards.py, services.py
## dirty
-
@@ -0,0 +1,29 @@
"""Pydantic models for the named-dashboards API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class NamedDashboardInput(BaseModel):
"""Input for create/update of a named dashboard."""
id: str | None = None
label: str = Field(default="Dashboard")
slug: str | None = None
sort_order: int = 0
payload: dict[str, Any] = Field(default_factory=dict)
class NamedDashboard(BaseModel):
"""A named dashboard record."""
id: str
label: str
slug: str
sort_order: int
payload: dict[str, Any]
created_at: int
updated_at: int
@@ -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).
@@ -0,0 +1,32 @@
# backend/src/media_library_viewer_api/routers (index)
dir: backend/src/media_library_viewer_api/routers
## role
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
## children
-
## files
- __init__.py
- authentik_users.py
- backups.py
- dashboard.py
- dashboards.py
- files.py
- jobs.py
- media.py
- monitoring.py
- services.py
- settings.py
- tasks.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, authentik_users.py, backups.py
## dirty
-
@@ -0,0 +1,39 @@
# backend/src/media_library_viewer_api/routers
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 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.
- 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 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
- 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 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:, raise:httpexception, service, backup, get, media_library_viewer_api, ssh, call:store.get
## symbols
- MessageRequest
- RunJobRequest
- MonitoringMachineInput
- SSHKeyInput
- SSHKeyGenerateInput
- ResetLocalDatabaseInput
- TaskInput
- RunTaskRequest
## workflows
- change routers behavior
read: __init__.py, authentik_users.py, backups.py
## dirty
-
@@ -0,0 +1,122 @@
"""Authentik directory + messaging router.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies paginated directory queries plus message-compose (email enqueue).
Graceful "not configured" / "unreachable" payloads (matching the monitoring
router's pattern) so the UI always renders.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient
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
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint."""
recipient_emails: list[str]
subject: str
html_body: str
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 "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
def _empty(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
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")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
@router.get("/{service_id}/message/status")
def get_authentik_message_status(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
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()
@router.post("/{service_id}/message")
def post_authentik_message(
service_id: str,
body: MessageRequest,
store: SettingsStore = Depends(get_settings_store),
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, "authentik", service_id)
if service is None:
return {"status": "error", "error": "Authentik service not configured"}
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings()
try:
validate_smtp_settings(settings)
except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=body.subject,
html_body=body.html_body,
)
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return {
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
router = APIRouter(prefix="/api/backups", tags=["backups"])
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
"""Return the service_id for backup attribution.
First-wins: if no explicit service_id is given, pick the first enabled
``backups`` service instance (spec R6.1). Returns an empty string when
none is configured (backward-compatible with pre-service reports).
"""
if explicit:
return explicit
candidates = store.list_services("backups")
for svc in candidates:
if svc.get("enabled"):
return svc["id"]
return ""
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
job = store.get_backup_job_by_name(report.name)
if not job:
job = store.upsert_backup_job(
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source,
"target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
}
)
elif report.schedule_interval_seconds:
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source,
"target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
}
)
job = store.get_backup_job(job["id"])
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
@router.post("/report")
def post_backup_report(
report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key),
) -> BackupRunResponse:
job = _get_or_create_job(store, report)
resolved_service_id = _resolve_backup_service_id(store, service_id)
job = _get_or_create_job(store, report, resolved_service_id)
# Check for duplicate (same job + started_at within 1s)
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
@@ -88,10 +108,12 @@ def post_backup_report(
@router.post("/report/start")
def post_backup_start(
report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key),
) -> BackupRunResponse:
job = _get_or_create_job(store, report)
resolved_service_id = _resolve_backup_service_id(store, service_id)
job = _get_or_create_job(store, report, resolved_service_id)
run_data = {
"job_id": job["id"],
@@ -0,0 +1,53 @@
"""Named dashboards CRUD router."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
@router.get("")
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
rows = store.list_dashboards()
return [NamedDashboard(**row) for row in rows]
@router.get("/slug/{slug}")
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
row = store.get_dashboard_by_slug(slug)
if not row:
raise HTTPException(status_code=404, detail="Dashboard not found")
return NamedDashboard(**row)
@router.post("")
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
row = store.upsert_dashboard(body.model_dump())
return NamedDashboard(**row)
@router.put("/{dashboard_id}")
def update_dashboard(
dashboard_id: str,
body: NamedDashboardInput,
store: SettingsStore = Depends(get_settings_store),
) -> NamedDashboard:
if not store.get_dashboard(dashboard_id):
raise HTTPException(status_code=404, detail="Dashboard not found")
if body.id and body.id != dashboard_id:
raise HTTPException(status_code=400, detail="ID mismatch")
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
return NamedDashboard(**row)
@router.delete("/{dashboard_id}")
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_dashboard(dashboard_id):
raise HTTPException(status_code=404, detail="Dashboard not found")
store.delete_dashboard(dashboard_id)
return {"status": "deleted"}
@@ -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,64 +1,56 @@
"""Monitoring router — observability stack status (Alertmanager + Prometheus)."""
"""Monitoring router — observability service status.
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.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
from fastapi import APIRouter, Body, Depends
from media_library_viewer_api.config import get_settings
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
logger = logging.getLogger(__name__)
def _alertmanager_client() -> Any:
"""Return a simple HTTP client for the configured Alertmanager URL."""
import requests
settings = get_settings()
return requests.Session(), settings.alertmanager_url
def _base_url(service: ServiceRecord) -> str:
return str(service.config.get("base_url") or "").rstrip("/")
def _webhook_client() -> Any:
"""Return a simple HTTP client for the optional webhook receiver URL."""
import requests
def _timeout(service: ServiceRecord, default: int) -> int:
return int(service.config.get("timeout_seconds") or default)
settings = get_settings()
return requests.Session(), settings.alertmanager_webhook_url
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
api_key = str(service.secrets.get("api_key") or "")
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
def _status_response(service: ServiceRecord | None, *, version: str = "", error: str | None = None) -> dict[str, Any]:
return {
"up": error is None,
"version": version or "",
"service_id": service.id if service else "",
"name": service.name if service else "",
"error": error,
}
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
by_severity: dict[str, int] = {}
open_alerts: list[dict[str, Any]] = []
for alert in alerts:
labels = alert.get("labels") or {}
annotations = alert.get("annotations") or {}
severity = labels.get("severity", "unknown")
by_severity[severity] = by_severity.get(severity, 0) + 1
open_alerts.append(
{
"name": labels.get("alertname", "unknown"),
"severity": severity,
"category": labels.get("category", ""),
"job_name": labels.get("job_name", labels.get("job", "")),
"summary": annotations.get("summary", ""),
"description": annotations.get("description", ""),
"active_since": alert.get("startsAt"),
"state": alert.get("status", "firing"),
"labels": labels,
}
)
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
return {
"total": len(open_alerts),
"by_severity": by_severity,
"alerts": open_alerts[:50],
}
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
return summarize_alerts(alerts)
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@@ -72,11 +64,9 @@ def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dic
@router.get("/prometheus-targets")
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return Prometheus file-SD targets for remote Node Exporters.
"""Return Prometheus scrape targets for remote Node Exporters.
The backend writes these targets to a JSON file that Prometheus reads via
file_sd_configs. This endpoint returns the same list live from the store so
the UI can preview which machines will be scraped.
External Prometheus instances consume this list via ``http_sd_configs``.
"""
targets = build_node_exporter_targets(store)
logger.info("Prometheus targets requested count=%s", len(targets))
@@ -84,47 +74,90 @@ def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -
@router.get("/alerts")
def get_alertmanager_alerts() -> dict[str, Any]:
def get_alertmanager_alerts(
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Return a summary of active Alertmanager alerts for the UI.
Proxies the Alertmanager `/api/v1/alerts` endpoint and reshapes the payload
into a stable, UI-friendly format. If Alertmanager is unreachable, the
endpoint returns an empty summary and logs the failure so the UI can still
render a health card instead of an error page.
Resolves an ``alertmanager`` service instance from the registry. When none
is configured the endpoint returns an empty summary with an
``alertmanager_not_configured`` error so the UI can render a health card.
"""
session, base_url = _alertmanager_client()
service = resolve_service_record(store, "alertmanager", service_id)
if service is None:
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
try:
response = session.get(f"{base_url}/api/v1/alerts", timeout=5)
response = requests.get(
f"{_base_url(service)}/api/v1/alerts",
headers=_auth_headers(service),
timeout=_timeout(service, 5),
)
response.raise_for_status()
data = response.json()
except Exception:
logger.exception("Failed to fetch Alertmanager alerts from %s", base_url)
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_unreachable"}
logger.exception("Failed to fetch Alertmanager alerts")
return {
"total": 0,
"by_severity": {},
"alerts": [],
"error": "alertmanager_unreachable",
"service_id": service.id,
"name": service.name,
}
if data.get("status") != "success":
return {"total": 0, "by_severity": {}, "alerts": [], "error": data.get("error", "unknown")}
return {
"total": 0,
"by_severity": {},
"alerts": [],
"error": data.get("error", "unknown"),
"service_id": service.id,
"name": service.name,
}
summary = _summary_from_alerts(data.get("data", []))
summary["service_id"] = service.id
summary["name"] = service.name
logger.info("Alertmanager alerts requested total=%s", summary["total"])
return summary
@router.get("/alertmanager-status")
def get_alertmanager_status() -> dict[str, Any]:
"""Return Alertmanager cluster/status for the UI health card.
Uses the Alertmanager `/api/v2/status` endpoint and exposes only the high-
level fields the UI needs: uptime, version, and whether the cluster is
healthy.
"""
session, base_url = _alertmanager_client()
def get_alertmanager_status(
service_id: str | None = None,
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)
if service is None:
return {
"up": False,
"version": "",
"uptime": "",
"name": "",
"peers": [],
"error": "alertmanager_not_configured",
}
try:
response = session.get(f"{base_url}/api/v2/status", timeout=5)
response = requests.get(
f"{_base_url(service)}/api/v2/status",
headers=_auth_headers(service),
timeout=_timeout(service, 5),
)
response.raise_for_status()
data = response.json()
except Exception:
logger.exception("Failed to fetch Alertmanager status from %s", base_url)
return {"up": False, "version": "", "uptime": ""}
logger.exception("Failed to fetch Alertmanager status")
return {
"up": False,
"version": "",
"uptime": "",
"name": service.name,
"peers": [],
"service_id": service.id,
"error": "alertmanager_unreachable",
}
cluster = data.get("cluster") or {}
status = data.get("clusterStatus") or {}
@@ -132,20 +165,45 @@ def get_alertmanager_status() -> dict[str, Any]:
"up": True,
"version": data.get("versionInfo", {}).get("version", ""),
"uptime": status.get("createdAt", ""),
"name": "",
"name": service.name,
"peers": [p.get("name", "") for p in cluster.get("peers", [])],
"service_id": service.id,
"error": None,
}
@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)
if service is None:
return _status_response(None, error="no_service_configured")
base = _base_url(service)
timeout = _timeout(service, 10)
headers = _auth_headers(service)
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")
return _status_response(service, error="prometheus_unreachable")
return _status_response(service, version=version)
@router.post("/alertmanager-webhook")
def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[str, str]:
"""Receive alerts from Alertmanager and optionally forward to a webhook URL.
"""Receive alerts from Alertmanager and log them for audit/debug.
This endpoint is the receiver referenced by the optional `webhook_configs`
block in Alertmanager. It logs the payload for audit/debug purposes and, if
`ALERTMANAGER_WEBHOOK_URL` is configured, forwards the alert JSON verbatim.
Forwarding is best-effort: a failure to reach the downstream webhook does
not fail this endpoint, so Alertmanager sees a successful delivery.
This endpoint is the receiver referenced by the optional ``webhook_configs``
block in Alertmanager. It is log-only: received payloads are recorded but not
forwarded anywhere. (The previous outbound relay to ``ALERTMANAGER_WEBHOOK_URL``
was removed when observability became service-registry configured.)
"""
alerts = payload.get("alerts", [])
logger.info(
@@ -153,16 +211,4 @@ def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[st
len(alerts),
payload.get("status", "unknown"),
)
session, webhook_url = _webhook_client()
if webhook_url:
try:
response = session.post(webhook_url, json=payload, timeout=10)
response.raise_for_status()
logger.info("Forwarded Alertmanager webhook to %s", webhook_url)
except Exception:
logger.exception("Failed to forward Alertmanager webhook to %s", webhook_url)
else:
logger.debug("No ALERTMANAGER_WEBHOOK_URL configured; webhook stored in logs only")
return {"status": "received"}
@@ -17,7 +17,6 @@ from media_library_viewer_api.services.db_maintenance import remove_sqlite_datab
from media_library_viewer_api.services.known_hosts import has_known_host
from media_library_viewer_api.services.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.targets import write_prometheus_targets
logger = logging.getLogger(__name__)
@@ -41,8 +40,6 @@ class MonitoringMachineInput(BaseModel):
ssh_private_key: str = ""
ssh_private_key_passphrase: str = ""
password: str = ""
media_root: str = ""
path_prefix: str = ""
notes: str = ""
@@ -121,14 +118,6 @@ def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: Settings
client.close()
def _write_prometheus_targets(store: SettingsStore) -> None:
"""Regenerate Prometheus file-SD targets after machine changes."""
try:
write_prometheus_targets(store)
except Exception:
logger.exception("Failed to write Prometheus file-SD targets")
@router.post("/machines/test-ssh")
def test_machine_ssh(
machine: MonitoringMachineInput,
@@ -189,7 +178,6 @@ def post_machine(
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
_write_prometheus_targets(store)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@@ -204,7 +192,6 @@ def put_machine(
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
_write_prometheus_targets(store)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@@ -215,7 +202,6 @@ def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
store.delete_machine(machine_id)
_write_prometheus_targets(store)
return {"status": "deleted"}
@@ -3,18 +3,15 @@
from __future__ import annotations
import logging
import shlex
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__)
@@ -27,7 +24,7 @@ class TaskInput(BaseModel):
task_type: str = Field(default="shell", description="shell or python")
content: str = Field(default="")
enabled: bool = True
default_machine_id: str = ""
default_service_id: str = ""
notes: str = ""
@@ -35,61 +32,31 @@ class RunTaskRequest(BaseModel):
task_id: str
def _machine_label(machine: dict[str, Any] | None) -> str:
if not machine:
def _service_label(service: dict[str, Any] | None) -> str:
if not service:
return ""
return str(machine.get("name") or machine.get("host") or machine.get("id") or "")
return str(service.get("name") or service.get("id") or "")
def _resolve_machine_for_task(
def _resolve_service_for_task(
store: SettingsStore,
task: dict[str, Any],
machine_id: str | None,
service_id: str | None,
) -> dict[str, Any] | None:
if machine_id:
return store.get_machine_config(machine_id) or store.get_machine(machine_id)
default_machine_id = str(task.get("default_machine_id") or "").strip()
if default_machine_id:
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id)
machines = [machine for machine in store.list_machines() if machine.get("enabled")]
return machines[0] if machines else None
if service_id:
return store.get_service(service_id)
default_service_id = str(task.get("default_service_id") or "").strip()
if default_service_id:
return store.get_service(default_service_id)
services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
return services[0] if services else None
def _client_for_machine(store: SettingsStore, machine: dict[str, Any]):
mode = str(machine.get("mode") or "local").lower()
if mode == "local":
return LocalCommandClient()
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
"""Build a ServiceRecord from a raw settings_store service row."""
from media_library_viewer_api.services.settings_store import get_settings_store
host = str(machine.get("host") or "").strip()
username = str(machine.get("username") or "").strip()
if not host or not username:
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
settings = get_settings()
private_key = str(machine.get("ssh_private_key") or "")
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
private_key = str(ssh_key.get("private_key") or private_key)
passphrase = str(ssh_key.get("passphrase") or passphrase)
key_filename = ""
if machine.get("key_directory") and machine.get("key_name"):
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
return RemoteSSHClient(
host=host,
username=username,
port=int(machine.get("port") or 22),
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=str(machine.get("password") or "") or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
return build_service_record(get_settings_store(), service_row)
@router.get("")
@@ -125,14 +92,14 @@ def list_task_runs(
) -> dict[str, Any]:
if not store.get_task(task_id):
raise HTTPException(status_code=404, detail="Task not found")
runs = store.list_task_runs(task_id, limit=limit)
runs = store.list_service_task_runs(task_id=task_id, limit=limit)
return {"items": runs, "total": len(runs)}
@router.post("/run")
def run_task(
request: RunTaskRequest,
machine_id: str | None = Query(default=None),
service_id: str | None = Query(default=None),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
task = store.get_task(request.task_id)
@@ -141,68 +108,22 @@ def run_task(
if not task.get("enabled", True):
raise HTTPException(status_code=400, detail="Task is disabled")
machine = _resolve_machine_for_task(store, task, machine_id)
if not machine:
raise HTTPException(status_code=400, detail="No machine is available for this action")
service_row = _resolve_service_for_task(store, task, service_id)
if not service_row:
raise HTTPException(status_code=400, detail="No SSH task service is available for this action")
if not service_row.get("enabled", True):
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
task_type = str(task.get("task_type") or "shell").lower()
command = str(task.get("content") or "")
if task_type == "python":
command = f"python3 -c {shlex.quote(command)}"
elif task_type != "shell":
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
service = _service_row_to_record(service_row)
result = run_saved_task(store, task, service)
client = _client_for_machine(store, machine)
start = time.perf_counter()
machine_name = _machine_label(machine)
try:
result = client.run(command, timeout=1200)
stdout = result.stdout or ""
stderr = result.stderr or ""
status_text = "success" if result.exit_status == 0 else "error"
store.record_task_run(
task,
status_text,
machine_id=str(machine.get("id") or ""),
machine_name=machine_name,
task_type=task_type,
duration_ms=int((time.perf_counter() - start) * 1000),
stdout_tail=stdout[-4000:],
stderr_tail=stderr[-4000:],
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
)
return {
"task_id": task["id"],
"task_name": task["name"],
"machine_id": str(machine.get("id") or ""),
"machine_name": machine_name,
"task_type": task_type,
"exit_status": result.exit_status,
"stdout": stdout,
"stderr": stderr,
}
except Exception as exc:
duration_ms = int((time.perf_counter() - start) * 1000)
error_text = str(exc)
store.record_task_run(
task,
"error",
machine_id=str(machine.get("id") or ""),
machine_name=machine_name,
task_type=task_type,
duration_ms=duration_ms,
stdout_tail="",
stderr_tail=error_text[-4000:],
error=error_text,
)
logger.exception("Task execution failed task_id=%s", task["id"])
return {
"task_id": task["id"],
"task_name": task["name"],
"machine_id": str(machine.get("id") or ""),
"machine_name": machine_name,
"task_type": task_type,
"exit_status": 1,
"stdout": "",
"stderr": error_text,
}
return {
"task_id": task["id"],
"task_name": task["name"],
"service_id": service.id,
"service_name": _service_label(service_row),
"task_type": task.get("task_type", "shell"),
"exit_status": result.exit_status,
"stdout": result.stdout,
"stderr": result.stderr,
}
@@ -1 +0,0 @@
from .users_impl import * # noqa: F401,F403
@@ -1,389 +0,0 @@
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
)
from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/users", tags=["users"])
_PERMISSION_FLAGS = [
(2, "admin"),
(4, "manage_settings"),
(8, "manage_users"),
(16, "manage_requests"),
(32, "request"),
(64, "vote"),
(128, "auto_approve"),
(256, "auto_approve_movie"),
(512, "auto_approve_tv"),
(1024, "request_4k"),
(2048, "request_4k_movie"),
(4096, "request_4k_tv"),
(8192, "request_advanced"),
(16384, "request_view"),
(32768, "auto_approve_4k"),
(65536, "auto_approve_4k_movie"),
(131072, "auto_approve_4k_tv"),
(262144, "request_movie"),
(524288, "request_tv"),
(1048576, "manage_issues"),
(2097152, "view_issues"),
]
_USER_TYPES = {
1: "plex",
2: "local",
3: "jellyfin",
4: "emby",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _permission_labels(permissions: int) -> list[str]:
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
return labels or ["none"]
def _role_label(permissions: int) -> str:
if permissions & 2:
return "admin"
if permissions & (4 | 8 | 16):
return "manager"
if permissions & (32 | 64 | 128):
return "requester"
return "user"
def _account_type(user_type: Any) -> str:
return _USER_TYPES.get(_safe_int(user_type), "unknown")
def _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]:
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _looks_like_email(value: Any) -> bool:
text = str(value or "").strip()
return bool(text and "@" in text and " " not in text)
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
if _looks_like_email(value):
return source, str(value).strip()
return "", ""
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
text = str(value or "").strip()
if text:
return source, text
return "", ""
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
return ", ".join(
[
f"name={name_source or 'none'}",
f"email={email_source or 'none'}",
f"avatar={avatar_source or 'none'}",
f"access={access_source or 'none'}",
]
)
def _lookup_keys(item: dict[str, Any]) -> list[str]:
return [
_normalize(item.get("id")),
_normalize(item.get("Id")),
_normalize(item.get("userId")),
_normalize(item.get("user_id")),
_normalize(item.get("jellyfinUserId")),
_normalize(item.get("jellyfin_user_id")),
_normalize(item.get("jellyfinUsername")),
_normalize(item.get("jellyfin_username")),
_normalize(item.get("username")),
_normalize(item.get("displayName")),
_normalize(item.get("display_name")),
]
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
for item in jellyseerr_jellyfin_users or []:
for key in (
item.get("id"),
item.get("Id"),
item.get("userId"),
item.get("user_id"),
item.get("jellyfinUserId"),
item.get("jellyfin_user_id"),
):
normalized = _normalize(key)
if normalized:
linked_by_jellyfin_id[normalized] = item
seerr_by_key: dict[str, dict[str, Any]] = {}
for item in jellyseerr_users or []:
for key in _lookup_keys(item):
if key:
seerr_by_key[key] = item
items: list[dict[str, Any]] = []
enriched_count = 0
for user in jellyfin_users:
jellyfin_id = str(user.get("Id") or user.get("id") or "")
jellyfin_name = str(user.get("Name") or user.get("name") or "")
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
seerr_user = None
for candidate in [
jellyfin_name,
(jf_link or {}).get("jellyfinUsername"),
(jf_link or {}).get("jellyfin_username"),
(jf_link or {}).get("username"),
(jf_link or {}).get("displayName"),
(jf_link or {}).get("display_name"),
]:
seerr_user = seerr_by_key.get(_normalize(candidate))
if seerr_user:
break
email_source, email = _pick_source_and_value(
[
("jellyseerr:user", (seerr_user or {}).get("email")),
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
]
)
avatar_source, avatar = _first_value(
[
("jellyseerr:user", (seerr_user or {}).get("avatar")),
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
]
)
if avatar and jellyseerr_client:
avatar = jellyseerr_client.absolute_url(avatar)
permissions = _safe_int((seerr_user or {}).get("permissions"))
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
role = _role_label(permissions)
access_source = "jellyseerr:user" if seerr_user else ""
name_source = "jellyfin"
summary = _source_summary(name_source, email_source, avatar_source, access_source)
if seerr_user or jf_link:
enriched_count += 1
items.append(
{
"jellyfin_id": jellyfin_id,
"username": jellyfin_name,
"display_name": jellyfin_name,
"email": email,
"email_source": email_source,
"avatar": avatar,
"avatar_source": avatar_source,
"contactable": bool(email),
"source": summary,
"source_summary": summary,
"name_source": name_source,
"access_source": access_source,
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
or None,
"jellyseerr_username": str(
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
),
"user_type": user_type or None,
"user_type_label": _account_type(user_type),
"role": role,
"permissions": permissions,
"permissions_label": ", ".join(_permission_labels(permissions)),
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
}
)
logger.info(
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
len(jellyfin_users),
len(jellyseerr_jellyfin_users or []),
len(jellyseerr_users or []),
enriched_count,
)
return {
"items": items,
"total": len(items),
"jellyseerr_configured": jellyseerr_client is not None,
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
"jellyseerr_user_count": len(jellyseerr_users or []),
"enriched_count": enriched_count,
}
@router.get("")
def get_users(
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
) -> dict[str, Any]:
"""Return the known users, enriched with Jellyseerr data when available."""
jellyfin_users = jellyfin.users()
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
jellyseerr_users: list[dict[str, Any]] | None = None
jellyseerr_error = ""
if jellyseerr:
try:
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
try:
jellyseerr_users = jellyseerr.users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr user list fetch failed")
jellyseerr_error = (
f"{jellyseerr_error}; " if jellyseerr_error else ""
) + f"Jellyseerr user list fetch failed: {exc}"
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
result["jellyseerr_error"] = jellyseerr_error
logger.info(
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
result["total"],
result["jellyseerr_configured"],
result["jellyseerr_available"],
result["enriched_count"],
bool(jellyseerr_error),
)
return result
@router.get("/message/status")
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
"""Return the current background email queue status."""
return mail_queue.status()
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
async def post_user_message(
recipient_ids: str = Form(...),
subject: str = Form(...),
html_body: str = Form(""),
text_body: str = Form(""),
attachments: list[UploadFile] | None = File(default=None),
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
mail_queue=Depends(get_mail_queue),
) -> dict[str, Any]:
"""Queue a single email to the selected users without blocking the API."""
try:
requested_ids = json.loads(recipient_ids)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
if not isinstance(requested_ids, list):
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
if not cleaned_ids:
raise HTTPException(status_code=400, detail="At least one recipient is required")
subject = subject.strip()
if not subject:
raise HTTPException(status_code=400, detail="Subject is required")
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
recipients: list[str] = []
recipient_labels: list[str] = []
skipped: list[dict[str, str]] = []
for user_id in cleaned_ids:
item = users_by_id.get(user_id)
if not item:
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
continue
email = str(item.get("email") or "").strip()
if not email:
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
continue
recipients.append(email)
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
if not recipients:
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
settings = get_settings()
validate_smtp_settings(settings)
queue_status = mail_queue.status()
if not queue_status["worker_running"]:
raise HTTPException(status_code=503, detail="Email queue worker is not running")
attachment_payloads: list[EmailAttachment] = []
for upload in attachments or []:
data = await upload.read()
if not data:
continue
attachment_payloads.append(
EmailAttachment(
filename=upload.filename or "attachment",
content_type=upload.content_type or "application/octet-stream",
data=data,
)
)
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=attachment_payloads,
)
from_address = (
str(getattr(settings, "smtp_from_address", "") or "").strip()
or str(getattr(settings, "smtp_username", "") or "").strip()
)
logger.info(
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
request_id,
subject,
len(recipients),
len(attachment_payloads),
len(skipped),
)
return {
"status": "queued",
"request_id": request_id,
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_payloads),
"subject": subject,
"recipient_labels": recipient_labels,
"skipped": skipped,
}
@@ -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__)
@@ -103,10 +113,18 @@ def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
@router.get("/instances")
def list_instances(
service_id: str | None = None,
scope: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""Return all persisted widget instances."""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
"""Return widget instances, optionally filtered.
- ``?service_id=X``: only widgets for service X.
- ``?scope=dashboard``: only widgets with NULL service_id.
- ``?scope=service``: only widgets with a non-null service_id.
- No params: all widgets (backward-compatible).
"""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
@@ -213,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
@@ -0,0 +1,36 @@
# backend/src/media_library_viewer_api/services (index)
dir: backend/src/media_library_viewer_api/services
## role
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
## children
-
## files
- __init__.py
- backup_alert_engine.py
- backup_poller.py
- db_maintenance.py
- known_hosts.py
- mail_queue.py
- mailer.py
- 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
## links
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
map: backend/src/media_library_viewer_api/services/.pi-map.md
## workflows
- change services behavior
read: __init__.py, backup_alert_engine.py, backup_poller.py
## dirty
-
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
@@ -8,6 +8,7 @@ in the same UI.
from __future__ import annotations
import json
import logging
import sqlite3
import time
import uuid
@@ -17,16 +18,16 @@ from typing import Any
import paramiko
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.models.widgets import _validate_config_keys
logger = logging.getLogger(__name__)
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files", "jellyfin"]
DEFAULT_SERVICES = ["monitoring", "files"]
def _default_local_machine() -> dict[str, Any]:
settings = get_settings()
return {
"id": LOCAL_MACHINE_ID,
"name": "This machine",
@@ -42,8 +43,6 @@ def _default_local_machine() -> dict[str, Any]:
"ssh_private_key": "",
"ssh_private_key_passphrase": "",
"password": "",
"media_root": settings.media_root,
"path_prefix": settings.path_prefix,
"node_exporter_enabled": False,
"node_exporter_port": 9100,
"node_exporter_scrape_host": "",
@@ -82,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(
@@ -113,7 +112,7 @@ class SettingsStore:
task_type TEXT NOT NULL,
content TEXT NOT NULL,
enabled INTEGER NOT NULL,
default_machine_id TEXT NOT NULL,
default_service_id TEXT NOT NULL,
notes TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
@@ -121,28 +120,14 @@ class SettingsStore:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS saved_task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
task_name TEXT NOT NULL,
machine_id TEXT NOT NULL,
machine_name TEXT NOT NULL,
task_type TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
request_id TEXT NOT NULL,
stdout_tail TEXT NOT NULL,
stderr_tail TEXT NOT NULL,
error TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
)
# saved_tasks.default_machine_id → default_service_id (saved tasks now
# target ssh_tasks service instances). Migrate existing columns.
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
# Run history for saved tasks now lives in service_task_runs; the
# legacy machine-based table is dropped.
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
@@ -180,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,
@@ -190,6 +188,9 @@ class SettingsStore:
created_at INTEGER NOT NULL
)
""")
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
if "service_id" not in backup_job_cols:
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_runs (
id TEXT PRIMARY KEY,
@@ -262,6 +263,19 @@ class SettingsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS named_dashboards (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
@staticmethod
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
@@ -299,8 +313,6 @@ class SettingsStore:
"ssh_private_key_set": bool(data.get("ssh_private_key")),
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
"password_set": bool(data.get("password")),
"media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -348,8 +360,6 @@ class SettingsStore:
if password in (None, ""):
password = (current or {}).get("password", "")
password = str(password or "")
media_root = _current_str("media_root")
path_prefix = _current_str("path_prefix")
node_exporter_enabled = bool(
payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None
@@ -379,8 +389,6 @@ class SettingsStore:
"ssh_private_key": ssh_private_key,
"ssh_private_key_passphrase": ssh_private_key_passphrase,
"password": password,
"media_root": media_root,
"path_prefix": path_prefix,
"node_exporter_enabled": node_exporter_enabled,
"node_exporter_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host,
@@ -402,8 +410,6 @@ class SettingsStore:
"ssh_private_key": "",
"ssh_private_key_passphrase": "",
"password": "",
"media_root": machine["media_root"],
"path_prefix": machine["path_prefix"],
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -441,6 +447,75 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._migrate_jellyseerr_into_jellyfin()
def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
paired -> drop with a logged warning.
"""
from media_library_viewer_api.services.secrets import decrypt_value
self.init_schema()
jellyseerr_rows: list[sqlite3.Row] = []
with self.connect() as conn:
jellyseerr_rows = conn.execute(
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
).fetchall()
if not jellyseerr_rows:
return
jellyfin_rows = self.list_services("jellyfin")
for js_row in jellyseerr_rows:
js_config = json.loads(js_row["config_json"] or "{}")
js_secrets = json.loads(js_row["secrets_json"] or "{}")
js_url = str(js_config.get("base_url", "")).strip()
js_api_key = str(js_secrets.get("api_key", "")).strip()
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
if js_api_key:
try:
js_api_key = decrypt_value(js_api_key)
except Exception:
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
js_api_key = ""
js_name = js_row["name"]
target = None
if len(jellyfin_rows) == 1:
target = jellyfin_rows[0]
elif len(jellyfin_rows) > 1:
for jf in jellyfin_rows:
if not str(jf["config"].get("jellyseerr_url", "")).strip():
target = jf
break
if target:
merged_config = dict(target["config"])
merged_config["jellyseerr_url"] = js_url
merged_config["jellyseerr_api_key"] = js_api_key
self.upsert_service(
{
"id": target["id"],
"service_type": "jellyfin",
"name": target["name"],
"config": merged_config,
"enabled": target["enabled"],
},
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
)
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
else:
logger.warning(
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
js_name,
)
with self.connect() as conn:
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
conn.commit()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
@@ -487,8 +562,6 @@ class SettingsStore:
"ssh_private_key": data.get("ssh_private_key", ""),
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
"password": data.get("password", ""),
"media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -526,8 +599,6 @@ class SettingsStore:
"ssh_private_key": machine["ssh_private_key"],
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
"password": machine["password"],
"media_root": machine["media_root"],
"path_prefix": machine["path_prefix"],
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -714,7 +785,7 @@ class SettingsStore:
"task_type": row["task_type"],
"content": row["content"],
"enabled": bool(row["enabled"]),
"default_machine_id": row["default_machine_id"],
"default_service_id": row["default_service_id"],
"notes": row["notes"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
@@ -731,10 +802,10 @@ class SettingsStore:
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
)
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
default_machine_id = str(
payload.get("default_machine_id")
if payload.get("default_machine_id") is not None
else (current or {}).get("default_machine_id", "") or ""
default_service_id = str(
payload.get("default_service_id")
if payload.get("default_service_id") is not None
else (current or {}).get("default_service_id", "") or ""
).strip()
notes = str(
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
@@ -745,7 +816,7 @@ class SettingsStore:
"task_type": task_type,
"content": content,
"enabled": enabled,
"default_machine_id": default_machine_id,
"default_service_id": default_service_id,
"notes": notes,
}
@@ -773,7 +844,7 @@ class SettingsStore:
conn.execute(
"""
INSERT INTO saved_tasks (
id, name, task_type, content, enabled, default_machine_id,
id, name, task_type, content, enabled, default_service_id,
notes, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -782,7 +853,7 @@ class SettingsStore:
task_type = excluded.task_type,
content = excluded.content,
enabled = excluded.enabled,
default_machine_id = excluded.default_machine_id,
default_service_id = excluded.default_service_id,
notes = excluded.notes,
updated_at = excluded.updated_at
""",
@@ -792,7 +863,7 @@ class SettingsStore:
task["task_type"],
task["content"],
1 if task["enabled"] else 0,
task["default_machine_id"],
task["default_service_id"],
task["notes"],
created_at,
now,
@@ -805,58 +876,6 @@ class SettingsStore:
with self.connect() as conn:
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
(task_id, max(1, min(int(limit), 50))),
).fetchall()
return [dict(row) for row in rows]
def record_task_run(
self,
task: dict[str, Any],
status: str,
*,
machine_id: str,
machine_name: str,
task_type: str,
duration_ms: int,
request_id: str = "",
stdout_tail: str = "",
stderr_tail: str = "",
error: str = "",
) -> None:
self.init_schema()
now = int(time.time())
with self.connect() as conn:
conn.execute(
"""
INSERT INTO saved_task_runs (
id, task_id, task_name, machine_id, machine_name, task_type,
status, created_at, duration_ms, request_id, stdout_tail,
stderr_tail, error
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
uuid.uuid4().hex,
str(task.get("id") or ""),
str(task.get("name") or ""),
machine_id,
machine_name,
task_type,
status,
now,
duration_ms,
request_id,
stdout_tail,
stderr_tail,
error,
),
)
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
target = json.loads(row["target_json"] or "{}")
return {
@@ -974,6 +993,7 @@ class SettingsStore:
"source": row["source"],
"target": row["target"],
"schedule_interval_seconds": row["schedule_interval_seconds"],
"service_id": row["service_id"],
"created_at": row["created_at"],
}
@@ -992,12 +1012,18 @@ class SettingsStore:
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
if schedule_interval_seconds is not None:
schedule_interval_seconds = int(schedule_interval_seconds)
service_id = str(
payload.get("service_id")
if payload.get("service_id") is not None
else (current or {}).get("service_id", "") or ""
).strip()
return {
"id": job_id,
"name": name,
"source": source,
"target": target,
"schedule_interval_seconds": schedule_interval_seconds,
"service_id": service_id,
}
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
@@ -1015,17 +1041,19 @@ class SettingsStore:
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
source = excluded.source,
target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds
schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
ON CONFLICT(name) DO UPDATE SET
source = excluded.source,
target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds
schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
""",
(
job["id"],
@@ -1033,6 +1061,7 @@ class SettingsStore:
job["source"],
job["target"],
job["schedule_interval_seconds"],
job["service_id"],
created_at,
),
)
@@ -1387,10 +1416,36 @@ class SettingsStore:
"sort_order": sort_order,
}
def list_widgets(self) -> list[dict[str, Any]]:
def list_widgets(
self,
service_id: str | None = None,
*,
scope: str | None = None,
all_widgets: bool = True,
) -> list[dict[str, Any]]:
"""List widget instances, optionally filtered.
- ``service_id=X``: only widgets for service X.
- ``scope="dashboard"``: only widgets with NULL service_id.
- ``scope="service"``: only widgets with a non-null service_id.
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
"""
self.init_schema()
clauses: list[str] = []
params: list[Any] = []
if service_id is not None:
clauses.append("service_id = ?")
params.append(service_id)
if scope == "dashboard":
clauses.append("service_id IS NULL")
elif scope == "service":
clauses.append("service_id IS NOT NULL")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as conn:
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
rows = conn.execute(
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
params,
).fetchall()
return [self._row_to_widget(row) for row in rows]
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
@@ -1449,6 +1504,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
@@ -1568,7 +1740,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
@@ -1581,6 +1753,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()
@@ -1648,6 +1829,113 @@ class SettingsStore:
for row in rows
]
# ------------------------------------------------------------------
# Named dashboards
# ------------------------------------------------------------------
@staticmethod
def _slugify(label: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
return slug or "dashboard"
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
self.init_schema()
base = slug
suffix = 1
with self.connect() as conn:
while True:
row = conn.execute(
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
(slug, exclude_id or ""),
).fetchone()
if not row:
return slug
suffix += 1
slug = f"{base}-{suffix}"
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"label": row["label"],
"slug": row["slug"],
"sort_order": row["sort_order"],
"payload": json.loads(row["payload_json"] or "{}"),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def list_dashboards(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
).fetchall()
return [self._row_to_dashboard(row) for row in rows]
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
if not dashboard_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
return self._row_to_dashboard(row) if row else None
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
if not slug:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
return self._row_to_dashboard(row) if row else None
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
self.init_schema()
current = self.get_dashboard(dashboard_id) if dashboard_id else None
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
slug = self._unique_slug(slug, exclude_id=dash_id)
sort_order = payload.get("sort_order")
if sort_order is None:
sort_order = (current or {}).get("sort_order", 0)
sort_order = int(sort_order)
payload_data = payload.get("payload")
if payload_data is None:
payload_data = (current or {}).get("payload", {})
now = int(time.time())
with self.connect() as conn:
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
label = excluded.label,
slug = excluded.slug,
sort_order = excluded.sort_order,
payload_json = excluded.payload_json,
updated_at = excluded.updated_at
""",
(
dash_id,
label,
slug,
sort_order,
json.dumps(payload_data),
created_at,
now,
),
)
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
def delete_dashboard(self, dashboard_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
_store: SettingsStore | None = None

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