Compare commits

..

72 Commits

Author SHA1 Message Date
alex 96e6177d86 docs: refresh README 2026-07-27 15:24:01 +02:00
Developer 0866dc4136 feat(qbittorrent): show share ratio for active torrents 2026-07-21 12:36:03 +00:00
Developer 11f093cd2c fix(qbittorrent): preserve torrent metadata in incremental updates 2026-07-21 12:27:04 +00:00
Developer 1bf8a34a97 fix(charting): remove duplicate range selector 2026-07-15 19:15:06 +00:00
Developer e0f66a51f7 feat(charting): unify configurable time windows 2026-07-15 18:55:57 +00:00
Developer 3871f24724 fix: show only live torrent transfers 2026-07-14 21:46:20 +00:00
Developer 17976eab80 feat: add Authentik access widgets 2026-07-14 21:41:24 +00:00
Developer 4562a9dfca feat: add navigation for remote machines 2026-07-14 21:06:25 +00:00
Developer 37533dd219 refactor: unify SSH machines as services 2026-07-14 20:58:46 +00:00
Developer fe90feb1b7 fix: use saved SSH keys for task runners 2026-07-14 17:20:27 +00:00
Developer 230b4b8533 fix: include all active torrent states 2026-07-14 17:20:27 +00:00
Developer 03aece02b8 feat: unify chart range controls 2026-07-14 17:00:32 +00:00
Developer a541a4fd16 fix: show active qBittorrent transfers 2026-07-14 16:07:04 +00:00
Developer 70511d97f9 refactor: move service administration into settings 2026-07-14 15:47:54 +00:00
Developer a9488af0b4 feat: add typed qBittorrent scheduled polling 2026-07-14 15:22:34 +00:00
Developer eac9b5d33d fix(jellyseer): add externalServiceSlug as title fallback 2026-07-13 10:26:48 +00:00
Developer 70f4e5b6e1 diag(jellyseer): log first request shape to verify tmdbId field 2026-07-13 10:09:14 +00:00
Developer 45c295457a fix(jellyseer): resolve request titles via /movie|tv endpoints
The requests table showed all names as "—" because Jellyseerr's /api/v1/request
list does NOT embed titles — they live on the Movie/Series records. Added
JellyseerrClient._resolve_title(media_type, tmdb_id) that fetches
/api/v1/movie/{tmdbId} (→ title) or /api/v1/tv/{tmdbId} (→ name), cached on
the client instance so subsequent polls are instant.

Also scoped the table fetch to open requests only (pending + approved) via
Jellyseerr's filter param, instead of fetching all 800+ historical requests.
open_requests() fetches pending+approved (paginated), resolves their titles
(small set → fast), and returns them sorted by date added desc.

Updated the frontend table's status filter to Open/Pending/Approved (the data
only contains open requests now).

Tests: title resolution end-to-end (movie tmdbId → title), caching across
polls, filter param used. 404/404 backend + 184/184 frontend + build green.
2026-07-13 09:58:25 +00:00
Developer 7665ef4d10 feat(jellyseer): sortable/filterable requests table on the Requests tab
Replace the static "recent requests" list with a proper table of all Jellyseerr
requests, sorted by date added (newest first by default) with standard sorting
and filtering.

Backend:
- JellyseerrClient.requests(max_count=500): paginated GET /api/v1/request
  (sort=added), mapped with type (movie/tv), status, media_status, and
  created_at labels. Returns up to 500 so the table can sort/filter client-side.
- fetch_jellyseer_requests(service) reuses the per-service cached client
  (shared with the stats widgets).
- new GET /api/jellyseerr/requests endpoint.

Frontend:
- JellyseerRequestsTable: TanStack Table (sorting via getSortedRowModel,
  pagination via getPaginationRowModel) reusing the Table primitives +
  TablePagination. Columns: Name / Type / Status / Media / Requested, all
  sortable; default sort Requested desc. A search box filters by name and a
  status dropdown defaults to "Open" (pending+approved+processing) with
  All/Pending/Approved/Declined options. (The shared DataTable is deliberately
  visibility-only, so this is a dedicated sortable table.)
- RequestsTab renders the stats grid + the new table (the compact recent list
  stays on the Requests overview widget).
- useJellyseerRequests hook + fetchJellyseerRequests API client.

Tests: client requests() mapping + single-page stop; fetch helper not-configured;
RequestsTab test mocks both hooks. 404/404 backend + 184/184 frontend pass;
build (tsc -b && vite build) + ESLint clean.
2026-07-12 17:18:21 +00:00
Developer 54851779fb fix(build): JellyseerStatsResponse export/import spelling + test mock type
The frontend production build (tsc -b) was failing, which blocked deployment:

- api/jellyseerr.ts exported `JellyseerrStatsResponse` (double-r) while every
  import used `JellyseerStatsResponse` (single-r) — a mismatch TS reported as
  "no exported member" (with a misleading identical-name suggestion). The
  sibling types (JellyseerStat, JellyseerRecentRequest) are single-r, so
  align the export to single-r. (tsc --noEmit missed it because the root
  tsconfig is solution-style; tsc -b builds the app project and catches it.)
- RequestsTab.test.tsx's useJellyseerrStats mock returned a partial object
  that didn't satisfy UseQueryResult's full shape; cast via a typed helper.

`npm run build` (tsc -b && vite build) now succeeds; 184/184 tests + ESLint clean.
2026-07-12 16:30:59 +00:00
Developer e757f4ba21 fix(services): test connection merges stored secrets for blank fields
The credential tester (POST /api/services/test) used only body.secrets — the
values typed in the form. When editing an existing service the secret fields
are masked and intentionally left blank ("leave blank to keep current"), so the
test ran with empty credentials and failed auth even though the stored secret
was valid.

When body.id is set, look up the stored service, decrypt its secrets, and fall
back to the stored value for any known secret key that is absent or blank in
the input. The test still uses the freshly-typed config (so you can test an
edited URL) but authenticates with the effective credentials. New-service tests
(no id) are unchanged.

Test: editing a service and testing with empty secrets now authenticates with
the stored secret (asserts the stored key reaches the upstream request).
402/402 backend pass; ruff clean.
2026-07-12 15:55:10 +00:00
Developer 0b039529f6 feat(jellyseer): stat widgets + rich Requests tab (slice 3/3)
Frontend for Jellyseerr request stats, reusing the generic stat abstraction.

- api/jellyseerr.ts + hooks/useJellyseer.ts: fetchJellyseerrStats +
  useJellyseerrStats (polls /api/jellyseerr/stats, no-retry; shares the backend
  cache with the widgets).
- Two reusable widgets backed by the stat/stats_overview kinds:
  - RequestStatWidget: a single selected stat (big value + label).
  - RequestsOverviewWidget: a MetricCard grid of all stats + a recent-requests
    list with status/media-status badges.
- registry.ts: Jellyfin gains `stat` (a dropdown over
  total/pending/approved/declined/processing/available — the "extract one stat
  into a widget" affordance, rendered as a Select via the existing enum UI) and
  `stats_overview` widget kinds, wired to the new components.
- RequestsTab rewritten: live stats grid (6 counts) + recent-requests list +
  a hint to pin individual stats via the Request stat widget. Reads
  jellyseerr_url from config and the now-secret jellyseerr_api_key from
  secrets_set.

Tests: RequestStatWidget + RequestsOverviewWidget rendering/error; RequestsTab
not-configured CTA, configured stats grid, and error states. 184/184 frontend
tests pass; tsc + ESLint clean.
2026-07-12 13:59:08 +00:00
Developer e25240c2f3 feat(jellyseer): move jellyseerr_api_key to an encrypted secret (slice 2/3)
The Jellyseerr API key was stored as plaintext in the Jellyfin service config.
It is now a SecretField on the Jellyfin service, so it is encrypted at rest and
rendered as a masked secret input (the generic config editor stops exposing
it, and the secret editor picks it up automatically).

Migration (idempotent, runs in ensure_defaults):
- _migrate_jellyseerr_api_key_to_secret: for every Jellyfin service with a
  plaintext jellyseerr_api_key still in config, encrypt it ONCE into the
  secrets blob (direct UPDATE so existing encrypted secrets are preserved, not
  re-encrypted) and remove it from config.
- _migrate_jellyseerr_into_jellyfin: standalone-jellyseerr absorption now
  stores the key as a secret, and decrypts the Jellyfin api_key before handing
  it to upsert_service (fixes a pre-existing double-encrypt on that rare path).

The stats provider already reads jellyseerr_api_key from secrets-or-config, so
it works before, during, and after the migration.

Tests: absorbed-key lands in secrets (and existing api_key isn't corrupted);
new plaintext-config -> secret migration + idempotency. 401/401 backend pass.
2026-07-12 13:40:56 +00:00
Developer b8cb29e330 feat(jellyseer): stats backend — provider, stat widgets, router (slice 1/3)
Groundwork for Jellyseerr request stats in the Jellyfin service, behind a
small reusable abstraction so future stats services (Sonarr/Radarr) reuse it.

Backend:
- JellyseerrClient.request_count() -> /api/v1/request/count (normalized
  total/pending/approved/declined/processing/available) and recent_requests()
  -> /api/v1/request mapped to {name,type,status,media_status,created_at}
  with numeric status enums labelled.
- widgets/stats_provider.py: StatsProvider protocol + registry keyed by
  service_type (StatValue/StatsResult). A thin generic interface.
- widgets/jellyseerr_stats.py: JellyseerrStatsProvider registered for the
  Jellyfin service; reuses one authenticated client per service (lru_cache) and
  caches the StatsResult for ~10s under a lock, so multiple widgets + the tab
  collapse onto one Jellyseerr fetch (same lesson as the qBittorrent client).
  Accepts jellyseerr_api_key from secrets OR config during the upcoming
  config->secret migration.
- Jellyfin service gains two widget kinds: `stat` (a Literal selector over the
  six stats — the "extract one value into a widget" affordance) and
  `stats_overview` (all stats + recent list).
- widgets router routes widget_kind in {stat, stats_overview} to a generic
  StatsWidgetSource (dispatches to the service type's provider), independent of
  service type.
- new /api/jellyseerr/stats router endpoint for the Requests tab (resolves the
  Jellyfin service by id or first-enabled; shares the provider cache).

Tests: provider normalization, not-configured, TTL caching; stat selector +
overview + unknown-stat widget dispatch; 7 new tests. 400/400 backend pass;
ruff clean.
2026-07-12 13:10:09 +00:00
Developer ba01ad7c0c perf(qbittorrent): rid incremental sync + shared cache + backoff (stop hanging qBittorrent)
The app was saturating qBittorrent's single-threaded web server and causing
its own Web UI (and the reverse proxy) to hang/504: each of the 3 qBittorrent
widgets fetched /sync/maindata independently, every call was a FULL snapshot
(no rid), and polling was aggressive (5s for speed). For large torrent lists
each snapshot is heavy, so the server queued and Traefik timed out.

QbittorrentClient.maindata now:
- Uses the incremental rid protocol: the first call is a full_update;
  subsequent calls send the last rid and get a small diff that is merged into
  a cached snapshot (full_update replaces; partial_update merges server_state,
  torrents {added/None-removed/..._removed}, categories, tags, trackers).
  Payloads shrink dramatically for large libraries.
- Serves a short-TTL (3s) cached snapshot under a lock, so concurrent widget
  polls collapse onto a single HTTP fetch instead of N.
- Backs off exponentially (capped 30s) on repeated failure, serving the last
  good snapshot when available, so a struggling qBittorrent isn't hammered
  further. Returns a shallow race-safe copy of the snapshot per call.

Also slow the speed widget poll from 5s -> 15s (backend widget-kind +
frontend registry) for ~3x fewer calls.

Tests: rid full+partial merge, cache collapses within-TTL calls, backoff
skips the network after failure and serves stale. 393/393 backend + 180/180
frontend tests pass; ruff + tsc + ESLint clean.
2026-07-12 12:20:05 +00:00
Developer 7e4222ef00 fix(widgets): expose unit/scale options in the frontend widget registry
The config dialog reads each widget kind's schema from the static frontend
SERVICE_REGISTRY (registry.ts), not the backend pydantic schema. The previous
commit added unit/scale to the backend configs but not to the frontend mirror,
so the options never appeared in the dialog — the Prometheus "chart" binding
still listed only promql/window and the qBittorrent "speed" binding had an
empty configSchema.

Add a shared AXIS_FORMAT_PROPERTIES fragment (unit + scale enums) and spread
it into the prometheus chart and qbittorrent speed bindings, with matching
defaultConfig (chart: none/auto; speed: bytes_per_sec/auto). Combined with the
enum <Select> rendering already added to WidgetConfigDialog, the options now
show up as dropdowns when editing those widgets.

Test: registry exposes unit/scale enums on chart + speed; speed defaults to
bytes_per_sec. 180/180 frontend tests pass; tsc + ESLint clean.
2026-07-12 12:00:11 +00:00
Developer b7019b33ac feat(widgets): scale chart axes/tooltips with unit + scale options
Consistent graph scaling across every line-chart widget. A new shared
frontend/src/lib/metricFormat.ts picks a decimal prefix (kB/MB/GB, kbps/Mbps,
Gbps, …) from the series magnitude and formats values; LineSeriesChart accepts
unit + scale and formats both the Y-axis ticks and the tooltip with the SAME
prefix (one consistent unit per axis). MetricChartWidget (Prometheus) and
QbittorrentSpeedWidget pass the widget config through; qBit speed defaults to
bytes/sec → MB/s.

WidgetConfigDialog now renders `enum` schema fields as a <Select> dropdown, so
the backend's unit/scale Literal enums become consistent pickers in every graph
widget's config (and any future enum option).

Decimal (x1000) prefixes by default (matches Mbps/MB/s/Grafana).

Tests: 13 new metricFormat tests (auto/fixed scaling, percent, seconds,
nulls, trailing-zero trimming). 179/179 frontend tests pass; tsc + ESLint clean.
2026-07-12 11:46:07 +00:00
Developer 05a9faca3e feat(widgets): add unit/scale config to chart widget kinds
Graph widgets need consistent value scaling (kB/MB/GB, kbps/Mbps, …). Add
shared `unit` (none/bytes/bytes_per_sec/bits_per_sec/bits/percent/seconds) and
`scale` (auto/k/m/g/t) enum fields to:

- PrometheusChartWidgetConfig (alongside promql/window)
- new QbittorrentSpeedWidgetConfig — the speed widget previously had NO config
  options at all; totals/active keep their empty config.

Declared as Pydantic Literal enums so the widget-kind JSON schema exposes
`enum`, which the frontend config dialog renders as a dropdown. The data
sources are unchanged (raw values); scaling is a display concern handled
client-side. qBittorrent speed defaults to bytes/sec.

Test: chart widget kinds expose the shared unit/scale enums; speed defaults to
bytes_per_sec; totals/active stay option-less. 389/389 backend tests pass.
2026-07-12 11:45:25 +00:00
Developer 39775a82ef fix(qbittorrent): recognize QBT_SID session cookie (newer qBittorrent)
Newer qBittorrent renamed its session cookie from "SID" to "QBT_SID" /
"QBT_SID_<port>" (the diagnostic revealed cookies=['QBT_SID_5080']). The
client only accepted "SID", so a valid login (cookie present in the jar) was
reported as "Unexpected response". Re-entering correct credentials never
helped because login was succeeding all along.

Treat any cookie named "SID" OR starting with "QBT_SID" as the session
cookie, checked in both the parsed jar and the raw Set-Cookie header.
qBittorrent only sets this cookie on a valid login, so it stays authoritative.
Diagnostic message updated to mention both names.

New regression test covers the QBT_SID_<port> case. 388/388 backend tests
pass; ruff clean.
2026-07-12 11:09:10 +00:00
Developer 7e53edfcc6 fix(qbittorrent): recognize SID cookie from raw Set-Cookie header on login
qBittorrent (or its reverse proxy) was returning 204 No Content with an SID
cookie and no body on a successful login, but the client only treated the
response as success if body == "Ok." or the cookie was in the parsed
requests cookie jar (resp.cookies.get("SID")). In the reported case the
Set-Cookie header was present (set-cookie=yes) yet the jar was empty —
requests doesn't always populate the jar from such headers (proxy-set/oddly-
attributed cookies) — so a valid login was reported as "Unexpected response".
The user re-entering correct credentials never helped.

Detect a successful login from EITHER the parsed jar OR the raw Set-Cookie
header (cookie name == SID). qBittorrent only sets SID on a valid login, so
this remains authoritative. The diagnostic now lists the cookie names it saw
for future-proofing.

New regression test reproduces the exact 204 + Set-Cookie SID + empty jar
case. 387/387 backend tests pass; ruff clean.
2026-07-12 10:52:31 +00:00
Developer b9a79b85d1 fix(qbittorrent): show set-cookie presence in login diagnostic
When the login endpoint returns an unexpected response (e.g. a 204 No Content
with no body), the diagnostic now reports whether a Set-Cookie header was
present. That single fact tells us whether qBittorrent attempted to establish
a session at all — distinguishing "qBittorrent answered weirdly" from
"something in the proxy path answered before qBittorrent" (e.g. a 204 from a
misrouted reverse proxy), which is the key clue when diagnosing login failures
behind a proxy.

42/42 qBittorrent + credential-tester tests pass; ruff clean.
2026-07-12 10:35:30 +00:00
Developer 6d46de26c4 fix(qbittorrent): stop mislabeling gateway/URL errors as auth failures
The credential tester always reported "Authentication failed — qBittorrent
rejected the credentials" for the qBittorrent service, even when credentials
were correct. test_connection classified any RuntimeError whose message
contained "login failed" as an auth failure — and the gateway-timeout error
(502/503/504 from the reverse proxy) and the wrong-URL diagnostic both started
with "qBittorrent login failed:", so a proxy timeout was reported as a
credentials rejection. That sent users down the wrong path (re-entering correct
passwords to fix a 504).

- QbittorrentClient._login: gateway and URL/routing errors no longer contain
  "login failed"; only a genuine "Fails." body carries the
  "invalid username or password" signal.
- integrations/qbittorrent.test_connection: key the auth message off
  "invalid username or password" specifically; all other login errors flow
  through translate_connection_error so the real reason (proxy timeout, wrong
  URL, empty body) is surfaced.

After this, a failing test reports the actual cause (e.g. "qBittorrent is
unreachable: reverse proxy returned HTTP 504 ...") instead of accusing the
credentials. New regression test asserts a gateway error is NOT reported as
"Authentication failed". 386/386 backend tests pass; ruff clean.
2026-07-11 13:07:08 +00:00
Developer 50c0c9b548 fix(gauge): render single value arc with correct Tailwind v4 colors
The gauge rendered as multiple black rings. Two causes:

1. recharts RadialBarChart draws each data entry as a CONCENTRIC RING, not an
   arc segment, so the 3 "track band" entries + value produced 4 nested rings.
   Render a single value arc over a neutral background track instead, colored
   by status, with the readout absolutely centered (replacing the -mt-12 hack).

2. The fills used hsl(var(--primary)) / hsl(var(--chart-1)) etc., but this
   project's Tailwind v4 theme (index.css) defines colors as --color-* holding
   full hex values (--color-primary: #4f8cff). So the references were doubly
   invalid (wrong name + hsl() wrapping a hex) -> invalid SVG fill defaults to
   black. Use var(--color-*) directly, with the semantically correct chart
   colors: ok=--color-chart-2 (green), warn=--color-chart-3 (amber),
   crit=--color-chart-4 (red).

Also fix the same hsl(var(--x)) -> var(--color-x) bug in LineSeriesChart's
tooltip contentStyle (popover/border/popover-foreground). The line stroke
palette already used the correct var(--color-chart-N) form.

166/166 frontend tests pass; typecheck + ESLint clean.
2026-07-11 13:05:42 +00:00
Developer b011d2421b fix(qbittorrent): reuse authenticated client across widget fetches
QbittorrentWidgetSource built a brand-new QbittorrentClient on every fetch,
logging in each time. With three qBittorrent widgets polling every 5-30s and
qBittorrent verifying passwords with slow PBKDF2 hashing, the concurrent login
load saturates its web thread pool and the reverse proxy returns 504 gateway
timeouts on /api/v2/auth/login. The client was already designed for reuse
(login once, SID cookie reuse, 403 re-login) — the source just wasn't using it.

Cache one QbittorrentClient per service (lru_cache keyed by service id, URL,
credentials, timeout) so the SID cookie persists across fetches and login
happens once. Mirrors dependencies._jellyfin_client_for. A credentials/URL
change produces a new cache key, so stale clients aren't reused after
reconfiguration.

Also surface 502/503/504 from the login as a clear "reverse proxy returned
HTTP <code> ... qBittorrent may be down/starting/overloaded" RuntimeError
instead of a bare HTTPError, so future gateway issues read as infrastructure,
not auth.

Tests: autouse fixture clears the client cache between tests; new gateway-error
login test. 385/385 backend tests pass; ruff clean.
2026-07-11 12:19:19 +00:00
Developer dad2202756 fix: reset service editor on switch + add service-page settings shortcut
Settings.tsx: ServiceConfigEditor derived editable state (name, config,
secrets) from the instance prop via useState, but the parent rendered it
without a key. Switching services in the rail reused the same component, so
name/config stayed pinned to the previously selected service while
instance.id/service_type (read live from props) pointed at the new one —
saving then wrote the stale values onto the wrong row (e.g. saving qBittorrent
renamed it "Jellyfin" with Jellyfin's URL). Add key={selectedService.id} so
the editor remounts and resets on switch.

ServicePage: add a Settings shortcut in the header that deep-links to
/settings?tab=services&service=<id>. Settings now reads tab + service query
params (useSearchParams) to open the Services tab with that service
pre-selected, via a new initialServiceId prop on ServicesAdminCard.

Tests: new Settings.services.test.tsx regression test (fails without the key,
passes with it); wrap existing Settings tests in MemoryRouter since Settings
now uses useSearchParams. 166/166 frontend tests pass; typecheck + ESLint clean.
2026-07-11 11:54:18 +00:00
Developer 84dcf9e010 fix: resolve Jellyfin usernames to internal Id and harden qBittorrent login
Jellyfin: get_user_id() returned the configured user_id verbatim, so a
username like "admin" hit /Users/admin/Views and got HTTP 400 ("The value
'admin' is not valid."). The index worker already had username->Id
resolution, but the live API paths (dashboard counts, media query) did not.
Route all user-scoped paths through the new JellyfinClient.resolve_user_id()
(exact Id match -> Name match -> first user), cached per service/credentials
in get_user_id() so repeated requests don't re-list users. The worker is
simplified to call the same method.

qBittorrent: _login() raised "qBittorrent login failed: " (empty) on a 200
with an empty body, which happens when base_url doesn't reach the qBittorrent
login handler (wrong URL/path or a reverse proxy misroute) — not a credentials
issue. Now accepts the SID cookie as a success signal (reverse proxies that
mangle the body), returns a clear "invalid username or password" for "Fails.",
and surfaces a diagnostic error (HTTP status + body + base_url/proxy hint) for
any other/empty body.

Tests: new tests/test_jellyfin_client.py (5) + 3 qBittorrent login tests.
Full backend suite (384) passes; ruff clean.
2026-07-11 11:21:31 +00:00
Developer ecabc65dd4 fix: widget edit crash (#185) + resizable textarea for complex fields
WidgetConfigDialog crashed on edit with React error #185 (Maximum update
depth exceeded) when the references/instances query returned undefined and
the inline '= []' fallback created a new array ref every render, looping the
auto-edit useEffect. Stabilize via useMemo(data ?? []). Also moved the
referencedWidgetIds Set inside the availableWidgets useMemo (clears the
pre-existing exhaustive-deps warning).

Complex config fields (promql, query, text, command, notes, or opt-in via
format: 'textarea') now render as a taller resizable Textarea (rows=6,
min-h-120px, font-mono, resize) in both WidgetConfigFields and
ServiceConfigFields, instead of a single-line Input.

Build + lint clean (referencedWidgetIds warning gone), 165 vitest pass.
2026-07-11 10:31:57 +00:00
Developer 044d386ac7 fix: split HTTP connect/read timeouts (Jellyfin build + qBit stats)
Every HTTP client passed an integer timeout to requests, applying the same
value to BOTH connect and read phases. A slow Jellyfin /Items page or qBit
/sync/maindata blew through the 10s read budget → ReadTimeoutError. Split
into a (connect=5s, read=60s default) tuple via shared http_timeout() helper.
The media index build worker uses a 180s read floor. Existing services with
low timeout_seconds benefit from bumping to 60+.
2026-07-10 11:43:07 +00:00
Developer 9bc8fab971 chore: fold pre-existing ServicesPage.tsx formatter stray
Whitespace-only JSX reflow (prettier) from earlier #1 validation-surfacing fix;
folding so the working tree goes pristine before the final push.
2026-07-10 00:17:51 +00:00
Developer 29650ca512 chore(per-instance-hook-scoping): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-per-instance-hook-scoping/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
service-instance-scoping/ remains. Resolves multi-instance wrong-data bug
(hooks now scope by instance.id; instance switcher re-scopes).
Carry-overs: fetchBackupDashboard untouched (design decision 5); subquery
scoping for runs/alerts (schema asymmetry).
2026-07-10 00:17:51 +00:00
Developer f921524d37 spec(per-instance-hook-scoping): sync into new canonical domain
New canonical openspec/specs/service-instance-scoping/spec.md (21 reqs
PI-101..121). Change-side delta + sync-report. web-ui/prometheus-charting/
service-storage/service-credential-testing canonicals untouched.
2026-07-10 00:12:54 +00:00
Developer 8d3c44d87f spec(per-instance-hook-scoping): verify + reconcile tracking
Write apply-progress.md, tick all 17 tasks, add verify-report.md (21/21 PI-101..121
PASS). Gates green: 368 pytest, ruff clean, npm build+lint 0 errors, 165 vitest.
fetchBackupDashboard/useBackupDashboard/get_backup_dashboard confirmed untouched
(design decision 5). No blocking code findings.
2026-07-10 00:05:58 +00:00
Developer 3bc7ce5269 feat(per-instance-hook-scoping): scope observability + backup hooks by instance 2026-07-09 23:54:31 +00:00
Developer ad61d92b32 spec(per-instance-hook-scoping): add tasks (single slice, ~257 lines)
Backend backup endpoint+store filter (subquery for runs/alerts); frontend 6
hooks + 7 API fns + 3 tabs. fetchBackupDashboard excluded (widget path).
Each gate green.
2026-07-09 23:40:39 +00:00
Developer dbc332d1b6 spec(per-instance-hook-scoping): add design
6 decisions: queryKey appends serviceId??''; API client reuses get(path,params);
service_id: str|None=None on backup endpoints; store filter via subquery for
runs/alerts (schema asymmetry — only backup_jobs has service_id column);
fetchBackupDashboard excluded (widget path, PI-117 risk). Single slice ~257
lines. 3 source findings: Alertmanager/Prom endpoints confirmed already take
service_id; backup runs/alerts attributed via FK chain (subquery needed).
2026-07-09 23:37:35 +00:00
Developer 87f42b4ec3 spec(per-instance-hook-scoping): add spec (21 reqs PI-101..121)
Correctness fix for multi-instance: 6 hooks gain optional serviceId in queryKey;
7 API fns append ?service_id; 4 backup endpoints gain service_id filter
(Alertmanager/Prometheus status already take it — zero backend change there);
3 tabs pass instance.id; dashboard widgets unaffected; all params optional
(backward-compat). usePrometheusTargets + useMonitoringMachines stay global.
2026-07-09 23:32:40 +00:00
Developer 5addc9dae9 chore(service-credential-tester): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-service-credential-tester/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
service-credential-testing/ remains. Resolves qBit 'login failed' #3 pain
at the UI layer (auth failure surfaced in result pill, no log-digging).
Carry-overs in archive-report incl N-2 strengthened, N-6 presentational panel,
edit-surface-is-Settings.tsx source-finding.
2026-07-09 23:29:19 +00:00
Developer 6bcb60a74d spec(service-credential-tester): sync into new canonical domain
New canonical openspec/specs/service-credential-testing/spec.md (21 reqs
CT-101..121). Change-side delta + sync-report. web-ui/prometheus-charting/
service-storage canonicals untouched.
2026-07-09 23:22:53 +00:00
Developer 98bf496a98 spec(service-credential-tester): verify + strengthen no-secret-logs test + reconcile
Strengthen test_secrets_not_logged (N-2): now sends real-looking secrets
through prometheus + qbittorrent test_callables (mocked at network boundary),
asserts no fragments leak into caplog, verified non-vacuous. Write
apply-progress.md, tick all 29 tasks, add verify-report.md (21/21 PASS).
Gates green: 362+ pytest, ruff clean, npm build+lint 0 errors, 158 vitest.
2026-07-09 23:15:47 +00:00
Developer f6c67bd3ff feat(service-credential-tester): slice 2 — Test button + gating (shared ServiceTestPanel)
Presentational ServiceTestPanel (props-driven, no internal hooks) wired into
both CreateServiceDialog (ServicesPage.tsx) and ServiceConfigEditor
(Settings.tsx). Parent owns testResult + saveAnyway state; store-previous
pattern resets on input change (avoids setState-in-effect). Create/Save
button gated on testPassed || saveAnyway. 7 panel tests (button states,
success/failure pills, checkbox toggle). All gates: 158 vitest, build exit 0,
lint 0 errors, 362 backend pytest (regression).
2026-07-09 22:57:02 +00:00
Developer 3391fbc85d feat(service-credential-tester): slice 1 — backend test endpoint + per-type routines
TestResult dataclass + translate_connection_error shared helper in base.py.
test_callable field on ServiceDefinition (default None). 7 per-type
test_connection routines (qbittorrent, prometheus via Grafana gateway,
alertmanager, jellyfin, authentik, ssh_tasks via build_ssh_client, nextcloud).
POST /api/services/test endpoint: validation-first (422 on malformed config),
dispatch, no-persistence, no-secret-logs. backups has test_callable=None.
qBit 'Fails.' → specific auth message (resolves #3 at API layer).
Backend: 362 pytest pass (+31 new), ruff clean. Frontend: build green.
2026-07-09 22:41:06 +00:00
Developer c4f68b4938 spec(service-credential-tester): add tasks (2 slices, each <=400 lines)
S1 backend: TestResult + test_callable + translate_connection_error + POST
/api/services/test + 7 per-type routines + tests. S2 frontend: type + API fn +
hook + shared ServiceTestPanel wired into CreateServiceDialog + Settings.tsx
ServiceConfigEditor (per design source-finding). Each slice leaves pytest/npm
build/npm lint green.
2026-07-09 22:25:27 +00:00
Developer 1fc3127b58 spec(service-credential-tester): add design
8 decisions: TestResult dataclass, test_callable(store, config, secrets),
translate_connection_error shared helper (extracts test_machine_ssh patterns),
POST /api/services/test validation-first, shared ServiceTestPanel component,
field-edit-clears-result, Prom test via Grafana gateway, ssh_tasks reuses
build_ssh_client. 2-slice plan. Source findings: edit dialog is in Settings.tsx
ServiceConfigEditor (not ServicePage.tsx — spec drift); test_machine_ssh is
inlined in router (not reusable as-is).
2026-07-09 22:21:03 +00:00
Developer ce5ee4f0a0 spec(service-credential-tester): add spec (21 reqs CT-101..121)
Per-type test routines for 7 remote types (qbittorrent, prometheus via Grafana
gateway, alertmanager, jellyfin, authentik, ssh_tasks, nextcloud) + backups
(no test). Endpoint POST /api/services/test, no persistence, friendly error
translation (resolves qBit login #3 at UI layer). Frontend Test button + gate
Create/Save on pass with Save-anyway override. Stale-proposal correction:
jellyseerr is not in the registry (merged into jellyfin).
2026-07-09 22:12:32 +00:00
Developer a5ca1521fe spec(service-credential-tester): update prom test to gateway path
Dependency on grafana-metric-gateway: the prometheus service now sources via
Grafana /api/ds/query (no direct Prom endpoint). Test routine changes from
GET /api/v1/query?query=up to POST {grafana_url}/api/ds/query with api_key +
datasource_uid, expr 'up'.
2026-07-09 22:07:37 +00:00
Developer 9236fd8ac2 chore(grafana-metric-gateway): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-grafana-metric-gateway/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
prometheus-charting/ (30 reqs, first non-additive sync) remains. Carry-overs
in archive-report: SC-106 stale component name (cosmetic); partial revert of
prometheus-direct-charting per new network constraint.
2026-07-09 22:06:43 +00:00
Developer cb8dd13514 spec(grafana-metric-gateway): sync — FIRST non-additive (MODIFIED) canonical
16 MODIFIED prometheus-charting requirements (transport: direct Prom -> Grafana
gateway; intent preserved where applicable), 3 ADDED (SC-128 gateway status,
SC-129 startup validation, SC-130 sanctioned transport), 11 PRESERVED, 0 REMOVED.
All 16 MODIFIED headers matched canonical exactly. Post-sync: 30 requirements.
web-ui + service-storage canonicals untouched.
2026-07-09 22:01:55 +00:00
Developer c886fcdf09 spec(grafana-metric-gateway): verify + close GM-115 + reconcile tracking
Add 3 test cases (startup old-config validation warning; status auth_failed
for 401/403) closing the GM-115 PARTIAL. Write apply-progress.md, tick all 33
tasks, add verify-report.md (15/16 PASS, 1 PARTIAL->PASS). Gates green: 331+
pytest, ruff clean, npm build+lint 0 errors, 151 vitest.
2026-07-09 21:53:18 +00:00
Developer 7e91e7f931 feat(grafana-metric-gateway): slice 2 — rename widgets to Metric*
git mv PrometheusChartWidget→MetricChartWidget, PrometheusGaugeWidget→
MetricGaugeWidget, PrometheusMeanWidget→MetricMeanWidget (+ 3 test files,
R100 history preserved). Update registry imports/refs + barrel exports.
Adapt PrometheusMetricWidget for §3.4 Option A: read normalized {result:
[{label,points}]} series shape (last-point extraction) instead of old Prom
{resultType,result} vector. PrometheusMetricWidget NOT renamed (design §3.1).
GM-111/112/116 satisfied. All gates: 151 vitest, build exit 0, lint 0 errors.
2026-07-09 21:34:26 +00:00
Developer df80c68f89 feat(grafana-metric-gateway): slice 1 — backend gateway transport
Route all prometheus widget queries through Grafana /api/ds/query instead of
direct Prom HTTP. PrometheusConfig: drop base_url, add grafana_url +
datasource_uid; secret grafana_api_key (required). PrometheusWidgetSource →
MetricSource with _gateway_query POST method. normalize_grafana_frames
recovered from 65bae95 + shared _dedup_label helper. Gateway-path status
check. Startup old-config validation. CHANGELOG migration note. All adapter
tests rewritten for POST /api/ds/query + Grafana frames mock. Backend: 331
pytest pass, ruff clean. Frontend: build green (unchanged in S1).
2026-07-09 21:26:05 +00:00
Developer 798196ffc7 spec(grafana-metric-gateway): add tasks (2 slices, each <=400 lines)
S1 backend transport: gateway config + normalize_grafana_frames (from 65bae95)
+ MetricSource adapter + status + validation + CHANGELOG + tests. S2 frontend:
git mv widget renames to Metric* + registry/barrel updates. Each slice leaves
pytest/npm build/npm lint green. 5 risk flags incl first non-additive sync.
2026-07-09 21:01:38 +00:00
Developer 872e95f8f7 spec(grafana-metric-gateway): add design
8 design decisions: PrometheusConfig (grafana_url/datasource_uid + grafana_api_key
secret), /api/ds/query body per widget kind (window presets -> intervalMs/
maxDataPoints), normalize_grafana_frames refactored from 65bae95 into
prometheus_range.py (shares label-dedup with matrix normalizer), MetricSource
adapter, gateway-path status check, git mv widget renames, startup old-config
validation. 2-slice plan. 3 source findings flagged.
2026-07-09 20:57:07 +00:00
Developer bf8de32815 spec(grafana-metric-gateway): add spec (16 reqs GM-101..116)
Gateway transport: prometheus service config gains grafana_url/api_key/
datasource_uid; all queries via POST /api/ds/query; frames->series restored.
First non-additive canonical sync: MODIFIES 17 SC- requirements (transport
changes, intent preserved), PRESERVES 11, ADDS 3 (gateway status, startup
validation, sanctioned-transport statement). 3 spec assumptions settled where
proposal was silent.
2026-07-09 20:49:45 +00:00
Developer 8afdd9c2bc spec(grafana-metric-gateway): add proposal
Route metric queries through Grafana /api/ds/query instead of direct Prom
(Prom is firewalled / unreachable from Manage; Grafana is the only path).
Partial revert of prometheus-direct-charting: keep the gauge/mean/
LineSeriesChart rendering, restore the frames->series normalizer (recovered
from git 65bae95), change prometheus service config to hold Grafana gateway
fields (url + api_key + datasource_uid). Rename widgets to neutral Metric*.
First non-additive canonical sync (prometheus-charting MODIFIED). Updates the
pending service-credential-tester proposal dependency.
2026-07-09 20:41:59 +00:00
Developer 3e3e69b0be spec(service-credential-tester): add proposal
On-demand per-service-type credential tester for the add/edit dialog. Model on
test_machine_ssh. POST /api/services/test dispatching to per-type routines
(qBit login+maindata, Prom instant query, Jellyfin /Users, etc.) returning
{ok, detail, evidence}. Frontend Test button + gate Create/Save on pass with
'Save anyway' override. Resolves #3 (qBit login failures visible in UI not
logs) and complements #1 (validation surfacing, already shipped).
2026-07-09 20:13:57 +00:00
Developer cadb6d0991 fix(nav): add qBittorrent to per-service-type navbar entries
qBittorrent was missing from SERVICE_TYPE_NAV_ENTRIES, so configured qBit
instances never appeared in the left nav (unlike jellyfin/prometheus/etc.).
Add entry with Magnet icon. navEntries.test.ts filters by configured types
(no fixed-count assertion) so it stays green.
2026-07-09 20:11:12 +00:00
Developer 493c0e1aeb fix(services): surface validation errors in add-service dialog
CreateServiceDialog.save() awaited mutateAsync without a try/catch, so a
backend 422 (e.g. base_url missing http:// schema) threw uncaught and the
dialog sat silent with no feedback. Wrap in try/catch, hold the error in
local state, render a destructive Alert above the footer. Reset/onClose
only on success; on error the user can fix and retry.
2026-07-09 20:10:46 +00:00
Developer a9381e2471 spec(per-instance-hook-scoping): add proposal
Correctness fix: observability + backup hooks query globally, so multi-instance
service pages show data for the wrong instance. Tabs already accept instance
prop with TODO comments; backend mostly supports service_id already. Scope:
add serviceId to 6 hooks + fetch fns + 3 tabs; add service_id to backup
endpoints. Backward-compatible (optional params). ~250-350 lines, single slice.
2026-07-09 14:35:14 +00:00
Developer e461279566 chore(services-as-hub-ia): archive verified change (paperwork-only)
Code merged to main on 2026-06-26 (01527ae) + fix passes. All 10 ACs PASS.
~8600 ins / ~3900 del across 103 files. Only SDD artifacts were untracked.
No code changes, no canonical sync. NOTE: verify-report describes a Grafana
LinksTab later removed by prometheus-direct-charting (2026-07-08); historically
accurate point-in-time record — see archive-report.md footnote.
2026-07-09 14:11:15 +00:00
Developer 1fe7c06083 chore(mobile-responsive-parity): archive verified change (paperwork-only)
Code merged to main on 2026-06-26 via rebase (01527ae) + bug-fix passes
(5a43894/04871bd/f7f590f). All 8 ACs PASS. Only SDD artifacts were untracked;
this archive closes the paperwork gap. No code changes, no canonical sync.
See archive-report.md for carry-over notes (iOS real-device test residual).
2026-07-09 14:11:05 +00:00
Developer ec7ebd7013 chore: regenerate stale .pi-map.md / .pi-map.index.md project maps
Project map had drifted: claimed recharts unused (was used), ObservabilityPage.tsx
exists (refactored to service-tabs/), missed JellyfinNowPlayingWidget +
authentik/backups service types. Regenerate to reflect post-change reality:
new Qbittorrent/LineSeriesChart/Prometheus{Gauge,Mean}Widget files,
service_data.py/qbittorrent_store.py, canonical prometheus-charting +
service-storage specs, archived changes.
2026-07-09 14:05:56 +00:00
Developer 98f17f6e5d chore: apply pre-existing formatter reformat to MediaTab.tsx
Whitespace-only line reflow (formatter-on-save artifact from a prior session,
not logic). Committing to pristine the working tree.
2026-07-09 13:50:36 +00:00
370 changed files with 19183 additions and 5363 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .claude dir: .claude
## role ## role
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines. Configuration and settings directory for Claude AI assistant integration within the project workspace.
## parent ## parent
index: ./.pi-map.index.md index: ./.pi-map.index.md
map: ./.pi-map.md map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .claude
index: .claude/.pi-map.index.md index: .claude/.pi-map.index.md
## role ## role
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines. Configuration and settings directory for Claude AI assistant integration within the project workspace.
## files ## files
## arch ## arch
Flat configuration structure containing markdown/YAML files that define custom commands, project context, and operational rules for Claude's interactions with the codebase. Flat configuration directory following standard AI assistant tool conventions, typically containing permission rules, context files, and project-specific behavioral settings.
## tags ## tags
- -
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .claude/skills dir: .claude/skills
## role ## role
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration. Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
## parent ## parent
index: .claude/.pi-map.index.md index: .claude/.pi-map.index.md
map: .claude/.pi-map.md map: .claude/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .claude/skills
index: .claude/skills/.pi-map.index.md index: .claude/skills/.pi-map.index.md
## role ## role
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration. Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
## files ## files
## arch ## arch
Flat configuration file structure defining modular skill behaviors and prompts used to extend Claude's domain-specific abilities. Flat directory structure with markdown-based skill modules that define specialized assistant capabilities.
## tags ## tags
- -
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .claude/skills/sift-backlog dir: .claude/skills/sift-backlog
## role ## role
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
## parent ## parent
index: .claude/skills/.pi-map.index.md index: .claude/skills/.pi-map.index.md
map: .claude/skills/.pi-map.md map: .claude/skills/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: .claude/skills/sift-backlog
index: .claude/skills/sift-backlog/.pi-map.index.md index: .claude/skills/sift-backlog/.pi-map.index.md
## role ## role
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
## files ## 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) - 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 ## arch
Single-file declarative skill definition following a prompt-driven workflow pattern with structured triage and activation instructions for Claude to execute. Single-file declarative skill definition following a prompt-engineering pattern that encodes step-by-step procedures and decision rules for Claude to execute when invoked.
## tags ## tags
skill, defines, workflow, triaging, organizing, activating, backlog, tasks skill, defines, workflow, triaging, organizing, activating, backlog, tasks
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .opencode dir: .opencode
## role ## role
Configuration directory for the opencode tool, managing project-specific settings and preferences. Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
## parent ## parent
index: ./.pi-map.index.md index: ./.pi-map.index.md
map: ./.pi-map.md map: ./.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .opencode
index: .opencode/.pi-map.index.md index: .opencode/.pi-map.index.md
## role ## role
Configuration directory for the opencode tool, managing project-specific settings and preferences. Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
## files ## files
## arch ## arch
Flat directory structure containing configuration files that define opencode behavior for the associated project. Flat directory structure with declarative configuration files; no executable code or architectural patterns involved.
## tags ## tags
- -
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .opencode/commands dir: .opencode/commands
## role ## role
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive). Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
## parent ## parent
index: .opencode/.pi-map.index.md index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md map: .opencode/.pi-map.md
+2 -2
View File
@@ -4,14 +4,14 @@ dir: .opencode/commands
index: .opencode/commands/.pi-map.index.md index: .opencode/commands/.pi-map.index.md
## role ## role
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive). Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
## files ## 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-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-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-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 - 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 ## arch
Markdown-based declarative templates serving as structured prompts/playbooks that guide an AI assistant through specific operational phases of a spec-driven lifecycle. Markdown-based command-definition pattern where each file encodes a discrete, step-by-step procedural prompt controlling assistant behavior for a specific development phase.
## tags ## tags
opsx, defines, tasks, md, workflow, openspec, openspec cli, askuserquestion tool opsx, defines, tasks, md, workflow, openspec, openspec cli, askuserquestion tool
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: .opencode/skills dir: .opencode/skills
## role ## role
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework. Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
## parent ## parent
index: .opencode/.pi-map.index.md index: .opencode/.pi-map.index.md
map: .opencode/.pi-map.md map: .opencode/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: .opencode/skills
index: .opencode/skills/.pi-map.index.md index: .opencode/skills/.pi-map.index.md
## role ## role
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework. Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
## files ## files
## arch ## arch
Configuration-based skill definition directory; skills are declared as individual files consumed by the opencode agent runtime to extend or specialize assistant behavior. Configuration-driven skill registry with declarative definition files (no implementation code present in this directory).
## tags ## tags
- -
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-apply-change dir: .opencode/skills/openspec-apply-change
## role ## role
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking. Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
## parent ## parent
index: .opencode/skills/.pi-map.index.md index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md map: .opencode/skills/.pi-map.md
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-apply-change
index: .opencode/skills/openspec-apply-change/.pi-map.index.md index: .opencode/skills/openspec-apply-change/.pi-map.index.md
## role ## role
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking. Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
## files ## 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 - 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 ## arch
Documentation-based skill specification using markdown with defined workflow steps, schema references, and contextual file reading rules. Declarative skill specification using markdown-based instructions, schema-driven task processing, and progressive context loading patterns.
## tags ## tags
skill, defines, implementing, tasks, openspec, change, schema, driven skill, defines, implementing, tasks, openspec, change, schema, driven
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-archive-change dir: .opencode/skills/openspec-archive-change
## role ## role
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps. Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
## parent ## parent
index: .opencode/skills/.pi-map.index.md index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md map: .opencode/skills/.pi-map.md
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-archive-change
index: .opencode/skills/openspec-archive-change/.pi-map.index.md index: .opencode/skills/openspec-archive-change/.pi-map.index.md
## role ## role
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps. Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
## files ## 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 - 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 ## arch
Single-document declarative skill specification following a procedural checklist pattern (validate, assess sync, confirm) designed for an AI agent to execute. Single-file declarative skill definition using a markdown-based pattern description format, structured as a procedural workflow with validation gates and conditional user interaction steps.
## tags ## tags
skill, openspec, sync, defines, archiving, completed, change, experimental skill, openspec, sync, defines, archiving, completed, change, experimental
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-explore dir: .opencode/skills/openspec-explore
## role ## role
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements. Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
## parent ## parent
index: .opencode/skills/.pi-map.index.md index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md map: .opencode/skills/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-explore
index: .opencode/skills/openspec-explore/.pi-map.index.md index: .opencode/skills/openspec-explore/.pi-map.index.md
## role ## role
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements. Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
## files ## 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 - 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 ## arch
Skill-definition pattern using a single Markdown file (SKILL.md) that declaratively specifies the assistant's behavioral constraints, workflow, and operational guidelines. Skill-definition pattern using a single Markdown manifest (SKILL.md) that declaratively specifies the assistant's behavioral instructions, interaction style, and operational constraints for the explore workflow.
## tags ## tags
skill, defines, conversational, explore, mode, openspec, cli, acts skill, defines, conversational, explore, mode, openspec, cli, acts
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: .opencode/skills/openspec-propose dir: .opencode/skills/openspec-propose
## role ## role
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks). Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
## parent ## parent
index: .opencode/skills/.pi-map.index.md index: .opencode/skills/.pi-map.index.md
map: .opencode/skills/.pi-map.md map: .opencode/skills/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-propose
index: .opencode/skills/openspec-propose/.pi-map.index.md index: .opencode/skills/openspec-propose/.pi-map.index.md
## role ## role
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks). Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
## files ## 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 - 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 ## arch
Skill-definition pattern using a declarative markdown document (SKILL.md) that encodes a step-by-step procedural workflow with CLI integration conventions. Declarative skill-definition pattern using a markdown-based manifest (SKILL.md) that encodes a step-by-step procedural workflow, CLI commands, and file-system conventions for the AI to follow.
## tags ## tags
skill, defines, assistant, automates, proposing, new, changes, scaffolding skill, defines, assistant, automates, proposing, new, changes, scaffolding
## symbols ## symbols
+4 -2
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides. Trust boundary: index routes, map orients, source decides.
## role ## role
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation. Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
## parent ## parent
- -
## children ## children
@@ -32,6 +32,9 @@ Root project configuration and orchestration package for a media library managem
- .pi - .pi
index: .pi/.pi-map.index.md index: .pi/.pi-map.index.md
map: .pi/.pi-map.md map: .pi/.pi-map.md
- .pi-tmp
index: .pi-tmp/.pi-map.index.md
map: .pi-tmp/.pi-map.md
- .ruff_cache - .ruff_cache
index: .ruff_cache/.pi-map.index.md index: .ruff_cache/.pi-map.index.md
map: .ruff_cache/.pi-map.md map: .ruff_cache/.pi-map.md
@@ -66,7 +69,6 @@ Root project configuration and orchestration package for a media library managem
- docker-compose.dev.yml - docker-compose.dev.yml
- docker-compose.observability.yml - docker-compose.observability.yml
- docker-compose.yml - docker-compose.yml
- swap-pane
- token-usage-output.txt - token-usage-output.txt
## links ## links
index: ./.pi-map.index.md index: ./.pi-map.index.md
+3 -4
View File
@@ -18,13 +18,13 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides. Trust boundary: index routes, map orients, source decides.
## role ## role
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation. Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
## files ## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker - .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. - .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. - .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 - AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across recent versions. - CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across releases.
- CONTRIBUTING.md | Provides contribution guidelines and setup instructions for the Manage project's backend (FastAPI) and frontend (React) codebases. | dep: FastAPI, React, Vite, TypeScript, Ruff, pytest, Docker Compose, Tailwind CSS, TanStack Query - 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 - 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 - README.md | Project README documenting a media and server operations tool with Jellyfin integration, SSH file inspection, and server monitoring capabilities. | dep: FastAPI, React, TypeScript, Docker Compose, SQLite, Traefik, OIDC/Authentik, Jellyfin, Prometheus, Grafana, Alertmanager
@@ -32,10 +32,9 @@ Root project configuration and orchestration package for a media library managem
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker - docker-compose.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.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 - docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons. - 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 ## arch
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager). Full-stack containerized architecture using Docker Compose orchestration with a FastAPI/Uvicorn backend and Vite React frontend, Traefik reverse proxy with TLS/OIDC, and an optional observability stack (Prometheus, Grafana, Loki, Alertmanager, Alloy).
## tags ## tags
docker, grafana, application, fastapi, compose, prometheus, backend, frontend docker, grafana, application, fastapi, compose, prometheus, backend, frontend
## symbols ## symbols
+25
View File
@@ -0,0 +1,25 @@
# .pi-tmp (index)
dir: .pi-tmp
## role
Documentation and reporting workspace containing acceptance reports and change documentation for iterative feature development and refactoring efforts.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
## children
-
## files
- followups-batch1-out.md
- four-fixes-out.md
- grafana-chart-out.md
- refine-23-out.md
- refine-4-out.md
- reusable-widgets-out.md
- widgets-out.md
## links
index: .pi-tmp/.pi-map.index.md
map: .pi-tmp/.pi-map.md
## workflows
-
## dirty
-
+25
View File
@@ -0,0 +1,25 @@
# .pi-tmp
dir: .pi-tmp
index: .pi-tmp/.pi-map.index.md
## role
Documentation and reporting workspace containing acceptance reports and change documentation for iterative feature development and refactoring efforts.
## files
- followups-batch1-out.md | This file documents a batch of fixes for a reusable widget system, including a code change summary, validation test results, and a formal acceptance report.
- four-fixes-out.md | Documentation report detailing four bug fixes across frontend and backend components, including changes made, validation results, and residual risks.
- grafana-chart-out.md | Documentation and acceptance report for replacing a Grafana iframe panel widget with a server-side chart query widget using recharts. | dep: recharts, Grafana API, Tailwind CSS, pytest, ruff, eslint
- refine-23-out.md | Documentation of a refactoring effort that moved service configuration from the ServicePage to Settings, replacing it with instance tabs.
- refine-4-out.md | Documentation of a change implementing configurable per-service widget overview tabs with backend filtering by service_id/scope, replacing stubs with a real OverviewTab component. | dep: React, TypeScript, Python/FastAPI, pytest, ruff, Vite, React Query (useWidgets hook)
- reusable-widgets-out.md | This file is an implementation report documenting the addition of reusable widget references across a full-stack application (backend CRUD/API and frontend UI/hooks).
- widgets-out.md | Documentation/acceptance report describing the implementation of two new widgets (Jellyfin now_playing and Grafana panel embed) across backend and frontend.
## arch
Flat collection of Markdown reports, each following a consistent structure of change summary, validation results, and acceptance/risk assessment across full-stack changes.
## tags
out, widget, report, documentation, widgets, fixes, reusable, backend
## symbols
-
## workflows
-
## dirty
-
+1
View File
@@ -17,6 +17,7 @@
- Focused frontend typecheck: `npx tsc --noEmit` - Focused frontend typecheck: `npx tsc --noEmit`
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build` - Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
- Production stack: `docker compose up --build` - Production stack: `docker compose up --build`
- Solo landing: after review and verification, squash-land a feature branch with `bash scripts/land-branch.sh <feature-branch> "<conventional commit message>"`; do not commit directly on `main`.
## Repo-Specific Gotchas ## Repo-Specific Gotchas
+26
View File
@@ -4,6 +4,32 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
## [Unreleased] ## [Unreleased]
### Fixed — HTTP read timeouts
- Service HTTP clients now use a `(connect, read)` timeout tuple (connect 5s,
read 60s default) instead of a single integer, resolving `ReadTimeoutError`
on slow Jellyfin index builds and qBittorrent stats. The media index build
worker uses a 180s read floor so slow `/Items` pages on large libraries
don't time out mid-build.
- The shared `http_timeout()` helper (`clients/http_timeout.py`) decouples
connect (fail-fast on dead hosts) from read (generous for slow responses).
- Integration `timeout_seconds` defaults were raised from 5/10s to 15/60s.
- Existing services with a low `timeout_seconds` may benefit from bumping it
to 60+ via the service editor.
### **BREAKING** — Prometheus queries now route through Grafana gateway
- The `prometheus` service config changed: `base_url` is replaced by
`grafana_url` + `datasource_uid`, and the `api_key` secret is replaced by
`grafana_api_key` (a Grafana service account token or API key with read
access to the Prometheus datasource). All metric widget queries (`chart`,
`gauge`, `mean`, `metric`) now issue `POST {grafana_url}/api/ds/query`
instead of direct Prometheus HTTP calls.
- **Migration:** Reconfigure existing `prometheus` services — replace
`base_url` with `grafana_url` (your Grafana instance URL), add the
`grafana_api_key` secret, and optionally set `datasource_uid` (defaults
to `"prometheus"`).
### Added — Direct Prometheus charting ### Added — Direct Prometheus charting
- **Prometheus is now the direct source for in-app charts.** New widget kinds - **Prometheus is now the direct source for in-app charts.** New widget kinds
+89 -135
View File
@@ -1,62 +1,67 @@
# Manage # Manage
Manage is a media and server operations tool with Jellyfin integration, SSH file inspection, server monitoring, and safe remote job templates. Manage is a media and server-operations application with Jellyfin integration, SSH file inspection, monitoring integrations, safe remote-job templates, a FastAPI backend, and a React single-page application.
See `docs/REQUIREMENTS.md` for the living requirements, decisions, and planning history. It includes a configurable dashboard, service registry, per-machine settings, a SQLite-indexed media library, a read-only Users view with optional Jellyseerr enrichment, remote file browsing with `ffprobe`, and SSH-based job execution.
See `docs/MIGRATION_PLAN.md` for the FastAPI + React architecture plan.
Project policy/docs: ## Architecture and scope
- License: `LICENSE` (MIT) - `backend/` is the FastAPI API.
- Contributing guide: `CONTRIBUTING.md` - `frontend/` is the React and TypeScript SPA.
- `archive/` retains the original Streamlit prototype for reference.
## Architecture The root Compose files deploy **only** Manage's backend and frontend. Manage can expose `/metrics` and optional Alertmanager proxy endpoints, but it does not deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter as part of its normal stack. Configure service instances in the app's Services page.
The project consists of two subprojects: ## Prerequisites
- **`backend/`** — FastAPI Python API (see `backend/README.md`) - Docker and Docker Compose for the supplied Compose stacks.
- **`frontend/`** — React + TypeScript SPA (see `frontend/README.md`) - Python 3.11 or newer for manual backend development.
- **`archive/`** — Original Streamlit prototype (preserved for reference) - Node.js and npm for manual frontend development.
- A valid Fernet key for `MANAGE_ENCRYPTION_KEY`, including in development Compose.
- For production: an existing external Docker network named `web`, Traefik, DNS/TLS configuration, and an OIDC provider.
## Features ## Local development with Compose
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, Alertmanager alerts, SSH task output, static text) and shortcuts 1. Create `.env` from the template and set a valid `MANAGE_ENCRYPTION_KEY`. Docker Compose automatically reads `.env` for interpolation; alternatively, export the same variables in the shell.
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
- 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
## Quick Start ```bash
cp .env.example .env
```
### Docker Compose (recommended) Generate a Fernet key if needed:
Production-style deployment with the frontend serving the SPA and proxying `/api` to the backend. The compose files rely on environment-variable interpolation, so export the required values in your shell before running them (no `env_file` is needed): ```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
2. Start the development stack:
```bash
docker compose -f docker-compose.dev.yml up --build
```
The development frontend is available at <http://localhost:5173> and the backend at <http://localhost:8000>. Development Compose sets `AUTH_ENABLED=false` and `VITE_OIDC_ENABLED=false`, but still requires `MANAGE_ENCRYPTION_KEY`. The backend cache, settings database, media index, saved SSH keys, tasks, and dashboard widgets persist outside rebuilt containers.
## Production-style deployment
The root [`docker-compose.yml`](docker-compose.yml) is designed for deployment behind Traefik; it does not publish localhost ports. Before starting it, configure `.env` (or shell variables) with the required values:
- `BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, and `CERT_RESOLVER` for Traefik routing and certificates.
- `OIDC_ISSUER_URL` and `OIDC_AUDIENCE` for backend authentication.
- `VITE_OIDC_ISSUER`, `VITE_OIDC_CLIENT_ID`, `VITE_OIDC_REDIRECT_URI`, and `VITE_OIDC_POST_LOGOUT_REDIRECT_URI` for the frontend build.
- `MANAGE_ENCRYPTION_KEY`, a valid Fernet key used to encrypt service secrets at rest.
Then run:
```bash ```bash
docker compose up --build docker compose up --build
``` ```
Open the app at <http://localhost:8080>. The production Compose file requires its external `web` network to exist. It is not a standalone local deployment; access is through the configured Traefik hostnames.
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`. ## Manual development
> **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). ### Backend
Local development with hot reload:
```bash
docker compose -f docker-compose.dev.yml up --build
```
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
### Manual backend/frontend development
```bash ```bash
cd backend cd backend
@@ -66,126 +71,75 @@ pip install -e '.[dev]'
uvicorn media_library_viewer_api.main:app --reload --port 8000 uvicorn media_library_viewer_api.main:app --reload --port 8000
``` ```
### Frontend
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
## Configuration ## Tests and quality checks
The Compose files use environment-variable interpolation. Export the required variables in your shell or pass them inline; a `.env` file is optional, not required.
### Compose examples
Production-style example with shell exports:
```bash ```bash
export BACKEND_APP_HOST=api.manage.example.com # Backend
export FRONTEND_APP_HOST=manage.example.com cd backend
export CERT_RESOLVER=letsencrypt ruff check .
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ python -m pytest
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 MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
docker compose up --build # Frontend
cd ../frontend
npm run lint
npm run build
npm run test
``` ```
> Observability services (Grafana, Prometheus, Alertmanager) are configured in A focused frontend typecheck can be run with `npx tsc --noEmit` from `frontend/`.
> the app on the **Services** page — no env vars for them.
Inline one-liner example: ## Configuration and operations
```bash [`.env.example`](.env.example) is a template; do not commit real credentials or encryption keys. The Compose files interpolate environment values directly. Some template entries are for the optional observability example and are not consumed by the normal Manage Compose stack.
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: Optional SMTP settings (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USERNAME`, `SMTP_PASSWORD`, `SMTP_FROM_ADDRESS`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`, `SMTP_USE_SSL`, and `SMTP_TIMEOUT`) support the Users message popup.
```bash ### Remote servers
docker compose -f docker-compose.dev.yml up --build
```
Example environment variables: A managed remote server needs a POSIX `/bin/sh`, `python3`, `ffprobe`, `find`, `stat`, `df`, and `awk`. Configure its SSH credentials in Manage's Settings. Unknown SSH host keys are rejected; establish trust first, for example:
```bash
# Optional backend logging level
LOG_LEVEL=INFO
# Optional SMTP settings for the Users -> message popup
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USERNAME=your-smtp-username
SMTP_PASSWORD=your-smtp-password
SMTP_FROM_ADDRESS=no-reply@example.com
SMTP_FROM_NAME=Manage
SMTP_USE_TLS=true
SMTP_USE_SSL=false
SMTP_TIMEOUT=30
# Jellyfin, Jellyseerr, and SSH targets are now configured per machine in the app's Settings tab.
# The backend seeds a local machine automatically, so no global Jellyfin or SSH env vars are required.
#
# Remote SSH machines can store their private key and optional passphrase directly in Settings,
# so no SSH key mount is required for normal use.
# Authentik / OIDC
AUTH_ENABLED=true
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
OIDC_AUDIENCE=manage
OIDC_JWKS_URL=
OIDC_CLOCK_SKEW_SECONDS=30
# Frontend OIDC settings
VITE_OIDC_ENABLED=true
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
VITE_OIDC_CLIENT_ID=manage
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/
# 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())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
```
## Remote server requirements
The remote server needs:
- `/bin/sh` (POSIX shell)
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
- SSH access with a key configured in the app's Settings tab
The SSH client rejects unknown host keys. Connect manually once first:
```bash ```bash
ssh user@host ssh user@host
``` ```
## Development SSH commands run through `/bin/sh -c` regardless of the remote login shell.
### Optional observability example
[`docker-compose.observability.yml`](docker-compose.observability.yml) is a separate, optional stack for Grafana, Prometheus, Loki, Alertmanager, Alloy, and Node Exporter. It is not required by Manage. Its header documents required `*_ROOT` persistence directories, `CERT_RESOLVER`, and Grafana/Prometheus/Alertmanager host variables. With those prepared, run:
```bash ```bash
# Backend (lint + tests) docker compose -f docker-compose.observability.yml up -d
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest
# Frontend (lint + typecheck/build + tests)
cd frontend && npm run lint && npm run build && npm run test
``` ```
Focused frontend typecheck: `npx tsc --noEmit`. Set a non-default `GRAFANA_ADMIN_USER` and a strong, secret `GRAFANA_ADMIN_PASSWORD` before deploying this stack. Do not expose the example observability services with their defaults.
## Notes See [`docs/observability-runbooks.md`](docs/observability-runbooks.md) for its operational documentation.
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively. ## Repository layout
- 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`. ```text
- 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. ├── backend/ # FastAPI API and tests
- 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. ├── frontend/ # React/TypeScript SPA and tests
├── archive/ # Preserved Streamlit prototype
├── docs/ # Requirements, migration, and operations docs
├── docker-compose.yml # Traefik-backed production-style stack
├── docker-compose.dev.yml # Local hot-reload development stack
└── docker-compose.observability.yml # Optional standalone observability example
```
## Project documents
- [Requirements and planning history](docs/REQUIREMENTS.md)
- [FastAPI + React migration plan](docs/MIGRATION_PLAN.md)
- [Contributing guide](CONTRIBUTING.md)
- [MIT license](LICENSE)
+1 -1
View File
@@ -2,7 +2,7 @@
dir: archive dir: archive
## role ## role
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities. Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
## parent ## parent
index: ./.pi-map.index.md index: ./.pi-map.index.md
map: ./.pi-map.md map: ./.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: archive
index: archive/.pi-map.index.md index: archive/.pi-map.index.md
## role ## role
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities. Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
## files ## 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 - 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 - 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 - requirements.txt | Installs the current package in editable/development mode using pip | dep: pip, setuptools
## arch ## arch
Thin entrypoint pattern using a bootstrap app.py that manipulates sys.path to delegate execution to a nested media_library_viewer package, managed via standard Python packaging (pyproject.toml). Thin bootstrap layer using path manipulation to delegate to a source module (src/), packaged with standard Python tooling (pyproject.toml) for dependency management and Streamlit deployment.
## tags ## tags
streamlit, app, python, media, library, package, pyproject, pip streamlit, app, python, media, library, package, pyproject, pip
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: archive/src dir: archive/src
## role ## role
No files provided directory appears to be empty or contents were not included, so the package's role cannot be determined. Insufficient information — no files provided in the directory listing to determine this package's role.
## parent ## parent
index: archive/.pi-map.index.md index: archive/.pi-map.index.md
map: archive/.pi-map.md map: archive/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: archive/src
index: archive/src/.pi-map.index.md index: archive/src/.pi-map.index.md
## role ## role
No files provided directory appears to be empty or contents were not included, so the package's role cannot be determined. Insufficient information — no files provided in the directory listing to determine this package's role.
## files ## files
## arch ## arch
Cannot be assessed due to missing file contents; please provide the file listing for analysis. Unable to assess — empty directory or missing file contents for architectural analysis.
## tags ## tags
- -
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer dir: archive/src/media_library_viewer
## role ## role
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems. Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
## parent ## parent
index: archive/src/.pi-map.index.md index: archive/src/.pi-map.index.md
map: archive/src/.pi-map.md map: archive/src/.pi-map.md
+2 -2
View File
@@ -4,7 +4,7 @@ dir: archive/src/media_library_viewer
index: archive/src/media_library_viewer/.pi-map.index.md index: archive/src/media_library_viewer/.pi-map.index.md
## role ## role
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems. Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
## files ## files
- __init__.py | Package initialization file that defines the Media Library Viewer package metadata and exports the version string. - __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 - app.py | Streamlit UI entrypoint for a Media Library Viewer that connects to Jellyfin and SSH backends, providing dashboard, monitoring, media browsing, and file browser tabs with cached data and path resolution between systems. | exp: func:get_jellyfin_client(base_url: str, api_key: str) → JellyfinClient, call:JellyfinClient, func:cached_users(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).users, func:get_ssh_client(host: str, username: str, port: int, key_filename: str, password: str) → RemoteSSHClient, call:RemoteSSHClient, call:client.connect, func:cached_libraries(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).libraries, func:cached_media_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).media_counts, func:cached_library_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client, call:client.libraries, call:client.library_item_counts, func:cached_active_sessions(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).active_sessions, func:cached_dir_listing(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.list_dir, call:json.loads, raise:RuntimeError, func:cached_ffprobe_preview(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.ffprobe_json, func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:apply_remote_path_prefix, func:credentials_panel(), call:load_config, call:st.header, call:st.expander, call:st.text_input, call:st.number_input, call:int, func:main(), call:st.set_page_config, call:st.title, call:st.caption, call:credentials_panel, call:st.info, call:get_jellyfin_client, call:cached_users, call:st.error, call:user.get, call:st.selectbox, call:list, call:user_options.keys, call:st.tabs, call:render_now_playing, call:st.divider, call:render_resource_dashboard, call:render_media_overview, call:cached_libraries, call:set_file_browser_path, call:resolve_remote_media_path, call:render_media_tab, call:render_file_browser, call:get_ssh_client, call:render_ssh_tools, func:set_prefixed_file_browser_path(path: str, selected_path, reset_filters) → None, call:set_file_browser_path, call:resolve_remote_media_path | dep: json, posixpath, typing, media_library_viewer.clients.jellyfin, media_library_viewer.clients.ssh, media_library_viewer.config, media_library_viewer.ui.dashboard, media_library_viewer.ui.file_browser, media_library_viewer.ui.media, media_library_viewer.ui.preview, streamlit
@@ -12,7 +12,7 @@ Streamlit-based media library viewer that provides a unified dashboard for brows
- jobs.py | Defines safe, template-based remote SSH jobs with shell-quoted parameter rendering. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:ssh.run | dep: shlex, dataclasses, typing, media_library_viewer.clients.ssh, typing.Mapping - 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 - 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 ## arch
Layered Streamlit application using immutable dataclass configuration, template-based remote job execution, cached data access, and separated utility functions following a tab-based modular UI pattern. Tab-based modular frontend using immutable dataclass configuration, environment-driven settings, cached data access, template-based remote job execution, and separated utility functions for metadata formatting.
## tags ## tags
client, path, media, call:, jellyfin, call:get, ssh, cached client, path, media, call:, jellyfin, call:get, ssh, cached
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/clients dir: archive/src/media_library_viewer/clients
## role ## role
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection. External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
## parent ## parent
index: archive/src/media_library_viewer/.pi-map.index.md index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md map: archive/src/media_library_viewer/.pi-map.md
@@ -4,14 +4,14 @@ dir: archive/src/media_library_viewer/clients
index: archive/src/media_library_viewer/clients/.pi-map.index.md index: archive/src/media_library_viewer/clients/.pi-map.index.md
## role ## role
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection. External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
## files ## files
- __init__.py | Package initialization file that defines external service clients module boundaries and constraints - __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 - 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 - 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 - 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 ## arch
Client-wrapper pattern with each module encapsulating a specific integration concern (Jellyfin HTTP API, SSH filesystem access, remote resource monitoring), returning plain Python types for frontend agnosticism. Modular client-per-service pattern with plain Python return types for frontend agnosticism, wrapping HTTP APIs (Jellyfin/Emby) and SSH/Paramiko connections with POSIX shell compatibility enforcement.
## tags ## tags
call:shlex.quote, resource, error, collector, call:ssh.run, raise:runtime, call:self.get, call:result.stdout.strip call:shlex.quote, resource, error, collector, call:ssh.run, raise:runtime, call:self.get, call:result.stdout.strip
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/domain dir: archive/src/media_library_viewer/domain
## role ## role
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display. Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
## parent ## parent
index: archive/src/media_library_viewer/.pi-map.index.md index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md map: archive/src/media_library_viewer/.pi-map.md
@@ -4,12 +4,12 @@ dir: archive/src/media_library_viewer/domain
index: archive/src/media_library_viewer/domain/.pi-map.index.md index: archive/src/media_library_viewer/domain/.pi-map.index.md
## role ## role
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display. Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
## files ## files
- __init__.py | Serves as the package docstring for a domain-level helpers/normalization module that converts external data into stable app concepts. - __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) - 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 ## arch
Functional transformation layer pattern mapping raw external API JSON directly into normalized flat dictionaries without intermediate ORM or complex object hierarchies. Functional transformation layer using dictionary flattening and field mapping to decouple external API data shapes from internal storage (SQLite) and presentation (frontend) concerns.
## tags ## tags
media, call:str, date, added, item, call:item.get, streams, call:stream media, call:str, date, added, item, call:item.get, streams, call:stream
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/services dir: archive/src/media_library_viewer/services
## role ## role
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations. Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
## parent ## parent
index: archive/src/media_library_viewer/.pi-map.index.md index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md map: archive/src/media_library_viewer/.pi-map.md
@@ -4,12 +4,12 @@ dir: archive/src/media_library_viewer/services
index: archive/src/media_library_viewer/services/.pi-map.index.md index: archive/src/media_library_viewer/services/.pi-map.index.md
## role ## role
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations. Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
## files ## 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. - __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 - 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 ## arch
Service-oriented pattern with SQLite-backed indexing, query filtering, and pagination encapsulated behind a single cohesive media index service module. Service-oriented architecture with SQLite persistence, providing filtering, sorting, and pagination capabilities abstracted away from UI concerns.
## tags ## tags
media, call:conn.execute, index, call:self.connect, schema, call:int, init, status media, call:conn.execute, index, call:self.connect, schema, call:int, init, status
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: archive/src/media_library_viewer/ui dir: archive/src/media_library_viewer/ui
## role ## role
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities. Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
## parent ## parent
index: archive/src/media_library_viewer/.pi-map.index.md index: archive/src/media_library_viewer/.pi-map.index.md
map: archive/src/media_library_viewer/.pi-map.md map: archive/src/media_library_viewer/.pi-map.md
@@ -4,7 +4,7 @@ dir: archive/src/media_library_viewer/ui
index: archive/src/media_library_viewer/ui/.pi-map.index.md index: archive/src/media_library_viewer/ui/.pi-map.index.md
## role ## role
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities. Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
## files ## files
- __init__.py | Package initialization file for Streamlit UI modules that documents the architectural pattern of splitting the application into separate render modules. - __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 - dashboard.py | Implements a Streamlit dashboard for monitoring a Jellyfin media server, displaying media library statistics, active playback sessions, and server resource metrics via SSH. | exp: func:format_rate_bytes(bytes_per_second: float | int | None) → str, call:human_size, func:rate_scale(max_value: float | int | None) → tuple[float, str], call:abs, call:float, func:scaled_rate_chart_df(chart_df: pd.DataFrame, columns: list[str], labels: list[str]) → tuple[pd.DataFrame, str], call:chart_df[columns].max(numeric_only=True).max, call:rate_scale, call:chart_df[columns].copy, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str, user_id: str) → None, call:st.subheader, call:cached_media_counts, call:st.warning, call:counts.get, call:st.columns, call:top_cols[0].metric, call:top_cols[1].metric, call:top_cols[2].metric, call:top_cols[3].metric, call:cached_library_counts, call:st.caption, call:st.markdown, call:e.get, call:st.container, call:m_cols[0].metric, call:m_cols[1].metric, func:render_now_playing(cached_active_sessions, base_url: str, api_key: str) → None, call:st.subheader, call:cached_active_sessions, call:st.warning, call:st.caption, call:session.get, call:bool, call:play_state.get, call:item.get, call:transcoding.get, call:transcode_type.append, call:rows.append, call:", ".join, call:st.dataframe, call:pd.DataFrame, func:render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed) → None, call:st.subheader, call:get_ssh_client, call:resource_collector_status, call:st.error, call:st.columns, call:control_col.caption, call:start_col.button, call:st.success, call:start_resource_collector, call:restart_col.button, call:restart_resource_collector, call:stop_col.button, call:st.info, call:stop_resource_collector, call:refresh_col.button, call:st.rerun, call:st.caption, call:read_resource_metrics, call:disk_space, call:float, call:str(space.get("used_pct", "0")).rstrip, call:space.get, call:disk_cols[0].metric, call:human_size, call:disk_cols[1].metric, call:disk_cols[2].metric, call:disk_cols[3].metric, call:st.progress, call:min, call:max, call:st.warning, call:st.expander, call:st.code, call:resource_collector_debug_info, call:pd.DataFrame, call:pd.to_numeric, call:df.dropna, call:pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert, call:time.time, call:len, call:st.write, call:raw_df['ts'].astype(float).max, call:st.dataframe, call:raw_df.tail, call:df.sort_values, call:df["cpu_pct"].mean, call:df["cpu_pct"].max, call:df["iowait_pct"].mean, call:df["iowait_pct"].max, call:df["mem_pct"].mean, call:df["mem_pct"].max, call:df["net_rx_bytes_per_sec"].mean, call:df["net_rx_bytes_per_sec"].max, call:df["net_tx_bytes_per_sec"].mean, call:df["net_tx_bytes_per_sec"].max, call:df["disk_read_bps"].mean, call:df["disk_read_bps"].max, call:df["disk_write_bps"].mean, call:df["disk_write_bps"].max, call:metric_cols[0].metric, call:metric_cols[0].caption, call:metric_cols[1].metric, call:latest.get, call:metric_cols[1].caption, call:metric_cols[2].metric, call:metric_cols[2].caption, call:metric_cols[3].metric, call:format_rate_bytes, call:metric_cols[3].caption, call:metric_cols[4].metric, call:metric_cols[4].caption, call:metric_cols[5].metric, call:metric_cols[5].caption, call:metric_cols[6].metric, call:metric_cols[6].caption, call:df.set_index, call:st.markdown, call:st.line_chart, call:scaled_rate_chart_df | dep: time, typing, media_library_viewer.clients.resources, media_library_viewer.utils, pandas, streamlit
@@ -12,7 +12,7 @@ Streamlit UI rendering layer for the media library viewer application, providing
- media.py | Renders a Streamlit UI tab for browsing and filtering a local SQLite-backed media index with ag-grid table display and automatic file browser synchronization. | exp: func:aggrid_selected_rows(response: dict[str, Any]) → list[dict[str, Any]], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_tab(client, user_id: str, libraries: list[dict[str, Any]], set_file_browser_path: Callable[[str, str | None, bool], None]) → None, call:st.subheader, call:st.caption, call:MediaIndex, call:index.status, call:st.columns, call:status_parts.append, call:format_elapsed, call:status_col.caption, call:" | ".join, call:status_col.warning, call:build_col.button, call:st.spinner, call:build_media_index, call:st.success, call:st.rerun, call:refresh_col.button, call:st.info, call:filter_col.multiselect, call:list, call:library_options.keys, call:type_col.multiselect, call:search_col.text_input, call:page_size_col.selectbox, call:page_col.number_input, call:sort_col.selectbox, call:sort_options.keys, call:order_col.selectbox, call:hdr_col.selectbox, call:index.query, call:int, call:len, call:pd.DataFrame(rows)[columns].fillna, call:st.session_state.get, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:min, call:aggrid_selected_rows, call:selected_rows[0].get, call:set_file_browser_path, call:str, call:PurePosixPath, call:st.expander, call:st.write | dep: pathlib, typing, st_aggrid, media_library_viewer.services.media_index, pandas, streamlit - 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 - 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 ## arch
Module-based render pattern where each UI tab/view is isolated in its own module, sharing session state for cross-component synchronization (e.g., file browser auto-sync) and leveraging ag-grid for interactive data tables. Modular page-by-page rendering pattern where each module is a self-contained Streamlit view, integrated through shared session state for cross-component synchronization.
## tags ## tags
call:metric, call:grid, render, call:st.caption, col.button, browser, col.selectbox, media call:metric, call:grid, render, call:st.caption, col.button, browser, col.selectbox, media
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: archive/tests dir: archive/tests
## role ## role
Legacy or archived test directory currently containing only a placeholder file with no active test code. Empty placeholder directory retained for historical/archived test files that are no longer actively used.
## parent ## parent
index: archive/.pi-map.index.md index: archive/.pi-map.index.md
map: archive/.pi-map.md map: archive/.pi-map.md
+2 -2
View File
@@ -4,11 +4,11 @@ dir: archive/tests
index: archive/tests/.pi-map.index.md index: archive/tests/.pi-map.index.md
## role ## role
Legacy or archived test directory currently containing only a placeholder file with no active test code. Empty placeholder directory retained for historical/archived test files that are no longer actively used.
## files ## files
- .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
## arch ## arch
Empty placeholder structure using a `.gitkeep` file to preserve the directory in version control for potential future use. No active code; contains only a `.gitkeep` placeholder file (with an unrelated description) to preserve the directory structure in version control.
## tags ## tags
tmux, swaps, position, two, panes, within, window, windows tmux, swaps, position, two, panes, within, window, windows
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend dir: backend
## role ## role
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints. FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
## parent ## parent
index: ./.pi-map.index.md index: ./.pi-map.index.md
map: ./.pi-map.md map: ./.pi-map.md
+2 -2
View File
@@ -4,13 +4,13 @@ dir: backend
index: backend/.pi-map.index.md index: backend/.pi-map.index.md
## role ## role
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints. FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
## files ## 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 - 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 - 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 - 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 ## arch
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support. Layered API architecture using FastAPI with Uvicorn ASGI server, containerized via Docker, configured through pyproject.toml with standardized linting and testing pipelines.
## tags ## tags
uvicorn, fastapi, python, backend, pyproject, settings, docker, api uvicorn, fastapi, python, backend, pyproject, settings, docker, api
## symbols ## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend/src dir: backend/src
## role ## role
Root source directory serving as the main entry point and organizational container for the backend application. Core backend application source directory containing server-side business logic, API routes, models, and configuration.
## parent ## parent
index: backend/.pi-map.index.md index: backend/.pi-map.index.md
map: backend/.pi-map.md map: backend/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: backend/src
index: backend/src/.pi-map.index.md index: backend/src/.pi-map.index.md
## role ## role
Root source directory serving as the main entry point and organizational container for the backend application. Core backend application source directory containing server-side business logic, API routes, models, and configuration.
## files ## files
## arch ## arch
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture). Cannot be fully determined as no files are listed in the directory; likely follows standard Node.js/Python backend patterns (e.g., MVC, layered architecture) depending on framework used.
## tags ## tags
- -
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api dir: backend/src/media_library_viewer_api
## role ## role
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems. FastAPI backend providing authenticated APIs for remote media library inspection, SSH job execution, and observability via Jellyfin integration.
## parent ## parent
index: backend/src/.pi-map.index.md index: backend/src/.pi-map.index.md
map: backend/src/.pi-map.md map: backend/src/.pi-map.md
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
## role ## role
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems. FastAPI backend providing authenticated APIs for remote media library inspection, SSH job execution, and observability via Jellyfin integration.
## files ## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - __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 - 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 - config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
- dependencies.py | Provides FastAPI dependency injection functions for resolving and caching service clients (Jellyfin, Jellyseerr, SSH/Local) and settings based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets - dependencies.py | Provides FastAPI dependency injection functions that resolve and instantiate service clients like Jellyfin and SSH based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:_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:str(service.get("config", {}).get("user_id") or "").strip, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_resolved_user_id, raise:HTTPException, func:_resolved_user_id(cache_key: tuple[str, str, str, str]) → str, call:_jellyfin_client_for, call:client.resolve_user_id | 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 - 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 - logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version - main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health, version, and metrics endpoints. | exp: func:_validate_prometheus_gateway_config() → None, call:get_settings_store, call:store.list_services, call:service.get, call:logger.warning, call:logger.exception, 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:_validate_prometheus_gateway_config, 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, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
- 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 - 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 - 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 - 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 - 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 ## arch
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution. Layered FastAPI architecture using dependency injection, Pydantic settings, middleware-based auth (OIDC/JWT/API key), and modular utilities for configuration, logging, metrics, and path mapping.
## tags ## tags
call:, settings, call:get, request, get, client, call:str, id call:, settings, call:get, request, id, get, call:str, client
## symbols ## symbols
- Settings - Settings
- JobTemplate - JobTemplate
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/clients dir: backend/src/media_library_viewer_api/clients
## role ## role
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection. Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems.
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -10,15 +10,18 @@ map: backend/src/media_library_viewer_api/.pi-map.md
- -
## files ## files
- __init__.py - __init__.py
- authentik.py
- http_timeout.py
- jellyfin.py - jellyfin.py
- jellyseerr.py - jellyseerr.py
- local.py - local.py
- qbittorrent.py
- ssh.py - ssh.py
## links ## links
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
map: backend/src/media_library_viewer_api/clients/.pi-map.md map: backend/src/media_library_viewer_api/clients/.pi-map.md
## workflows ## workflows
- change clients behavior - change clients behavior
read: __init__.py, jellyfin.py, jellyseerr.py read: __init__.py, authentik.py, http_timeout.py
## dirty ## dirty
- -
@@ -4,28 +4,31 @@ dir: backend/src/media_library_viewer_api/clients
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
## role ## role
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection. Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems.
## files ## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- 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 - authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, 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, media_library_viewer_api.clients.http_timeout
- 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 - http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float
- jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, 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:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, 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, media_library_viewer_api.clients.http_timeout
- jellyseerr.py | HTTP API client for Jellyseerr that fetches and enriches Jellyfin user and request metadata. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, 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:_resolve_title(self, media_type: Any, tmdb_id: Any) → str, call:str, call:self.get, call:data.get, 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, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:mapped.append, call:_label, call:(media or {}).get, method:open_requests(self, max_per_filter) → list[dict[str, Any]], call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:results.append, call:_label, call:(media or {}).get, call:len, call:results.sort, call:logger.info, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
- 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 - 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 | Minimal read-only qBittorrent Web API client that authenticates via username/password and fetches/merges incremental sync/maindata snapshots with caching, locking, and exponential backoff. | 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:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, 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:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout
- 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 - 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 ## arch
Client-wrapper pattern with framework-agnostic abstractions; parallel local/remote execution strategies via paramiko SSH and local subprocess; centralized REST API communication modules. Adapter/wrapper pattern around `requests` HTTP and SSH/paramiko protocols, with each client encapsulating authentication, data fetching, and response normalization into plain Python dictionaries.
## tags ## tags
call:logger.info, call:logger.debug, call:self.get, call:shlex.quote, error, host, call:self.run, init call:logger.info, call:self.get, call:self., error, call:logger.debug, call:logger.warning, call:isinstance, client
## symbols ## symbols
- AuthentikClient
- JellyfinClient - JellyfinClient
- JellyseerrClient - JellyseerrClient
- CommandResult - CommandResult
- LocalCommandClient - LocalCommandClient
- QbittorrentClient
- RemoteSSHClient - RemoteSSHClient
- __init__ - __init__
- get
- users
## workflows ## workflows
- change clients behavior - change clients behavior
read: __init__.py, jellyfin.py, jellyseerr.py read: __init__.py, authentik.py, http_timeout.py
## dirty ## dirty
- -
@@ -1,9 +1,7 @@
"""Authentik directory API client. """Read-only Authentik directory client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users The client normalizes the subset of Authentik core data that Manage displays.
page). This client wraps the Authentik REST API for browsing the user directory It deliberately does not fetch individual users or expose policy/provider data.
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,13 +11,40 @@ from typing import Any
import requests import requests
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_MAX_COLLECTION_ITEMS = 10_000
_PAGE_SIZE = 100
def _text(value: Any) -> str:
return str(value).strip() if value is not None else ""
def _identifier(item: dict[str, Any]) -> str:
for key in ("pk", "id", "uuid"):
value = _text(item.get(key))
if value:
return value
return ""
def _page_total(payload: dict[str, Any], fallback: int) -> int:
pagination = payload.get("pagination")
if isinstance(pagination, dict):
try:
return max(0, int(pagination.get("count") or fallback))
except (TypeError, ValueError):
pass
return fallback
class AuthentikClient: class AuthentikClient:
"""Small wrapper around the Authentik core directory API.""" """Small wrapper around Authentik's read-only core API."""
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0): def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT):
if not base_url: if not base_url:
raise ValueError("Authentik base_url is required") raise ValueError("Authentik base_url is required")
if not api_token: if not api_token:
@@ -29,87 +54,139 @@ class AuthentikClient:
if self.base_url.endswith("/api/v3"): if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7] self.base_url = self.base_url[:-7]
self.api_token = api_token self.api_token = api_token
self.timeout = timeout self.timeout = http_timeout(timeout)
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update( self.session.headers.update({"Authorization": f"Bearer {api_token}", "Accept": "application/json"})
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any: def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors.""" """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 != ""} clean_params = {key: value for key, value in params.items() if value is not None and value != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys())) logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get( response = self.session.get(f"{self.base_url}/api/v3{path}", params=clean_params, timeout=self.timeout)
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
try: try:
response.raise_for_status() response.raise_for_status()
except requests.HTTPError as exc: except requests.HTTPError as exc:
detail = response.text[:500] detail = response.text[:500]
logger.warning( logger.warning("Authentik GET %s failed status=%s url=%s", path, response.status_code, response.url)
"Authentik GET %s failed status=%s url=%s", raise requests.HTTPError(f"{response.status_code} for {response.url}: {detail}", response=response) from exc
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() return response.json()
def users( def users(self, search: str | None = None, page: int = 1, page_size: int = 50) -> dict[str, Any]:
"""Return one raw user page for the directory and messaging surfaces."""
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 = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
return {"items": items, "total": _page_total(payload, len(items)), "page": page, "page_size": page_size}
def _collection(self, path: str, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Read a paginated core collection with a hard cap and loop protection."""
try:
requested = max(1, min(int(limit), _MAX_COLLECTION_ITEMS))
except (TypeError, ValueError):
requested = _MAX_COLLECTION_ITEMS
items: list[dict[str, Any]] = []
page = 1
total = 0
while len(items) < requested:
payload = self.get(path, page=page, page_size=min(_PAGE_SIZE, requested - len(items)))
if not isinstance(payload, dict):
logger.warning("Authentik %s payload was not a dict: %s", path, type(payload).__name__)
break
results = payload.get("results")
page_items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
total = _page_total(payload, len(items) + len(page_items))
items.extend(page_items[: requested - len(items)])
if not page_items or len(items) >= total:
break
page += 1
if page > 100: # defensive limit for malformed pagination responses
logger.warning("Authentik %s pagination stopped after 100 pages", path)
break
return {"items": items, "total": total or len(items)}
@staticmethod
def _normalize_group(item: dict[str, Any]) -> dict[str, str] | None:
group_id = _identifier(item)
if not group_id:
return None
name = _text(item.get("name") or item.get("display_name") or item.get("slug"))
return {"id": group_id, "name": name or f"Unnamed group ({group_id})"}
@staticmethod
def _normalize_application(item: dict[str, Any]) -> dict[str, str]:
app_id = _identifier(item)
return {
"id": app_id,
"name": _text(item.get("name") or item.get("slug") or item.get("meta_name")) or "Unnamed application",
"slug": _text(item.get("slug")),
"launch_url": _text(item.get("launch_url") or item.get("meta_launch_url")),
}
def groups(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Return normalized groups; only display-safe identifiers and names are retained."""
raw = self._collection("/core/groups/", limit)
items = [normalized for item in raw["items"] if (normalized := self._normalize_group(item)) is not None]
return {"items": items, "total": raw["total"]}
def applications(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
"""Return normalized applications without provider, policy, or secret fields."""
raw = self._collection("/core/applications/", limit)
return {"items": [self._normalize_application(item) for item in raw["items"]], "total": raw["total"]}
@staticmethod
def _group_references(user: dict[str, Any]) -> list[str]:
"""Extract group ids from release-dependent user reference shapes."""
raw = user.get("groups", user.get("group", []))
if not isinstance(raw, list):
raw = [raw] if raw is not None else []
ids: list[str] = []
for reference in raw:
if isinstance(reference, dict):
group_id = _identifier(reference)
else:
group_id = _text(reference)
if group_id and group_id not in ids:
ids.append(group_id)
return ids
def access_summaries(
self, self,
search: str | None = None, search: str | None = None,
page: int = 1, page: int = 1,
page_size: int = 50, page_size: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return a normalized page of Authentik users. """Summarize user group references and privileged flags without N+1 user reads.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated This is directory metadata only: group membership plus the explicit
Authentik response into ``{items, total, page, page_size}``. Each item ``is_superuser`` and ``is_staff`` fields. It does not evaluate policies
is the raw Authentik user dict (pk, username, name, email, avatar, …) or claim to calculate effective authorization.
so the frontend can pick the fields it needs.
""" """
payload = self.get( users = self.users(search=search, page=page, page_size=page_size)
"/core/users/", groups = self.groups()
search=search, group_names = {group["id"]: group["name"] for group in groups["items"]}
page=page, summaries: list[dict[str, Any]] = []
page_size=page_size, for user in users["items"]:
) group_ids = self._group_references(user)
if not isinstance(payload, dict): summaries.append(
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__) {
return {"items": [], "total": 0, "page": page, "page_size": page_size} "id": _identifier(user),
"username": _text(user.get("username")),
results = payload.get("results") "name": _text(user.get("name")),
items: list[dict[str, Any]] = ( "email": _text(user.get("email")),
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] "is_active": bool(user.get("is_active", True)),
) "is_superuser": bool(user.get("is_superuser", False)),
"is_staff": bool(user.get("is_staff", False)),
pagination = payload.get("pagination") or {} "groups": [
total = 0 {
if isinstance(pagination, dict): "id": group_id,
try: "name": group_names.get(group_id, f"Unknown group ({group_id})"),
total = int(pagination.get("count") or 0) "known": group_id in group_names,
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,
} }
for group_id in group_ids
],
}
)
return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]}
@@ -0,0 +1,36 @@
"""Shared HTTP timeout helpers.
``requests`` accepts a single integer timeout and applies it to BOTH the
connect and read phases. For slow upstream services (large Jellyfin
libraries, qBittorrent with many torrents), the read phase needs a much
larger budget than connect. These helpers produce ``(connect, read)`` tuples
so the two phases are decoupled.
"""
from __future__ import annotations
#: Short connect timeout — fail fast on unreachable/dead hosts.
DEFAULT_CONNECT_TIMEOUT = 5.0
#: Generous read timeout — let slow responses complete.
DEFAULT_READ_TIMEOUT = 60.0
def http_timeout(
read_timeout: float | int | None = None,
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
) -> tuple[float, float]:
"""Build a ``(connect, read)`` timeout tuple for ``requests``.
``read_timeout`` is the per-response read budget (seconds). When omitted
or non-positive, :data:`DEFAULT_READ_TIMEOUT` applies.
"""
effective_read = DEFAULT_READ_TIMEOUT
if read_timeout is not None:
try:
parsed = float(read_timeout)
if parsed > 0:
effective_read = parsed
except (TypeError, ValueError):
pass # fall back to default on non-numeric input
return (connect_timeout, effective_read)
@@ -12,6 +12,8 @@ from typing import Any, cast
import requests import requests
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,7 +37,7 @@ DEFAULT_FIELDS = ",".join(
class JellyfinClient: class JellyfinClient:
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API.""" """Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
def __init__(self, base_url: str, api_key: str, timeout: int = 30): def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT):
if not base_url: if not base_url:
raise ValueError("Jellyfin URL is required") raise ValueError("Jellyfin URL is required")
if not api_key: if not api_key:
@@ -47,7 +49,8 @@ class JellyfinClient:
if self.base_url.endswith("/web"): if self.base_url.endswith("/web"):
self.base_url = self.base_url[:-4] self.base_url = self.base_url[:-4]
self.api_key = api_key self.api_key = api_key
self.timeout = timeout # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
self.timeout = http_timeout(timeout)
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update( self.session.headers.update(
{ {
@@ -87,6 +90,32 @@ class JellyfinClient:
logger.info("Jellyfin returned %s visible users", len(users)) logger.info("Jellyfin returned %s visible users", len(users))
return users return users
def resolve_user_id(self, identifier: str | None) -> str:
"""Resolve a configured user identifier to Jellyfin's internal Id.
The service ``user_id`` config field accepts either the internal Jellyfin
Id (a hash) or a username (e.g. ``'admin'``). Jellyfin's
``/Users/{id}/...`` endpoints reject usernames with HTTP 400
(``"The value 'admin' is not valid."``), so any caller must resolve
usernames to the real Id before hitting user-scoped endpoints.
Resolution order: exact ``Id`` match → ``Name`` match → first visible
user. Raises if the API key cannot see any users.
"""
users = self.users()
if not users:
raise RuntimeError("No Jellyfin users visible to this API key")
if identifier:
if any(str(u.get("Id")) == identifier for u in users):
return identifier
match = next((u for u in users if str(u.get("Name", "")) == identifier), None)
if match:
resolved = str(match["Id"])
logger.info("Resolved Jellyfin username %r to Id %s", identifier, resolved)
return resolved
logger.warning("Jellyfin user identifier %r not found; using first user", identifier)
return str(users[0]["Id"])
def libraries(self, user_id: str) -> list[dict[str, Any]]: def libraries(self, user_id: str) -> list[dict[str, Any]]:
"""Return top-level library views visible to the selected Jellyfin user.""" """Return top-level library views visible to the selected Jellyfin user."""
items = self.get(f"/Users/{user_id}/Views").get("Items", []) items = self.get(f"/Users/{user_id}/Views").get("Items", [])
@@ -11,13 +11,33 @@ from typing import Any
import requests import requests
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Jellyseerr numeric status enums (see Overseerr/Jellyseerr source).
_REQUEST_STATUS: dict[int, str] = {1: "pending", 2: "approved", 3: "declined"}
_MEDIA_STATUS: dict[int, str] = {
1: "unknown",
2: "pending",
3: "processing",
4: "partially_available",
5: "available",
}
_REQUEST_TYPE: dict[int, str] = {1: "movie", 2: "tv"}
def _label(value: Any, table: dict[int, str]) -> str:
try:
return table.get(int(value), str(value))
except (TypeError, ValueError):
return str(value) if value is not None else ""
class JellyseerrClient: class JellyseerrClient:
"""Small wrapper around the Jellyseerr REST API.""" """Small wrapper around the Jellyseerr REST API."""
def __init__(self, base_url: str, api_key: str, timeout: int = 30): def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT):
if not base_url: if not base_url:
raise ValueError("Jellyseerr URL is required") raise ValueError("Jellyseerr URL is required")
if not api_key: if not api_key:
@@ -27,7 +47,8 @@ class JellyseerrClient:
if self.base_url.endswith("/api/v1"): if self.base_url.endswith("/api/v1"):
self.base_url = self.base_url[:-7] self.base_url = self.base_url[:-7]
self.api_key = api_key self.api_key = api_key
self.timeout = timeout # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
self.timeout = http_timeout(timeout)
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update( self.session.headers.update(
{ {
@@ -35,6 +56,7 @@ class JellyseerrClient:
"Accept": "application/json", "Accept": "application/json",
} }
) )
self._title_cache: dict[tuple[str, str], str] = {}
def get(self, path: str, **params: Any) -> Any: def get(self, path: str, **params: Any) -> Any:
"""GET a Jellyseerr endpoint and include useful response text on errors.""" """GET a Jellyseerr endpoint and include useful response text on errors."""
@@ -63,6 +85,26 @@ class JellyseerrClient:
path = f"/{path}" path = f"/{path}"
return f"{self.base_url}{path}" return f"{self.base_url}{path}"
def _resolve_title(self, media_type: Any, tmdb_id: Any) -> str:
"""Resolve a media title via /movie/{tmdbId} or /tv/{tmdbId}, cached.
Jellyseerr's /request list doesn't include titles; they live on the
Movie/Series records. Cached per (type, tmdbId) so repeated polls reuse.
"""
if not tmdb_id:
return ""
key = (str(media_type or ""), str(tmdb_id))
if key in self._title_cache:
return self._title_cache[key]
try:
is_tv = str(media_type) in ("2", "tv")
data = self.get(f"/{'tv' if is_tv else 'movie'}/{tmdb_id}")
title = str(data.get("name" if is_tv else "title") or "")
except Exception:
title = ""
self._title_cache[key] = title
return title
def jellyfin_users(self) -> list[dict[str, Any]]: def jellyfin_users(self) -> list[dict[str, Any]]:
"""Return Jellyfin-linked users known to Jellyseerr. """Return Jellyfin-linked users known to Jellyseerr.
@@ -133,3 +175,102 @@ class JellyseerrClient:
logger.info("Jellyseerr returned %s users", len(results)) logger.info("Jellyseerr returned %s users", len(results))
return results return results
def request_count(self) -> dict[str, int]:
"""Return normalized request counts from /api/v1/request/count.
Jellyseerr reports pending/approved/declined/processing/available/total.
Missing keys default to 0 so callers can rely on a stable shape.
"""
payload = self.get("/request/count")
if not isinstance(payload, dict):
payload = {}
keys = ("total", "pending", "approved", "declined", "processing", "available")
counts = {k: int(payload.get(k) or 0) for k in keys}
logger.info(
"Jellyseerr request counts total=%s pending=%s processing=%s",
counts["total"],
counts["pending"],
counts["processing"],
)
return counts
def recent_requests(self, take: int = 20) -> list[dict[str, Any]]:
"""Return the most recently modified requests with resolved titles."""
take = max(1, min(int(take), 100))
payload = self.get("/request", sort="modified", skip=0, take=take)
if not isinstance(payload, dict):
return []
results = payload.get("results") or []
items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
mapped: list[dict[str, Any]] = []
for r in items:
media = r.get("media") or {}
tmdb_id = media.get("tmdbId")
name = r.get("title") or media.get("title") or media.get("name") or ""
if not name and tmdb_id:
name = self._resolve_title(r.get("type"), tmdb_id)
if not name:
name = media.get("externalServiceSlug") or ""
mapped.append(
{
"id": r.get("id"),
"type": _label(r.get("type"), _REQUEST_TYPE),
"name": name or "",
"status": _label(r.get("status"), _REQUEST_STATUS),
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
"created_at": r.get("createdAt"),
}
)
return mapped
def open_requests(self, max_per_filter: int = 100) -> list[dict[str, Any]]:
"""Return open (pending + approved) requests with resolved titles.
Fetches pending and approved requests via Jellyseerr's filter param
(not all 800+ historical requests), then resolves titles from
/movie/{tmdbId} or /tv/{tmdbId}. Titles are cached on the client so
subsequent polls are instant.
"""
results: list[dict[str, Any]] = []
take = 50
for filter_val in ("pending", "approved"):
skip = 0
while skip < max_per_filter:
payload = self.get("/request", filter=filter_val, sort="added", skip=skip, take=take)
if not isinstance(payload, dict):
break
page = payload.get("results") or []
items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else []
for r in items:
media = r.get("media") or {}
tmdb_id = media.get("tmdbId") or r.get("tmdbId")
# Diagnostic: log the first request's shape once so we can verify tmdbId.
if not results and filter_val == "pending":
logger.info(
"Jellyseerr request sample: keys=%s media_keys=%s tmdbId=%s",
sorted(r.keys()),
sorted(media.keys()) if isinstance(media, dict) else "N/A",
tmdb_id,
)
name = r.get("title") or media.get("title") or media.get("name") or ""
if not name and tmdb_id:
name = self._resolve_title(r.get("type"), tmdb_id)
if not name:
name = media.get("externalServiceSlug") or ""
results.append(
{
"id": r.get("id"),
"type": _label(r.get("type"), _REQUEST_TYPE),
"name": name or "",
"status": _label(r.get("status"), _REQUEST_STATUS),
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
"created_at": r.get("createdAt"),
}
)
if len(items) < take:
break
skip += len(items)
results.sort(key=lambda r: r.get("created_at") or 0, reverse=True)
logger.info("Jellyseerr returned %s open requests (with titles)", len(results))
return results
@@ -8,12 +8,21 @@ SID cookie in the requests session. The client re-logins transparently on 403.
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading
import time
from typing import Any from typing import Any
import requests import requests
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# qBittorrent's built-in web server is effectively single-threaded; collapse
# concurrent widget polls onto one fetch and back off when it struggles.
MAINDATA_CACHE_TTL = 3.0 # seconds a snapshot is served without re-hitting qBittorrent
MAINDATA_BACKOFF_MAX = 30.0 # cap exponential backoff after repeated failures
class QbittorrentClient: class QbittorrentClient:
"""Small wrapper around the qBittorrent Web API. """Small wrapper around the qBittorrent Web API.
@@ -23,7 +32,7 @@ class QbittorrentClient:
:class:`requests.Session` that carries the login cookie. :class:`requests.Session` that carries the login cookie.
""" """
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None: def __init__(self, base_url: str, username: str, password: str, timeout: float = DEFAULT_READ_TIMEOUT) -> None:
if not base_url: if not base_url:
raise ValueError("qBittorrent base_url is required") raise ValueError("qBittorrent base_url is required")
if not username: if not username:
@@ -34,15 +43,42 @@ class QbittorrentClient:
self.base_url += "/api/v2" self.base_url += "/api/v2"
self._username = username self._username = username
self._password = password self._password = password
self.timeout = timeout # timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
self.timeout = http_timeout(timeout)
self._session = requests.Session() self._session = requests.Session()
self._logged_in = False self._logged_in = False
# /sync/maindata is the only hot endpoint. Maintain a rid-merged
# snapshot (incremental updates -> small payloads), a short-TTL cache
# + lock so concurrent widgets share one fetch, and back off when
# qBittorrent is struggling rather than piling on (its web server is
# single-threaded and otherwise hangs the Web UI for everyone).
self._rid: int | None = None
self._snapshot: dict[str, Any] = {
"server_state": {},
"torrents": {},
"categories": {},
"tags": [],
"trackers": [],
}
self._maindata_lock = threading.Lock()
self._maindata_fetched_at: float = 0.0
self._maindata_ttl: float = MAINDATA_CACHE_TTL
self._backoff_until: float = 0.0
self._consecutive_failures = 0
def _login(self) -> None: def _login(self) -> None:
"""POST username/password to ``/auth/login``; store the SID cookie. """POST username/password to ``/auth/login``; store the SID cookie.
qBittorrent returns the plain text ``"Ok."`` on success. The qBittorrent replies with the plain text ``"Ok."`` and a ``SID`` cookie
``Referer`` header is required by some qBittorrent CSRF protections. on success, ``"Fails."`` on bad credentials, and ``403 Forbidden`` when
the source IP is banned (too many failed attempts). The ``Referer``
header is required by qBittorrent's CSRF protection.
Any other body — in particular an *empty* 200 — means the request did not
reach qBittorrent's login handler, almost always because ``base_url`` is
wrong (wrong host/port/path) or a reverse proxy is misrouting
``/api/v2/auth/login``. We surface a diagnostic error in that case
instead of the useless ``"login failed: "`` message.
""" """
resp = self._session.post( resp = self._session.post(
f"{self.base_url}/auth/login", f"{self.base_url}/auth/login",
@@ -50,11 +86,49 @@ class QbittorrentClient:
timeout=self.timeout, timeout=self.timeout,
headers={"Referer": self.base_url}, headers={"Referer": self.base_url},
) )
# 502/503/504 come from the reverse proxy when qBittorrent is down,
# starting up, or can't answer within the proxy's forwarding timeout
# (qBittorrent's PBKDF2 password check is intentionally slow, so a
# flood of concurrent logins can trip this). Surface it clearly rather
# than as a bare HTTPError.
if resp.status_code in (502, 503, 504):
raise RuntimeError(
f"qBittorrent is unreachable: reverse proxy returned HTTP {resp.status_code} "
f"for {resp.url}. qBittorrent may be down, starting up, or unable to "
"answer within the proxy's forwarding timeout."
)
resp.raise_for_status() resp.raise_for_status()
if resp.text.strip() != "Ok.": body = resp.text.strip()
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
# qBittorrent signals a successful login with the body "Ok." and/or by
# setting a session cookie. The cookie is named "SID" in older versions
# and "QBT_SID" / "QBT_SID_<port>" in newer ones. Some setups return 204
# No Content with the cookie and no body, and ``requests`` doesn't always
# populate the cookie jar, so check both the jar and the raw Set-Cookie
# header. qBittorrent only sets this cookie on a valid login.
def _is_session_cookie(name: str) -> bool:
upper = name.strip().upper()
return upper == "SID" or upper.startswith("QBT_SID")
set_cookie_hdr = resp.headers.get("Set-Cookie", "") or ""
first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip()
sid_ok = any(_is_session_cookie(k) for k in resp.cookies.keys()) or (
bool(first_cookie_name) and _is_session_cookie(first_cookie_name)
)
if body == "Ok." or sid_ok:
self._logged_in = True self._logged_in = True
logger.info("qBittorrent login successful for %s", self.base_url) logger.info("qBittorrent login successful for %s", self.base_url)
return
if body == "Fails.":
raise RuntimeError(f"qBittorrent login failed (HTTP {resp.status_code}): invalid username or password")
cookie_names = sorted(resp.cookies.keys()) or (["<unparsed>"] if set_cookie_hdr else [])
raise RuntimeError(
f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, "
f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or a session "
"cookie (SID / QBT_SID) from /api/v2/auth/login — this usually means base_url does "
"not reach the qBittorrent Web API (check the URL, path, and any reverse proxy in "
"front of qBittorrent)."
)
def _get(self, path: str, **params: Any) -> dict[str, Any]: def _get(self, path: str, **params: Any) -> dict[str, Any]:
"""GET an endpoint with auto-login on first call and re-login on 403.""" """GET an endpoint with auto-login on first call and re-login on 403."""
@@ -71,10 +145,107 @@ class QbittorrentClient:
return resp.json() return resp.json()
def maindata(self) -> dict[str, Any]: def maindata(self) -> dict[str, Any]:
"""Fetch ``/sync/maindata``. """Return the current ``/sync/maindata`` snapshot.
Returns a dict with ``server_state`` (containing ``dl_info_speed``, Uses qBittorrent's incremental ``rid`` protocol (first call is a full
``up_info_speed``, etc.) and ``torrents`` (a dict of update, subsequent calls send the last rid and get a small diff that is
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``). merged into the cached snapshot), so payloads stay small. A short-TTL
cache + lock collapses concurrent widget polls onto a single fetch, and
on repeated failures the client backs off instead of hammering
qBittorrent's single-threaded web server (serving the last good
snapshot when available).
Returns a dict with ``server_state`` and ``torrents``.
""" """
return self._get("/sync/maindata") now = time.time()
with self._maindata_lock:
# Serve a fresh-enough cached snapshot without re-hitting qBittorrent.
if self._snapshot.get("torrents") and (now - self._maindata_fetched_at) < self._maindata_ttl:
return self._copy_snapshot()
# While backing off, don't pile on; serve stale or raise.
if now < self._backoff_until:
if self._snapshot.get("torrents"):
return self._copy_snapshot()
raise RuntimeError(
"qBittorrent maindata unavailable (backing off after repeated failures)"
)
try:
update = self._fetch_maindata_incremental()
self._apply_update(update)
except Exception as exc:
self._consecutive_failures += 1
delay = min(2 ** self._consecutive_failures, MAINDATA_BACKOFF_MAX)
self._backoff_until = time.time() + delay
logger.warning(
"qBittorrent maindata fetch failed (#%s); backing off %.0fs: %s",
self._consecutive_failures,
delay,
exc,
)
if self._snapshot.get("torrents"):
return self._copy_snapshot()
raise RuntimeError(f"qBittorrent maindata failed: {exc}") from exc
self._maindata_fetched_at = time.time()
self._consecutive_failures = 0
self._backoff_until = 0.0
return self._copy_snapshot()
def _fetch_maindata_incremental(self) -> dict[str, Any]:
"""GET /sync/maindata, sending the last rid for an incremental update."""
params: dict[str, Any] = {}
if self._rid is not None:
params["rid"] = self._rid
return self._get("/sync/maindata", **params)
def _apply_update(self, update: dict[str, Any]) -> None:
"""Merge a full or partial maindata update into the cached snapshot."""
is_full = bool(update.get("full_update")) or self._rid is None
self._rid = update.get("rid", self._rid)
snap = self._snapshot
if is_full:
snap.clear()
snap["server_state"] = dict(update.get("server_state") or {})
snap["torrents"] = dict(update.get("torrents") or {})
snap["categories"] = dict(update.get("categories") or {})
snap["tags"] = list(update.get("tags") or [])
snap["trackers"] = list(update.get("trackers") or [])
return
# Partial update — merge the diff.
server_state = update.get("server_state")
if isinstance(server_state, dict):
snap["server_state"].update(server_state)
changed = update.get("torrents")
if isinstance(changed, dict):
for hash_, fields in changed.items():
if fields is None:
snap["torrents"].pop(hash_, None)
else:
previous = snap["torrents"].get(hash_)
snap["torrents"][hash_] = (
{**previous, **fields}
if isinstance(previous, dict) and isinstance(fields, dict)
else fields
)
for hash_ in update.get("torrents_removed") or []:
snap["torrents"].pop(hash_, None)
categories = update.get("categories")
if isinstance(categories, dict):
snap["categories"].update(categories)
for name in update.get("categories_removed") or []:
snap["categories"].pop(name, None)
if "tags" in update:
snap["tags"] = list(update.get("tags") or [])
if "trackers" in update:
snap["trackers"] = list(update.get("trackers") or [])
def _copy_snapshot(self) -> dict[str, Any]:
"""Return a shallow, race-safe copy of the current snapshot."""
snap = self._snapshot
return {
"rid": self._rid,
"server_state": dict(snap.get("server_state") or {}),
"torrents": dict(snap.get("torrents") or {}),
"categories": dict(snap.get("categories") or {}),
"tags": list(snap.get("tags") or []),
"trackers": list(snap.get("trackers") or []),
}
@@ -1,12 +1,12 @@
"""Dependency injection for FastAPI. """Dependency injection for FastAPI.
Provides access to service-specific Jellyfin/Jellyseerr clients and Provides access to service-specific Jellyfin/Jellyseerr clients and
machine-specific SSH clients via FastAPI's request context. remote-machine SSH clients via FastAPI's request context.
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query - Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
parameter (resolved against the service registry); the backend falls back to parameter (resolved against the service registry); the backend falls back to
the first enabled ``jellyfin``/``jellyseerr`` service instance. the first enabled ``jellyfin``/``jellyseerr`` service instance.
- SSH/Files transport is selected with ``machine_id`` as before. - SSH/Files transport is selected with an enabled ``remote_machine`` ``service_id``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,9 +18,7 @@ from typing import Any
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.services.mail_queue import MailQueue from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
@@ -29,11 +27,11 @@ from media_library_viewer_api.services.settings_store import get_settings_store
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _request_machine_id(request: Request | None) -> str | None: def _request_remote_machine_service_id(request: Request | None) -> str | None:
if request is None: if request is None:
return None return None
machine_id = request.query_params.get("machine_id") service_id = request.query_params.get("service_id")
return machine_id or None return service_id or None
def _request_jellyfin_service_id(request: Request | None) -> str | None: def _request_jellyfin_service_id(request: Request | None) -> str | None:
@@ -80,87 +78,10 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
return JellyfinClient(url, api_key) return JellyfinClient(url, api_key)
@lru_cache(maxsize=32) def get_jellyfin_client(request: Request) -> JellyfinClient:
def _ssh_client_for( """Return a Jellyfin client for the selected enabled service instance."""
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
) -> RemoteSSHClient:
machine_id, host, username, port, key_filename, password, private_key, private_key_passphrase, known_hosts_path = (
cache_key
)
logger.info(
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%s passphrase=%s",
machine_id or "<default>",
host or "<unset>",
username or "<unset>",
port,
key_filename or "<unset>",
"set" if password else "missing",
"set" if private_key else "missing",
"set" if private_key_passphrase else "missing",
)
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=private_key_passphrase or None,
password=password or None,
known_hosts_path=known_hosts_path or None,
)
try:
client.connect()
except RuntimeError as exc:
message = str(exc)
lowered = message.lower()
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
if "banner" in lowered:
raise HTTPException(
status_code=502,
detail=(
f"SSH banner not received from {host}:{port}. "
"Confirm the host, port, and firewall; the backend could not complete the SSH handshake."
),
) from exc
if "authentication failed" in lowered or "no authentication methods available" in lowered:
raise HTTPException(
status_code=401,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected key, passphrase, username, or password."
),
) from exc
raise HTTPException(status_code=502, detail=message) from exc
except Exception:
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
raise
return client
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
"""Resolve an SSH/Files machine for the given transport service.
Jellyfin/Jellyseerr are resolved against the service registry, not here.
"""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service = _service_record(store, "jellyfin", _request_jellyfin_service_id(request))
if machine_id:
machine = store.get_machine(machine_id)
if machine and (service in machine.get("services", []) or service == "ssh"):
return machine
return machine
if service == "ssh":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else:
machines = store.list_machines_for_service(service)
return machines[0] if machines else None
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
"""Return a Jellyfin client for the selected Jellyfin service instance."""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id)
if service is None: if service is None:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
@@ -173,83 +94,25 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
status_code=503, status_code=503,
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.", 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((service["id"], base_url, api_key))
return _jellyfin_client_for(cache_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient: def get_ssh_client(request: Request) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict.""" """Return SSH transport for the requested enabled remote-machine service."""
store = store or get_settings_store() from media_library_viewer_api.services.task_runner import build_ssh_client
known_hosts_path = get_settings().ssh_known_hosts_file from media_library_viewer_api.widgets.sources import build_service_record
key_data = None
key_passphrase = None
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:
key_data = ssh_key.get("private_key") or None
key_passphrase = ssh_key.get("passphrase") or None
if not key_data and machine.get("ssh_private_key"):
key_data = machine.get("ssh_private_key") or None
key_passphrase = machine.get("ssh_private_key_passphrase") or None
cache_key = (
machine["id"],
machine["host"],
machine["username"],
int(machine.get("port") or 22),
f"{machine.get('key_directory')}/{machine.get('key_name')}"
if machine.get("key_directory") and machine.get("key_name")
else "",
machine.get("password") or None,
key_data,
key_passphrase,
str(known_hosts_path),
)
return _ssh_client_for(cache_key)
def get_ssh_client(request: Request = None):
"""Return a command client for the selected machine or legacy env fallback."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_remote_machine_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None if not service_id:
if machine is None: raise HTTPException(status_code=400, detail="service_id is required for remote file and job operations")
machine_ref = _resolve_machine("ssh", request) row = store.get_service(service_id)
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None if not row or row.get("service_type") != "remote_machine" or not row.get("enabled", True):
if machine and str(machine.get("mode") or "local").strip().lower() == "local": raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
logger.info("Creating LocalCommandClient machine_id=%s", machine["id"]) try:
return LocalCommandClient() return build_ssh_client(store, build_service_record(store, row))
if machine and machine.get("host") and machine.get("username"): except ValueError as exc:
return _ssh_client_from_machine_config(machine, store) raise HTTPException(status_code=400, detail=str(exc)) from exc
settings = get_settings()
logger.info(
"Creating SSH client from legacy env host=%s user=%s port=%s key_dir=%s key_name=%s password=%s",
settings.ssh_host or "<unset>",
settings.ssh_username or "<unset>",
settings.ssh_port,
settings.ssh_key_directory or "<unset>",
settings.ssh_key_name or "<unset>",
"set" if settings.ssh_password else "missing",
)
if not settings.ssh_key_path:
raise HTTPException(
status_code=503,
detail="No SSH machine is configured and SSH key settings must be configured",
)
return _ssh_client_for(
(
"legacy",
settings.ssh_host,
settings.ssh_username,
settings.ssh_port,
settings.ssh_key_path,
settings.ssh_password or None,
None,
None,
str(settings.ssh_known_hosts_file),
)
)
def get_mail_queue() -> MailQueue: def get_mail_queue() -> MailQueue:
@@ -262,18 +125,42 @@ def get_settings_store() -> SettingsStore:
return _get_settings_store() return _get_settings_store()
def get_user_id(request: Request = None) -> str: def get_user_id(request: Request) -> str:
"""Return the configured Jellyfin user ID or discover the first available one.""" """Return the Jellyfin user Id, resolving a configured username if needed.
The service ``user_id`` config field accepts either the internal Jellyfin Id
or a username (e.g. ``'admin'``). Jellyfin's ``/Users/{id}/...`` endpoints
reject usernames with HTTP 400 (``"The value 'admin' is not valid."``), so
always resolve to the internal Id before use. Resolution is cached per
(service, base_url, api_key, configured) so repeated dashboard/media requests
don't re-list users on every call.
"""
store = get_settings_store() store = get_settings_store()
service_id = _request_jellyfin_service_id(request) service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyfin", service_id) service = _service_record(store, "jellyfin", service_id)
if service and service.get("config", {}).get("user_id"): if service is None:
return str(service["config"]["user_id"])
client = get_jellyfin_client(request)
users = client.users()
if not users:
raise HTTPException( raise HTTPException(
status_code=503, status_code=503,
detail="No Jellyfin users found and no user_id configured on the service", detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
) )
return users[0]["Id"] configured = str(service.get("config", {}).get("user_id") or "").strip()
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 HTTPException(
status_code=503,
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
)
return _resolved_user_id((service["id"], base_url, api_key, configured))
@lru_cache(maxsize=64)
def _resolved_user_id(cache_key: tuple[str, str, str, str]) -> str:
"""Resolve a configured Jellyfin identifier (Id or username) to the internal Id.
Keyed by (service_id, base_url, api_key, configured) so a credentials change
or a different configured user busts the cache automatically.
"""
service_id, base_url, api_key, configured = cache_key
client = _jellyfin_client_for((service_id, base_url, api_key))
return client.resolve_user_id(configured or None)
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/domain dir: backend/src/media_library_viewer_api/domain
## role ## role
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries. Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -4,13 +4,13 @@ dir: backend/src/media_library_viewer_api/domain
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
## role ## role
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries. Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
## files ## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - __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 - 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 - 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 ## arch
Stateless functional modules that transform inconsistent upstream API JSON into normalized dictionaries for persistence and display. Functional utility module pattern with pure helper functions that transform external API JSON into normalized domain objects.
## tags ## tags
media, backup, call:item.get, call:str, date, added, dashboard, call:store.list media, backup, call:item.get, call:str, date, added, dashboard, call:store.list
## symbols ## symbols
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/integrations dir: backend/src/media_library_viewer_api/integrations
## role ## role
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API. Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -11,12 +11,13 @@ map: backend/src/media_library_viewer_api/.pi-map.md
## files ## files
- __init__.py - __init__.py
- alertmanager.py - alertmanager.py
- authentik.py
- backups.py
- base.py - base.py
- grafana.py
- jellyfin.py - jellyfin.py
- jellyseerr.py
- nextcloud.py - nextcloud.py
- prometheus.py - prometheus.py
- qbittorrent.py
- registry.py - registry.py
- ssh_tasks.py - ssh_tasks.py
## links ## links
@@ -24,6 +25,6 @@ index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
map: backend/src/media_library_viewer_api/integrations/.pi-map.md map: backend/src/media_library_viewer_api/integrations/.pi-map.md
## workflows ## workflows
- change integrations behavior - change integrations behavior
read: __init__.py, alertmanager.py, base.py read: __init__.py, alertmanager.py, authentik.py
## dirty ## dirty
- -
@@ -4,33 +4,34 @@ dir: backend/src/media_library_viewer_api/integrations
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
## role ## role
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API. Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
## files ## files
- __init__.py | Defines a closed registry module for service integrations. - __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 - alertmanager.py | Defines a service integration for Prometheus Alertmanager, providing configuration models, connection testing, alert summarization, and widget definitions for displaying active alerts. | 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, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:int, call:secrets.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get("versionInfo", {}).get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic - authentik.py | Defines the Authentik service integration for user-directory access, including connection config, API token secret management, and a connection test. | exp: class:AuthentikConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:secrets.get, call:float, call:AuthentikClient, call:client.users, call:result.get, call:isinstance, call:TestResult, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.authentik, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.authentik.AuthentikClient
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base - 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
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base - base.py | Provides base classes and utility functions for defining external service integrations, including config schemas, secrets, widgets, and connection error translation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:TestResult, 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, func:translate_connection_error(exc: Exception, context) → TestResult, call:str, call:message.lower, call:isinstance, call:TestResult | dep: asyncio, dataclasses, typing, requests, pydantic, media_library_viewer_api.services.settings_store
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base - jellyfin.py | Defines the Jellyfin service integration configuration, connection testing, and widget definitions for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, class:JellyfinRequestStatWidgetConfig, class:JellyfinRequestsOverviewWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore
- 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 - nextcloud.py | Defines a Nextcloud service integration with connection testing and configuration for a media library viewer API. | exp: class:NextcloudConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base - prometheus.py | Defines the Prometheus service integration for a media library viewer API, including connection testing via a Grafana gateway and configuration models for metric, chart, gauge, and mean widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("grafana_url") or "").rstrip, call:config.get, call:secrets.get, call:int, call:TestResult, call:requests.post, call:resp.raise_for_status, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks - qbittorrent.py | Defines the qBittorrent service integration, including connection config models, secret fields, widget definitions (totals, active, speed), and a connection test function. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, class:QbittorrentSpeedWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore
- 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 - 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 plugin that runs reusable saved tasks over SSH by managing connection configuration, secrets, and connection testing. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("host") or "").strip, call:config.get, call:int, call:ServiceRecord, call:build_ssh_client, call:client.connect, call:str(exc).lower, call:TestResult, call:translate_connection_error, call:client.close | dep: typing, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
## arch ## arch
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions. Registry-based plugin pattern with a shared base class defining standard interfaces (config models, secrets, widgets, connection tests) that each service integration implements and registers with a central closed registry for dynamic discovery.
## tags ## tags
config, service, widget, integrations, base, media_library_viewer_api, definition, kind config, connection, widget, media_library_viewer_api, service, error, integrations, test
## symbols ## symbols
- AlertmanagerConfig - AlertmanagerConfig
- AlertmanagerAlertsWidgetConfig - AlertmanagerAlertsWidgetConfig
- AuthentikConfig
- BackupsConfig
- BackupsSummaryWidgetConfig
- ServiceConfigBase - ServiceConfigBase
- WidgetConfigBase - WidgetConfigBase
- SecretField - SecretField
- WidgetKind
- ServiceDefinition
- GrafanaConfig
## workflows ## workflows
- change integrations behavior - change integrations behavior
read: __init__.py, alertmanager.py, base.py read: __init__.py, alertmanager.py, authentik.py
## dirty ## dirty
- -
@@ -2,23 +2,30 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import TYPE_CHECKING, Any
import requests
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
ServiceBaseUrl, ServiceBaseUrl,
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult,
WidgetConfigBase, WidgetConfigBase,
translate_connection_error,
widget_kind, widget_kind,
) )
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
class AlertmanagerConfig(ServiceConfigBase): class AlertmanagerConfig(ServiceConfigBase):
"""Non-secret Alertmanager connection config.""" """Non-secret Alertmanager connection config."""
base_url: ServiceBaseUrl base_url: ServiceBaseUrl
timeout_seconds: int = 5 timeout_seconds: int = 15
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase): class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
@@ -68,6 +75,28 @@ def summarize_alerts(
} }
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""GET /api/v2/status with optional bearer auth."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
timeout = int(config.get("timeout_seconds") or 15)
headers: dict[str, str] = {}
api_key = str(secrets.get("api_key") or "")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp = requests.get(f"{base_url}/api/v2/status", headers=headers, timeout=timeout)
resp.raise_for_status()
payload = resp.json()
version = str(payload.get("versionInfo", {}).get("version", "") or "connected")
return TestResult(ok=True, detail="Connected to Alertmanager.", evidence=version)
except Exception as exc:
return translate_connection_error(exc, context="Alertmanager")
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="alertmanager", service_type="alertmanager",
name="Alertmanager", name="Alertmanager",
@@ -86,4 +115,5 @@ DEFINITION = ServiceDefinition(
refresh_interval_ms=30_000, refresh_interval_ms=30_000,
), ),
], ],
test_callable=test_connection,
) )
@@ -1,35 +1,85 @@
"""Authentik service definition. """Authentik service definition for read-only directory and access metadata."""
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 __future__ import annotations
from typing import TYPE_CHECKING, Any
from pydantic import Field
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
ServiceBaseUrl, ServiceBaseUrl,
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
) )
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) -> TestResult:
"""Probe the least-expensive Authentik directory endpoint."""
try:
client = AuthentikClient(
base_url=str(config.get("base_url") or "").rstrip("/"),
api_token=str(secrets.get("api_token") or ""),
timeout=float(config.get("timeout_seconds") or 60),
)
result = client.users(page=1, page_size=1)
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
except Exception as exc:
return translate_connection_error(exc, context="Authentik")
class AuthentikConfig(ServiceConfigBase): class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config.""" """Non-secret Authentik connection config."""
base_url: ServiceBaseUrl base_url: ServiceBaseUrl
timeout_seconds: int = 10 timeout_seconds: int = Field(default=60, ge=1, le=300)
class AuthentikListWidgetConfig(WidgetConfigBase):
"""Bounded display count for read-only Authentik list widgets."""
limit: int = Field(default=10, ge=1, le=50)
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="authentik", service_type="authentik",
name="Authentik", name="Authentik",
description="User directory and identity provider integration.", description="Read-only user directory, groups, and application access metadata.",
config_model=AuthentikConfig, config_model=AuthentikConfig,
secret_fields=[ secret_fields=[SecretField(key="api_token", label="API token", required=True)],
SecretField(key="api_token", label="API token", required=True), widget_kinds=[
widget_kind(
kind="access_summary",
name="User access summary",
description="User group memberships and explicit staff/superuser status; not effective authorization.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
widget_kind(
kind="groups",
name="Groups",
description="Read-only Authentik group list.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
widget_kind(
kind="applications",
name="Applications",
description="Read-only Authentik application list.",
model_cls=AuthentikListWidgetConfig,
default_config={"limit": 10},
refresh_interval_ms=60_000,
),
], ],
widget_kinds=[], test_callable=test_connection,
) )
@@ -15,11 +15,16 @@ map. There is no runtime plugin loading.
from __future__ import annotations from __future__ import annotations
import asyncio
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Annotated, Any from typing import TYPE_CHECKING, Annotated, Any, Callable
import requests
from pydantic import BaseModel, BeforeValidator, Field from pydantic import BaseModel, BeforeValidator, Field
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def _validate_service_base_url(value: Any) -> str: def _validate_service_base_url(value: Any) -> str:
"""Require an absolute http(s) URL for service ``base_url`` fields. """Require an absolute http(s) URL for service ``base_url`` fields.
@@ -93,6 +98,20 @@ class WidgetKind:
config_model: type[WidgetConfigBase] | None = None config_model: type[WidgetConfigBase] | None = None
@dataclass(frozen=True)
class TestResult:
"""Outcome of a credential/connectivity test for a service instance."""
ok: bool
detail: str
evidence: str | None = None
#: A test routine receives (config, secrets, store). The store is needed for
#: remote_machine (SSH-key resolution). Other types ignore it.
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
@dataclass(frozen=True) @dataclass(frozen=True)
class ServiceDefinition: class ServiceDefinition:
"""Closed description of an external service type.""" """Closed description of an external service type."""
@@ -103,6 +122,7 @@ class ServiceDefinition:
config_model: type[ServiceConfigBase] config_model: type[ServiceConfigBase]
secret_fields: list[SecretField] secret_fields: list[SecretField]
widget_kinds: list[WidgetKind] widget_kinds: list[WidgetKind]
test_callable: TestCallable | None = None
@property @property
def config_schema(self) -> dict[str, Any]: def config_schema(self) -> dict[str, Any]:
@@ -148,3 +168,58 @@ def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -
"""Validate a config dict against a Pydantic model and return the cleaned dict.""" """Validate a config dict against a Pydantic model and return the cleaned dict."""
instance = model_cls.model_validate(config or {}) instance = model_cls.model_validate(config or {})
return instance.model_dump(exclude_none=True) return instance.model_dump(exclude_none=True)
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
"""Map a common connection/auth exception to a human-friendly TestResult.
Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus
HTTP-client patterns from the widget sources. Each per-type test routine
calls this for unexpected exceptions, but handles its **type-specific**
auth failures directly (e.g., qBit ``"Fails."``).
"""
message = str(exc)
lowered = message.lower()
# Auth failures (HTTP 401/403)
if isinstance(exc, requests.HTTPError):
status_code = exc.response.status_code if exc.response is not None else 0
if status_code in (401, 403):
return TestResult(
ok=False,
detail=f"Authentication failed — the service rejected the credentials ({status_code}).",
)
if "authentication failed" in lowered or "no authentication methods available" in lowered:
return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.")
# Timeout (before OSError check, since requests.Timeout is a subclass of OSError)
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
# Connection refused / DNS / unreachable
if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)):
if (
"name or service not known" in lowered
or "nodename nor servname" in lowered
or "getaddrinfo failed" in lowered
):
return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.")
return TestResult(
ok=False,
detail="Connection refused — the service is not reachable at the configured address.",
)
# SSL / certificate errors
if "ssl" in lowered or "certificate" in lowered:
return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.")
# SSH banner (from test_machine_ssh pattern)
if "protocol banner" in lowered:
return TestResult(
ok=False,
detail="SSH banner not received — confirm the SSH service is running and the port is correct.",
)
# Fallback
prefix = f"{context}: " if context else ""
return TestResult(ok=False, detail=f"{prefix}{message[:200]}")
@@ -2,31 +2,54 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
ServiceBaseUrl, ServiceBaseUrl,
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult,
WidgetConfigBase, WidgetConfigBase,
translate_connection_error,
widget_kind, widget_kind,
) )
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Call JellyfinClient.users() — the lightest authenticated probe."""
try:
base_url = str(config.get("base_url") or "")
api_key = str(secrets.get("api_key") or "")
timeout = int(config.get("timeout_seconds") or 60)
client = JellyfinClient(base_url, api_key, timeout=timeout)
users = client.users()
return TestResult(ok=True, detail="Connected to Jellyfin.", evidence=f"{len(users)} users")
except Exception as exc:
return translate_connection_error(exc, context="Jellyfin")
class JellyfinConfig(ServiceConfigBase): class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config. """Non-secret Jellyfin connection config.
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the The optional ``jellyseerr_url`` field pairs a Jellyseerr companion with this
paired Jellyseerr companion config, absorbed from the former standalone Jellyfin instance; the matching ``jellyseerr_api_key`` is a secret field on
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``). the service. When both are set, the Jellyfin service page renders a Requests
When both are set, the Jellyfin service page renders a Requests tab backed tab backed by Jellyseerr.
by Jellyseerr.
""" """
base_url: ServiceBaseUrl base_url: ServiceBaseUrl
user_id: str = "" user_id: str = ""
timeout_seconds: int = 10 timeout_seconds: int = 60
jellyseerr_url: str = "" jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase): class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -42,6 +65,25 @@ class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
pass pass
class JellyfinRequestStatWidgetConfig(WidgetConfigBase):
"""A single Jellyseerr request stat (e.g. pending / approved / total)."""
stat: Literal[
"total",
"pending",
"approved",
"declined",
"processing",
"available",
] = "pending"
class JellyfinRequestsOverviewWidgetConfig(WidgetConfigBase):
"""Grid of all Jellyseerr request stats + a recent-requests list."""
pass
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="jellyfin", service_type="jellyfin",
name="Jellyfin", name="Jellyfin",
@@ -49,6 +91,12 @@ DEFINITION = ServiceDefinition(
config_model=JellyfinConfig, config_model=JellyfinConfig,
secret_fields=[ secret_fields=[
SecretField(key="api_key", label="API key", required=True), SecretField(key="api_key", label="API key", required=True),
SecretField(
key="jellyseerr_api_key",
label="Jellyseerr API key",
required=False,
helper="Enables the Requests tab + request-stats widgets (optional).",
),
], ],
widget_kinds=[ widget_kinds=[
widget_kind( widget_kind(
@@ -67,5 +115,22 @@ DEFINITION = ServiceDefinition(
default_config={}, default_config={},
refresh_interval_ms=30_000, refresh_interval_ms=30_000,
), ),
widget_kind(
kind="stat",
name="Request stat",
description="A single Jellyseerr request statistic (e.g. pending requests).",
model_cls=JellyfinRequestStatWidgetConfig,
default_config={"stat": "pending"},
refresh_interval_ms=60_000,
),
widget_kind(
kind="stats_overview",
name="Requests overview",
description="All Jellyseerr request stats plus a recent-requests list.",
model_cls=JellyfinRequestsOverviewWidgetConfig,
default_config={},
refresh_interval_ms=60_000,
),
], ],
test_callable=test_connection,
) )
@@ -6,13 +6,39 @@ dashboard widgets yet; its service page holds connection config only.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any
import requests
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
ServiceBaseUrl, ServiceBaseUrl,
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult,
translate_connection_error,
) )
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""GET {base_url}/status.php (unauthenticated server probe)."""
try:
base_url = str(config.get("base_url") or "").rstrip("/")
resp = requests.get(f"{base_url}/status.php", timeout=(5.0, 60.0))
resp.raise_for_status()
payload = resp.json()
version = str(payload.get("version", "") or "connected")
return TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)
except Exception as exc:
return translate_connection_error(exc, context="Nextcloud")
class NextcloudConfig(ServiceConfigBase): class NextcloudConfig(ServiceConfigBase):
"""Non-secret Nextcloud connection config.""" """Non-secret Nextcloud connection config."""
@@ -30,4 +56,5 @@ DEFINITION = ServiceDefinition(
SecretField(key="app_password", label="App password", required=True), SecretField(key="app_password", label="App password", required=True),
], ],
widget_kinds=[], widget_kinds=[],
test_callable=test_connection,
) )
@@ -2,21 +2,78 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
import requests
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
ServiceBaseUrl, ServiceBaseUrl,
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult,
WidgetConfigBase, WidgetConfigBase,
translate_connection_error,
widget_kind, widget_kind,
) )
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""POST {grafana_url}/api/ds/query with expr 'up' via the Grafana gateway."""
try:
grafana_url = str(config.get("grafana_url") or "").rstrip("/")
api_key = str(secrets.get("grafana_api_key") or "")
datasource_uid = str(config.get("datasource_uid") or "prometheus")
timeout = int(config.get("timeout_seconds") or 60)
if not grafana_url:
return TestResult(ok=False, detail="Grafana gateway URL is required.")
if not api_key:
return TestResult(ok=False, detail="Grafana API key is required.")
body = {
"queries": [
{
"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": "up",
"format": "time_series",
"intervalMs": 15000,
"maxDataPoints": 1,
"refId": "A",
}
],
"from": "now-1m",
"to": "now",
}
resp = requests.post(
f"{grafana_url}/api/ds/query",
json=body,
timeout=timeout,
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
)
resp.raise_for_status()
return TestResult(
ok=True,
detail="Grafana gateway reachable.",
evidence="Gateway reachable; datasource responded.",
)
except requests.HTTPError as exc:
return translate_connection_error(exc, context="Prometheus via Grafana")
except Exception as exc:
return translate_connection_error(exc, context="Prometheus via Grafana")
class PrometheusConfig(ServiceConfigBase): class PrometheusConfig(ServiceConfigBase):
"""Non-secret Prometheus connection config.""" """Non-secret Prometheus-via-Grafana gateway config."""
base_url: ServiceBaseUrl grafana_url: ServiceBaseUrl
timeout_seconds: int = 10 datasource_uid: str = "prometheus"
timeout_seconds: int = 60
class PrometheusMetricWidgetConfig(WidgetConfigBase): class PrometheusMetricWidgetConfig(WidgetConfigBase):
@@ -29,7 +86,19 @@ class PrometheusChartWidgetConfig(WidgetConfigBase):
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104).""" """A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
promql: str promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
# Display scaling for the Y axis + tooltip. "none" shows raw values; the
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
unit: Literal[
"none",
"bytes",
"bytes_per_sec",
"bits_per_sec",
"bits",
"percent",
"seconds",
] = "none"
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
class PrometheusGaugeWidgetConfig(WidgetConfigBase): class PrometheusGaugeWidgetConfig(WidgetConfigBase):
@@ -47,7 +116,7 @@ class PrometheusMeanWidgetConfig(WidgetConfigBase):
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114).""" """A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
promql: str promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS) window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
unit: str | None = None unit: str | None = None
@@ -57,7 +126,12 @@ DEFINITION = ServiceDefinition(
description="Metrics storage and PromQL queries.", description="Metrics storage and PromQL queries.",
config_model=PrometheusConfig, config_model=PrometheusConfig,
secret_fields=[ secret_fields=[
SecretField(key="api_key", label="API key", helper="Optional bearer token"), SecretField(
key="grafana_api_key",
label="Grafana API key",
required=True,
helper="Service account token or API key for the Grafana gateway",
),
], ],
widget_kinds=[ widget_kinds=[
widget_kind( widget_kind(
@@ -93,4 +167,5 @@ DEFINITION = ServiceDefinition(
refresh_interval_ms=60_000, refresh_interval_ms=60_000,
), ),
], ],
test_callable=test_connection,
) )
@@ -7,29 +7,105 @@ password), and three widget kinds (totals, active, speed). Models on
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from pydantic import Field, field_validator
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.integrations.base import ( from media_library_viewer_api.integrations.base import (
SecretField, SecretField,
ServiceBaseUrl, ServiceBaseUrl,
ServiceConfigBase, ServiceConfigBase,
ServiceDefinition, ServiceDefinition,
TestResult,
WidgetConfigBase, WidgetConfigBase,
translate_connection_error,
widget_kind, widget_kind,
) )
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Login + probe maindata; surface auth failures specifically."""
try:
base_url = str(config.get("base_url") or "")
username = str(secrets.get("username") or "")
password = str(secrets.get("password") or "")
timeout = int(config.get("timeout_seconds") or 60)
client = QbittorrentClient(base_url, username, password, timeout=timeout)
data = client.maindata()
version = str(data.get("server_state", {}).get("qbittorrent_version", "") or "connected")
return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version)
except RuntimeError as exc:
lowered = str(exc).lower()
if "invalid username or password" in lowered:
return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.")
# Gateway timeout, wrong URL/path, empty body, etc. — surface the real
# reason instead of masking every login error as an auth failure.
return translate_connection_error(exc, context="qBittorrent")
except Exception as exc:
return translate_connection_error(exc, context="qBittorrent")
class QbittorrentConfig(ServiceConfigBase): class QbittorrentConfig(ServiceConfigBase):
"""Non-secret qBittorrent connection config.""" """Non-secret qBittorrent connection and sampling config."""
base_url: ServiceBaseUrl base_url: ServiceBaseUrl
timeout_seconds: int = 10 timeout_seconds: int = Field(default=60, ge=1, le=300)
polling_enabled: bool = Field(default=True, description="Collect speed samples without an open dashboard")
poll_interval_seconds: int = Field(default=15, ge=5, le=300, description="Seconds between speed samples")
sample_retention_seconds: int = Field(
default=1_800,
ge=60,
le=86_400,
description="How long speed samples remain available",
)
sample_max_rows: int = Field(
default=1_200,
ge=60,
le=1_200,
description="Maximum speed samples retained per service",
)
class QbittorrentWidgetConfig(WidgetConfigBase): class QbittorrentWidgetConfig(WidgetConfigBase):
"""Per-widget config (empty — all three kinds derive from the service connection).""" """Per-widget config for totals/active (empty — derived from the service connection)."""
pass pass
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
window_seconds: int | Literal["all"] = 1_800
unit: Literal[
"none",
"bytes",
"bytes_per_sec",
"bits_per_sec",
"bits",
"percent",
"seconds",
] = "bytes_per_sec"
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
@field_validator("window_seconds")
@classmethod
def validate_window_seconds(cls, value: int | str) -> int | str:
"""Allow all retained samples while bounding explicit numeric windows."""
if value == "all":
return value
if not isinstance(value, int) or not 60 <= value <= 86_400:
raise ValueError("window_seconds must be between 60 and 86400, or 'all'")
return value
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="qbittorrent", service_type="qbittorrent",
name="qBittorrent", name="qBittorrent",
@@ -51,7 +127,7 @@ DEFINITION = ServiceDefinition(
widget_kind( widget_kind(
kind="active", kind="active",
name="Active torrents", name="Active torrents",
description="Torrents currently downloading or uploading.", description="All active download/upload work, including queued and stalled transfers.",
model_cls=QbittorrentWidgetConfig, model_cls=QbittorrentWidgetConfig,
default_config={}, default_config={},
refresh_interval_ms=15_000, refresh_interval_ms=15_000,
@@ -60,9 +136,10 @@ DEFINITION = ServiceDefinition(
kind="speed", kind="speed",
name="Speed chart", name="Speed chart",
description="Live download/upload speed over a short window.", description="Live download/upload speed over a short window.",
model_cls=QbittorrentWidgetConfig, model_cls=QbittorrentSpeedWidgetConfig,
default_config={}, default_config={"window_seconds": 1_800, "unit": "bytes_per_sec", "scale": "auto"},
refresh_interval_ms=5_000, refresh_interval_ms=15_000,
), ),
], ],
test_callable=test_connection,
) )
@@ -14,7 +14,7 @@ from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFI
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD 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.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS from media_library_viewer_api.integrations.remote_machine import DEFINITION as REMOTE_MACHINE
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
PROMETHEUS.service_type: PROMETHEUS, PROMETHEUS.service_type: PROMETHEUS,
@@ -22,7 +22,7 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
JELLYFIN.service_type: JELLYFIN, JELLYFIN.service_type: JELLYFIN,
NEXTCLOUD.service_type: NEXTCLOUD, NEXTCLOUD.service_type: NEXTCLOUD,
QBITTORRENT.service_type: QBITTORRENT, QBITTORRENT.service_type: QBITTORRENT,
SSH_TASKS.service_type: SSH_TASKS, REMOTE_MACHINE.service_type: REMOTE_MACHINE,
BACKUPS.service_type: BACKUPS, BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK, AUTHENTIK.service_type: AUTHENTIK,
} }
@@ -0,0 +1,122 @@
"""Remote machine service definition.
An ``remote_machine`` instance is an SSH endpoint that can run reusable saved tasks.
Tasks themselves stay in the global saved-task registry; the instance only owns
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
and shown as history on the instance's service page.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
TestResult,
WidgetConfigBase,
translate_connection_error,
widget_kind,
)
if TYPE_CHECKING:
from media_library_viewer_api.services.settings_store import SettingsStore
def test_connection(
config: dict[str, Any],
secrets: dict[str, str],
store: SettingsStore,
) -> TestResult:
"""Build an SSH client via build_ssh_client and attempt .connect().
Reuses the same error-translation patterns as test_machine_ssh (banner,
auth failed). Known-host recording is preserved.
"""
from media_library_viewer_api.services.task_runner import build_ssh_client
from media_library_viewer_api.widgets.sources import ServiceRecord
host = str(config.get("host") or "").strip()
port = int(config.get("port") or 22)
try:
service = ServiceRecord(
id="",
service_type="remote_machine",
name="test",
config=config,
secrets=secrets,
enabled=True,
)
client = build_ssh_client(store, service)
try:
client.connect()
except Exception as exc:
lowered = str(exc).lower()
if "protocol banner" in lowered:
return TestResult(
ok=False,
detail=f"SSH banner not received from {host}:{port}; confirm the SSH service is running.",
)
if "no authentication methods available" in lowered or "authentication failed" in lowered:
return TestResult(
ok=False,
detail=f"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.",
)
return translate_connection_error(exc, context=f"SSH {host}:{port}")
finally:
client.close()
return TestResult(
ok=True,
detail=f"SSH connection succeeded for {host}:{port}.",
evidence=f"Connected to {host}:{port}",
)
except ValueError as exc:
return TestResult(ok=False, detail=str(exc))
except Exception as exc:
return translate_connection_error(exc, context=f"SSH {host}:{port}")
class RemoteMachineConfig(ServiceConfigBase):
"""Non-secret Remote machine config.
The SSH key itself lives in the saved SSH-key registry and is referenced by
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
"""
host: str
port: int = 22
username: str = ""
ssh_key_id: str = ""
timeout_seconds: int = 30
class RemoteMachineTaskOutputWidgetConfig(WidgetConfigBase):
"""Output of a saved task run on this instance."""
task_id: str
# service_id is implicit (the widget's service); allow overriding per-widget.
service_id: str | None = None
DEFINITION = ServiceDefinition(
service_type="remote_machine",
name="Remote machine",
description="SSH transport for files and reusable actions.",
config_model=RemoteMachineConfig,
secret_fields=[
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
SecretField(key="password", label="SSH password", helper="Optional"),
],
widget_kinds=[
widget_kind(
kind="task_output",
name="Task output",
description="Output of a saved task run.",
model_cls=RemoteMachineTaskOutputWidgetConfig,
default_config={"task_id": ""},
refresh_interval_ms=0,
),
],
test_callable=test_connection,
)
@@ -1,60 +0,0 @@
"""SSH task runner service definition.
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
Tasks themselves stay in the global saved-task registry; the instance only owns
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
and shown as history on the instance's service page.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class SshTasksConfig(ServiceConfigBase):
"""Non-secret SSH task runner config.
The SSH key itself lives in the saved SSH-key registry and is referenced by
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
"""
host: str
port: int = 22
username: str = ""
ssh_key_id: str = ""
timeout_seconds: int = 30
class SshTaskOutputWidgetConfig(WidgetConfigBase):
"""Output of a saved task run on this instance."""
task_id: str
# service_id is implicit (the widget's service); allow overriding per-widget.
service_id: str | None = None
DEFINITION = ServiceDefinition(
service_type="ssh_tasks",
name="SSH task runner",
description="Run reusable saved tasks over SSH and keep run history.",
config_model=SshTasksConfig,
secret_fields=[
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
],
widget_kinds=[
widget_kind(
kind="task_output",
name="Task output",
description="Output of a saved task run.",
model_cls=SshTaskOutputWidgetConfig,
default_config={"task_id": ""},
refresh_interval_ms=0,
),
],
)
@@ -52,42 +52,6 @@ JOB_TEMPLATES: dict[str, JobTemplate] = {
description="Lists empty directories under the selected path. Does not delete anything.", description="Lists empty directories under the selected path. Does not delete anything.",
command_template="find {path} -type d -empty -print", command_template="find {path} -type d -empty -print",
), ),
"install_node_exporter": JobTemplate(
name="Install Node Exporter",
description="Downloads and installs prometheus-node-exporter via package manager (apt/dnf/yum/zypper).",
command_template=(
"set -e; "
"if command -v apt-get >/dev/null 2>&1; then "
"sudo apt-get update && sudo apt-get install -y prometheus-node-exporter; "
"elif command -v dnf >/dev/null 2>&1; then "
"sudo dnf install -y prometheus-node-exporter; "
"elif command -v yum >/dev/null 2>&1; then "
"sudo yum install -y prometheus-node-exporter; "
"elif command -v zypper >/dev/null 2>&1; then "
"sudo zypper install -y prometheus-node-exporter; "
"else echo 'No supported package manager found' >&2; exit 1; "
"fi; "
"sudo systemctl enable --now prometheus-node-exporter; "
"echo installed at {path}"
),
),
"restart_node_exporter": JobTemplate(
name="Restart Node Exporter",
description="Restarts the prometheus-node-exporter systemd service.",
command_template="sudo systemctl restart prometheus-node-exporter; echo restarted at {path}",
),
"node_exporter_status": JobTemplate(
name="Node Exporter status",
description="Checks whether prometheus-node-exporter is installed, enabled, and running.",
command_template=(
"systemctl status prometheus-node-exporter --no-pager || true; "
"echo '---'; "
"command -v node_exporter >/dev/null 2>&1 "
"&& node_exporter --version 2>&1 | head -1 "
"|| echo 'node_exporter binary not found'; "
"echo checked {path}"
),
),
} }
+27 -1
View File
@@ -27,16 +27,36 @@ from media_library_viewer_api.routers import (
from media_library_viewer_api.routers import backups as backups_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 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 dashboards as dashboards_router
from media_library_viewer_api.routers import jellyseerr as jellyseerr_router
from media_library_viewer_api.routers import scheduler as scheduler_router # type: ignore[reportAttributeAccessIssue]
from media_library_viewer_api.routers import services as services_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 import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router from media_library_viewer_api.routers.settings import router as settings_router
from .services.backup_poller import get_backup_poller from .services.backup_poller import get_backup_poller
from .services.scheduler import get_scheduler # type: ignore[reportMissingImports]
from .version import get_backend_version, get_version_info from .version import get_backend_version, get_version_info
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _validate_prometheus_gateway_config() -> None:
"""Warn (not crash) about old-shape prometheus services needing migration (GM-113)."""
try:
store = get_settings_store()
for service in store.list_services("prometheus"):
config = service.get("config") or {}
if "base_url" in config and "grafana_url" not in config:
logger.warning(
"Prometheus service '%s' (id=%s) uses the old 'base_url' config shape. "
"Reconfigure with grafana_url + grafana_api_key (see CHANGELOG).",
service.get("name"),
service.get("id"),
)
except Exception: # pragma: no cover - startup best-effort
logger.exception("Failed to validate prometheus gateway config during startup")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Application lifespan — startup/shutdown.""" """Application lifespan — startup/shutdown."""
@@ -58,11 +78,15 @@ async def lifespan(app: FastAPI):
get_service_data_harness() get_service_data_harness()
except Exception: except Exception:
logger.exception("Failed to initialize service data harness during startup") logger.exception("Failed to initialize service data harness during startup")
_validate_prometheus_gateway_config()
mail_queue = get_mail_queue() mail_queue = get_mail_queue()
backup_poller = get_backup_poller() backup_poller = get_backup_poller()
scheduler = get_scheduler()
mail_queue.start() mail_queue.start()
backup_poller.start() backup_poller.start()
scheduler.start()
yield yield
scheduler.stop()
backup_poller.stop() backup_poller.stop()
mail_queue.stop() mail_queue.stop()
logger.info("Backend shutdown complete") logger.info("Backend shutdown complete")
@@ -150,7 +174,9 @@ app.include_router(tasks.router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(backups_router.router) app.include_router(backups_router.router)
app.include_router(widgets_router.router) app.include_router(widgets_router.router)
app.include_router(scheduler_router.router)
app.include_router(dashboards_router.router) app.include_router(dashboards_router.router)
app.include_router(jellyseerr_router.router)
app.include_router(services_router.router) app.include_router(services_router.router)
app.include_router(authentik_users_router.router) app.include_router(authentik_users_router.router)
@@ -177,4 +203,4 @@ def metrics() -> Response:
if __name__ == "__main__": if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="127.0.0.1", port=8000)
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/models dir: backend/src/media_library_viewer_api/models
## role ## role
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs. Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -10,6 +10,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md
- -
## files ## files
- backups.py - backups.py
- dashboards.py
- services.py - services.py
- widgets.py - widgets.py
## links ## links
@@ -17,6 +18,6 @@ index: backend/src/media_library_viewer_api/models/.pi-map.index.md
map: backend/src/media_library_viewer_api/models/.pi-map.md map: backend/src/media_library_viewer_api/models/.pi-map.md
## workflows ## workflows
- change models behavior - change models behavior
read: backups.py, services.py, widgets.py read: backups.py, dashboards.py, services.py
## dirty ## dirty
- -
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/models
index: backend/src/media_library_viewer_api/models/.pi-map.index.md index: backend/src/media_library_viewer_api/models/.pi-map.index.md
## role ## role
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs. Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
## files ## 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 - 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 - services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
- widgets.py | Defines Pydantic models for a dashboard widget system with validation to prevent secrets/credentials in widget configuration. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic - widgets.py | Defines Pydantic models for a dashboard widget system, including input/output schemas and validation to prevent credential leakage in widget configurations. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
## arch ## arch
Pydantic-based model layer implementing data validation, serialization contracts, and custom validators enforcing security constraints (e.g., blocking credentials in non-secret configs). Pydantic-based model layer following a schema-first design pattern with built-in validators for domain-specific business rules and data integrity.
## tags ## tags
widget, backup, instance, response, info, call:isinstance, call:, service widget, backup, instance, dashboard, pydantic, response, info, call:isinstance
## symbols ## symbols
- BackupReportRequest - BackupReportRequest
- BackupJobResponse - BackupJobResponse
- BackupRunResponse - BackupRunResponse
- BackupAlertResponse - BackupAlertResponse
- BackupDashboardSummary - BackupDashboardSummary
- NamedDashboardInput
- NamedDashboard
- ServiceInstanceInput - ServiceInstanceInput
- ServiceInstance
- SecretFieldInfo
## workflows ## workflows
- change models behavior - change models behavior
read: backups.py, services.py, widgets.py read: backups.py, dashboards.py, services.py
## dirty ## dirty
- -
@@ -0,0 +1,70 @@
"""API models for backend scheduled actions."""
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, Field
class SchedulerStatus(BaseModel):
service_id: str
action_key: str
worker_running: bool
enabled: bool
running: bool = False
poll_interval_seconds: int = Field(ge=5, le=300)
sample_retention_seconds: int = Field(ge=60, le=86_400)
sample_max_rows: int = Field(ge=60, le=1_200)
next_run_at: int | None = None
last_attempt_at: int | None = None
last_success_at: int | None = None
last_error: str = ""
consecutive_failures: int = 0
backoff_until: int | None = None
is_stale: bool = False
class SchedulerRun(BaseModel):
id: str
service_id: str
action_key: str
trigger: Literal["schedule", "manual"]
started_at: int
finished_at: int | None = None
status: Literal["running", "success", "failure", "cancelled"]
attempt: int = 0
duration_ms: int | None = None
error: str = ""
created_at: int
class SchedulerRunsResponse(BaseModel):
items: list[SchedulerRun]
total: int
limit: int
offset: int
class SchedulerSample(BaseModel):
ts: int
dl_speed: int
up_speed: int
class SchedulerSamplesResponse(BaseModel):
service_id: str
window_seconds: int | None
all_values: bool = False
samples: list[SchedulerSample]
class SchedulerManualRunResponse(BaseModel):
run: SchedulerRun
status: SchedulerStatus
class SchedulerActionResult(BaseModel):
"""Internal-friendly result payload exposed for diagnostics/tests."""
data: dict[str, Any] = Field(default_factory=dict)
@@ -77,6 +77,28 @@ MAIL_QUEUE_SIZE = Counter(
["status"], ["status"],
) )
SCHEDULED_ACTIONS_TOTAL = Counter(
"manage_scheduled_actions_total",
"Total typed scheduled action attempts",
["service_id", "action", "status"],
)
SCHEDULED_ACTION_DURATION = Histogram(
"manage_scheduled_action_duration_seconds",
"Typed scheduled action duration",
["action"],
buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
)
SCHEDULED_ACTION_LAST_SUCCESS = Gauge(
"manage_scheduled_action_last_success_timestamp",
"Unix timestamp of the last successful typed scheduled action",
["service_id", "action"],
)
SCHEDULED_ACTION_FAILURES = Gauge(
"manage_scheduled_action_consecutive_failures",
"Current consecutive failure count for a typed scheduled action",
["service_id", "action"],
)
def set_current_request_id(request_id: str | None) -> None: def set_current_request_id(request_id: str | None) -> None:
"""Set the context-local request id.""" """Set the context-local request id."""
@@ -147,6 +169,25 @@ def record_mail_queue(status: str) -> None:
MAIL_QUEUE_SIZE.labels(status=status).inc() MAIL_QUEUE_SIZE.labels(status=status).inc()
def record_scheduled_action(
service_id: str,
action: str,
status: str,
duration_seconds: float | None = None,
success: bool = False,
consecutive_failures: int = 0,
) -> None:
"""Record secret-safe metrics for a typed scheduled action."""
safe_service = service_id or "unknown"
safe_action = action or "unknown"
SCHEDULED_ACTIONS_TOTAL.labels(service_id=safe_service, action=safe_action, status=status).inc()
SCHEDULED_ACTION_FAILURES.labels(service_id=safe_service, action=safe_action).set(consecutive_failures)
if duration_seconds is not None:
SCHEDULED_ACTION_DURATION.labels(action=safe_action).observe(duration_seconds)
if success:
SCHEDULED_ACTION_LAST_SUCCESS.labels(service_id=safe_service, action=safe_action).set_to_current_time()
def log_extra(request: Request | None = None, **kwargs: Any) -> dict[str, Any]: def log_extra(request: Request | None = None, **kwargs: Any) -> dict[str, Any]:
"""Build a standard extra dict for structured logging.""" """Build a standard extra dict for structured logging."""
extra: dict[str, Any] = {"request_id": get_request_id(request)} extra: dict[str, Any] = {"request_id": get_request_id(request)}
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/routers dir: backend/src/media_library_viewer_api/routers
## role ## role
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets). FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -10,23 +10,24 @@ map: backend/src/media_library_viewer_api/.pi-map.md
- -
## files ## files
- __init__.py - __init__.py
- authentik_users.py
- backups.py - backups.py
- dashboard.py - dashboard.py
- dashboards.py
- files.py - files.py
- jellyseerr.py
- jobs.py - jobs.py
- media.py - media.py
- monitoring.py - monitoring.py
- services.py - services.py
- settings.py - settings.py
- tasks.py - tasks.py
- users.py
- users_impl.py
- widgets.py - widgets.py
## links ## links
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
map: backend/src/media_library_viewer_api/routers/.pi-map.md map: backend/src/media_library_viewer_api/routers/.pi-map.md
## workflows ## workflows
- change routers behavior - change routers behavior
read: __init__.py, backups.py, dashboard.py read: __init__.py, authentik_users.py, backups.py
## dirty ## dirty
- -
@@ -4,26 +4,28 @@ dir: backend/src/media_library_viewer_api/routers
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
## role ## role
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets). FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
## files ## files
- __init__.py | Marks the directory as a Python package for routers. - __init__.py | Marks the directory as a Python package for routers.
- backups.py | FastAPI router that provides REST endpoints for reporting, tracking, and alerting on backup jobs and runs. | exp: func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store - authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
- backups.py | FastAPI router providing REST endpoints for reporting, querying, and managing backup jobs, runs, and 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(service_id, 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, service_id, 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, service_id, 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 - 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 - 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
- jellyseerr.py | FastAPI router providing Jellyseerr request stats and recent requests endpoints for the Jellyfin page. | exp: func:_serialize(result) → dict, func:get_jellyseerr_stats(jellyfin_service_id, store) → dict, call:resolve_service_record, call:get_stats_provider, call:provider.fetch_stats, call:logger.exception, call:_serialize, raise:HTTPException, func:get_jellyseerr_requests(jellyfin_service_id, store) → dict, call:resolve_service_record, call:fetch_jellyseer_requests, call:logger.exception, raise:HTTPException | dep: logging, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets, media_library_viewer_api.widgets.jellyseerr_stats, media_library_viewer_api.widgets.stats_provider
- 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 - jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
- media.py | FastAPI router that manages media index lifecycle (status, build, stop, query) with subprocess worker orchestration and cooperative/force cancellation. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path) → list[str], call:str, func:_start_worker(index: MediaIndex) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(client, user_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:client.libraries, call:logger.info, call:len, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index, media_library_viewer_api.workers.media_index_worker - 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 endpoints to check the health/status of Alertmanager, Grafana, and Prometheus services and expose Prometheus scrape targets. | exp: func:_resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services, func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_grafana_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager - monitoring.py | FastAPI router providing observability endpoints for monitoring machines, Alertmanager alerts/status, Prometheus targets/status, and webhook ingestion. | 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) → tuple[float, float], call:int, call:service.config.get, call:http_timeout, 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:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:requests.post, call:http_timeout, call:resp.raise_for_status, 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.clients.http_timeout, 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, fastapi.APIRouter
- 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 - services.py | Provides REST API endpoints for listing service types and CRUD-managing service instances, including a test endpoint that validates connectivity and credentials without persisting them. | 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, func:test_instance(body: ServiceInstanceInput, store) → dict[str, Any], call:_validate_input, call:require_service_definition, call:dict, call:store.get_service, call:existing.get, call:decrypt_secrets, call:logger.exception, call:secrets.get, call:stored.get, call:logger.info, call:definition.test_callable, call:TestResult | 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, media_library_viewer_api.services.secrets
- 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 - 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 - tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
- users.py | Re-exports all public names from the users_impl module to provide a unified public API for user-related functionality. | dep: .users_impl, users_impl - widgets.py | Provides a FastAPI REST API for CRUD operations on dashboard widget instances and widget references (live-links), including data fetching through registered adapters. | 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_stats_adapter, 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
- users_impl.py | FastAPI router that fetches and merges Jellyfin users with optional Jellyseerr enrichment, plus endpoints for queueing user emails via a background mail worker. | exp: func:_safe_int(value: Any) → int, call:int, func:_permission_labels(permissions: int) → list[str], func:_role_label(permissions: int) → str, func:_account_type(user_type: Any) → str, call:_USER_TYPES.get, call:_safe_int, func:_merge_users(jellyfin_users: list[dict[str, Any]], jellyseerr_jellyfin_users: list[dict[str, Any]] | None, jellyseerr_users: list[dict[str, Any]] | None, jellyseerr_client: JellyseerrClient | None) → dict[str, Any], call:str(value or "").strip().lower, call:bool, call:_looks_like_email, call:str(value).strip, call:", ".join, call:_normalize, call:item.get, call:_lookup_keys, call:user.get, call:linked_by_jellyfin_id.get, call:(jf_link or {}).get, call:seerr_by_key.get, call:_pick_source_and_value, call:(seerr_user or {}).get, call:_first_value, call:jellyseerr_client.absolute_url, call:_safe_int, call:_role_label, call:_source_summary, call:items.append, call:_account_type, call:_permission_labels, call:logger.info, call:len, func:_normalize(value: Any) → str, call:str(value or "").strip().lower, func:_looks_like_email(value: Any) → bool, call:str(value or "").strip, call:bool, func:_pick_source_and_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:_looks_like_email, call:str(value).strip, func:_first_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:str(value or "").strip, func:_source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) → str, call:", ".join, func:_lookup_keys(item: dict[str, Any]) → list[str], call:_normalize, call:item.get, func:get_users(jellyfin, jellyseerr) → dict[str, Any], call:jellyfin.users, call:logger.info, call:len, call:jellyseerr.jellyfin_users, call:logger.exception, call:jellyseerr.users, call:_merge_users, call:bool, func:get_user_message_status() → dict[str, Any], call:mail_queue.status, func:post_user_message(recipient_ids, subject, html_body, text_body, attachments, jellyfin, jellyseerr) → dict[str, Any], call:json.loads, call:isinstance, call:str(item).strip, call:subject.strip, call:get_users, call:item.get, call:directory.get, call:users_by_id.get, call:skipped.append, call:str(item.get("email") or "").strip, call:recipients.append, call:recipient_labels.append, call:get_settings, call:validate_smtp_settings, call:mail_queue.status, call:upload.read, call:attachment_payloads.append, call:EmailAttachment, call:mail_queue.enqueue, call:str(getattr(settings, "smtp_from_address", "") or "").strip, call:getattr, call:str(getattr(settings, "smtp_username", "") or "").strip, call:logger.info, call:len, raise:HTTPException | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mailer
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances, including CRUD operations, built-in widget discovery, and data fetching through service or built-in adapters. | exp: func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException | dep: logging, time, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
## arch ## arch
Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter; routers are registered by the parent application and share common dependencies for SSH orchestration, database access, and service resolution. Modular router-per-domain pattern where each file exposes a FastAPI APIRouter for a specific feature area; routers delegate business logic to service clients and adapters, using dependency injection for SSH/database access and standard Pydantic models for request/response validation.
## tags ## tags
call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh, call:logger.info call:, service, raise:httpexception, media_library_viewer_api, get, backup, call:store.get, ssh
## symbols ## symbols
- MessageRequest
- RunJobRequest - RunJobRequest
- MonitoringMachineInput - MonitoringMachineInput
- SSHKeyInput - SSHKeyInput
@@ -31,9 +33,8 @@ call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh,
- ResetLocalDatabaseInput - ResetLocalDatabaseInput
- TaskInput - TaskInput
- RunTaskRequest - RunTaskRequest
- _get_or_create_job
## workflows ## workflows
- change routers behavior - change routers behavior
read: __init__.py, backups.py, dashboard.py read: __init__.py, authentik_users.py, backups.py
## dirty ## dirty
- -
@@ -1,10 +1,7 @@
"""Authentik directory + messaging router. """Read-only Authentik directory, access metadata, and messaging router.
Resolves an ``authentik`` service instance from the registry, builds an Directory data is service-scoped and fails gracefully so the service page can
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and render a useful empty/error state when Authentik is unavailable.
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 from __future__ import annotations
@@ -12,7 +9,7 @@ from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient from media_library_viewer_api.clients.authentik import AuthentikClient
@@ -30,7 +27,7 @@ router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel): class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint.""" """Compose-request body for the existing Authentik messaging endpoint."""
recipient_emails: list[str] recipient_emails: list[str]
subject: str subject: str
@@ -38,39 +35,99 @@ class MessageRequest(BaseModel):
def _build_client(service: ServiceRecord) -> AuthentikClient: 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: try:
timeout = float(service.config.get("timeout_seconds") or 10) timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError): except (TypeError, ValueError):
timeout = 10.0 timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout) return AuthentikClient(
base_url=str(service.config.get("base_url") or "").rstrip("/"),
api_token=str(service.secrets.get("api_token") or ""),
timeout=timeout,
)
def _empty(error: str) -> dict[str, Any]: def _empty_directory(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error} return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
def _empty_collection(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "error": error}
def _service_or_error(store: SettingsStore, service_id: str) -> ServiceRecord | None:
return resolve_service_record(store, "authentik", service_id)
@router.get("/{service_id}/users") @router.get("/{service_id}/users")
def get_authentik_users( def get_authentik_users(
service_id: str, service_id: str,
search: str | None = None, search: str | None = None,
page: int = 1, page: int = Query(default=1, ge=1),
page_size: int = 50, page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance.""" """Paginated raw directory users for the existing messaging surface."""
service = resolve_service_record(store, "authentik", service_id) service = _service_or_error(store, service_id)
if service is None: if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id) return _empty_directory("Authentik service not configured")
return _empty("Authentik service not configured")
try: try:
client = _build_client(service) return _build_client(service).users(search=search, page=page, page_size=page_size)
return client.users(search=search, page=page, page_size=page_size)
except Exception: except Exception:
logger.exception("Authentik users query failed for service %s", service_id) logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable") return _empty_directory("Authentik is unreachable")
@router.get("/{service_id}/access-summary")
def get_authentik_access_summary(
service_id: str,
search: str | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""User groups plus explicit staff/superuser flags, not effective permissions."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_directory("Authentik service not configured")
try:
return _build_client(service).access_summaries(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik access summary query failed for service %s", service_id)
return _empty_directory("Authentik is unreachable")
@router.get("/{service_id}/groups")
def get_authentik_groups(
service_id: str,
limit: int = Query(default=100, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Display-safe, service-scoped Authentik group list."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_collection("Authentik service not configured")
try:
return _build_client(service).groups(limit=limit)
except Exception:
logger.exception("Authentik groups query failed for service %s", service_id)
return _empty_collection("Authentik is unreachable")
@router.get("/{service_id}/applications")
def get_authentik_applications(
service_id: str,
limit: int = Query(default=100, ge=1, le=200),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Display-safe Authentik applications without provider or policy details."""
service = _service_or_error(store, service_id)
if service is None:
return _empty_collection("Authentik service not configured")
try:
return _build_client(service).applications(limit=limit)
except Exception:
logger.exception("Authentik applications query failed for service %s", service_id)
return _empty_collection("Authentik is unreachable")
@router.get("/{service_id}/message/status") @router.get("/{service_id}/message/status")
@@ -80,8 +137,7 @@ def get_authentik_message_status(
mail_queue: MailQueue = Depends(get_mail_queue), mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab.""" """Mail-queue status snapshot for the Authentik messaging tab."""
service = resolve_service_record(store, "authentik", service_id) if _service_or_error(store, service_id) is None:
if service is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"} return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status() return mail_queue.status()
@@ -94,20 +150,16 @@ def post_authentik_message(
mail_queue: MailQueue = Depends(get_mail_queue), mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue.""" """Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = resolve_service_record(store, "authentik", service_id) if _service_or_error(store, service_id) is None:
if service is None:
return {"status": "error", "error": "Authentik service not configured"} return {"status": "error", "error": "Authentik service not configured"}
recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.strip()]
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
if not recipients: if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."} return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings() settings = get_settings()
try: try:
validate_smtp_settings(settings) validate_smtp_settings(settings)
except ValueError as exc: except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"} return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue( request_id = mail_queue.enqueue(
settings=settings, settings=settings,
recipients=recipients, recipients=recipients,
@@ -115,8 +167,4 @@ def post_authentik_message(
html_body=body.html_body, html_body=body.html_body,
) )
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients)) logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return { return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)}
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
@@ -129,9 +129,10 @@ def post_backup_start(
@router.get("/jobs") @router.get("/jobs")
def get_backup_jobs( def get_backup_jobs(
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
jobs = store.list_backup_jobs() jobs = store.list_backup_jobs(service_id=service_id)
return jobs return jobs
@@ -155,9 +156,10 @@ def get_backup_runs(
job_id: str | None = None, job_id: str | None = None,
status: str | None = None, status: str | None = None,
limit: int = 50, limit: int = 50,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> list[BackupRunResponse]: ) -> list[BackupRunResponse]:
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit) runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit, service_id=service_id)
return [BackupRunResponse(**run) for run in runs] return [BackupRunResponse(**run) for run in runs]
@@ -177,9 +179,15 @@ def get_backup_alerts(
job_id: str | None = None, job_id: str | None = None,
acknowledged: bool | None = None, acknowledged: bool | None = None,
severity: str | None = None, severity: str | None = None,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> list[BackupAlertResponse]: ) -> list[BackupAlertResponse]:
alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity) alerts = store.list_backup_alerts(
job_id=job_id,
acknowledged=acknowledged,
severity=severity,
service_id=service_id,
)
return [BackupAlertResponse(**alert) for alert in alerts] return [BackupAlertResponse(**alert) for alert in alerts]
@@ -0,0 +1,70 @@
"""Jellyseerr request stats — powers the Requests tab on the Jellyfin page.
Jellyseerr is an optional companion of the Jellyfin service. This router
resolves the Jellyfin service instance (by ``jellyfin_service_id`` or the first
enabled one) and delegates to the registered Jellyseerr stats provider, which
shares its short-TTL cache with the ``stat`` / ``stats_overview`` widgets so
the tab and the widgets don't each hit Jellyseerr.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException
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.widgets import jellyseerr_stats # noqa: F401 — ensure provider registration
from media_library_viewer_api.widgets.jellyseerr_stats import fetch_jellyseer_requests
from media_library_viewer_api.widgets.stats_provider import get_stats_provider
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/jellyseerr", tags=["jellyseerr"])
def _serialize(result) -> dict:
return {
"stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats],
"recent": result.recent,
"detail": result.detail,
}
@router.get("/stats")
def get_jellyseerr_stats(
jellyfin_service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict:
"""Return Jellyseerr request counts + a recent-requests list."""
service = resolve_service_record(store, "jellyfin", jellyfin_service_id)
if service is None:
raise HTTPException(status_code=503, detail="No Jellyfin service is configured.")
provider = get_stats_provider("jellyfin")
if provider is None: # pragma: no cover - registered at import
raise HTTPException(status_code=503, detail="Jellyseerr stats provider is not available.")
try:
result = provider.fetch_stats(service)
except Exception as exc: # pragma: no cover - provider guards internally
logger.exception("Jellyseerr stats endpoint failed")
raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc
return _serialize(result)
@router.get("/requests")
def get_jellyseerr_requests(
jellyfin_service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict:
"""Return Jellyseerr requests for the Requests tab table (filter/sort client-side)."""
service = resolve_service_record(store, "jellyfin", jellyfin_service_id)
if service is None:
raise HTTPException(status_code=503, detail="No Jellyfin service is configured.")
try:
requests = fetch_jellyseer_requests(service)
except Exception as exc: # pragma: no cover - client guards internally
logger.exception("Jellyseerr requests endpoint failed")
raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc
return {"requests": requests}
@@ -14,10 +14,10 @@ from typing import Any
import requests import requests
from fastapi import APIRouter, Body, Depends from fastapi import APIRouter, Body, Depends
from media_library_viewer_api.clients.http_timeout import http_timeout
from media_library_viewer_api.dependencies import get_settings_store 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.service_resolution import resolve_service_record
from media_library_viewer_api.services.settings_store import SettingsStore 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 from media_library_viewer_api.widgets.sources import ServiceRecord
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -27,8 +27,10 @@ def _base_url(service: ServiceRecord) -> str:
return str(service.config.get("base_url") or "").rstrip("/") return str(service.config.get("base_url") or "").rstrip("/")
def _timeout(service: ServiceRecord, default: int) -> int: def _timeout(service: ServiceRecord, default: int) -> tuple[float, float]:
return int(service.config.get("timeout_seconds") or default) """Return a (connect, read) timeout tuple from the service config."""
read = int(service.config.get("timeout_seconds") or default)
return http_timeout(read)
def _auth_headers(service: ServiceRecord) -> dict[str, str]: def _auth_headers(service: ServiceRecord) -> dict[str, str]:
@@ -56,21 +58,6 @@ def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"]) router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return enabled monitoring machines for the UI."""
return [m for m in store.list_machines() if m.get("enabled")]
@router.get("/prometheus-targets")
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return Prometheus scrape targets for remote Node Exporters.
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))
return targets
@router.get("/alerts") @router.get("/alerts")
@@ -177,23 +164,52 @@ def get_prometheus_status(
service_id: str | None = None, service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Probe a Prometheus service instance's health and build info.""" """Probe a Prometheus service's health via the Grafana gateway path (GM-110).
Issues a trivial ``up`` query through Grafana ``/api/ds/query``. Success
validates the full path: Grafana is reachable, the API key works, and the
Prometheus datasource responds.
"""
service = resolve_service_record(store, "prometheus", service_id) service = resolve_service_record(store, "prometheus", service_id)
if service is None: if service is None:
return _status_response(None, error="no_service_configured") return _status_response(None, error="no_service_configured")
base = _base_url(service) grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
timeout = _timeout(service, 10) api_key = str(service.secrets.get("grafana_api_key") or "")
headers = _auth_headers(service) datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
timeout = int(service.config.get("timeout_seconds") or 60)
if not grafana_url or not api_key:
return _status_response(service, error="gateway_not_configured")
body = {
"queries": [
{
"datasource": {"uid": datasource_uid, "type": "prometheus"},
"expr": "up",
"format": "time_series",
"intervalMs": 15_000,
"maxDataPoints": 1,
"refId": "A",
}
],
"from": "now-1m",
"to": "now",
}
try: try:
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout) resp = requests.post(
health.raise_for_status() f"{grafana_url}/api/ds/query",
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout) json=body,
build_info.raise_for_status() headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
version = build_info.json().get("data", {}).get("version", "") timeout=http_timeout(timeout),
except Exception: )
logger.exception("Failed to fetch Prometheus status") resp.raise_for_status()
except requests.HTTPError as exc:
status_code = exc.response.status_code if exc.response else 0
if status_code in (401, 403):
return _status_response(service, error="auth_failed")
return _status_response(service, error="gateway_error")
except requests.RequestException:
logger.exception("Failed to fetch Prometheus status via gateway")
return _status_response(service, error="prometheus_unreachable") return _status_response(service, error="prometheus_unreachable")
return _status_response(service, version=version) return _status_response(service, version="ok")
@router.post("/alertmanager-webhook") @router.post("/alertmanager-webhook")
@@ -0,0 +1,126 @@
"""Endpoints for typed scheduled-action status and history."""
from __future__ import annotations
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.models.scheduler import ( # type: ignore[reportMissingImports]
SchedulerManualRunResponse,
SchedulerRun,
SchedulerRunsResponse,
SchedulerSamplesResponse,
SchedulerStatus,
)
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.scheduler import ( # type: ignore[reportMissingImports]
SchedulerBusyError,
get_scheduler,
)
from media_library_viewer_api.services.scheduler_actions import (
QBITTORRENT_SPEED_ACTION, # type: ignore[reportMissingImports]
)
from media_library_viewer_api.services.scheduler_store import SchedulerRunStore # type: ignore[reportMissingImports]
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/scheduler", tags=["scheduler"])
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _require_qbittorrent(service_id: str, store: SettingsStore) -> dict[str, Any]:
service = store.get_service(service_id)
if not service or service.get("service_type") != "qbittorrent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="qBittorrent service not found")
return service
@router.get("/services/{service_id}/status", response_model=SchedulerStatus)
def get_scheduler_status(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> SchedulerStatus:
_require_qbittorrent(service_id, store)
try:
return SchedulerStatus(**get_scheduler().status(service_id, store))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.get("/services/{service_id}/runs", response_model=SchedulerRunsResponse)
def get_scheduler_runs(
service_id: str,
status_filter: str | None = Query(default=None, alias="status"),
trigger: str | None = None,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
store: SettingsStore = Depends(get_settings_store),
) -> SchedulerRunsResponse:
_require_qbittorrent(service_id, store)
items, total = SchedulerRunStore().list_runs(
service_id,
QBITTORRENT_SPEED_ACTION,
status=status_filter,
trigger=trigger,
limit=limit,
offset=offset,
)
return SchedulerRunsResponse(
items=[SchedulerRun(**item) for item in items],
total=total,
limit=limit,
offset=offset,
)
@router.post("/services/{service_id}/run", response_model=SchedulerManualRunResponse)
def run_scheduler_action(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> SchedulerManualRunResponse:
_require_qbittorrent(service_id, store)
try:
result = get_scheduler().run_now(service_id, store)
except SchedulerBusyError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return SchedulerManualRunResponse(
run=SchedulerRun(**result["run"]),
status=SchedulerStatus(**result["status"]),
)
@router.get("/services/{service_id}/samples", response_model=SchedulerSamplesResponse)
def get_scheduler_samples(
service_id: str,
window_seconds: int = Query(default=1_800, ge=60, le=86_400),
all_values: bool = Query(default=False),
store: SettingsStore = Depends(get_settings_store),
) -> SchedulerSamplesResponse:
_require_qbittorrent(service_id, store)
sample_store = QbittorrentSampleStore()
if all_values:
samples = sample_store.window(service_id)
response_window: int | None = None
else:
since_ts = _safe_int(time.time()) - window_seconds
samples = sample_store.window(service_id, since_ts=since_ts)
response_window = window_seconds
return SchedulerSamplesResponse(
service_id=service_id,
window_seconds=response_window,
all_values=all_values,
samples=samples,
)
__all__ = ["router"]
@@ -12,7 +12,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config from media_library_viewer_api.integrations.base import TestResult, validate_config
from media_library_viewer_api.integrations.registry import ( from media_library_viewer_api.integrations.registry import (
SERVICE_DEFINITIONS, SERVICE_DEFINITIONS,
get_service_definition, get_service_definition,
@@ -180,3 +180,50 @@ def delete_instance(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
store.delete_service(service_id) store.delete_service(service_id)
return {"status": "deleted"} return {"status": "deleted"}
@router.post("/test")
def test_instance(
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Test connectivity + credentials for unsaved service input.
Validates first (422 on malformed config), dispatches to the per-type
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
"""
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
definition = require_service_definition(body.service_type)
# When editing an existing service, secret fields are masked and not
# re-entered (the UI says "leave blank to keep current"), so body.secrets
# only carries freshly-typed values. Fall back to the stored (decrypted)
# secret for any known key that is absent or blank, so the test runs with
# the effective credentials rather than failing auth on empty fields.
secrets = dict(body.secrets)
if body.id:
existing = store.get_service(body.id)
if existing and existing.get("service_type") == body.service_type:
from media_library_viewer_api.services.secrets import decrypt_secrets
stored: dict[str, str] = {}
try:
stored = decrypt_secrets(existing.get("secrets") or {})
except Exception:
logger.exception("failed to decrypt stored secrets for test service_id=%s", body.id)
for key in definition.secret_keys:
if not secrets.get(key) and stored.get(key):
secrets[key] = stored[key]
if definition.test_callable is None:
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
try:
result: TestResult = definition.test_callable(body.config, secrets, store)
except Exception as exc:
logger.exception("test_callable raised for type=%s", body.service_type)
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}
@@ -10,11 +10,8 @@ import paramiko
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
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.dependencies import get_settings_store
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
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.media_index import MediaIndex
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
@@ -23,188 +20,6 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
class MonitoringMachineInput(BaseModel):
"""Payload for creating or updating a machine."""
id: str | None = None
name: str = Field(default="")
mode: str = Field(default="local", description="local or ssh")
enabled: bool = True
services: list[str] = Field(default_factory=list)
host: str = ""
port: int = 22
username: str = ""
key_directory: str = ""
key_name: str = ""
ssh_key_id: str = ""
ssh_private_key: str = ""
ssh_private_key_passphrase: str = ""
password: str = ""
notes: str = ""
@router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
return store.list_machines()
def _resolve_ssh_client(
machine: MonitoringMachineInput,
store: SettingsStore,
) -> tuple[RemoteSSHClient, str, int]:
host = machine.host.strip()
username = machine.username.strip()
port = int(machine.port or 22)
if not host or not username:
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
private_key = machine.ssh_private_key
passphrase = machine.ssh_private_key_passphrase
if machine.ssh_key_id:
ssh_key = store.get_ssh_key(machine.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.key_directory and machine.key_name:
key_filename = f"{machine.key_directory}/{machine.key_name}"
settings = get_settings()
client = RemoteSSHClient(
host=host,
username=username,
port=port,
key_filename=key_filename or None,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=machine.password or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
return client, host, port
def _raise_ssh_validation_error(host: str, port: int, exc: Exception) -> None:
message = str(exc)
lowered = message.lower()
if "protocol banner" in lowered:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(f"SSH banner not received from {host}:{port}; the backend could not complete the SSH handshake."),
) from exc
if "no authentication methods available" in lowered or "authentication failed" in lowered:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
f"SSH authentication failed for {host}:{port}. "
"Check the selected SSH key, passphrase, username, or password."
),
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"SSH validation failed for {host}:{port}: {message}",
) from exc
def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) -> None:
if str(machine.mode or "").strip().lower() != "ssh":
return
client, host, port = _resolve_ssh_client(machine, store)
try:
client.connect()
except Exception as exc:
_raise_ssh_validation_error(host, port, exc)
finally:
client.close()
@router.post("/machines/test-ssh")
def test_machine_ssh(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
if str(machine.mode or "").strip().lower() != "ssh":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="SSH validation only applies to SSH machines"
)
client, host, port = _resolve_ssh_client(machine, store)
settings = get_settings()
known_hosts_updated = not has_known_host(host, port, settings.ssh_known_hosts_file)
try:
client.connect()
except Exception as exc:
message = str(exc)
lowered = message.lower()
if "protocol banner" in lowered:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=(
f"SSH banner not received from {host}:{port}; the backend recorded the host key, "
"but SSH auth could not be validated. Confirm the SSH service is running."
),
) from exc
if "no authentication methods available" in lowered or "authentication failed" in lowered:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=(
f"SSH banner received from {host}:{port}, but authentication failed. "
"Check the selected SSH key, passphrase, username, or password."
),
) from exc
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"SSH validation failed for {host}:{port}: {message}",
) from exc
finally:
client.close()
return {
"status": "ok",
"message": (
f"SSH connection succeeded for {host}:{port}; host key "
f"{'was recorded' if known_hosts_updated else 'was already trusted'} and authentication worked."
),
"host": host,
"port": port,
"known_hosts_updated": known_hosts_updated,
}
@router.post("/machines", status_code=status.HTTP_201_CREATED)
def post_machine(
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@router.put("/machines/{machine_id}")
def put_machine(
machine_id: str,
machine: MonitoringMachineInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
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)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@router.delete("/machines/{machine_id}")
def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_machine(machine_id):
raise HTTPException(status_code=404, detail="Machine not found")
store.delete_machine(machine_id)
return {"status": "deleted"}
class SSHKeyInput(BaseModel): class SSHKeyInput(BaseModel):
id: str | None = None id: str | None = None
name: str = Field(default="") name: str = Field(default="")
@@ -24,7 +24,7 @@ class TaskInput(BaseModel):
task_type: str = Field(default="shell", description="shell or python") task_type: str = Field(default="shell", description="shell or python")
content: str = Field(default="") content: str = Field(default="")
enabled: bool = True enabled: bool = True
default_service_id: str = "" service_id: str = ""
notes: str = "" notes: str = ""
@@ -38,18 +38,16 @@ def _service_label(service: dict[str, Any] | None) -> str:
return str(service.get("name") or service.get("id") or "") return str(service.get("name") or service.get("id") or "")
def _resolve_service_for_task( def _owned_remote_machine(store: SettingsStore, service_id: str) -> dict[str, Any] | None:
store: SettingsStore, service = store.get_service(service_id)
task: dict[str, Any], if service and service.get("service_type") == "remote_machine" and service.get("enabled", True):
service_id: str | None, return service
) -> dict[str, Any] | None: return None
if service_id:
return store.get_service(service_id)
default_service_id = str(task.get("default_service_id") or "").strip() def _require_task_owner(task: dict[str, Any], service_id: str) -> None:
if default_service_id: if str(task.get("service_id") or "") != service_id:
return store.get_service(default_service_id) raise HTTPException(status_code=404, detail="Task not found for this remote machine service")
services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
return services[0] if services else None
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord: def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
@@ -60,12 +58,23 @@ def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
@router.get("") @router.get("")
def list_tasks(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: def list_tasks(
return store.list_tasks() service_id: str = Query(..., min_length=1),
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
if not _owned_remote_machine(store, service_id):
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
return [task for task in store.list_tasks() if task.get("service_id") == service_id]
def _validate_task_owner(task: TaskInput, store: SettingsStore) -> None:
if not task.service_id or not _owned_remote_machine(store, task.service_id):
raise HTTPException(status_code=400, detail="Task owner must be an enabled remote machine service")
@router.post("", status_code=status.HTTP_201_CREATED) @router.post("", status_code=status.HTTP_201_CREATED)
def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]: def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
_validate_task_owner(task, store)
return store.upsert_task(task.model_dump(exclude_none=True), task.id) return store.upsert_task(task.model_dump(exclude_none=True), task.id)
@@ -73,13 +82,20 @@ def create_task(task: TaskInput, store: SettingsStore = Depends(get_settings_sto
def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]: def update_task(task_id: str, task: TaskInput, store: SettingsStore = Depends(get_settings_store)) -> dict[str, Any]:
if not store.get_task(task_id): if not store.get_task(task_id):
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
_validate_task_owner(task, store)
return store.upsert_task(task.model_dump(exclude_none=True), task_id) return store.upsert_task(task.model_dump(exclude_none=True), task_id)
@router.delete("/{task_id}") @router.delete("/{task_id}")
def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]: def delete_task(
if not store.get_task(task_id): task_id: str,
service_id: str = Query(..., min_length=1),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
task = store.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
_require_task_owner(task, service_id)
store.delete_task(task_id) store.delete_task(task_id)
return {"status": "deleted"} return {"status": "deleted"}
@@ -87,19 +103,22 @@ def delete_task(task_id: str, store: SettingsStore = Depends(get_settings_store)
@router.get("/{task_id}/runs") @router.get("/{task_id}/runs")
def list_task_runs( def list_task_runs(
task_id: str, task_id: str,
service_id: str = Query(..., min_length=1),
limit: int = Query(default=10, ge=1, le=50), limit: int = Query(default=10, ge=1, le=50),
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
if not store.get_task(task_id): task = store.get_task(task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
runs = store.list_service_task_runs(task_id=task_id, limit=limit) _require_task_owner(task, service_id)
runs = store.list_service_task_runs(service_id=service_id, task_id=task_id, limit=limit)
return {"items": runs, "total": len(runs)} return {"items": runs, "total": len(runs)}
@router.post("/run") @router.post("/run")
def run_task( def run_task(
request: RunTaskRequest, request: RunTaskRequest,
service_id: str | None = Query(default=None), service_id: str = Query(..., min_length=1),
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
task = store.get_task(request.task_id) task = store.get_task(request.task_id)
@@ -108,11 +127,10 @@ def run_task(
if not task.get("enabled", True): if not task.get("enabled", True):
raise HTTPException(status_code=400, detail="Task is disabled") raise HTTPException(status_code=400, detail="Task is disabled")
service_row = _resolve_service_for_task(store, task, service_id) _require_task_owner(task, service_id)
service_row = _owned_remote_machine(store, service_id)
if not service_row: if not service_row:
raise HTTPException(status_code=400, detail="No SSH task service is available for this action") raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
if not service_row.get("enabled", True):
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
service = _service_row_to_record(service_row) service = _service_row_to_record(service_row)
result = run_saved_task(store, task, service) result = run_saved_task(store, task, service)
@@ -33,6 +33,7 @@ from media_library_viewer_api.widgets.sources import (
build_service_record, build_service_record,
get_builtin_adapter, get_builtin_adapter,
get_service_adapter, get_service_adapter,
get_stats_adapter,
) )
@@ -199,7 +200,11 @@ async def fetch_data(
error="Service is disabled", error="Service is disabled",
fetched_at=int(time.time()), fetched_at=int(time.time()),
).model_dump() ).model_dump()
adapter = get_service_adapter(service_row["service_type"]) adapter = (
get_stats_adapter()
if widget_kind in ("stat", "stats_overview")
else get_service_adapter(service_row["service_type"])
)
if adapter is None: if adapter is None:
return WidgetDataResponse( return WidgetDataResponse(
widget_id=widget_id, widget_id=widget_id,
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/services dir: backend/src/media_library_viewer_api/services
## role ## role
Backend service layer providing business logic for media library management, backup monitoring, email notifications, SSH task execution, encryption, and persistent settings storage. Backend service layer providing business logic for backup monitoring, media indexing, email delivery, task execution, secrets management, and persistent data storage.
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -19,7 +19,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
- mailer_impl.py - mailer_impl.py
- media_index.py - media_index.py
- media_index_impl.py - media_index_impl.py
- qbittorrent_store.py
- secrets.py - secrets.py
- service_data.py
- service_resolution.py
- settings_store.py - settings_store.py
- targets.py - targets.py
- task_runner.py - task_runner.py
File diff suppressed because one or more lines are too long
@@ -9,6 +9,7 @@ the first real consumer of the harness lifecycle layer.
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from media_library_viewer_api.services.service_data import StorageConcern from media_library_viewer_api.services.service_data import StorageConcern
@@ -37,8 +38,17 @@ QBITTORRENT_CONCERN = StorageConcern(
tables=["qbittorrent_speed_samples"], tables=["qbittorrent_speed_samples"],
) )
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll). #: Maximum samples kept per service. The scheduler may choose a lower cap.
MAX_SAMPLES = 120 MAX_SAMPLES = 1_200
MIN_SAMPLE_ROWS = 60
DEFAULT_RETENTION_SECONDS = 1_800
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class QbittorrentSampleStore: class QbittorrentSampleStore:
@@ -56,21 +66,52 @@ class QbittorrentSampleStore:
harness = get_service_data_harness() harness = get_service_data_harness()
self._harness = harness self._harness = harness
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None: def append(
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``.""" self,
service_id: str,
ts: int,
dl_speed: int,
up_speed: int,
*,
retention_seconds: int | None = None,
max_rows: int = MAX_SAMPLES,
) -> None:
"""Append a sample and prune by the configured time and row limits."""
max_rows = max(MIN_SAMPLE_ROWS, min(_safe_int(max_rows, MAX_SAMPLES), MAX_SAMPLES))
cutoff = None
if retention_seconds is not None:
retention = max(MIN_SAMPLE_ROWS, _safe_int(retention_seconds, DEFAULT_RETENTION_SECONDS))
cutoff = _safe_int(time.time()) - retention
with self._harness.connect("qbittorrent") as conn: with self._harness.connect("qbittorrent") as conn:
conn.execute( conn.execute(
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)", "INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
(service_id, ts, dl_speed, up_speed), (service_id, _safe_int(ts), _safe_int(dl_speed), _safe_int(up_speed)),
) )
if cutoff is None:
conn.execute( conn.execute(
"DELETE FROM qbittorrent_speed_samples " """
"WHERE service_id = ? AND ts NOT IN (" DELETE FROM qbittorrent_speed_samples
" SELECT ts FROM qbittorrent_speed_samples" WHERE service_id = ? AND rowid NOT IN (
" WHERE service_id = ?" SELECT rowid FROM qbittorrent_speed_samples
" ORDER BY ts DESC LIMIT ?" WHERE service_id = ?
")", ORDER BY ts DESC, rowid DESC LIMIT ?
(service_id, service_id, MAX_SAMPLES), )
""",
(service_id, service_id, max_rows),
)
else:
conn.execute(
"""
DELETE FROM qbittorrent_speed_samples
WHERE service_id = ? AND (
ts < ? OR rowid NOT IN (
SELECT rowid FROM qbittorrent_speed_samples
WHERE service_id = ?
ORDER BY ts DESC, rowid DESC LIMIT ?
)
)
""",
(service_id, cutoff, service_id, max_rows),
) )
conn.commit() conn.commit()
@@ -0,0 +1,296 @@
"""Single-worker scheduler for typed backend actions."""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any
from media_library_viewer_api.observability import record_scheduled_action
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
from .scheduler_actions import ( # type: ignore[reportMissingImports]
QBITTORRENT_SPEED_ACTION,
get_scheduled_action,
)
from .scheduler_store import SchedulerRunStore # type: ignore[reportMissingImports]
from .settings_store import SettingsStore, get_settings_store
logger = logging.getLogger(__name__)
DEFAULT_INTERVAL_SECONDS = 15
MIN_INTERVAL_SECONDS = 5
MAX_INTERVAL_SECONDS = 300
DEFAULT_RETENTION_SECONDS = 1_800
MAX_RETENTION_SECONDS = 86_400
DEFAULT_MAX_ROWS = 1_200
MAX_MAX_ROWS = 1_200
SCHEDULER_LOOP_SECONDS = 1.0
BACKOFF_CAP_SECONDS = 300
class SchedulerBusyError(RuntimeError):
"""Raised when a manual action overlaps an existing service run."""
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
def _bounded_config(config: dict[str, Any]) -> tuple[int, int, int]:
interval = max(
MIN_INTERVAL_SECONDS,
min(_safe_int(config.get("poll_interval_seconds"), DEFAULT_INTERVAL_SECONDS), MAX_INTERVAL_SECONDS),
)
retention = max(
60,
min(_safe_int(config.get("sample_retention_seconds"), DEFAULT_RETENTION_SECONDS), MAX_RETENTION_SECONDS),
)
max_rows = max(60, min(_safe_int(config.get("sample_max_rows"), DEFAULT_MAX_ROWS), MAX_MAX_ROWS))
return interval, retention, max_rows
@dataclass
class _ServiceState:
signature: tuple[Any, ...]
next_run_at: float | None = None
running: bool = False
last_attempt_at: int | None = None
last_success_at: int | None = None
last_error: str = ""
consecutive_failures: int = 0
backoff_until: int | None = None
class Scheduler:
"""Run registered service actions from one lifespan-managed worker."""
action_key = QBITTORRENT_SPEED_ACTION
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._state_lock = threading.RLock()
self._service_locks: dict[str, threading.Lock] = {}
self._states: dict[str, _ServiceState] = {}
self._run_store = SchedulerRunStore()
def start(self) -> None:
with self._state_lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="scheduled-actions", daemon=True)
self._thread.start()
logger.info("Scheduled action worker started")
def stop(self, timeout: float = 5.0) -> None:
with self._state_lock:
thread = self._thread
if not thread:
return
self._stop_event.set()
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Scheduled action worker did not stop within %.1fs", timeout)
else:
logger.info("Scheduled action worker stopped")
def status(self, service_id: str, store: SettingsStore | None = None) -> dict[str, Any]:
store = store or get_settings_store()
service = store.get_service(service_id)
if not service or service.get("service_type") != "qbittorrent":
raise ValueError("qBittorrent service not found")
config = service.get("config") or {}
interval, retention, max_rows = _bounded_config(config)
with self._state_lock:
state = self._states.get(service_id)
worker_running = bool(self._thread and self._thread.is_alive())
if state is None:
state = _ServiceState(signature=())
last_success = state.last_success_at
is_stale = last_success is None or time.time() - last_success > max(2 * interval, 60)
return {
"service_id": service_id,
"action_key": self.action_key,
"worker_running": worker_running,
"enabled": bool(service.get("enabled", True)) and bool(config.get("polling_enabled", True)),
"running": state.running,
"poll_interval_seconds": interval,
"sample_retention_seconds": retention,
"sample_max_rows": max_rows,
"next_run_at": _safe_int(state.next_run_at) if state.next_run_at is not None else None,
"last_attempt_at": state.last_attempt_at,
"last_success_at": last_success,
"last_error": state.last_error,
"consecutive_failures": state.consecutive_failures,
"backoff_until": state.backoff_until,
"is_stale": is_stale,
}
def run_now(self, service_id: str, store: SettingsStore | None = None) -> dict[str, Any]:
store = store or get_settings_store()
service_row = store.get_service(service_id)
if not service_row or service_row.get("service_type") != "qbittorrent":
raise ValueError("qBittorrent service not found")
if not service_row.get("enabled", True):
raise ValueError("Service is disabled")
config = service_row.get("config") or {}
if not config.get("polling_enabled", True):
raise ValueError("Polling is disabled")
service = build_service_record(store, service_row)
run = self._execute(service, "manual")
return {"run": run, "status": self.status(service_id, store)}
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._reconcile_and_run()
except Exception:
logger.exception("Scheduled action cycle failed")
self._stop_event.wait(SCHEDULER_LOOP_SECONDS)
def _reconcile_and_run(self) -> None:
store = get_settings_store()
services = sorted(store.list_services("qbittorrent"), key=lambda row: str(row.get("id") or ""))
active_ids = {str(row.get("id") or "") for row in services}
with self._state_lock:
for service_id in set(self._states) - active_ids:
self._states.pop(service_id, None)
self._service_locks.pop(service_id, None)
now = time.time()
for index, service_row in enumerate(services):
service_id = str(service_row.get("id") or "")
if not service_id:
continue
config = service_row.get("config") or {}
interval, _, _ = _bounded_config(config)
enabled = bool(service_row.get("enabled", True)) and bool(config.get("polling_enabled", True))
signature = (enabled, interval, config.get("sample_retention_seconds"), config.get("sample_max_rows"))
with self._state_lock:
state = self._states.get(service_id)
if state is None:
state = _ServiceState(signature=signature, next_run_at=now + min(index * 0.5, 5.0))
self._states[service_id] = state
elif state.signature != signature:
state.signature = signature
state.next_run_at = now if enabled else None
if not enabled:
state.next_run_at = None
continue
due = state.next_run_at is not None and now >= state.next_run_at
if due:
service = build_service_record(store, service_row)
try:
self._execute(service, "schedule")
except SchedulerBusyError:
logger.debug("Scheduled action already running service_id=%s", service_id)
with self._state_lock:
state = self._states.get(service_id)
if state:
delay = interval
if state.backoff_until:
delay = max(delay, state.backoff_until - _safe_int(time.time()))
state.next_run_at = time.time() + max(1, delay)
def _execute(self, service: ServiceRecord, trigger: str) -> dict[str, Any]:
lock = self._service_lock(service.id)
if not lock.acquire(blocking=False):
raise SchedulerBusyError(f"Action already running for service {service.id}")
try:
return self._execute_locked(service, trigger)
finally:
lock.release()
def _execute_locked(self, service: ServiceRecord, trigger: str) -> dict[str, Any]:
config = service.config
interval, _, _ = _bounded_config(config)
state = self._state_for(service.id, config)
attempt = state.consecutive_failures
start = time.perf_counter()
run = self._run_store.start_run(service.id, self.action_key, trigger, attempt=attempt)
with self._state_lock:
state.running = True
state.last_attempt_at = _safe_int(time.time())
action = get_scheduled_action(self.action_key)
try:
if action is None:
raise RuntimeError(f"Scheduled action is not registered: {self.action_key}")
action.run(service)
except Exception as exc:
duration_ms = _safe_int((time.perf_counter() - start) * 1000)
error = str(exc)[:1000]
finished = self._run_store.finish_run(run["id"], "failure", duration_ms=duration_ms, error=error)
self._run_store.prune(service.id, self.action_key)
with self._state_lock:
state.running = False
state.last_error = error
state.consecutive_failures += 1
delay = min(BACKOFF_CAP_SECONDS, max(interval, 2**state.consecutive_failures))
state.backoff_until = _safe_int(time.time()) + delay
record_scheduled_action(
service.id,
self.action_key,
"failure",
duration_seconds=duration_ms / 1000.0,
consecutive_failures=state.consecutive_failures,
)
logger.warning("Scheduled action failed service_id=%s action=%s: %s", service.id, self.action_key, error)
return finished if finished is not None else run
duration_ms = _safe_int((time.perf_counter() - start) * 1000)
finished = self._run_store.finish_run(run["id"], "success", duration_ms=duration_ms)
self._run_store.prune(service.id, self.action_key)
with self._state_lock:
state.running = False
state.last_success_at = _safe_int(time.time())
state.last_error = ""
state.consecutive_failures = 0
state.backoff_until = None
if trigger == "manual":
state.next_run_at = max(state.next_run_at or 0, time.time() + interval)
record_scheduled_action(
service.id,
self.action_key,
"success",
duration_seconds=duration_ms / 1000.0,
success=True,
consecutive_failures=0,
)
return finished if finished is not None else run
def _state_for(self, service_id: str, config: dict[str, Any]) -> _ServiceState:
with self._state_lock:
state = self._states.get(service_id)
if state is None:
interval, _, _ = _bounded_config(config)
state = _ServiceState(signature=(), next_run_at=time.time() + interval)
self._states[service_id] = state
return state
def _service_lock(self, service_id: str) -> threading.Lock:
with self._state_lock:
return self._service_locks.setdefault(service_id, threading.Lock())
_SCHEDULER = Scheduler()
def get_scheduler() -> Scheduler:
return _SCHEDULER
def reset_scheduler() -> None:
"""Reset the singleton for tests."""
global _SCHEDULER
_SCHEDULER.stop()
_SCHEDULER = Scheduler()
__all__ = ["Scheduler", "SchedulerBusyError", "get_scheduler", "reset_scheduler"]
@@ -0,0 +1,83 @@
"""Typed scheduled-action registry and qBittorrent sampling action."""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Any, Protocol
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.widgets.sources import ServiceRecord, _qbittorrent_client
QBITTORRENT_SPEED_ACTION = "qbittorrent.speed_sample"
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
@dataclass(frozen=True)
class ActionResult:
data: dict[str, Any]
class ScheduledAction(Protocol):
action_key: str
def run(self, service: ServiceRecord) -> ActionResult:
"""Run one action for one service instance."""
return ActionResult(data={})
class QbittorrentSpeedAction:
action_key = QBITTORRENT_SPEED_ACTION
def run(self, service: ServiceRecord) -> ActionResult:
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
timeout = _safe_int(service.config.get("timeout_seconds") or 60, 60)
if not base_url or not username or not password:
raise ValueError("qBittorrent service is missing base_url, username, or password")
client = _qbittorrent_client((service.id, base_url, username, password, timeout))
payload = client.maindata()
server_state = payload.get("server_state", {})
dl_speed = _safe_int(server_state.get("dl_info_speed", 0) or 0)
up_speed = _safe_int(server_state.get("up_info_speed", 0) or 0)
ts = _safe_int(time.time())
store = QbittorrentSampleStore()
store.append(
service.id,
ts,
dl_speed,
up_speed,
retention_seconds=_safe_int(service.config.get("sample_retention_seconds") or 1800, 1800),
max_rows=_safe_int(service.config.get("sample_max_rows") or 1200, 1200),
)
return ActionResult(data={"ts": ts, "dl_speed": dl_speed, "up_speed": up_speed})
_ACTIONS: dict[str, ScheduledAction] = {
QBITTORRENT_SPEED_ACTION: QbittorrentSpeedAction(),
}
def get_scheduled_action(action_key: str) -> ScheduledAction | None:
return _ACTIONS.get(action_key)
def list_scheduled_actions() -> list[str]:
return sorted(_ACTIONS)
__all__ = [
"ActionResult",
"QBITTORRENT_SPEED_ACTION",
"ScheduledAction",
"get_scheduled_action",
"list_scheduled_actions",
]
@@ -0,0 +1,181 @@
"""Persistence for typed scheduled-action execution history."""
from __future__ import annotations
import time
import uuid
from typing import Any
from media_library_viewer_api.services.service_data import ServiceDataHarness, StorageConcern
SCHEDULER_CONCERN = StorageConcern(
concern_key="scheduler",
db_filename="scheduler.db",
migrations=[
"""
CREATE TABLE IF NOT EXISTS scheduler_action_runs (
id TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
action_key TEXT NOT NULL,
trigger TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER,
status TEXT NOT NULL,
attempt INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_service_action_started
ON scheduler_action_runs(service_id, action_key, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_scheduler_runs_status_started
ON scheduler_action_runs(status, started_at DESC);
"""
],
tables=["scheduler_action_runs"],
)
MAX_RUNS_PER_ACTION = 1_000
RUN_RETENTION_SECONDS = 30 * 24 * 60 * 60
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except (TypeError, ValueError):
return default
class SchedulerRunStore:
"""Store scheduler run records in the scheduler service-data concern."""
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 start_run(self, service_id: str, action_key: str, trigger: str, attempt: int = 0) -> dict[str, Any]:
now = _safe_int(time.time())
run = {
"id": uuid.uuid4().hex[:12],
"service_id": service_id,
"action_key": action_key,
"trigger": trigger,
"started_at": now,
"finished_at": None,
"status": "running",
"attempt": _safe_int(attempt),
"duration_ms": None,
"error": "",
"created_at": now,
}
with self._harness.connect("scheduler") as conn:
conn.execute(
"""
INSERT INTO scheduler_action_runs
(id, service_id, action_key, trigger, started_at, finished_at,
status, attempt, duration_ms, error, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run["id"],
run["service_id"],
run["action_key"],
run["trigger"],
run["started_at"],
run["finished_at"],
run["status"],
run["attempt"],
run["duration_ms"],
run["error"],
run["created_at"],
),
)
conn.commit()
return run
def finish_run(
self,
run_id: str,
status: str,
finished_at: int | None = None,
duration_ms: int | None = None,
error: str = "",
) -> dict[str, Any] | None:
finished_at = _safe_int(finished_at if finished_at is not None else time.time())
with self._harness.connect("scheduler") as conn:
conn.execute(
"""
UPDATE scheduler_action_runs
SET finished_at = ?, status = ?, duration_ms = ?, error = ?
WHERE id = ?
""",
(finished_at, status, duration_ms, str(error or "")[:1000], run_id),
)
conn.commit()
row = conn.execute("SELECT * FROM scheduler_action_runs WHERE id = ?", (run_id,)).fetchone()
return dict(row) if row else None
def get_run(self, run_id: str) -> dict[str, Any] | None:
with self._harness.connect("scheduler") as conn:
row = conn.execute("SELECT * FROM scheduler_action_runs WHERE id = ?", (run_id,)).fetchone()
return dict(row) if row else None
def list_runs(
self,
service_id: str,
action_key: str,
*,
status: str | None = None,
trigger: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
limit = max(1, min(_safe_int(limit, 50), 100))
offset = max(0, _safe_int(offset))
with self._harness.connect("scheduler") as conn:
rows = conn.execute(
"""
SELECT * FROM scheduler_action_runs
WHERE service_id = ? AND action_key = ?
ORDER BY started_at DESC, id DESC LIMIT 1000
""",
(service_id, action_key),
).fetchall()
filtered = [
dict(row)
for row in rows
if (not status or row["status"] == status) and (not trigger or row["trigger"] == trigger)
]
return filtered[offset : offset + limit], len(filtered)
def prune(self, service_id: str, action_key: str, now: int | None = None) -> int:
now = _safe_int(now if now is not None else time.time())
cutoff = now - RUN_RETENTION_SECONDS
with self._harness.connect("scheduler") as conn:
cursor = conn.execute(
"""
DELETE FROM scheduler_action_runs
WHERE service_id = ? AND action_key = ?
AND (
created_at < ? OR rowid NOT IN (
SELECT rowid FROM scheduler_action_runs
WHERE service_id = ? AND action_key = ?
ORDER BY started_at DESC, rowid DESC LIMIT ?
)
)
""",
(service_id, action_key, cutoff, service_id, action_key, MAX_RUNS_PER_ACTION),
)
conn.commit()
return _safe_int(cursor.rowcount)
__all__ = [
"MAX_RUNS_PER_ACTION",
"RUN_RETENTION_SECONDS",
"SCHEDULER_CONCERN",
"SchedulerRunStore",
]
@@ -16,12 +16,33 @@ from __future__ import annotations
import logging import logging
import os import os
import re
import sqlite3 import sqlite3
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def _quote_identifier(value: str) -> str:
if not _IDENTIFIER_RE.fullmatch(value):
raise ValueError(f"Unsafe SQLite identifier: {value!r}")
return f'"{value}"'
def _cascade_delete(conn: sqlite3.Connection, table: str, column: str, service_id: str) -> None:
table_sql = _quote_identifier(table)
column_sql = _quote_identifier(column)
# Identifiers are strictly allowlisted; the value remains parameterized.
# nosemgrep: python.lang.security.audit.formatted-sql-query.formatted-sql-query
# nosemgrep: python.sqlalchemy.security.sqlalchemy-execute-raw-query.sqlalchemy-execute-raw-query
conn.execute(
f"DELETE FROM {table_sql} WHERE {column_sql} = ?",
(service_id,),
)
@dataclass(frozen=True) @dataclass(frozen=True)
class StorageConcern: class StorageConcern:
@@ -96,7 +117,7 @@ class ServiceDataHarness:
conn.execute(stmt) conn.execute(stmt)
except sqlite3.OperationalError as exc: except sqlite3.OperationalError as exc:
lowered = str(exc).lower() lowered = str(exc).lower()
if "duplicate column name" in lowered or "no such table" in lowered: if any(marker in lowered for marker in ("duplicate column name", "no such table")):
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80]) logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
else: else:
raise raise
@@ -118,9 +139,7 @@ class ServiceDataHarness:
continue continue
with sqlite3.connect(path, timeout=30) as conn: with sqlite3.connect(path, timeout=30) as conn:
for table in concern.tables: for table in concern.tables:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} _cascade_delete(conn, table, col, service_id)
if col in cols:
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -148,6 +167,10 @@ def get_service_data_harness() -> ServiceDataHarness:
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
_HARNESS.register(MEDIA_INDEX_CONCERN) _HARNESS.register(MEDIA_INDEX_CONCERN)
from .scheduler_store import SCHEDULER_CONCERN # type: ignore[reportMissingImports]
_HARNESS.register(SCHEDULER_CONCERN)
_HARNESS.run_migrations() _HARNESS.run_migrations()
return _HARNESS return _HARNESS
@@ -1,9 +1,4 @@
"""Persistent application settings stored in a small SQLite database. """Persistent application settings stored in a small SQLite database."""
The store manages machine definitions, machine services, and per-machine
application configuration so the frontend can present local and remote targets
in the same UI.
"""
from __future__ import annotations from __future__ import annotations
@@ -23,31 +18,6 @@ from media_library_viewer_api.models.widgets import _validate_config_keys
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite") DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"]
def _default_local_machine() -> dict[str, Any]:
return {
"id": LOCAL_MACHINE_ID,
"name": "This machine",
"mode": "local",
"enabled": True,
"services": list(DEFAULT_SERVICES),
"host": "",
"port": 22,
"username": "",
"key_directory": "",
"key_name": "",
"ssh_key_id": "",
"ssh_private_key": "",
"ssh_private_key_passphrase": "",
"password": "",
"node_exporter_enabled": False,
"node_exporter_port": 9100,
"node_exporter_scrape_host": "",
"notes": "",
}
class SettingsStore: class SettingsStore:
@@ -66,23 +36,6 @@ class SettingsStore:
def init_schema(self) -> None: def init_schema(self) -> None:
with self.connect() as conn: with self.connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS monitoring_machines (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
mode TEXT NOT NULL,
enabled INTEGER NOT NULL,
config_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
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. Drop the orphan
# table on startup so existing databases get a clean slate.
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions") conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
conn.execute( conn.execute(
""" """
@@ -112,7 +65,7 @@ class SettingsStore:
task_type TEXT NOT NULL, task_type TEXT NOT NULL,
content TEXT NOT NULL, content TEXT NOT NULL,
enabled INTEGER NOT NULL, enabled INTEGER NOT NULL,
default_service_id TEXT NOT NULL, service_id TEXT NOT NULL,
notes TEXT NOT NULL, notes TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL updated_at INTEGER NOT NULL
@@ -120,11 +73,15 @@ class SettingsStore:
""" """
) )
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)") conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
# saved_tasks.default_machine_id → default_service_id (saved tasks now # Migrate legacy task ownership column names in place.
# target ssh_tasks service instances). Migrate existing columns.
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()} 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: if "service_id" not in saved_tasks_cols:
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id") legacy_column = next(
(column for column in ("default_service_id", "default_machine_id") if column in saved_tasks_cols),
None,
)
if legacy_column:
conn.execute(f"ALTER TABLE saved_tasks RENAME COLUMN {legacy_column} TO service_id")
# Run history for saved tasks now lives in service_task_runs; the # Run history for saved tasks now lives in service_task_runs; the
# legacy machine-based table is dropped. # legacy machine-based table is dropped.
conn.execute("DROP TABLE IF EXISTS saved_task_runs") conn.execute("DROP TABLE IF EXISTS saved_task_runs")
@@ -276,191 +233,163 @@ class SettingsStore:
) )
""" """
) )
self._migrate_remote_machine_services(conn)
@staticmethod def _migrate_remote_machine_services(self, conn: sqlite3.Connection) -> None:
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]: """Migrate legacy SSH endpoints into encrypted ``remote_machine`` services.
if isinstance(value, str):
items = [part.strip() for part in value.split(",")]
elif isinstance(value, list):
items = [str(part).strip() for part in value]
else:
items = list(fallback or DEFAULT_SERVICES)
services = [item for item in items if item]
if not services:
services = list(fallback or DEFAULT_SERVICES)
deduped: list[str] = []
for service in services:
if service not in deduped:
deduped.append(service)
return deduped
def _row_to_machine(self, row: sqlite3.Row) -> dict[str, Any]: Local placeholders are deliberately skipped. Invalid legacy rows abort
data = json.loads(row["config_json"]) the transaction, retaining the source table instead of silently losing
default_services = DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else [] credential material.
services = self._normalize_services(data.get("services"), default_services) """
return { tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
"id": row["id"], conn.execute("UPDATE services SET service_type = 'remote_machine' WHERE service_type = 'ssh_tasks'")
"name": row["name"], if "monitoring_machines" not in tables:
"mode": row["mode"], return
"enabled": bool(row["enabled"]),
"services": services,
"host": data.get("host", ""),
"port": int(data.get("port", 22) or 22),
"username": data.get("username", ""),
"key_directory": data.get("key_directory", ""),
"key_name": data.get("key_name", ""),
"ssh_key_id": data.get("ssh_key_id", ""),
"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")),
"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", ""),
"notes": data.get("notes", ""),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _normalize_machine_payload( from media_library_viewer_api.services.secrets import encrypt_value
self,
payload: dict[str, Any],
machine_id: str | None = None,
) -> dict[str, Any]:
current = self.get_machine(machine_id) if machine_id else None
machine_id = str(payload.get("id") or machine_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
mode = str(payload.get("mode") or (current or {}).get("mode") or "local").strip().lower()
if mode not in {"local", "ssh"}:
mode = "local"
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
name = str(payload.get("name") or (current or {}).get("name") or "").strip() or (
"This machine" if mode == "local" else machine_id
)
services = self._normalize_services(payload.get("services"), (current or {}).get("services", []))
def _current_str(field: str, default: str = "") -> str: rows = conn.execute("SELECT * FROM monitoring_machines ORDER BY created_at, id").fetchall()
return str( for row in rows:
payload.get(field) if payload.get(field) is not None else (current or {}).get(field, default) or default try:
).strip() data = json.loads(row["config_json"] or "{}")
except (TypeError, json.JSONDecodeError) as exc:
host = _current_str("host") raise RuntimeError(f"Legacy machine {row['id']!r} has invalid config JSON") from exc
port = int(payload.get("port") or (current or {}).get("port", 22) or 22) if not isinstance(data, dict):
username = _current_str("username") raise RuntimeError(f"Legacy machine {row['id']!r} config must be an object")
key_directory = _current_str("key_directory") if str(row["mode"] or "").lower() != "ssh":
key_name = _current_str("key_name") continue
ssh_key_id = _current_str("ssh_key_id") old_id = str(row["id"])
ssh_private_key = payload.get("ssh_private_key") target_id = self._remote_machine_target_id(conn, old_id)
if ssh_private_key in (None, ""): ssh_key_id = self._migrate_inline_ssh_key(conn, row, data, old_id)
ssh_private_key = (current or {}).get("ssh_private_key", "") config = self._legacy_remote_machine_config(data, ssh_key_id, old_id)
ssh_private_key = str(ssh_private_key or "") secrets = self._legacy_remote_machine_secrets(data, encrypt_value)
ssh_private_key_passphrase = payload.get("ssh_private_key_passphrase")
if ssh_private_key_passphrase in (None, ""):
ssh_private_key_passphrase = (current or {}).get("ssh_private_key_passphrase", "")
ssh_private_key_passphrase = str(ssh_private_key_passphrase or "")
password = payload.get("password")
if password in (None, ""):
password = (current or {}).get("password", "")
password = str(password or "")
node_exporter_enabled = bool(
payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None
else (current or {}).get("node_exporter_enabled", False)
)
node_exporter_port_raw = payload.get("node_exporter_port")
if node_exporter_port_raw is None:
node_exporter_port_raw = (current or {}).get("node_exporter_port", 9100)
node_exporter_port = int(node_exporter_port_raw or 9100)
node_exporter_scrape_host = _current_str("node_exporter_scrape_host")
notes = _current_str("notes")
if mode == "local":
host = host or "localhost"
username = username or ""
return {
"id": machine_id,
"name": name,
"mode": mode,
"enabled": enabled,
"services": services,
"host": host,
"port": port,
"username": username,
"key_directory": key_directory,
"key_name": key_name,
"ssh_key_id": ssh_key_id,
"ssh_private_key": ssh_private_key,
"ssh_private_key_passphrase": ssh_private_key_passphrase,
"password": password,
"node_exporter_enabled": node_exporter_enabled,
"node_exporter_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host,
"notes": notes,
}
def _seed_local_machine(self) -> None:
"""Seed the default local machine if none exists."""
machine = _default_local_machine()
now = int(time.time())
config = {
"services": machine["services"],
"host": machine["host"],
"port": machine["port"],
"username": machine["username"],
"key_directory": machine["key_directory"],
"key_name": machine["key_name"],
"ssh_key_id": machine.get("ssh_key_id", ""),
"ssh_private_key": "",
"ssh_private_key_passphrase": "",
"password": "",
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
"notes": machine["notes"],
}
with self.connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at) INSERT INTO services (
VALUES (?, ?, ?, ?, ?, ?, ?) id, service_type, name, config_json, secrets_json, enabled, created_at, updated_at
) VALUES (?, 'remote_machine', ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
""", """,
( (
machine["id"], target_id,
machine["name"], row["name"],
machine["mode"],
1,
json.dumps(config), json.dumps(config),
now, json.dumps(secrets),
now, row["enabled"],
row["created_at"],
row["updated_at"],
), ),
) )
if target_id != old_id:
conn.execute("UPDATE saved_tasks SET service_id = ? WHERE service_id = ?", (target_id, old_id))
conn.execute("UPDATE service_task_runs SET service_id = ? WHERE service_id = ?", (target_id, old_id))
conn.execute("UPDATE dashboard_widgets SET service_id = ? WHERE service_id = ?", (target_id, old_id))
conn.execute("DROP TABLE monitoring_machines")
def _seed_dashboard_widgets(self) -> None: @staticmethod
"""Default widget seeding was removed. def _remote_machine_target_id(conn: sqlite3.Connection, old_id: str) -> str:
existing = conn.execute("SELECT service_type FROM services WHERE id = ?", (old_id,)).fetchone()
if not existing or existing[0] == "remote_machine":
return old_id
base = f"remote-machine-{old_id}"
target_id, suffix = base, 2
while conn.execute("SELECT 1 FROM services WHERE id = ?", (target_id,)).fetchone():
target_id = f"{base}-{suffix}"
suffix += 1
return target_id
Widgets are now service-bound (or built-in). A fresh install starts with def _migrate_inline_ssh_key(
no widgets; the user configures services and adds widgets from the UI. self, conn: sqlite3.Connection, row: sqlite3.Row, data: dict[str, Any], old_id: str
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged. ) -> str:
ssh_key_id = str(data.get("ssh_key_id") or "").strip()
inline_key = str(data.get("ssh_private_key") or "")
if not inline_key or ssh_key_id:
return ssh_key_id
base = f"legacy-key-{old_id}"
ssh_key_id, suffix = base, 2
while conn.execute("SELECT 1 FROM ssh_keys WHERE id = ?", (ssh_key_id,)).fetchone():
ssh_key_id = f"{base}-{suffix}"
suffix += 1
summary = self._private_key_summary(inline_key)
conn.execute(
""" """
return None INSERT INTO ssh_keys (
id, name, private_key, passphrase, public_key, fingerprint, notes, created_at, updated_at
) VALUES (?, ?, ?, '', ?, ?, ?, ?, ?)
""",
(
ssh_key_id,
f"Migrated key for {row['name']}",
inline_key,
summary["public_key"],
summary["fingerprint"],
"Migrated from legacy remote machine",
row["created_at"],
row["updated_at"],
),
)
return ssh_key_id
@staticmethod
def _legacy_remote_machine_config(data: dict[str, Any], ssh_key_id: str, machine_id: str) -> dict[str, Any]:
try:
port = int(data.get("port") or 22)
timeout = int(data.get("timeout_seconds") or 30)
except (TypeError, ValueError) as exc:
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout") from exc
if not 1 <= port <= 65535 or timeout <= 0:
raise RuntimeError(f"Legacy machine {machine_id!r} has invalid SSH port or timeout")
return {
"host": str(data.get("host") or ""),
"port": port,
"username": str(data.get("username") or ""),
"ssh_key_id": ssh_key_id,
"timeout_seconds": timeout,
}
@staticmethod
def _legacy_remote_machine_secrets(data: dict[str, Any], encrypt_value: Any) -> dict[str, str]:
secrets: dict[str, str] = {}
for legacy, secret in (("ssh_private_key_passphrase", "passphrase"), ("password", "password")):
value = str(data.get(legacy) or "")
if value:
secrets[secret] = encrypt_value(value)
return secrets
def ensure_defaults(self) -> None: def ensure_defaults(self) -> None:
self.init_schema() self.init_schema()
with self.connect() as conn:
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() self._migrate_jellyseerr_into_jellyfin()
self._migrate_jellyseerr_api_key_to_secret()
def _migrate_jellyseerr_api_key_to_secret(self) -> None:
"""Move Jellyfin's plaintext ``jellyseerr_api_key`` from config into secrets."""
from media_library_viewer_api.services.secrets import encrypt_value
moved = 0
for row in self.list_services("jellyfin"):
config = dict(row.get("config") or {})
plaintext = str(config.get("jellyseerr_api_key") or "").strip()
if not plaintext:
continue
secrets_blob = dict(row.get("secrets") or {})
if "jellyseerr_api_key" not in secrets_blob:
secrets_blob["jellyseerr_api_key"] = encrypt_value(plaintext)
config.pop("jellyseerr_api_key", None)
with self.connect() as conn:
conn.execute(
"UPDATE services SET config_json = ?, secrets_json = ?, updated_at = ? WHERE id = ?",
(json.dumps(config), json.dumps(secrets_blob), int(time.time()), row["id"]),
)
moved += 1
logger.info("migrated jellyseerr_api_key config->secret for jellyfin service %r", row["name"])
if moved:
logger.info("migrated jellyseerr_api_key to secret for %s jellyfin service(s)", moved)
def _migrate_jellyseerr_into_jellyfin(self) -> None: def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin. """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 from media_library_viewer_api.services.secrets import decrypt_value
self.init_schema()
jellyseerr_rows: list[sqlite3.Row] = []
with self.connect() as conn: with self.connect() as conn:
jellyseerr_rows = conn.execute( jellyseerr_rows = conn.execute(
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC" "SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
@@ -474,28 +403,32 @@ class SettingsStore:
js_secrets = json.loads(js_row["secrets_json"] or "{}") js_secrets = json.loads(js_row["secrets_json"] or "{}")
js_url = str(js_config.get("base_url", "")).strip() js_url = str(js_config.get("base_url", "")).strip()
js_api_key = str(js_secrets.get("api_key", "")).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: if js_api_key:
try: try:
js_api_key = decrypt_value(js_api_key) js_api_key = decrypt_value(js_api_key)
except Exception: except Exception:
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"]) logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
js_api_key = "" js_api_key = ""
js_name = js_row["name"]
target = None target = None
if len(jellyfin_rows) == 1: if len(jellyfin_rows) == 1:
target = jellyfin_rows[0] target = jellyfin_rows[0]
elif len(jellyfin_rows) > 1: elif len(jellyfin_rows) > 1:
for jf in jellyfin_rows: target = next(
if not str(jf["config"].get("jellyseerr_url", "")).strip(): (row for row in jellyfin_rows if not str(row["config"].get("jellyseerr_url", "")).strip()),
target = jf None,
break )
if target: if target:
target_api_key = str(target["secrets"].get("api_key") or "")
if target_api_key:
try:
target_api_key = decrypt_value(target_api_key)
except Exception:
logger.warning("could not decrypt jellyfin api_key for %r", target["name"])
target_api_key = ""
merged_config = dict(target["config"]) merged_config = dict(target["config"])
merged_config["jellyseerr_url"] = js_url merged_config["jellyseerr_url"] = js_url
merged_config["jellyseerr_api_key"] = js_api_key
self.upsert_service( self.upsert_service(
{ {
"id": target["id"], "id": target["id"],
@@ -504,139 +437,14 @@ class SettingsStore:
"config": merged_config, "config": merged_config,
"enabled": target["enabled"], "enabled": target["enabled"],
}, },
secret_values={"api_key": str(target["secrets"].get("api_key", ""))}, secret_values={"api_key": target_api_key, "jellyseerr_api_key": js_api_key},
) )
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"]) logger.info("migrated jellyseerr service %r into jellyfin service %r", js_row["name"], target["name"])
else: else:
logger.warning( logger.warning("dropped unpaired jellyseerr service %r; reconfigure manually", js_row["name"])
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
js_name,
)
with self.connect() as conn: with self.connect() as conn:
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],)) conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
conn.commit()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM monitoring_machines ORDER BY CASE WHEN id = ? THEN 0 ELSE 1 END, name COLLATE NOCASE",
(LOCAL_MACHINE_ID,),
).fetchall()
return [self._row_to_machine(row) for row in rows]
def get_machine(self, machine_id: str | None) -> dict[str, Any] | None:
if not machine_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
return self._row_to_machine(row) if row else None
def get_machine_config(self, machine_id: str | None) -> dict[str, Any] | None:
"""Return the full machine config including secrets."""
if not machine_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM monitoring_machines WHERE id = ?", (machine_id,)).fetchone()
if not row:
return None
data = json.loads(row["config_json"])
return {
"id": row["id"],
"name": row["name"],
"mode": row["mode"],
"enabled": bool(row["enabled"]),
"services": self._normalize_services(
data.get("services"),
DEFAULT_SERVICES if row["id"] == LOCAL_MACHINE_ID else [],
),
"host": data.get("host", ""),
"port": int(data.get("port", 22) or 22),
"username": data.get("username", ""),
"key_directory": data.get("key_directory", ""),
"key_name": data.get("key_name", ""),
"ssh_key_id": data.get("ssh_key_id", ""),
"ssh_private_key": data.get("ssh_private_key", ""),
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
"password": data.get("password", ""),
"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", ""),
"notes": data.get("notes", ""),
}
def list_machines_for_service(self, service: str) -> list[dict[str, Any]]:
return [
machine
for machine in self.list_machines()
if service in machine.get("services", []) and machine.get("enabled")
]
def get_machine_for_service(self, service: str, machine_id: str | None = None) -> dict[str, Any] | None:
if machine_id:
machine = self.get_machine(machine_id)
if machine and service in machine.get("services", []) and machine.get("enabled"):
return machine
return machine if machine else None
machines = self.list_machines_for_service(service)
return machines[0] if machines else None
def upsert_machine(self, payload: dict[str, Any], machine_id: str | None = None) -> dict[str, Any]:
self.init_schema()
machine = self._normalize_machine_payload(payload, machine_id)
now = int(time.time())
config = {
"services": machine["services"],
"host": machine["host"],
"port": machine["port"],
"username": machine["username"],
"key_directory": machine["key_directory"],
"key_name": machine["key_name"],
"ssh_key_id": machine.get("ssh_key_id", ""),
"ssh_private_key": machine["ssh_private_key"],
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
"password": machine["password"],
"node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
"notes": machine["notes"],
}
with self.connect() as conn:
existing = conn.execute(
"SELECT created_at FROM monitoring_machines WHERE id = ?",
(machine["id"],),
).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO monitoring_machines (id, name, mode, enabled, config_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
mode = excluded.mode,
enabled = excluded.enabled,
config_json = excluded.config_json,
updated_at = excluded.updated_at
""",
(
machine["id"],
machine["name"],
machine["mode"],
1 if machine["enabled"] else 0,
json.dumps(config),
created_at,
now,
),
)
return self.get_machine(machine["id"]) or machine
def delete_machine(self, machine_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
@staticmethod @staticmethod
def _private_key_summary(private_key: str) -> dict[str, str]: def _private_key_summary(private_key: str) -> dict[str, str]:
@@ -707,10 +515,9 @@ class SettingsStore:
def list_ssh_keys(self) -> list[dict[str, Any]]: def list_ssh_keys(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
machines = self.list_machines()
usage_counts: dict[str, int] = {} usage_counts: dict[str, int] = {}
for machine in machines: for service in self.list_services("remote_machine"):
ssh_key_id = str(machine.get("ssh_key_id") or "").strip() ssh_key_id = str((service.get("config") or {}).get("ssh_key_id") or "").strip()
if ssh_key_id: if ssh_key_id:
usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1 usage_counts[ssh_key_id] = usage_counts.get(ssh_key_id, 0) + 1
with self.connect() as conn: with self.connect() as conn:
@@ -785,7 +592,7 @@ class SettingsStore:
"task_type": row["task_type"], "task_type": row["task_type"],
"content": row["content"], "content": row["content"],
"enabled": bool(row["enabled"]), "enabled": bool(row["enabled"]),
"default_service_id": row["default_service_id"], "service_id": row["service_id"],
"notes": row["notes"], "notes": row["notes"],
"created_at": row["created_at"], "created_at": row["created_at"],
"updated_at": row["updated_at"], "updated_at": row["updated_at"],
@@ -802,10 +609,10 @@ class SettingsStore:
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or "" 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))) enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
default_service_id = str( service_id = str(
payload.get("default_service_id") payload.get("service_id")
if payload.get("default_service_id") is not None if payload.get("service_id") is not None
else (current or {}).get("default_service_id", "") or "" else (current or {}).get("service_id", "") or ""
).strip() ).strip()
notes = str( notes = str(
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or "" payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
@@ -816,7 +623,7 @@ class SettingsStore:
"task_type": task_type, "task_type": task_type,
"content": content, "content": content,
"enabled": enabled, "enabled": enabled,
"default_service_id": default_service_id, "service_id": service_id,
"notes": notes, "notes": notes,
} }
@@ -844,7 +651,7 @@ class SettingsStore:
conn.execute( conn.execute(
""" """
INSERT INTO saved_tasks ( INSERT INTO saved_tasks (
id, name, task_type, content, enabled, default_service_id, id, name, task_type, content, enabled, service_id,
notes, created_at, updated_at notes, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -853,7 +660,7 @@ class SettingsStore:
task_type = excluded.task_type, task_type = excluded.task_type,
content = excluded.content, content = excluded.content,
enabled = excluded.enabled, enabled = excluded.enabled,
default_service_id = excluded.default_service_id, service_id = excluded.service_id,
notes = excluded.notes, notes = excluded.notes,
updated_at = excluded.updated_at updated_at = excluded.updated_at
""", """,
@@ -863,7 +670,7 @@ class SettingsStore:
task["task_type"], task["task_type"],
task["content"], task["content"],
1 if task["enabled"] else 0, 1 if task["enabled"] else 0,
task["default_service_id"], task["service_id"],
task["notes"], task["notes"],
created_at, created_at,
now, now,
@@ -1075,10 +882,16 @@ class SettingsStore:
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone() row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
return self._row_to_job(row) if row else None return self._row_to_job(row) if row else None
def list_backup_jobs(self) -> list[dict[str, Any]]: def list_backup_jobs(self, service_id: str | None = None) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
where = ""
params: list[Any] = []
if service_id:
where = "WHERE service_id = ?"
params.append(service_id)
sql = f"SELECT * FROM backup_jobs {where} ORDER BY created_at DESC"
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall() rows = conn.execute(sql, params).fetchall()
return [self._row_to_job(row) for row in rows] return [self._row_to_job(row) for row in rows]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -1167,6 +980,7 @@ class SettingsStore:
job_id: str | None = None, job_id: str | None = None,
status: str | None = None, status: str | None = None,
limit: int = 50, limit: int = 50,
service_id: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
clauses: list[str] = [] clauses: list[str] = []
@@ -1177,6 +991,9 @@ class SettingsStore:
if status: if status:
clauses.append("status = ?") clauses.append("status = ?")
params.append(status) params.append(status)
if service_id:
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
params.append(service_id)
where = f"WHERE {' AND '.join(clauses)}" if clauses else "" where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?" sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
params.append(max(1, min(int(limit), 200))) params.append(max(1, min(int(limit), 200)))
@@ -1257,6 +1074,7 @@ class SettingsStore:
job_id: str | None = None, job_id: str | None = None,
acknowledged: bool | None = None, acknowledged: bool | None = None,
severity: str | None = None, severity: str | None = None,
service_id: str | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
clauses: list[str] = [] clauses: list[str] = []
@@ -1270,6 +1088,9 @@ class SettingsStore:
if severity: if severity:
clauses.append("severity = ?") clauses.append("severity = ?")
params.append(severity) params.append(severity)
if service_id:
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
params.append(service_id)
where = f"WHERE {' AND '.join(clauses)}" if clauses else "" where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC" sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
with self.connect() as conn: with self.connect() as conn:
@@ -1,58 +0,0 @@
"""Prometheus Node Exporter target discovery.
The backend owns the list of remote Node Exporter targets so that operators can
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
via ``http_sd_configs`` (no shared volume required).
"""
from __future__ import annotations
import logging
from typing import Any
from media_library_viewer_api.services.settings_store import SettingsStore
logger = logging.getLogger(__name__)
DEFAULT_NODE_EXPORTER_PORT = 9100
def _scrape_address(machine: dict[str, Any]) -> str | None:
"""Return host:port for the Node Exporter on a machine, or None if disabled."""
if not machine.get("node_exporter_enabled"):
return None
scrape_host = str(machine.get("node_exporter_scrape_host") or "").strip()
host = scrape_host or str(machine.get("host") or "").strip()
if not host or host == "localhost":
return None
port = int(machine.get("node_exporter_port") or DEFAULT_NODE_EXPORTER_PORT)
return f"{host}:{port}"
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
"""Build an http-SD target list for all enabled SSH machines.
Local machines are excluded because the Docker host is scraped directly.
"""
targets: list[dict[str, Any]] = []
for machine in store.list_machines():
if not machine.get("enabled"):
continue
if str(machine.get("mode") or "local").strip().lower() != "ssh":
continue
address = _scrape_address(machine)
if not address:
continue
targets.append(
{
"targets": [address],
"labels": {
"job": "node-exporter-remote",
"machine_id": str(machine.get("id") or ""),
"machine_name": str(machine.get("name") or ""),
"instance": address,
},
}
)
return targets
@@ -1,7 +1,7 @@
"""Shared runner for saved tasks over SSH task services. """Shared runner for saved tasks over Remote machine services.
Both the Actions page (``routers/tasks.py``) and the SSH task widget Both the Actions page (``routers/tasks.py``) and the SSH task widget
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` service instances. (``widgets/sources.py``) run saved tasks against ``remote_machine`` service instances.
This module is the single execution path: build the client from the service This module is the single execution path: build the client from the service
record, render the command, run it with the service timeout, append a record, render the command, run it with the service timeout, append a
``service_task_runs`` row, and return the result. ``service_task_runs`` row, and return the result.
@@ -40,12 +40,12 @@ class TaskRunResult:
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient: def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
"""Build an SSH client from an ssh_tasks service instance + referenced key.""" """Build an SSH client from an remote_machine service instance + referenced key."""
config = service.config config = service.config
host = str(config.get("host") or "").strip() host = str(config.get("host") or "").strip()
username = str(config.get("username") or "").strip() username = str(config.get("username") or "").strip()
if not host or not username: if not host or not username:
raise ValueError("SSH task service is missing host or username") raise ValueError("Remote machine service is missing host or username")
settings = get_settings() settings = get_settings()
private_key = "" private_key = ""
@@ -65,6 +65,7 @@ def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSS
port=int(config.get("port") or 22), port=int(config.get("port") or 22),
private_key=private_key or None, private_key=private_key or None,
private_key_passphrase=key_passphrase or None, private_key_passphrase=key_passphrase or None,
password=str(service.secrets.get("password") or "") or None,
known_hosts_path=str(settings.ssh_known_hosts_file), known_hosts_path=str(settings.ssh_known_hosts_file),
timeout=int(config.get("timeout_seconds") or 30), timeout=int(config.get("timeout_seconds") or 30),
) )
@@ -88,7 +89,7 @@ def run_saved_task(
*, *,
timeout: int | None = None, timeout: int | None = None,
) -> TaskRunResult: ) -> TaskRunResult:
"""Run a saved task on an ssh_tasks service instance and log the run. """Run a saved task on an remote_machine service instance and log the run.
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
is recorded in ``service_task_runs`` regardless of outcome (success, failure, is recorded in ``service_task_runs`` regardless of outcome (success, failure,
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/widgets dir: backend/src/media_library_viewer_api/widgets
## role ## role
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data. Provides widget data adapters, built-in widget definitions, and stats-provider abstractions for fetching and normalizing dashboard data from various external services.
## parent ## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md map: backend/src/media_library_viewer_api/.pi-map.md
@@ -11,12 +11,15 @@ map: backend/src/media_library_viewer_api/.pi-map.md
## files ## files
- __init__.py - __init__.py
- builtin.py - builtin.py
- jellyseerr_stats.py
- prometheus_range.py
- sources.py - sources.py
- stats_provider.py
## links ## links
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
map: backend/src/media_library_viewer_api/widgets/.pi-map.md map: backend/src/media_library_viewer_api/widgets/.pi-map.md
## workflows ## workflows
- change widgets behavior - change widgets behavior
read: __init__.py, builtin.py, sources.py read: __init__.py, builtin.py, jellyseerr_stats.py
## dirty ## dirty
- -
File diff suppressed because one or more lines are too long

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