Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8afdd9c2bc | |||
| 3e3e69b0be | |||
| cadb6d0991 | |||
| 493c0e1aeb | |||
| a9381e2471 | |||
| e461279566 | |||
| 1fe7c06083 | |||
| ec7ebd7013 | |||
| 98f17f6e5d | |||
| 5cb5e79032 | |||
| c9201a004c | |||
| 40a7ac80d3 | |||
| c9404f0794 | |||
| 75c949ad25 | |||
| c87f398e37 | |||
| 1fb12b8a0a | |||
| e7bd0afdd1 | |||
| 9a251db23c | |||
| cff082c7a1 | |||
| bc389ae3f7 | |||
| 7efc06a629 | |||
| 3e77075171 | |||
| 7440603cdb | |||
| 67ca0fc3bc | |||
| 65bae95e3c | |||
| 5dad98231f | |||
| d906b0392b | |||
| 9b7415080b | |||
| 78e273efe8 | |||
| b7e5ca3cbc | |||
| 67c51f9fc0 | |||
| 7497469d5e | |||
| a3888026ab | |||
| 787f46700f | |||
| 57fe04ae7b | |||
| 5a43894875 | |||
| 04871bd7d4 | |||
| a63467e163 | |||
| d8c0a37210 | |||
| eeb0cccbce | |||
| 691d78ff06 | |||
| 1e636fdbe2 | |||
| c36262d7b6 | |||
| 94bf830955 | |||
| f355d04278 | |||
| bfe7ce7367 | |||
| 447775048c | |||
| b877a32ad8 |
@@ -2,7 +2,7 @@
|
||||
dir: .claude
|
||||
|
||||
## role
|
||||
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
|
||||
Configuration and settings directory for Claude AI assistant integration within the project workspace.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,10 +4,10 @@ dir: .claude
|
||||
index: .claude/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
|
||||
Configuration and settings directory for Claude AI assistant integration within the project workspace.
|
||||
## files
|
||||
## arch
|
||||
Flat configuration structure containing markdown/YAML files that define custom commands, project context, and operational rules for Claude's interactions with the codebase.
|
||||
Flat configuration directory following standard AI assistant tool conventions, typically containing permission rules, context files, and project-specific behavioral settings.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .claude/skills
|
||||
|
||||
## role
|
||||
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
|
||||
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
|
||||
## parent
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: .claude/skills
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
|
||||
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
|
||||
## files
|
||||
## arch
|
||||
Flat configuration file structure defining modular skill behaviors and prompts used to extend Claude's domain-specific abilities.
|
||||
Flat directory structure with markdown-based skill modules that define specialized assistant capabilities.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .claude/skills/sift-backlog
|
||||
|
||||
## role
|
||||
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
|
||||
Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
|
||||
## parent
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: .claude/skills/sift-backlog
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
|
||||
Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
|
||||
## files
|
||||
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. | dep: sf CLI (task, plan, dependency, update subcommands)
|
||||
## arch
|
||||
Single-file declarative skill definition following a prompt-driven workflow pattern with structured triage and activation instructions for Claude to execute.
|
||||
Single-file declarative skill definition following a prompt-engineering pattern that encodes step-by-step procedures and decision rules for Claude to execute when invoked.
|
||||
## tags
|
||||
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode
|
||||
|
||||
## role
|
||||
Configuration directory for the opencode tool, managing project-specific settings and preferences.
|
||||
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: .opencode
|
||||
index: .opencode/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Configuration directory for the opencode tool, managing project-specific settings and preferences.
|
||||
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
|
||||
## files
|
||||
## arch
|
||||
Flat directory structure containing configuration files that define opencode behavior for the associated project.
|
||||
Flat directory structure with declarative configuration files; no executable code or architectural patterns involved.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode/commands
|
||||
|
||||
## role
|
||||
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
|
||||
Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
|
||||
## parent
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
|
||||
@@ -4,14 +4,14 @@ dir: .opencode/commands
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
|
||||
Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
|
||||
## files
|
||||
- opsx-apply.md | Defines a workflow for implementing tasks from an OpenSpec change in a structured, iterative manner with pause points for blockers and ambiguity. | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development process, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), tasks.md
|
||||
- opsx-explore.md | Defines the explore mode stance for a thinking/discussion assistant that investigates problems and clarifies requirements without implementing code | dep: OpenSpec system (openspec CLI, change artifacts like proposal.md/design.md/tasks.md/spec.md)
|
||||
- opsx-propose.md | Defines a workflow for creating a new change with all required planning artifacts (proposal, design, tasks) in a single step using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, file system
|
||||
## arch
|
||||
Markdown-based declarative templates serving as structured prompts/playbooks that guide an AI assistant through specific operational phases of a spec-driven lifecycle.
|
||||
Markdown-based command-definition pattern where each file encodes a discrete, step-by-step procedural prompt controlling assistant behavior for a specific development phase.
|
||||
## tags
|
||||
opsx, defines, tasks, md, workflow, openspec, openspec cli, askuserquestion tool
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode/skills
|
||||
|
||||
## role
|
||||
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
|
||||
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
|
||||
## parent
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: .opencode/skills
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
|
||||
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
|
||||
## files
|
||||
## arch
|
||||
Configuration-based skill definition directory; skills are declared as individual files consumed by the opencode agent runtime to extend or specialize assistant behavior.
|
||||
Configuration-driven skill registry with declarative definition files (no implementation code present in this directory).
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode/skills/openspec-apply-change
|
||||
|
||||
## role
|
||||
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking.
|
||||
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-apply-change
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking.
|
||||
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
|
||||
## files
|
||||
- SKILL.md | Defines a skill for implementing tasks from an OpenSpec change using a schema-driven workflow with progress tracking and contextual file reading. | dep: openspec CLI, AskUserQuestion tool
|
||||
## arch
|
||||
Documentation-based skill specification using markdown with defined workflow steps, schema references, and contextual file reading rules.
|
||||
Declarative skill specification using markdown-based instructions, schema-driven task processing, and progressive context loading patterns.
|
||||
## tags
|
||||
skill, defines, implementing, tasks, openspec, change, schema, driven
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode/skills/openspec-archive-change
|
||||
|
||||
## role
|
||||
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
|
||||
Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-archive-change
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
|
||||
Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
|
||||
## files
|
||||
- SKILL.md | Defines a skill for archiving a completed change in the openspec experimental workflow, including validation, sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool (subagent_type: general-purpose), openspec-sync-specs skill
|
||||
## arch
|
||||
Single-document declarative skill specification following a procedural checklist pattern (validate, assess sync, confirm) designed for an AI agent to execute.
|
||||
Single-file declarative skill definition using a markdown-based pattern description format, structured as a procedural workflow with validation gates and conditional user interaction steps.
|
||||
## tags
|
||||
skill, openspec, sync, defines, archiving, completed, change, experimental
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode/skills/openspec-explore
|
||||
|
||||
## role
|
||||
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
|
||||
Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-explore
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a conversational "explore mode" skill for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
|
||||
Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
|
||||
## files
|
||||
- SKILL.md | Defines a conversational "explore mode" skill for the OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||
## arch
|
||||
Skill-definition pattern using a single Markdown file (SKILL.md) that declaratively specifies the assistant's behavioral constraints, workflow, and operational guidelines.
|
||||
Skill-definition pattern using a single Markdown manifest (SKILL.md) that declaratively specifies the assistant's behavioral instructions, interaction style, and operational constraints for the explore workflow.
|
||||
## tags
|
||||
skill, defines, conversational, explore, mode, openspec, cli, acts
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: .opencode/skills/openspec-propose
|
||||
|
||||
## role
|
||||
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
|
||||
Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: .opencode/skills/openspec-propose
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides an AI assistant skill that automates the openspec proposal workflow by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
|
||||
Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
|
||||
## files
|
||||
- SKILL.md | Defines an AI assistant skill that automates proposing new changes by scaffolding a directory, generating dependent artifacts (proposal, design, tasks), and tracking progress through a structured workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||
## arch
|
||||
Skill-definition pattern using a declarative markdown document (SKILL.md) that encodes a step-by-step procedural workflow with CLI integration conventions.
|
||||
Declarative skill-definition pattern using a markdown-based manifest (SKILL.md) that encodes a step-by-step procedural workflow, CLI commands, and file-system conventions for the AI to follow.
|
||||
## tags
|
||||
skill, defines, assistant, automates, proposing, new, changes, scaffolding
|
||||
## symbols
|
||||
|
||||
+4
-2
@@ -16,7 +16,7 @@ dir: .
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
@@ -32,6 +32,9 @@ Root project configuration and orchestration package for a media library managem
|
||||
- .pi
|
||||
index: .pi/.pi-map.index.md
|
||||
map: .pi/.pi-map.md
|
||||
- .pi-tmp
|
||||
index: .pi-tmp/.pi-map.index.md
|
||||
map: .pi-tmp/.pi-map.md
|
||||
- .ruff_cache
|
||||
index: .ruff_cache/.pi-map.index.md
|
||||
map: .ruff_cache/.pi-map.md
|
||||
@@ -66,7 +69,6 @@ Root project configuration and orchestration package for a media library managem
|
||||
- docker-compose.dev.yml
|
||||
- docker-compose.observability.yml
|
||||
- docker-compose.yml
|
||||
- swap-pane
|
||||
- token-usage-output.txt
|
||||
## links
|
||||
index: ./.pi-map.index.md
|
||||
|
||||
+3
-4
@@ -18,13 +18,13 @@ index: ./.pi-map.index.md
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
|
||||
## files
|
||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
||||
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
||||
- .gitignore | Configures Git to ignore Python artifacts, virtual environments, secrets, editor files, frontend builds, and tool-specific metadata from version control.
|
||||
- AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
|
||||
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across recent versions.
|
||||
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across releases.
|
||||
- CONTRIBUTING.md | Provides contribution guidelines and setup instructions for the Manage project's backend (FastAPI) and frontend (React) codebases. | dep: FastAPI, React, Vite, TypeScript, Ruff, pytest, Docker Compose, Tailwind CSS, TanStack Query
|
||||
- LICENSE | Provides the MIT open-source software license terms for the project
|
||||
- README.md | Project README documenting a media and server operations tool with Jellyfin integration, SSH file inspection, and server monitoring capabilities. | dep: FastAPI, React, TypeScript, Docker Compose, SQLite, Traefik, OIDC/Authentik, Jellyfin, Prometheus, Grafana, Alertmanager
|
||||
@@ -32,10 +32,9 @@ Root project configuration and orchestration package for a media library managem
|
||||
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
|
||||
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
|
||||
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
|
||||
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
||||
## arch
|
||||
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
|
||||
Full-stack containerized architecture using Docker Compose orchestration with a FastAPI/Uvicorn backend and Vite React frontend, Traefik reverse proxy with TLS/OIDC, and an optional observability stack (Prometheus, Grafana, Loki, Alertmanager, Alloy).
|
||||
## tags
|
||||
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
||||
## symbols
|
||||
|
||||
@@ -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
|
||||
-
|
||||
@@ -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
|
||||
-
|
||||
@@ -0,0 +1,141 @@
|
||||
# Grafana Chart Widget — worker output
|
||||
|
||||
## Files changed (10 files, ~400 lines)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +12/-12 (panel→chart config + kind) |
|
||||
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +70/-12 (chart query adapter replaces panel URL logic) |
|
||||
| `backend/tests/test_widgets.py` | modified | +55/-20 (3 new chart tests replace 2 panel tests) |
|
||||
| `backend/tests/test_services.py` | modified | +2/-2 (grafana widget-kind + API-metadata assertions) |
|
||||
| `frontend/src/widgets/GrafanaChartWidget.tsx` | **new** | 100 |
|
||||
| `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` | **new** | 57 |
|
||||
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | **deleted** | -50 |
|
||||
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | **deleted** | -72 |
|
||||
| `frontend/src/integrations/registry.ts` | modified | +24/-14 (chart binding replaces panel) |
|
||||
| `frontend/src/integrations/registry.test.ts` | modified | +1/-1 (panel→chart) |
|
||||
| `frontend/src/widgets/index.ts` | modified | +1/-0 (export GrafanaChartWidget) |
|
||||
| `frontend/package.json` + `package-lock.json` | modified | +1 dep (recharts ^3.9.2) |
|
||||
|
||||
**recharts version installed:** `^3.9.2`
|
||||
|
||||
## Grafana `/api/ds/query` request/response shape
|
||||
|
||||
**Request** (POST):
|
||||
|
||||
```json
|
||||
{
|
||||
"queries": [{
|
||||
"datasource": {"uid": "prometheus", "type": "prometheus"},
|
||||
"expr": "rate(cpu[5m])",
|
||||
"format": "time_series",
|
||||
"intervalMs": 30000,
|
||||
"maxDataPoints": 100,
|
||||
"refId": "A"
|
||||
}],
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
}
|
||||
```
|
||||
|
||||
Headers: `Authorization: Bearer {api_key}`, `Content-Type: application/json`
|
||||
|
||||
**Response** (abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"A": {
|
||||
"frames": [{
|
||||
"data": { "values": [[1000, 2000], [0.5, 0.8]] },
|
||||
"schema": { "fields": [{"name":"Time"}, {"name":"cpu_usage"}] }
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Series normalization logic
|
||||
|
||||
Iterates `results[*].frames[]`. For each frame with `values` having >=2 arrays (timestamps + values), extracts the series label from `schema.fields[-1].name` and zips timestamps+values into `[{t: int, v: float|null}]`. Returns `{"series": [{"label": "...", "points": [...]}]}`.
|
||||
|
||||
## Frontend chart rendering
|
||||
|
||||
`GrafanaChartWidget` fetches widget data, extracts `data.series`, merges all series by timestamp into a single recharts data array (`[{time, cpu_usage: 0.5, mem: 0.3}, ...]`), and renders a `<LineChart>` with one `<Line>` per series. Uses Tailwind CSS variables (`--chart-1` through `--chart-5`) for colors so it respects dark mode. Includes loading skeleton, error Alert, and empty-state Alert.
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest → 279 passed, ruff clean
|
||||
cd frontend && npm run lint && npm run build && npm run test → 127 passed, lint/build clean
|
||||
```
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **No deviations from spec.** The `link` widget kind is unchanged. The `panel` kind is fully replaced by `chart`.
|
||||
2. **recharts `labelFormatter` type workaround.** Recharts 3.x types `labelFormatter` as `(label: ReactNode, ...) => ReactNode`, not `(number) => string`. Wrapped with `(label) => formatTime(Number(label))` to satisfy TS strict.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- The chart widget assumes the Grafana datasource is Prometheus-type (hardcoded `"type": "prometheus"` in the query body). If the user has a non-Prometheus datasource (InfluxDB, etc.), the query body format may need adjustment. The `datasource_uid` is configurable but the `type` is not.
|
||||
- recharts is ~45KB gzipped added to the bundle.
|
||||
|
||||
```acceptance-report
|
||||
{
|
||||
"criteriaSatisfied": [
|
||||
{
|
||||
"id": "criterion-1",
|
||||
"status": "satisfied",
|
||||
"evidence": "Replaces the broken iframe panel widget with a server-side chart query widget. Backend queries /api/ds/query with stored api_key; frontend renders recharts LineChart. No iframe, no browser auth, no CORS. The link widget kind is unchanged. 279 backend + 127 frontend tests pass; lint/build green both sides."
|
||||
}
|
||||
],
|
||||
"changedFiles": [
|
||||
"backend/src/media_library_viewer_api/integrations/grafana.py",
|
||||
"backend/src/media_library_viewer_api/widgets/sources.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"backend/tests/test_services.py",
|
||||
"frontend/src/widgets/GrafanaChartWidget.tsx",
|
||||
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
|
||||
"frontend/src/widgets/GrafanaPanelWidget.tsx (deleted)",
|
||||
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx (deleted)",
|
||||
"frontend/src/integrations/registry.ts",
|
||||
"frontend/src/integrations/registry.test.ts",
|
||||
"frontend/src/widgets/index.ts",
|
||||
"frontend/package.json"
|
||||
],
|
||||
"testsAddedOrUpdated": [
|
||||
"backend/tests/test_widgets.py",
|
||||
"backend/tests/test_services.py",
|
||||
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
|
||||
"frontend/src/integrations/registry.test.ts"
|
||||
],
|
||||
"commandsRun": [
|
||||
{ "command": "cd backend && .venv/bin/ruff check .", "result": "passed", "summary": "All checks passed" },
|
||||
{ "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "279 passed, 2 pre-existing warnings" },
|
||||
{ "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors, 0 warnings" },
|
||||
{ "command": "cd frontend && npm run build", "result": "passed", "summary": "tsc + vite build clean" },
|
||||
{ "command": "cd frontend && npm run test", "result": "passed", "summary": "39 files / 127 tests passed" }
|
||||
],
|
||||
"validationOutput": [
|
||||
"Backend ruff clean; 279 tests pass (was 278; -2 panel + 3 chart = +1 net).",
|
||||
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (-3 panel + 3 chart = net 0).",
|
||||
"GrafanaWidgetSource._fetch_chart POSTs to /api/ds/query with Bearer token; normalizes response to {series:[{label,points}]}",
|
||||
"GrafanaChartWidget renders recharts LineChart with dark-mode CSS variable colors.",
|
||||
"link widget kind unchanged; panel widget kind fully removed."
|
||||
],
|
||||
"residualRisks": [
|
||||
"Chart query body hardcodes datasource type 'prometheus' — non-Prometheus datasources (InfluxDB etc.) may need a type field on the config.",
|
||||
"recharts adds ~45KB gzipped to the frontend bundle."
|
||||
],
|
||||
"noStagedFiles": true,
|
||||
"diffSummary": "~400 lines: replaces Grafana panel iframe widget with server-side datasource-query chart widget. Backend: /api/ds/query POST with api_key + series normalization (70 lines). Frontend: recharts LineChart component with dark-mode support (100 lines). 3 backend + 3 frontend tests. recharts ^3.9.2 installed.",
|
||||
"reviewFindings": [
|
||||
"no blockers"
|
||||
],
|
||||
"manualNotes": "recharts labelFormatter type workaround: recharts 3.x types it as (ReactNode) => ReactNode, not (number) => string. Wrapped with Number() cast. The link widget kind is fully preserved. The panel widget kind and all its code/tests are fully deleted."
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# New widgets: Jellyfin now_playing + Grafana panel embed
|
||||
|
||||
## Files changed (10 files, ~390 lines)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `backend/src/media_library_viewer_api/integrations/jellyfin.py` | modified | +12 (new widget kind + config model) |
|
||||
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +17 (new widget kind + config model) |
|
||||
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +12 (now_playing filter + panel embed URL) |
|
||||
| `backend/tests/test_services.py` | modified | +3 (updated widget-kind assertions) |
|
||||
| `backend/tests/test_widgets.py` | modified | +85 (import + 6 new tests) |
|
||||
| `frontend/src/widgets/JellyfinNowPlayingWidget.tsx` | new | 41 |
|
||||
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | new | 50 |
|
||||
| `frontend/src/integrations/registry.ts` | modified | +24 (2 new widget bindings) |
|
||||
| `frontend/src/integrations/registry.test.ts` | modified | +1 (updated grafana kinds) |
|
||||
| `frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx` | new | 72 |
|
||||
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | new | 72 |
|
||||
|
||||
## Session-filter logic for now_playing
|
||||
|
||||
```python
|
||||
if widget_kind == "now_playing":
|
||||
sessions = [
|
||||
s for s in sessions
|
||||
if s.get("NowPlayingItem")
|
||||
and not s.get("PlayState", {}).get("IsPaused", True)
|
||||
]
|
||||
```
|
||||
|
||||
Filters raw Jellyfin sessions BEFORE `_map_sessions_to_activity_rows`. A session is "actively playing" when it has a `NowPlayingItem` (something is playing, not just idle) AND `PlayState.IsPaused` is false. The `activity` kind (default) is unchanged — shows all sessions including idle and paused.
|
||||
|
||||
## Embed URL format for panel
|
||||
|
||||
```python
|
||||
embed_url = f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
|
||||
```
|
||||
|
||||
Uses Grafana's `/d-solo/` endpoint which renders a single panel without dashboard chrome. `kiosk=tv` hides the top nav. Defaults: `from_ts="now-1h"`, `to_ts="now"`.
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/ruff check src/ tests/ → All checks passed!
|
||||
cd backend && .venv/bin/python -m pytest tests/ → 278 passed, 2 warnings (pre-existing)
|
||||
cd frontend && npm run lint → 0 errors, 0 warnings
|
||||
cd frontend && npm run build → ✓ built (tsc + vite)
|
||||
cd frontend && npm run test → 39 files / 127 tests passed
|
||||
```
|
||||
|
||||
Backend: +6 new tests (definition assertions x2, grafana panel URL x2, jellyfin now_playing filter x1, jellyfin activity shows all x1).
|
||||
Frontend: +6 new tests (JellyfinNowPlayingWidget x3, GrafanaPanelWidget x3).
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **No deviations from spec.** Both widgets are additive — no existing behavior changed. The `activity` and `link` kinds work exactly as before.
|
||||
2. **GrafanaPanelWidget pi-lens advisory** for `<Button asChild><a>` is a false positive (Radix Slot merges props, doesn't create nested `<a>`). Same pattern as GrafanaLinkWidget, ObservabilityPage, and PinnedServiceLink. Build and lint pass.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- **Grafana embedding may be blocked** by `X-Frame-Options` or CSP depending on Grafana config. The fallback "Open in Grafana" link is provided.
|
||||
- **GrafanaPanelWidget iframe height is fixed at 300px** — not responsive to panel content height. A follow-up could use Grafana's panel-content-height API or a ResizeObserver.
|
||||
- **now_playing filter operates on raw sessions before mapping** — if Jellyfin changes its session shape (e.g. moves `NowPlayingItem`/`PlayState`), the filter silently passes all sessions. Same fragility as the existing activity mapping.
|
||||
|
||||
```acceptance-report
|
||||
{
|
||||
"criteriaSatisfied": [
|
||||
{
|
||||
"id": "criterion-1",
|
||||
"status": "satisfied",
|
||||
"evidence": "Implements two additive widget kinds (jellyfin now_playing + grafana panel embed) without changing any existing behavior. Backend: new widget configs + definitions + source adapter logic + 6 tests. Frontend: 2 new components + registry bindings + 6 tests. 278 backend + 127 frontend tests pass; ruff/eslint/tsc/vite all green. No staged files."
|
||||
}
|
||||
],
|
||||
"changedFiles": [
|
||||
"backend/src/media_library_viewer_api/integrations/jellyfin.py",
|
||||
"backend/src/media_library_viewer_api/integrations/grafana.py",
|
||||
"backend/src/media_library_viewer_api/widgets/sources.py",
|
||||
"backend/tests/test_services.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/widgets/JellyfinNowPlayingWidget.tsx",
|
||||
"frontend/src/widgets/GrafanaPanelWidget.tsx",
|
||||
"frontend/src/integrations/registry.ts",
|
||||
"frontend/src/integrations/registry.test.ts",
|
||||
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
|
||||
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
|
||||
],
|
||||
"testsAddedOrUpdated": [
|
||||
"backend/tests/test_services.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/integrations/registry.test.ts",
|
||||
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
|
||||
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
|
||||
],
|
||||
"commandsRun": [
|
||||
{
|
||||
"command": "cd backend && .venv/bin/ruff check src/ tests/",
|
||||
"result": "passed",
|
||||
"summary": "All checks passed"
|
||||
},
|
||||
{
|
||||
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
|
||||
"result": "passed",
|
||||
"summary": "278 passed, 2 warnings (pre-existing deprecation)"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run lint",
|
||||
"result": "passed",
|
||||
"summary": "0 errors, 0 warnings"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run build",
|
||||
"result": "passed",
|
||||
"summary": "tsc + vite build clean"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run test",
|
||||
"result": "passed",
|
||||
"summary": "39 files / 127 tests passed"
|
||||
}
|
||||
],
|
||||
"validationOutput": [
|
||||
"Backend ruff clean; 278 tests pass (+6 new).",
|
||||
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (+6 new).",
|
||||
"Jellyfin now_playing filters: session has NowPlayingItem + IsPaused=false.",
|
||||
"Grafana panel embed URL: /d-solo/{uid}/manage?panelId={id}&from={from}&to={to}&kiosk=tv.",
|
||||
"Existing activity + link widget kinds unchanged (tested)."
|
||||
],
|
||||
"residualRisks": [
|
||||
"Grafana iframe may be blocked by X-Frame-Options/CSP; fallback link provided.",
|
||||
"Iframe height fixed at 300px (not responsive to panel content).",
|
||||
"now_playing filter depends on Jellyfin session shape (NowPlayingItem/PlayState)."
|
||||
],
|
||||
"noStagedFiles": true,
|
||||
"diffSummary": "~390 lines across 11 files: 2 new backend widget kinds (jellyfin now_playing + grafana panel) with source adapter logic, 2 new frontend components, registry bindings, and 12 new tests (6 backend + 6 frontend). Purely additive — no existing behavior changed.",
|
||||
"reviewFindings": [
|
||||
"no blockers"
|
||||
],
|
||||
"manualNotes": "The JellyfinClient mock approach uses patch on the class directly (not asyncio.to_thread) — let real asyncio handle the threading. The pi-lens nested-<a> advisory on GrafanaPanelWidget is a false positive (Button asChild uses Radix Slot)."
|
||||
}
|
||||
@@ -4,6 +4,24 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added — Direct Prometheus charting
|
||||
|
||||
- **Prometheus is now the direct source for in-app charts.** New widget kinds
|
||||
on the `prometheus` service: `chart` (multi-series line chart via recharts,
|
||||
backed by `/api/v1/query_range`), `gauge` (instant scalar with configurable
|
||||
threshold bands), and `mean` (client-side average over a time window).
|
||||
|
||||
### **BREAKING** — Grafana service type removed
|
||||
|
||||
- The `grafana` service type, Grafana link widget, Grafana chart widget, and
|
||||
`GET /api/monitoring/grafana-status` endpoint were **removed**. Manage now
|
||||
queries Prometheus directly for all chart data.
|
||||
- **Migration:** Delete any existing Grafana service instances and create
|
||||
Prometheus service instances instead (pointing at your Prometheus URL). Any
|
||||
configured `grafana/chart` widgets must be recreated as `prometheus/chart`
|
||||
widgets. Grafana link widgets are gone — use Prometheus chart/metric widgets
|
||||
instead.
|
||||
|
||||
### Added — Observability service registry
|
||||
|
||||
- **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive
|
||||
|
||||
## role
|
||||
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ dir: archive
|
||||
index: archive/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
## files
|
||||
- app.py | Provides a minimal Streamlit entrypoint that adds the src directory to Python's path and delegates to the actual application in media_library_viewer.app. | dep: sys, pathlib, media_library_viewer.app
|
||||
- pyproject.toml | Defines Python package metadata, dependencies, and tool configurations for a Streamlit-based Jellyfin media library browser with SSH remote file inspection. | dep: hatchling, streamlit, streamlit-aggrid, requests, paramiko, python-dotenv, pandas, ruff, pytest
|
||||
- requirements.txt | Installs the current package in editable/development mode using pip | dep: pip, setuptools
|
||||
## arch
|
||||
Thin entrypoint pattern using a bootstrap app.py that manipulates sys.path to delegate execution to a nested media_library_viewer package, managed via standard Python packaging (pyproject.toml).
|
||||
Thin bootstrap layer using path manipulation to delegate to a source module (src/), packaged with standard Python tooling (pyproject.toml) for dependency management and Streamlit deployment.
|
||||
## tags
|
||||
streamlit, app, python, media, library, package, pyproject, pip
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/src
|
||||
|
||||
## role
|
||||
No files provided — directory appears to be empty or contents were not included, so the package's role cannot be determined.
|
||||
Insufficient information — no files provided in the directory listing to determine this package's role.
|
||||
## parent
|
||||
index: archive/.pi-map.index.md
|
||||
map: archive/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: archive/src
|
||||
index: archive/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
No files provided — directory appears to be empty or contents were not included, so the package's role cannot be determined.
|
||||
Insufficient information — no files provided in the directory listing to determine this package's role.
|
||||
## files
|
||||
## arch
|
||||
Cannot be assessed due to missing file contents; please provide the file listing for analysis.
|
||||
Unable to assess — empty directory or missing file contents for architectural analysis.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/src/media_library_viewer
|
||||
|
||||
## role
|
||||
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
|
||||
Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
|
||||
## parent
|
||||
index: archive/src/.pi-map.index.md
|
||||
map: archive/src/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: archive/src/media_library_viewer
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
|
||||
Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
|
||||
## files
|
||||
- __init__.py | Package initialization file that defines the Media Library Viewer package metadata and exports the version string.
|
||||
- app.py | Streamlit UI entrypoint for a Media Library Viewer that connects to Jellyfin and SSH backends, providing dashboard, monitoring, media browsing, and file browser tabs with cached data and path resolution between systems. | exp: func:get_jellyfin_client(base_url: str, api_key: str) → JellyfinClient, call:JellyfinClient, func:cached_users(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).users, func:get_ssh_client(host: str, username: str, port: int, key_filename: str, password: str) → RemoteSSHClient, call:RemoteSSHClient, call:client.connect, func:cached_libraries(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).libraries, func:cached_media_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).media_counts, func:cached_library_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client, call:client.libraries, call:client.library_item_counts, func:cached_active_sessions(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).active_sessions, func:cached_dir_listing(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.list_dir, call:json.loads, raise:RuntimeError, func:cached_ffprobe_preview(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.ffprobe_json, func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:apply_remote_path_prefix, func:credentials_panel(), call:load_config, call:st.header, call:st.expander, call:st.text_input, call:st.number_input, call:int, func:main(), call:st.set_page_config, call:st.title, call:st.caption, call:credentials_panel, call:st.info, call:get_jellyfin_client, call:cached_users, call:st.error, call:user.get, call:st.selectbox, call:list, call:user_options.keys, call:st.tabs, call:render_now_playing, call:st.divider, call:render_resource_dashboard, call:render_media_overview, call:cached_libraries, call:set_file_browser_path, call:resolve_remote_media_path, call:render_media_tab, call:render_file_browser, call:get_ssh_client, call:render_ssh_tools, func:set_prefixed_file_browser_path(path: str, selected_path, reset_filters) → None, call:set_file_browser_path, call:resolve_remote_media_path | dep: json, posixpath, typing, media_library_viewer.clients.jellyfin, media_library_viewer.clients.ssh, media_library_viewer.config, media_library_viewer.ui.dashboard, media_library_viewer.ui.file_browser, media_library_viewer.ui.media, media_library_viewer.ui.preview, streamlit
|
||||
@@ -12,7 +12,7 @@ Streamlit-based media library viewer that provides a unified dashboard for brows
|
||||
- jobs.py | Defines safe, template-based remote SSH jobs with shell-quoted parameter rendering. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:ssh.run | dep: shlex, dataclasses, typing, media_library_viewer.clients.ssh, typing.Mapping
|
||||
- utils.py | Provides UI-independent formatting helpers and ffprobe output summarizers for video/audio/subtitle stream metadata. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||
## arch
|
||||
Layered Streamlit application using immutable dataclass configuration, template-based remote job execution, cached data access, and separated utility functions following a tab-based modular UI pattern.
|
||||
Tab-based modular frontend using immutable dataclass configuration, environment-driven settings, cached data access, template-based remote job execution, and separated utility functions for metadata formatting.
|
||||
## tags
|
||||
client, path, media, call:, jellyfin, call:get, ssh, cached
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/src/media_library_viewer/clients
|
||||
|
||||
## role
|
||||
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
|
||||
External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
|
||||
@@ -4,14 +4,14 @@ dir: archive/src/media_library_viewer/clients
|
||||
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
External service and system integration layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
|
||||
External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
|
||||
## files
|
||||
- __init__.py | Package initialization file that defines external service clients module boundaries and constraints
|
||||
- jellyfin.py | HTTP API client for Jellyfin/Emby media servers providing user, library, item, and session management with plain Python return types for frontend agnosticism. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → dict[str, Any], call:params.items, call:self.session.get, call:response.raise_for_status, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:isinstance, call:session.get, method:image_url(self, item_id: str, image_type) → str | dep: typing, requests
|
||||
- resources.py | Manages a lightweight POSIX shell-based remote system metrics collector that samples CPU, memory, network, and disk statistics via SSH and reads the resulting JSONL data. | exp: class:ResourceMonitorPaths, func:start_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:shlex.quote, call:int, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:stop_resource_collector(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:restart_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:stop_resource_collector, call:start_resource_collector, func:resource_collector_status(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:resource_collector_debug_info(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, func:read_resource_metrics(ssh: RemoteSSHClient, max_lines, paths) → list[dict[str, Any]], call:shlex.quote, call:int, call:ssh.run, call:result.stdout.splitlines, call:line.strip, call:rows.append, call:json.loads, raise:RuntimeError, func:disk_space(ssh: RemoteSSHClient, path) → dict[str, Any], call:shlex.quote, call:ssh.run, call:result.stdout.strip, call:json.loads, raise:RuntimeError | dep: json, shlex, dataclasses, typing, media_library_viewer.clients.ssh
|
||||
- ssh.py | Provides an SSH client wrapper around Paramiko for remote filesystem inspection and media analysis, ensuring POSIX shell compatibility regardless of the user's login shell. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, password, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:client.connect, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, posixpath, shlex, dataclasses, typing, paramiko
|
||||
## arch
|
||||
Client-wrapper pattern with each module encapsulating a specific integration concern (Jellyfin HTTP API, SSH filesystem access, remote resource monitoring), returning plain Python types for frontend agnosticism.
|
||||
Modular client-per-service pattern with plain Python return types for frontend agnosticism, wrapping HTTP APIs (Jellyfin/Emby) and SSH/Paramiko connections with POSIX shell compatibility enforcement.
|
||||
## tags
|
||||
call:shlex.quote, resource, error, collector, call:ssh.run, raise:runtime, call:self.get, call:result.stdout.strip
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/src/media_library_viewer/domain
|
||||
|
||||
## role
|
||||
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display.
|
||||
Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
|
||||
@@ -4,12 +4,12 @@ dir: archive/src/media_library_viewer/domain
|
||||
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides domain-level normalization logic that transforms inconsistent Jellyfin API responses into stable, flattened data structures for storage and display.
|
||||
Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
|
||||
## files
|
||||
- __init__.py | Serves as the package docstring for a domain-level helpers/normalization module that converts external data into stable app concepts.
|
||||
- media.py | Flattens inconsistent Jellyfin API item JSON into stable, normalized dictionaries for SQLite storage and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer.utils, pandas, media_library_viewer.utils (human_size, ticks_to_minutes)
|
||||
## arch
|
||||
Functional transformation layer pattern mapping raw external API JSON directly into normalized flat dictionaries without intermediate ORM or complex object hierarchies.
|
||||
Functional transformation layer using dictionary flattening and field mapping to decouple external API data shapes from internal storage (SQLite) and presentation (frontend) concerns.
|
||||
## tags
|
||||
media, call:str, date, added, item, call:item.get, streams, call:stream
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/src/media_library_viewer/services
|
||||
|
||||
## role
|
||||
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
|
||||
Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
|
||||
@@ -4,12 +4,12 @@ dir: archive/src/media_library_viewer/services
|
||||
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Application service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
|
||||
Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package and documents it as the application services layer for coordinating clients/domain logic into reusable operations.
|
||||
- media_index.py | Provides a UI-agnostic SQLite-backed media inventory service that indexes, queries, and manages Jellyfin media metadata with filtering, sorting, and pagination capabilities. | exp: class:MediaIndexStatus, class:MediaIndex, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, method:init_schema(self) → None, call:self.connect, call:conn.executescript, method:set_metadata(self, key: str, value: str | int | float) → None, call:self.init_schema, call:self.connect, call:conn.execute, call:str, method:replace_items(self, rows: Iterable[dict[str, Any]]) → int, call:self.init_schema, call:list, call:",".join, call:len, call:self.connect, call:conn.execute, call:conn.executemany, call:','.join, call:row.get, call:str, call:int, call:time.time, method:status(self) → MediaIndexStatus, call:self.db_path.exists, call:MediaIndexStatus, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone, call:str(updated_row[0]).isdigit, call:time.strftime, call:time.localtime, call:float, method:query(self, library_id, library_ids, media_types, search, hdr_filter, sort_key, sort_order, limit, offset) → tuple[list[dict[str, Any]], int], call:self.init_schema, call:where.append, call:",".join, call:len, call:params.extend, call:params.append, call:search.lower, call:" AND ".join, call:SORT_COLUMNS.get, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone, call:conn.execute( "SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?", [*params, int(limit), int(offset)], ).fetchall, call:display_media_row, call:dict, func:build_media_index(client: JellyfinClient, user_id: str, libraries: list[dict[str, Any]], index, page_size) → int, call:MediaIndex, call:time.perf_counter, call:library.get, call:client.items, call:response.get, call:normalized_rows.extend, call:normalize_media_item, call:len, call:int, call:index.replace_items, call:index.set_metadata | dep: sqlite3, time, dataclasses, pathlib, typing, media_library_viewer.clients.jellyfin, media_library_viewer.domain.media, media_library_viewer.clients.jellyfin.JellyfinClient, media_library_viewer.domain.media.display_media_row, media_library_viewer.domain.media.normalize_media_item
|
||||
## arch
|
||||
Service-oriented pattern with SQLite-backed indexing, query filtering, and pagination encapsulated behind a single cohesive media index service module.
|
||||
Service-oriented architecture with SQLite persistence, providing filtering, sorting, and pagination capabilities abstracted away from UI concerns.
|
||||
## tags
|
||||
media, call:conn.execute, index, call:self.connect, schema, call:int, init, status
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/src/media_library_viewer/ui
|
||||
|
||||
## role
|
||||
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
|
||||
Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
|
||||
@@ -4,7 +4,7 @@ dir: archive/src/media_library_viewer/ui
|
||||
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Streamlit UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
|
||||
Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
|
||||
## files
|
||||
- __init__.py | Package initialization file for Streamlit UI modules that documents the architectural pattern of splitting the application into separate render modules.
|
||||
- dashboard.py | Implements a Streamlit dashboard for monitoring a Jellyfin media server, displaying media library statistics, active playback sessions, and server resource metrics via SSH. | exp: func:format_rate_bytes(bytes_per_second: float | int | None) → str, call:human_size, func:rate_scale(max_value: float | int | None) → tuple[float, str], call:abs, call:float, func:scaled_rate_chart_df(chart_df: pd.DataFrame, columns: list[str], labels: list[str]) → tuple[pd.DataFrame, str], call:chart_df[columns].max(numeric_only=True).max, call:rate_scale, call:chart_df[columns].copy, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str, user_id: str) → None, call:st.subheader, call:cached_media_counts, call:st.warning, call:counts.get, call:st.columns, call:top_cols[0].metric, call:top_cols[1].metric, call:top_cols[2].metric, call:top_cols[3].metric, call:cached_library_counts, call:st.caption, call:st.markdown, call:e.get, call:st.container, call:m_cols[0].metric, call:m_cols[1].metric, func:render_now_playing(cached_active_sessions, base_url: str, api_key: str) → None, call:st.subheader, call:cached_active_sessions, call:st.warning, call:st.caption, call:session.get, call:bool, call:play_state.get, call:item.get, call:transcoding.get, call:transcode_type.append, call:rows.append, call:", ".join, call:st.dataframe, call:pd.DataFrame, func:render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed) → None, call:st.subheader, call:get_ssh_client, call:resource_collector_status, call:st.error, call:st.columns, call:control_col.caption, call:start_col.button, call:st.success, call:start_resource_collector, call:restart_col.button, call:restart_resource_collector, call:stop_col.button, call:st.info, call:stop_resource_collector, call:refresh_col.button, call:st.rerun, call:st.caption, call:read_resource_metrics, call:disk_space, call:float, call:str(space.get("used_pct", "0")).rstrip, call:space.get, call:disk_cols[0].metric, call:human_size, call:disk_cols[1].metric, call:disk_cols[2].metric, call:disk_cols[3].metric, call:st.progress, call:min, call:max, call:st.warning, call:st.expander, call:st.code, call:resource_collector_debug_info, call:pd.DataFrame, call:pd.to_numeric, call:df.dropna, call:pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert, call:time.time, call:len, call:st.write, call:raw_df['ts'].astype(float).max, call:st.dataframe, call:raw_df.tail, call:df.sort_values, call:df["cpu_pct"].mean, call:df["cpu_pct"].max, call:df["iowait_pct"].mean, call:df["iowait_pct"].max, call:df["mem_pct"].mean, call:df["mem_pct"].max, call:df["net_rx_bytes_per_sec"].mean, call:df["net_rx_bytes_per_sec"].max, call:df["net_tx_bytes_per_sec"].mean, call:df["net_tx_bytes_per_sec"].max, call:df["disk_read_bps"].mean, call:df["disk_read_bps"].max, call:df["disk_write_bps"].mean, call:df["disk_write_bps"].max, call:metric_cols[0].metric, call:metric_cols[0].caption, call:metric_cols[1].metric, call:latest.get, call:metric_cols[1].caption, call:metric_cols[2].metric, call:metric_cols[2].caption, call:metric_cols[3].metric, call:format_rate_bytes, call:metric_cols[3].caption, call:metric_cols[4].metric, call:metric_cols[4].caption, call:metric_cols[5].metric, call:metric_cols[5].caption, call:metric_cols[6].metric, call:metric_cols[6].caption, call:df.set_index, call:st.markdown, call:st.line_chart, call:scaled_rate_chart_df | dep: time, typing, media_library_viewer.clients.resources, media_library_viewer.utils, pandas, streamlit
|
||||
@@ -12,7 +12,7 @@ Streamlit UI rendering layer for the media library viewer application, providing
|
||||
- media.py | Renders a Streamlit UI tab for browsing and filtering a local SQLite-backed media index with ag-grid table display and automatic file browser synchronization. | exp: func:aggrid_selected_rows(response: dict[str, Any]) → list[dict[str, Any]], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_tab(client, user_id: str, libraries: list[dict[str, Any]], set_file_browser_path: Callable[[str, str | None, bool], None]) → None, call:st.subheader, call:st.caption, call:MediaIndex, call:index.status, call:st.columns, call:status_parts.append, call:format_elapsed, call:status_col.caption, call:" | ".join, call:status_col.warning, call:build_col.button, call:st.spinner, call:build_media_index, call:st.success, call:st.rerun, call:refresh_col.button, call:st.info, call:filter_col.multiselect, call:list, call:library_options.keys, call:type_col.multiselect, call:search_col.text_input, call:page_size_col.selectbox, call:page_col.number_input, call:sort_col.selectbox, call:sort_options.keys, call:order_col.selectbox, call:hdr_col.selectbox, call:index.query, call:int, call:len, call:pd.DataFrame(rows)[columns].fillna, call:st.session_state.get, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:min, call:aggrid_selected_rows, call:selected_rows[0].get, call:set_file_browser_path, call:str, call:PurePosixPath, call:st.expander, call:st.write | dep: pathlib, typing, st_aggrid, media_library_viewer.services.media_index, pandas, streamlit
|
||||
- preview.py | Renders a Streamlit UI for previewing selected media file metadata via ffprobe and executing remote SSH diagnostic tools/jobs. | exp: func:render_ffprobe_sections(ffprobe_data: dict[str, Any]) → None, call:ffprobe_format_summary, call:summarize_video_streams, call:summarize_audio_streams, call:summarize_subtitle_streams, call:st.markdown, call:st.dataframe, call:pd.DataFrame, call:st.caption, func:render_selected_file_preview(ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:st.container, call:st.markdown, call:st.caption, call:is_known_video_file, call:st.columns, call:refresh_col.button, call:cached_ffprobe_preview.clear, call:st.rerun, call:st.spinner, call:status_col.error, call:status_col.success, call:render_ffprobe_sections, call:st.expander, call:st.json, func:render_ssh_tools(ssh, ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:render_selected_file_preview, call:st.subheader, call:st.tabs, call:st.button, call:ssh.ffprobe_json, call:render_ffprobe_sections, call:st.expander, call:st.dataframe, call:pd.DataFrame, call:summarize_streams, call:st.json, call:st.error, call:str, call:ssh.stat_path, call:st.code, call:st.warning, call:st.selectbox, call:list, call:JOB_TEMPLATES.keys, call:st.caption, call:JOB_TEMPLATES[job_key].render, call:run_job, call:st.write | dep: typing, media_library_viewer.jobs, media_library_viewer.utils, pandas, streamlit
|
||||
## arch
|
||||
Module-based render pattern where each UI tab/view is isolated in its own module, sharing session state for cross-component synchronization (e.g., file browser auto-sync) and leveraging ag-grid for interactive data tables.
|
||||
Modular page-by-page rendering pattern where each module is a self-contained Streamlit view, integrated through shared session state for cross-component synchronization.
|
||||
## tags
|
||||
call:metric, call:grid, render, call:st.caption, col.button, browser, col.selectbox, media
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: archive/tests
|
||||
|
||||
## role
|
||||
Legacy or archived test directory currently containing only a placeholder file with no active test code.
|
||||
Empty placeholder directory retained for historical/archived test files that are no longer actively used.
|
||||
## parent
|
||||
index: archive/.pi-map.index.md
|
||||
map: archive/.pi-map.md
|
||||
|
||||
@@ -4,11 +4,11 @@ dir: archive/tests
|
||||
index: archive/tests/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Legacy or archived test directory currently containing only a placeholder file with no active test code.
|
||||
Empty placeholder directory retained for historical/archived test files that are no longer actively used.
|
||||
## files
|
||||
- .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
## arch
|
||||
Empty placeholder structure using a `.gitkeep` file to preserve the directory in version control for potential future use.
|
||||
No active code; contains only a `.gitkeep` placeholder file (with an unrelated description) to preserve the directory structure in version control.
|
||||
## tags
|
||||
tmux, swaps, position, two, panes, within, window, windows
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend
|
||||
|
||||
## role
|
||||
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||
FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ dir: backend
|
||||
index: backend/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||
FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
## files
|
||||
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
||||
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
||||
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
||||
## arch
|
||||
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
|
||||
Layered API architecture using FastAPI with Uvicorn ASGI server, containerized via Docker, configured through pyproject.toml with standardized linting and testing pipelines.
|
||||
## tags
|
||||
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src
|
||||
|
||||
## role
|
||||
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
|
||||
## parent
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: backend/src
|
||||
index: backend/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
|
||||
## files
|
||||
## arch
|
||||
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
|
||||
Cannot be fully determined as no files are listed in the directory; likely follows standard Node.js/Python backend patterns (e.g., MVC, layered architecture) depending on framework used.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api
|
||||
|
||||
## role
|
||||
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||
FastAPI backend service providing authenticated API endpoints for viewing and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
||||
## parent
|
||||
index: backend/src/.pi-map.index.md
|
||||
map: backend/src/.pi-map.md
|
||||
|
||||
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||
FastAPI backend service providing authenticated API endpoints for viewing and managing media libraries across Jellyfin/Jellyseerr with remote SSH job execution.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
||||
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
|
||||
- dependencies.py | Provides FastAPI dependency injection functions for resolving and caching service clients (Jellyfin, Jellyseerr, SSH/Local) and settings based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||
- dependencies.py | Provides FastAPI dependency injection functions for resolving and instantiating Jellyfin/Jellyseerr API clients and machine-specific SSH/Local command clients. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
|
||||
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
|
||||
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
|
||||
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_service_data_harness, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.service_data, FastAPI, media_library_viewer_api.services.backup_poller
|
||||
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
|
||||
- path_utils.py | Maps Jellyfin media paths to SSH-accessible paths using media root anchoring or fallback prefixing. | exp: func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:logger.debug, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:logger.debug, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:logger.debug, call:apply_remote_path_prefix | dep: logging, posixpath
|
||||
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
||||
## arch
|
||||
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
|
||||
Layered FastAPI architecture using dependency injection for client resolution, middleware-based OIDC/API-key authentication, pydantic-settings configuration, and Prometheus-based observability with structured logging.
|
||||
## tags
|
||||
call:, settings, call:get, request, get, client, call:str, id
|
||||
call:, settings, call:get, request, get, call:str, id, client
|
||||
## symbols
|
||||
- Settings
|
||||
- JobTemplate
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/clients
|
||||
|
||||
## role
|
||||
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||
Provides HTTP and command-line client wrappers for integrating with external media services (Jellyfin, Authentik, Jellyseerr, qBittorrent) and executing local/remote filesystem operations.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -10,15 +10,17 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- authentik.py
|
||||
- jellyfin.py
|
||||
- jellyseerr.py
|
||||
- local.py
|
||||
- qbittorrent.py
|
||||
- ssh.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, jellyfin.py, jellyseerr.py
|
||||
read: __init__.py, authentik.py, jellyfin.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,28 +4,30 @@ dir: backend/src/media_library_viewer_api/clients
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||
Provides HTTP and command-line client wrappers for integrating with external media services (Jellyfin, Authentik, Jellyseerr, qBittorrent) and executing local/remote filesystem operations.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- authentik.py | Provides a client wrapper around the Authentik REST API for browsing and searching the user directory with pagination. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests
|
||||
- jellyfin.py | Provides a reusable, framework-agnostic HTTP client wrapper for the Jellyfin/Emby API with methods for browsing users, libraries, media items, and sessions. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests
|
||||
- jellyseerr.py | HTTP client wrapper for the Jellyseerr REST API to fetch user data and enrich Jellyfin user information | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests
|
||||
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
||||
- qbittorrent.py | Provides a minimal read-only client for the qBittorrent Web API to fetch sync/maindata using authenticated requests. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:logger.info, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests
|
||||
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||
## arch
|
||||
Client-wrapper pattern with framework-agnostic abstractions; parallel local/remote execution strategies via paramiko SSH and local subprocess; centralized REST API communication modules.
|
||||
Adapter/gateway pattern where each client encapsulates external API or protocol communication behind a uniform interface, isolating transport-level concerns (REST, SSH, local shell) from business logic.
|
||||
## tags
|
||||
call:logger.info, call:logger.debug, call:self.get, call:shlex.quote, error, host, call:self.run, init
|
||||
call:logger.info, error, call:logger.debug, client, call:self.get, init, call:shlex.quote, status
|
||||
## symbols
|
||||
- AuthentikClient
|
||||
- JellyfinClient
|
||||
- JellyseerrClient
|
||||
- CommandResult
|
||||
- LocalCommandClient
|
||||
- QbittorrentClient
|
||||
- RemoteSSHClient
|
||||
- __init__
|
||||
- get
|
||||
- users
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, jellyfin.py, jellyseerr.py
|
||||
read: __init__.py, authentik.py, jellyfin.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Minimal qBittorrent Web API client (read-only: sync/maindata only).
|
||||
|
||||
Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s
|
||||
session pattern. Authentication uses username/password login which stores an
|
||||
SID cookie in the requests session. The client re-logins transparently on 403.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QbittorrentClient:
|
||||
"""Small wrapper around the qBittorrent Web API.
|
||||
|
||||
Only the endpoints needed by the dashboard widgets are implemented
|
||||
(currently just ``/sync/maindata``). All calls share a single
|
||||
:class:`requests.Session` that carries the login cookie.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
|
||||
if not base_url:
|
||||
raise ValueError("qBittorrent base_url is required")
|
||||
if not username:
|
||||
raise ValueError("qBittorrent username is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if not self.base_url.endswith("/api/v2"):
|
||||
self.base_url += "/api/v2"
|
||||
self._username = username
|
||||
self._password = password
|
||||
self.timeout = timeout
|
||||
self._session = requests.Session()
|
||||
self._logged_in = False
|
||||
|
||||
def _login(self) -> None:
|
||||
"""POST username/password to ``/auth/login``; store the SID cookie.
|
||||
|
||||
qBittorrent returns the plain text ``"Ok."`` on success. The
|
||||
``Referer`` header is required by some qBittorrent CSRF protections.
|
||||
"""
|
||||
resp = self._session.post(
|
||||
f"{self.base_url}/auth/login",
|
||||
data={"username": self._username, "password": self._password},
|
||||
timeout=self.timeout,
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
if resp.text.strip() != "Ok.":
|
||||
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
|
||||
self._logged_in = True
|
||||
logger.info("qBittorrent login successful for %s", self.base_url)
|
||||
|
||||
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||
"""GET an endpoint with auto-login on first call and re-login on 403."""
|
||||
if not self._logged_in:
|
||||
self._login()
|
||||
url = f"{self.base_url}{path}"
|
||||
resp = self._session.get(url, params=params, timeout=self.timeout)
|
||||
if resp.status_code == 403:
|
||||
logger.debug("qBittorrent 403 on %s, re-logging in", path)
|
||||
self._logged_in = False
|
||||
self._login()
|
||||
resp = self._session.get(url, params=params, timeout=self.timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def maindata(self) -> dict[str, Any]:
|
||||
"""Fetch ``/sync/maindata``.
|
||||
|
||||
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
|
||||
``up_info_speed``, etc.) and ``torrents`` (a dict of
|
||||
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
|
||||
"""
|
||||
return self._get("/sync/maindata")
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/domain
|
||||
|
||||
## role
|
||||
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
|
||||
Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
|
||||
@@ -4,13 +4,13 @@ dir: backend/src/media_library_viewer_api/domain
|
||||
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
|
||||
Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- dashboard.py | Provides domain helper functions for building dashboard data, specifically normalizing Jellyfin session activity rows and computing backup job summaries. | exp: func:_map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) → list[dict[str, Any]], call:session.get, call:bool, call:item.get, call:play_state.get, call:transcoding.get, call:transcode_type.append, call:results.append, call:", ".join, func:build_backup_dashboard_summary(store: SettingsStore) → BackupDashboardSummary, call:store.list_backup_jobs, call:len, call:int, call:time.time, call:store.list_backup_runs, call:recent_runs.append, call:sum, call:store.list_backup_alerts, call:failed_runs.append, call:max, call:BackupDashboardSummary, call:round | dep: time, typing, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||
- media.py | Flattens inconsistent Jellyfin API JSON into normalized dictionaries for SQLite indexing and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer_api.utils, pandas
|
||||
## arch
|
||||
Stateless functional modules that transform inconsistent upstream API JSON into normalized dictionaries for persistence and display.
|
||||
Functional utility module pattern with pure helper functions that transform external API JSON into normalized domain objects.
|
||||
## tags
|
||||
media, backup, call:item.get, call:str, date, added, dashboard, call:store.list
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/integrations
|
||||
|
||||
## role
|
||||
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||
Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -11,12 +11,13 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- alertmanager.py
|
||||
- authentik.py
|
||||
- backups.py
|
||||
- base.py
|
||||
- grafana.py
|
||||
- jellyfin.py
|
||||
- jellyseerr.py
|
||||
- nextcloud.py
|
||||
- prometheus.py
|
||||
- qbittorrent.py
|
||||
- registry.py
|
||||
- ssh_tasks.py
|
||||
## links
|
||||
@@ -24,6 +25,6 @@ index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
|
||||
## workflows
|
||||
- change integrations behavior
|
||||
read: __init__.py, alertmanager.py, base.py
|
||||
read: __init__.py, alertmanager.py, authentik.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,33 +4,34 @@ dir: backend/src/media_library_viewer_api/integrations
|
||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||
Defines external service integrations (e.g., Jellyfin, Prometheus, Alertmanager) with configuration models, widget schemas, and data-fetching logic for the media library viewer API.
|
||||
## files
|
||||
- __init__.py | Defines a closed registry module for service integrations.
|
||||
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
||||
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
||||
- authentik.py | Defines the service configuration model and definition for integrating Authentik as a user directory and identity provider. | exp: class:AuthentikConfig | dep: media_library_viewer_api.integrations.base
|
||||
- backups.py | Defines a Backups service type with configuration and summary widget for monitoring backup jobs, run history, and alerting. | exp: class:BackupsConfig, class:BackupsSummaryWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- base.py | Provides foundational base classes and dataclasses for defining external service integrations, including config validation and widget schema generation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||
- jellyfin.py | Defines the Jellyfin media server service configuration, secret fields, and widget definitions for activity and now-playing sessions. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
||||
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
|
||||
- prometheus.py | Defines the Prometheus service configuration and widget types (metric, chart, gauge, mean) for querying and visualizing PromQL data. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- qbittorrent.py | Declares the qBittorrent service definition including config models, secret fields, and three widget kinds (totals, active, speed). | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks
|
||||
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
## arch
|
||||
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
|
||||
Plugin-style registry pattern with a shared base class hierarchy; each service module independently defines config dataclasses, widget types, and summarization logic, all registered in a central closed registry for discovery and lookup.
|
||||
## tags
|
||||
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
|
||||
config, widget, service, integrations, base, media_library_viewer_api, prometheus, definition
|
||||
## symbols
|
||||
- AlertmanagerConfig
|
||||
- AlertmanagerAlertsWidgetConfig
|
||||
- AuthentikConfig
|
||||
- BackupsConfig
|
||||
- BackupsSummaryWidgetConfig
|
||||
- ServiceConfigBase
|
||||
- WidgetConfigBase
|
||||
- SecretField
|
||||
- WidgetKind
|
||||
- ServiceDefinition
|
||||
- GrafanaConfig
|
||||
## workflows
|
||||
- change integrations behavior
|
||||
read: __init__.py, alertmanager.py, base.py
|
||||
read: __init__.py, alertmanager.py, authentik.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
|
||||
|
||||
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||
* the secret fields it accepts (API keys / tokens),
|
||||
@@ -24,7 +24,7 @@ from pydantic import BaseModel, BeforeValidator, Field
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -36,6 +36,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
|
||||
"""Only show sessions with active playback (not idle/paused)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="jellyfin",
|
||||
name="Jellyfin",
|
||||
@@ -53,5 +59,13 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="now_playing",
|
||||
name="Now Playing",
|
||||
description="Only sessions actively playing media.",
|
||||
model_cls=JellyfinNowPlayingWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -25,6 +25,32 @@ class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||
promql: str
|
||||
|
||||
|
||||
class PrometheusChartWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
||||
|
||||
promql: str
|
||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
||||
|
||||
|
||||
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111)."""
|
||||
|
||||
promql: str
|
||||
warn_at: float | None = None
|
||||
crit_at: float | None = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class PrometheusMeanWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
||||
|
||||
promql: str
|
||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
@@ -42,5 +68,29 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="chart",
|
||||
name="Chart",
|
||||
description="Multi-series line chart from a PromQL range query.",
|
||||
model_cls=PrometheusChartWidgetConfig,
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="gauge",
|
||||
name="Gauge",
|
||||
description="Instant query rendered as a gauge with optional threshold bands.",
|
||||
model_cls=PrometheusGaugeWidgetConfig,
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="mean",
|
||||
name="Mean",
|
||||
description="Average value of a PromQL query over a time window.",
|
||||
model_cls=PrometheusMeanWidgetConfig,
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""qBittorrent service definition.
|
||||
|
||||
Declares the config model (base URL + timeout), secret fields (username +
|
||||
password), and three widget kinds (totals, active, speed). Models on
|
||||
:mod:`media_library_viewer_api.integrations.prometheus`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class QbittorrentConfig(ServiceConfigBase):
|
||||
"""Non-secret qBittorrent connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||
"""Per-widget config (empty — all three kinds derive from the service connection)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="qbittorrent",
|
||||
name="qBittorrent",
|
||||
description="Torrent client activity, speeds, and item counts.",
|
||||
config_model=QbittorrentConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="username", label="Username", required=True),
|
||||
SecretField(key="password", label="Password", required=True, helper="Stored encrypted"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="totals",
|
||||
name="Totals",
|
||||
description="Count of all listed torrents, broken down by state.",
|
||||
model_cls=QbittorrentWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="active",
|
||||
name="Active torrents",
|
||||
description="Torrents currently downloading or uploading.",
|
||||
model_cls=QbittorrentWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=15_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="speed",
|
||||
name="Speed chart",
|
||||
description="Live download/upload speed over a short window.",
|
||||
model_cls=QbittorrentWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=5_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -10,18 +10,18 @@ from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALE
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
QBITTORRENT.service_type: QBITTORRENT,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
BACKUPS.service_type: BACKUPS,
|
||||
AUTHENTIK.service_type: AUTHENTIK,
|
||||
|
||||
@@ -52,6 +52,12 @@ async def lifespan(app: FastAPI):
|
||||
get_settings_store().ensure_defaults()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default settings during startup")
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness()
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize service data harness during startup")
|
||||
mail_queue = get_mail_queue()
|
||||
backup_poller = get_backup_poller()
|
||||
mail_queue.start()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/models
|
||||
|
||||
## role
|
||||
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -10,6 +10,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
-
|
||||
## files
|
||||
- backups.py
|
||||
- dashboards.py
|
||||
- services.py
|
||||
- widgets.py
|
||||
## links
|
||||
@@ -17,6 +18,6 @@ index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, services.py, widgets.py
|
||||
read: backups.py, dashboards.py, services.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/models
|
||||
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
|
||||
## files
|
||||
- backups.py | Defines Pydantic data models for backup system API requests and responses including reports, jobs, runs, alerts, and dashboard summaries. | exp: class:BackupReportRequest, class:BackupJobResponse, class:BackupRunResponse, class:BackupAlertResponse, class:BackupDashboardSummary | dep: datetime, typing, pydantic
|
||||
- dashboards.py | Defines Pydantic data models for creating, updating, and representing named dashboard records in an API. | exp: class:NamedDashboardInput, class:NamedDashboard | dep: typing, pydantic
|
||||
- services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
|
||||
- widgets.py | Defines Pydantic models for a dashboard widget system with validation to prevent secrets/credentials in widget configuration. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||
- widgets.py | Defines Pydantic models for a dashboard widget system, including input/output schemas and validation to prevent credential leakage in widget configurations. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||
## arch
|
||||
Pydantic-based model layer implementing data validation, serialization contracts, and custom validators enforcing security constraints (e.g., blocking credentials in non-secret configs).
|
||||
Pydantic-based model layer following a schema-first design pattern with built-in validators for domain-specific business rules and data integrity.
|
||||
## tags
|
||||
widget, backup, instance, response, info, call:isinstance, call:, service
|
||||
widget, backup, instance, dashboard, pydantic, response, info, call:isinstance
|
||||
## symbols
|
||||
- BackupReportRequest
|
||||
- BackupJobResponse
|
||||
- BackupRunResponse
|
||||
- BackupAlertResponse
|
||||
- BackupDashboardSummary
|
||||
- NamedDashboardInput
|
||||
- NamedDashboard
|
||||
- ServiceInstanceInput
|
||||
- ServiceInstance
|
||||
- SecretFieldInfo
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, services.py, widgets.py
|
||||
read: backups.py, dashboards.py, services.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Widgets are either:
|
||||
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||
by that service's definition (Prometheus metric, Jellyfin
|
||||
activity, SSH task output); or
|
||||
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||
service-less kinds (backups, static).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/routers
|
||||
|
||||
## role
|
||||
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -10,8 +10,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- authentik_users.py
|
||||
- backups.py
|
||||
- dashboard.py
|
||||
- dashboards.py
|
||||
- files.py
|
||||
- jobs.py
|
||||
- media.py
|
||||
@@ -19,14 +21,12 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
- services.py
|
||||
- settings.py
|
||||
- tasks.py
|
||||
- users.py
|
||||
- users_impl.py
|
||||
- widgets.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, backups.py, dashboard.py
|
||||
read: __init__.py, authentik_users.py, backups.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/routers
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for routers.
|
||||
- backups.py | FastAPI router that provides REST endpoints for reporting, tracking, and alerting on backup jobs and runs. | exp: func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||
- authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
|
||||
- backups.py | FastAPI router for receiving backup run reports, managing backup jobs/runs, and generating/acknowledging backup alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||
- dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store
|
||||
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
|
||||
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
||||
- media.py | FastAPI router that manages media index lifecycle (status, build, stop, query) with subprocess worker orchestration and cooperative/force cancellation. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path) → list[str], call:str, func:_start_worker(index: MediaIndex) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(client, user_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:client.libraries, call:logger.info, call:len, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index, media_library_viewer_api.workers.media_index_worker
|
||||
- monitoring.py | FastAPI router providing endpoints to check the health/status of Alertmanager, Grafana, and Prometheus services and expose Prometheus scrape targets. | exp: func:_resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services, func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_grafana_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
||||
- media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index
|
||||
- monitoring.py | FastAPI router providing monitoring observability endpoints that proxy and aggregate status, alerts, and scrape targets from Alertmanager and Prometheus. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
||||
- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store
|
||||
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
||||
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||
- users.py | Re-exports all public names from the users_impl module to provide a unified public API for user-related functionality. | dep: .users_impl, users_impl
|
||||
- users_impl.py | FastAPI router that fetches and merges Jellyfin users with optional Jellyseerr enrichment, plus endpoints for queueing user emails via a background mail worker. | exp: func:_safe_int(value: Any) → int, call:int, func:_permission_labels(permissions: int) → list[str], func:_role_label(permissions: int) → str, func:_account_type(user_type: Any) → str, call:_USER_TYPES.get, call:_safe_int, func:_merge_users(jellyfin_users: list[dict[str, Any]], jellyseerr_jellyfin_users: list[dict[str, Any]] | None, jellyseerr_users: list[dict[str, Any]] | None, jellyseerr_client: JellyseerrClient | None) → dict[str, Any], call:str(value or "").strip().lower, call:bool, call:_looks_like_email, call:str(value).strip, call:", ".join, call:_normalize, call:item.get, call:_lookup_keys, call:user.get, call:linked_by_jellyfin_id.get, call:(jf_link or {}).get, call:seerr_by_key.get, call:_pick_source_and_value, call:(seerr_user or {}).get, call:_first_value, call:jellyseerr_client.absolute_url, call:_safe_int, call:_role_label, call:_source_summary, call:items.append, call:_account_type, call:_permission_labels, call:logger.info, call:len, func:_normalize(value: Any) → str, call:str(value or "").strip().lower, func:_looks_like_email(value: Any) → bool, call:str(value or "").strip, call:bool, func:_pick_source_and_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:_looks_like_email, call:str(value).strip, func:_first_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:str(value or "").strip, func:_source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) → str, call:", ".join, func:_lookup_keys(item: dict[str, Any]) → list[str], call:_normalize, call:item.get, func:get_users(jellyfin, jellyseerr) → dict[str, Any], call:jellyfin.users, call:logger.info, call:len, call:jellyseerr.jellyfin_users, call:logger.exception, call:jellyseerr.users, call:_merge_users, call:bool, func:get_user_message_status() → dict[str, Any], call:mail_queue.status, func:post_user_message(recipient_ids, subject, html_body, text_body, attachments, jellyfin, jellyseerr) → dict[str, Any], call:json.loads, call:isinstance, call:str(item).strip, call:subject.strip, call:get_users, call:item.get, call:directory.get, call:users_by_id.get, call:skipped.append, call:str(item.get("email") or "").strip, call:recipients.append, call:recipient_labels.append, call:get_settings, call:validate_smtp_settings, call:mail_queue.status, call:upload.read, call:attachment_payloads.append, call:EmailAttachment, call:mail_queue.enqueue, call:str(getattr(settings, "smtp_from_address", "") or "").strip, call:getattr, call:str(getattr(settings, "smtp_username", "") or "").strip, call:logger.info, call:len, raise:HTTPException | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mailer
|
||||
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances, including CRUD operations, built-in widget discovery, and data fetching through service or built-in adapters. | exp: func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException | dep: logging, time, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances and their references, including CRUD operations, data fetching, and live-link detachments. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||
## arch
|
||||
Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter; routers are registered by the parent application and share common dependencies for SSH orchestration, database access, and service resolution.
|
||||
Modular FastAPI APIRouter pattern where each domain module exports its own router instance; routers encapsulate endpoint definitions and delegate business logic to underlying service clients, SSH utilities, and subprocess workers.
|
||||
## tags
|
||||
call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh, call:logger.info
|
||||
call:, raise:httpexception, service, backup, get, media_library_viewer_api, ssh, call:store.get
|
||||
## symbols
|
||||
- MessageRequest
|
||||
- RunJobRequest
|
||||
- MonitoringMachineInput
|
||||
- SSHKeyInput
|
||||
@@ -31,9 +32,8 @@ call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh,
|
||||
- ResetLocalDatabaseInput
|
||||
- TaskInput
|
||||
- RunTaskRequest
|
||||
- _get_or_create_job
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, backups.py, dashboard.py
|
||||
read: __init__.py, authentik_users.py, backups.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -20,8 +20,9 @@ from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,29 +37,6 @@ class MessageRequest(BaseModel):
|
||||
html_body: str
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested authentik instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled ``authentik`` instance is configured.
|
||||
"""
|
||||
service_type = "authentik"
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(service.secrets.get("api_token") or "")
|
||||
@@ -82,7 +60,7 @@ def get_authentik_users(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated Authentik user directory for a specific service instance."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||
return _empty("Authentik service not configured")
|
||||
@@ -102,7 +80,7 @@ def get_authentik_message_status(
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
@@ -116,7 +94,7 @@ def post_authentik_message(
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
str(final_db_path),
|
||||
"--staging-path",
|
||||
str(staging_db_path),
|
||||
"--service-id",
|
||||
service_id,
|
||||
]
|
||||
|
||||
|
||||
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
|
||||
def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
|
||||
staging_path = _staging_db_path(index)
|
||||
staging_path.unlink(missing_ok=True)
|
||||
return subprocess.Popen(
|
||||
_worker_command(index.db_path, staging_path),
|
||||
_worker_command(index.db_path, staging_path, service_id),
|
||||
start_new_session=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
@@ -131,20 +133,28 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
|
||||
|
||||
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
|
||||
def post_build_index(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
jellyfin_service_id: str | None = None,
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Start a media index build in a subprocess worker."""
|
||||
"""Start a media index build in a subprocess worker.
|
||||
|
||||
The worker resolves its own Jellyfin connection from the settings store.
|
||||
We do NOT use Depends(get_jellyfin_client) here because the worker runs
|
||||
in a separate process and needs to resolve the client itself. Validating
|
||||
the connection here would fail if Jellyfin is briefly unreachable, even
|
||||
though the build just needs to start the worker process.
|
||||
"""
|
||||
with _build_lock:
|
||||
current_status = _clean_stale_build_state(index)
|
||||
if current_status.build_running and _pid_is_alive(current_status.build_pid):
|
||||
logger.warning("Media build already running pid=%s", current_status.build_pid)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
|
||||
|
||||
libraries = client.libraries(user_id)
|
||||
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
||||
process = _start_worker(index)
|
||||
logger.info(
|
||||
"Starting media index build service_id=%s",
|
||||
jellyfin_service_id or "<default>",
|
||||
)
|
||||
process = _start_worker(index, jellyfin_service_id or "")
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
@@ -156,7 +166,7 @@ def post_build_index(
|
||||
"build_items_total": 0,
|
||||
"build_current_library": "",
|
||||
"build_library_index": 0,
|
||||
"build_libraries_total": len(libraries),
|
||||
"build_libraries_total": 0,
|
||||
"build_library_progress": None,
|
||||
"build_library_items_processed": 0,
|
||||
"build_library_items_total": 0,
|
||||
@@ -272,6 +282,7 @@ def query_media(
|
||||
sort_order: str = Query("Ascending", description="Ascending or Descending"),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
jellyfin_service_id: str | None = None,
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
@@ -306,6 +317,7 @@ def query_media(
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
service_id=jellyfin_service_id or "",
|
||||
)
|
||||
|
||||
logger.info("Media query returned total=%s rows=%s", total, len(rows))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Monitoring router — observability service status.
|
||||
|
||||
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||
Observability components (Alertmanager, Prometheus) are resolved from
|
||||
the service registry, not environment variables. The endpoints pick the first
|
||||
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||
@@ -15,34 +15,14 @@ import requests
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore, service_type: str, service_id: str | None = None
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested service instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled instance of ``service_type`` is configured.
|
||||
"""
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _base_url(service: ServiceRecord) -> str:
|
||||
return str(service.config.get("base_url") or "").rstrip("/")
|
||||
|
||||
@@ -104,7 +84,7 @@ def get_alertmanager_alerts(
|
||||
is configured the endpoint returns an empty summary with an
|
||||
``alertmanager_not_configured`` error so the UI can render a health card.
|
||||
"""
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
service = resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
||||
try:
|
||||
@@ -149,7 +129,7 @@ def get_alertmanager_status(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return Alertmanager cluster/status for the UI health card."""
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
service = resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {
|
||||
"up": False,
|
||||
@@ -192,36 +172,13 @@ def get_alertmanager_status(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grafana-status")
|
||||
def get_grafana_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||
service = _resolve_service_record(store, "grafana", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/health",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Grafana status")
|
||||
return _status_response(service, error="grafana_unreachable")
|
||||
return _status_response(service, version=data.get("version", ""))
|
||||
|
||||
|
||||
@router.get("/prometheus-status")
|
||||
def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Prometheus service instance's health and build info."""
|
||||
service = _resolve_service_record(store, "prometheus", service_id)
|
||||
service = resolve_service_record(store, "prometheus", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
base = _base_url(service)
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
|
||||
get_service_adapter,
|
||||
)
|
||||
|
||||
|
||||
class WidgetReferenceCreate(BaseModel):
|
||||
"""Payload for creating a widget reference (live-link)."""
|
||||
|
||||
dashboard_scope: str
|
||||
widget_id: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -221,3 +231,65 @@ async def fetch_data(
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/references")
|
||||
def list_references(
|
||||
dashboard_scope: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List widget references for a dashboard scope."""
|
||||
return store.list_widget_references(dashboard_scope)
|
||||
|
||||
|
||||
@router.post("/references", status_code=status.HTTP_201_CREATED)
|
||||
def create_reference(
|
||||
body: WidgetReferenceCreate,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a widget reference (live-link) on a dashboard."""
|
||||
try:
|
||||
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/references/{reference_id}")
|
||||
def delete_reference(
|
||||
reference_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Remove a widget reference from a dashboard."""
|
||||
store.delete_widget_reference(reference_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/references/{reference_id}")
|
||||
def update_reference(
|
||||
reference_id: str,
|
||||
sort_order: int,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Update a widget reference's sort_order (per-dashboard reordering)."""
|
||||
try:
|
||||
return store.update_widget_reference(reference_id, sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/references/{reference_id}/detach")
|
||||
def detach_reference(
|
||||
reference_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Clone the referenced widget into a standalone instance and remove the reference."""
|
||||
try:
|
||||
cloned = store.detach_widget_reference(reference_id, "")
|
||||
return WidgetInstance(**cloned).model_dump()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/services
|
||||
|
||||
## role
|
||||
Backend service layer providing business logic for media library management, backup monitoring, email notifications, SSH task execution, encryption, and persistent settings storage.
|
||||
Backend service layer providing business logic for media indexing, backup monitoring, email delivery, secrets management, task execution, and persistent storage operations.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -19,7 +19,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
- mailer_impl.py
|
||||
- media_index.py
|
||||
- media_index_impl.py
|
||||
- qbittorrent_store.py
|
||||
- secrets.py
|
||||
- service_data.py
|
||||
- service_resolution.py
|
||||
- settings_store.py
|
||||
- targets.py
|
||||
- task_runner.py
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ from typing import Any, Callable, Iterable
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
|
||||
from media_library_viewer_api.path_utils import resolve_remote_media_path
|
||||
from media_library_viewer_api.services.service_data import StorageConcern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +26,20 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
|
||||
MEDIA_TYPES = "Movie,Episode,Video"
|
||||
|
||||
# Harness concern registration: the media_index DB is scoped by service_id so
|
||||
# multiple Jellyfin instances can coexist. The ALTER TABLE migration adds the
|
||||
# service_id column to existing DBs; init_schema adds it for fresh installs.
|
||||
# The harness run_migrations catches "duplicate column name" on re-runs.
|
||||
MEDIA_INDEX_CONCERN = StorageConcern(
|
||||
concern_key="media_index",
|
||||
db_filename=DEFAULT_INDEX_PATH.name,
|
||||
migrations=[
|
||||
"ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
tables=["media_items"],
|
||||
service_id_column="service_id",
|
||||
)
|
||||
|
||||
# Only values from this whitelist are interpolated into ORDER BY. User-selected
|
||||
# sort keys map to these known SQL snippets to avoid SQL injection.
|
||||
SORT_COLUMNS = {
|
||||
@@ -135,7 +150,8 @@ class MediaIndex:
|
||||
date_added_ts INTEGER,
|
||||
path TEXT,
|
||||
library_id TEXT,
|
||||
library_name TEXT
|
||||
library_name TEXT,
|
||||
service_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS index_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -167,8 +183,13 @@ class MediaIndex:
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set."""
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set.
|
||||
|
||||
Scoped by ``service_id``: only rows belonging to this service are
|
||||
deleted before the new batch is inserted. This means building for one
|
||||
Jellyfin instance no longer wipes another instance's rows.
|
||||
"""
|
||||
self.init_schema()
|
||||
row_list = list(rows)
|
||||
columns = [
|
||||
@@ -194,13 +215,14 @@ class MediaIndex:
|
||||
"path",
|
||||
"library_id",
|
||||
"library_name",
|
||||
"service_id",
|
||||
]
|
||||
placeholders = ",".join(["?"] * len(columns))
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM media_items")
|
||||
conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
|
||||
conn.executemany(
|
||||
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
|
||||
[[row.get(column) for column in columns] for row in row_list],
|
||||
[[row.get(column) if column != "service_id" else service_id for column in columns] for row in row_list],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
|
||||
@@ -287,8 +309,14 @@ class MediaIndex:
|
||||
sort_order: str = "Ascending",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
service_id: str = "",
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Query indexed media with full-index filters, sorting, and pagination."""
|
||||
"""Query indexed media with full-index filters, sorting, and pagination.
|
||||
|
||||
When ``service_id`` is non-empty, only rows matching that service are
|
||||
returned. When empty (the default), all rows are returned (backward-
|
||||
compatible with callers that are not multi-instance aware).
|
||||
"""
|
||||
self.init_schema()
|
||||
where = []
|
||||
params: list[Any] = []
|
||||
@@ -309,6 +337,9 @@ class MediaIndex:
|
||||
where.append("hdr = 1")
|
||||
elif hdr_filter == "SDR/unknown only":
|
||||
where.append("(hdr IS NULL OR hdr = 0)")
|
||||
if service_id:
|
||||
where.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
|
||||
where_sql = " WHERE " + " AND ".join(where) if where else ""
|
||||
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
|
||||
@@ -338,6 +369,7 @@ def build_media_index(
|
||||
fallback_prefix: str = "",
|
||||
progress_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
service_id: str = "",
|
||||
) -> int:
|
||||
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
||||
index = index or MediaIndex()
|
||||
@@ -453,7 +485,7 @@ def build_media_index(
|
||||
logger.info("Media index finalizing rows=%s", len(normalized_rows))
|
||||
emit("finalizing", "Writing index to disk")
|
||||
ensure_not_cancelled()
|
||||
count = index.replace_items(normalized_rows)
|
||||
count = index.replace_items(normalized_rows, service_id=service_id)
|
||||
duration = time.perf_counter() - started_at
|
||||
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
|
||||
processed_total = count
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Speed-sample storage for qBittorrent widgets.
|
||||
|
||||
This module defines the storage concern for qBittorrent speed data and a
|
||||
bespoke store with ``append``/``window`` operations. It is registered with the
|
||||
:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as
|
||||
the first real consumer of the harness lifecycle layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.services.service_data import StorageConcern
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
QBITTORRENT_CONCERN = StorageConcern(
|
||||
concern_key="qbittorrent",
|
||||
db_filename="qbittorrent.db",
|
||||
migrations=[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
|
||||
service_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
dl_speed INTEGER NOT NULL DEFAULT 0,
|
||||
up_speed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
|
||||
ON qbittorrent_speed_samples(service_id, ts);
|
||||
"""
|
||||
],
|
||||
tables=["qbittorrent_speed_samples"],
|
||||
)
|
||||
|
||||
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
|
||||
MAX_SAMPLES = 120
|
||||
|
||||
|
||||
class QbittorrentSampleStore:
|
||||
"""Bespoke speed-sample store for qBittorrent widgets.
|
||||
|
||||
Each ``append`` inserts a new sample and prunes entries beyond
|
||||
:data:`MAX_SAMPLES`, keeping only the most recent rows for the given
|
||||
``service_id``.
|
||||
"""
|
||||
|
||||
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
|
||||
if harness is None:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
harness = get_service_data_harness()
|
||||
self._harness = harness
|
||||
|
||||
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
|
||||
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
||||
(service_id, ts, dl_speed, up_speed),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM qbittorrent_speed_samples "
|
||||
"WHERE service_id = ? AND ts NOT IN ("
|
||||
" SELECT ts FROM qbittorrent_speed_samples"
|
||||
" WHERE service_id = ?"
|
||||
" ORDER BY ts DESC LIMIT ?"
|
||||
")",
|
||||
(service_id, service_id, MAX_SAMPLES),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return all samples for a service since a timestamp (or all if ``None``)."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
if since_ts is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples "
|
||||
"WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
|
||||
(service_id, since_ts),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
|
||||
(service_id,),
|
||||
).fetchall()
|
||||
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Lifecycle-only storage harness for service-owned persistent data.
|
||||
|
||||
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
|
||||
schema migrations, and cascade-delete when a service instance is removed. It
|
||||
does **not** own data operations — each integration implements its own Store
|
||||
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
|
||||
etc.). This keeps the general interface narrow (lifecycle) and the specific
|
||||
interfaces rich (per-integration operations).
|
||||
|
||||
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
|
||||
declaring its DB filename, ordered migration statements, owned tables, and the
|
||||
column used for service scoping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageConcern:
|
||||
"""A per-integration storage namespace registered with the harness."""
|
||||
|
||||
concern_key: str # e.g. "qbittorrent", "media_index"
|
||||
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
|
||||
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
|
||||
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
|
||||
service_id_column: str = "service_id"
|
||||
|
||||
|
||||
class ServiceDataHarness:
|
||||
"""Lifecycle-only registry of per-concern storage.
|
||||
|
||||
Owns:
|
||||
* DB provisioning (per-concern SQLite files under ``base_dir``).
|
||||
* Schema migrations (run on first access via :meth:`run_migrations`).
|
||||
* ``service_id`` cascade-delete when a service instance is removed.
|
||||
|
||||
Does **not** own:
|
||||
* Data operations — each store keeps bespoke append/window/query/etc.
|
||||
* A generic value table or generic CRUD layer.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path | str) -> None:
|
||||
self._base_dir = Path(base_dir)
|
||||
self._concerns: dict[str, StorageConcern] = {}
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self._base_dir
|
||||
|
||||
def register(self, concern: StorageConcern) -> None:
|
||||
"""Register a storage concern. Called at startup / on first access."""
|
||||
self._concerns[concern.concern_key] = concern
|
||||
|
||||
def db_path(self, concern_key: str) -> Path:
|
||||
"""Return the absolute path to a concern's DB file."""
|
||||
concern = self._concerns[concern_key]
|
||||
return self._base_dir / concern.db_filename
|
||||
|
||||
def connect(self, concern_key: str) -> sqlite3.Connection:
|
||||
"""Open a WAL-mode connection to a concern's DB."""
|
||||
path = self.db_path(concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
"""Run pending migrations for every registered concern.
|
||||
|
||||
Each migration string is split into individual statements (by ``;``)
|
||||
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
|
||||
that fail with "duplicate column name" are silently skipped, making
|
||||
migrations idempotent across re-runs and fresh installs where
|
||||
``init_schema`` may have already created the column.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
path = self.db_path(concern.concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
for migration_sql in concern.migrations:
|
||||
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
|
||||
for stmt in statements:
|
||||
try:
|
||||
conn.execute(stmt)
|
||||
except sqlite3.OperationalError as exc:
|
||||
lowered = str(exc).lower()
|
||||
if "duplicate column name" in lowered or "no such table" in lowered:
|
||||
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def cascade_delete(self, service_id: str) -> None:
|
||||
"""Delete all rows for a ``service_id`` across every concern's tables.
|
||||
|
||||
Called from :meth:`SettingsStore.delete_service` after the service row
|
||||
is removed. Best-effort: callers wrap in try/except so a harness
|
||||
failure does not block service deletion.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
col = concern.service_id_column
|
||||
path = self.db_path(concern.concern_key)
|
||||
if not path.exists():
|
||||
continue
|
||||
with sqlite3.connect(path, timeout=30) as conn:
|
||||
for table in concern.tables:
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if col in cols:
|
||||
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HARNESS: ServiceDataHarness | None = None
|
||||
|
||||
|
||||
def get_service_data_harness() -> ServiceDataHarness:
|
||||
"""Return the process-wide harness singleton, initializing it on first call.
|
||||
|
||||
Lazy registration of built-in concerns happens here (local imports avoid
|
||||
circular dependencies). Migrations are run immediately after registration.
|
||||
"""
|
||||
global _HARNESS
|
||||
if _HARNESS is None:
|
||||
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
|
||||
_HARNESS = ServiceDataHarness(base_dir)
|
||||
# Register built-in concerns (lazy import avoids circular dependency).
|
||||
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
|
||||
|
||||
_HARNESS.register(QBITTORRENT_CONCERN)
|
||||
|
||||
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
|
||||
|
||||
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
||||
_HARNESS.run_migrations()
|
||||
return _HARNESS
|
||||
|
||||
|
||||
def reset_service_data_harness() -> None:
|
||||
"""Reset the singleton (for testing)."""
|
||||
global _HARNESS
|
||||
_HARNESS = None
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Shared helpers for resolving service instances at request time.
|
||||
|
||||
Extracted from the duplicated ``_resolve_service_record`` helpers that lived
|
||||
in ``routers/monitoring.py`` and ``routers/authentik_users.py``. Both routers
|
||||
need the same logic: return the requested service instance (by id), or fall
|
||||
back to the first enabled instance of the type. Returns ``None`` when the
|
||||
instance does not exist, is the wrong type, is disabled, or when no enabled
|
||||
instance of the type is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
|
||||
def resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_type: str,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested service instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled instance of ``service_type`` is configured.
|
||||
"""
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
@@ -81,7 +81,7 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
@@ -165,6 +165,19 @@ class SettingsStore:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||
if "widget_kind" not in widget_cols:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS widget_references (
|
||||
id TEXT PRIMARY KEY,
|
||||
dashboard_scope TEXT NOT NULL,
|
||||
widget_id TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1491,6 +1504,123 @@ class SettingsStore:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||
conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]:
|
||||
"""List widget references for a dashboard scope, joined with widget data."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order,
|
||||
wr.created_at AS ref_created_at
|
||||
FROM widget_references wr
|
||||
WHERE wr.dashboard_scope = ?
|
||||
ORDER BY wr.sort_order ASC, wr.created_at ASC
|
||||
""",
|
||||
(dashboard_scope,),
|
||||
).fetchall()
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
widget = self.get_widget(row["widget_id"])
|
||||
if not widget:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": row["ref_id"],
|
||||
"dashboard_scope": row["dashboard_scope"],
|
||||
"widget_id": row["widget_id"],
|
||||
"sort_order": int(row["sort_order"]),
|
||||
"created_at": row["ref_created_at"],
|
||||
"widget": widget,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
widget = self.get_widget(widget_id)
|
||||
if not widget:
|
||||
raise ValueError(f"Widget {widget_id} not found")
|
||||
ref_id = uuid.uuid4().hex[:12]
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ref_id, dashboard_scope, widget_id, sort_order, now),
|
||||
)
|
||||
return {
|
||||
"id": ref_id,
|
||||
"dashboard_scope": dashboard_scope,
|
||||
"widget_id": widget_id,
|
||||
"sort_order": sort_order,
|
||||
"created_at": now,
|
||||
"widget": widget,
|
||||
}
|
||||
|
||||
def delete_widget_reference(self, reference_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
|
||||
|
||||
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
|
||||
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM widget_references WHERE id = ?",
|
||||
(reference_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Reference {reference_id} not found")
|
||||
conn.execute(
|
||||
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
|
||||
(sort_order, reference_id),
|
||||
)
|
||||
widget = self.get_widget(row["widget_id"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"dashboard_scope": row["dashboard_scope"],
|
||||
"widget_id": row["widget_id"],
|
||||
"sort_order": sort_order,
|
||||
"created_at": int(row["created_at"]),
|
||||
"widget": widget,
|
||||
}
|
||||
|
||||
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
|
||||
"""Clone the referenced widget into a new standalone instance owned by the scope."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT widget_id FROM widget_references WHERE id = ?",
|
||||
(reference_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Reference {reference_id} not found")
|
||||
source = self.get_widget(row["widget_id"])
|
||||
if not source:
|
||||
raise ValueError(f"Source widget {row['widget_id']} not found")
|
||||
# Clone: copy the widget verbatim including service_id (so service-bound
|
||||
# widgets keep working), only the id/created_at change.
|
||||
cloned = self.upsert_widget(
|
||||
{
|
||||
"service_id": source.get("service_id"),
|
||||
"widget_kind": source["widget_kind"],
|
||||
"title": source["title"],
|
||||
"config": source["config"],
|
||||
"enabled": source["enabled"],
|
||||
"sort_order": source["sort_order"],
|
||||
}
|
||||
)
|
||||
self.delete_widget_reference(reference_id)
|
||||
return cloned
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Service registry
|
||||
@@ -1610,7 +1740,7 @@ class SettingsStore:
|
||||
return self.get_service(service["id"]) or service
|
||||
|
||||
def delete_service(self, service_id: str) -> None:
|
||||
"""Delete a service and cascade-delete widgets referencing it."""
|
||||
"""Delete a service and cascade-delete widgets + harness data."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
# The service_id column on dashboard_widgets is added in a later
|
||||
@@ -1623,6 +1753,15 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||
|
||||
# Cascade-delete harness-managed data (best-effort: the service row is
|
||||
# already removed; data cleanup must not block service deletion).
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness().cascade_delete(service_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to cascade-delete harness data for service %s", service_id)
|
||||
|
||||
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Append a service task run history row."""
|
||||
self.init_schema()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/widgets
|
||||
|
||||
## role
|
||||
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
|
||||
Widget subsystem providing configurable dashboard widget definitions, source adapters, and data transformation helpers for the media library viewer API.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -11,12 +11,13 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- builtin.py
|
||||
- prometheus_range.py
|
||||
- sources.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||
## workflows
|
||||
- change widgets behavior
|
||||
read: __init__.py, builtin.py, sources.py
|
||||
read: __init__.py, builtin.py, prometheus_range.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
||||
"""Shared helpers for Prometheus range queries.
|
||||
|
||||
These two pieces were called out by the spec's downstream-notes as needing a
|
||||
home: the step-derivation function (SC-104) and the series normalization helper
|
||||
(SC-102). Keeping them in their own module makes them unit-testable in isolation
|
||||
and reusable by the chart and mean widget paths (and, later, the in-service data
|
||||
path of the service-storage-harness change) without ``sources.py`` growing
|
||||
unbounded.
|
||||
|
||||
``normalize_prometheus_matrix`` is a direct extraction of the metric-label →
|
||||
readable-label rule that previously lived inside the Grafana datasource-proxy
|
||||
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
|
||||
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
|
||||
labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
||||
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
||||
WINDOW_PRESETS: dict[str, int] = {
|
||||
"1h": 3_600,
|
||||
"6h": 21_600,
|
||||
"24h": 86_400,
|
||||
"7d": 604_800,
|
||||
}
|
||||
|
||||
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
||||
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
|
||||
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")
|
||||
|
||||
|
||||
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
||||
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
||||
|
||||
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
||||
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
|
||||
the resulting point count to land in the 100–300 band; with
|
||||
``target_points=200`` every preset yields 200 points.
|
||||
"""
|
||||
return max(15, round(window_seconds / target_points))
|
||||
|
||||
|
||||
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
|
||||
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
|
||||
renderer consumes.
|
||||
|
||||
Label rule (matches the removed Grafana path so labels are stable on
|
||||
migration):
|
||||
|
||||
1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
|
||||
2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
|
||||
3. Else fall back to ``"value"``.
|
||||
4. Dedup label collisions with a `` (n)`` suffix.
|
||||
"""
|
||||
series: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
for entry in result:
|
||||
metric = entry.get("metric") or {}
|
||||
values = entry.get("values") or []
|
||||
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
if label in seen:
|
||||
seen[label] += 1
|
||||
label = f"{label} ({seen[label]})"
|
||||
else:
|
||||
seen[label] = 0
|
||||
points: list[dict[str, Any]] = []
|
||||
for ts, raw in values:
|
||||
t = _safe_int(ts)
|
||||
if t is None:
|
||||
# Drop samples whose timestamp is unusable rather than raising.
|
||||
continue
|
||||
points.append({"t": t, "v": _safe_float(raw)})
|
||||
series.append({"label": label, "points": points})
|
||||
return series
|
||||
|
||||
|
||||
def _safe_float(raw: Any) -> float | None:
|
||||
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
|
||||
if raw in _NON_NUMERIC:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(ts: Any) -> int | None:
|
||||
"""Best-effort int conversion for a Prometheus timestamp."""
|
||||
try:
|
||||
return int(float(ts))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -12,19 +12,27 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_prometheus_matrix,
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,29 +104,8 @@ class StaticWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link (no embedding)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Grafana widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
dashboard_uid = config.get("dashboard_uid")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run a PromQL instant query against a Prometheus service."""
|
||||
"""Run PromQL queries against a Prometheus service (instant + range)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -126,14 +113,63 @@ class PrometheusWidgetSource:
|
||||
return {"error": "Prometheus widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
url = f"{base_url}/api/v1/query"
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, timeout, config)
|
||||
if widget_kind == "gauge":
|
||||
return await self._fetch_gauge(base_url, timeout, config)
|
||||
if widget_kind == "mean":
|
||||
return await self._fetch_mean(base_url, timeout, config)
|
||||
# Default: instant-query metric path (unchanged).
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
|
||||
return raw
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
|
||||
"""Run a Prometheus ``/api/v1/query_range`` over a window (seconds).
|
||||
|
||||
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
|
||||
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
|
||||
per SC-103).
|
||||
"""
|
||||
step = step_for_window(window)
|
||||
end = int(time.time())
|
||||
start = end - window
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
url,
|
||||
f"{base_url}/api/v1/query_range",
|
||||
params={"query": promql, "start": start, "end": end, "step": step},
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Prometheus query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus range query failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
result = payload.get("data", {}).get("result", [])
|
||||
return {"matrix": result}
|
||||
|
||||
async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
|
||||
"""Run a Prometheus ``/api/v1/query`` instant query.
|
||||
|
||||
Shared by the ``metric`` and ``gauge`` widget kinds. Returns
|
||||
``{"result": data}`` on success or ``{"error": str}`` (never raises,
|
||||
per SC-103).
|
||||
"""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
f"{base_url}/api/v1/query",
|
||||
params={"query": promql},
|
||||
timeout=timeout,
|
||||
),
|
||||
@@ -141,15 +177,80 @@ class PrometheusWidgetSource:
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"result": payload.get("data", {})}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
return {"error": "Prometheus query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
logger.exception("prometheus instant query failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
return {"result": payload.get("data", {})}
|
||||
|
||||
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._range_query(base_url, timeout, promql, window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
return {"series": normalize_prometheus_matrix(raw["matrix"])}
|
||||
|
||||
async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).
|
||||
|
||||
Scalar-only: a multi-series query returns an error (SC-111). Threshold
|
||||
config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
|
||||
through for the frontend renderer.
|
||||
"""
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["result"].get("result", [])
|
||||
if len(result) != 1:
|
||||
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
||||
try:
|
||||
value = float(result[0]["value"][1])
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
return {
|
||||
"value": value,
|
||||
"warn_at": config.get("warn_at"),
|
||||
"crit_at": config.get("crit_at"),
|
||||
"min": config.get("min"),
|
||||
"max": config.get("max"),
|
||||
"unit": config.get("unit"),
|
||||
}
|
||||
|
||||
async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Range query → client-side mean for the mean widget (SC-112..SC-114).
|
||||
|
||||
Runs ``query_range`` over the configured window preset, averages all
|
||||
non-null numeric samples of the single series, and returns a scalar.
|
||||
Scalar-only: a multi-series query returns an error (SC-114).
|
||||
"""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._range_query(base_url, timeout, promql, window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["matrix"]
|
||||
if len(result) != 1:
|
||||
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
||||
points = result[0].get("values") or []
|
||||
nums: list[float] = []
|
||||
for _, v in points:
|
||||
if v in (None, "NaN", "+Inf", "-Inf"):
|
||||
continue
|
||||
try:
|
||||
nums.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not nums:
|
||||
return {"error": "Mean query returned no numeric samples in the window"}
|
||||
mean = sum(nums) / len(nums)
|
||||
return {"value": mean, "unit": config.get("unit")}
|
||||
|
||||
|
||||
class AlertmanagerWidgetSource:
|
||||
@@ -208,6 +309,10 @@ class JellyfinWidgetSource:
|
||||
asyncio.to_thread(client.sessions),
|
||||
timeout=timeout,
|
||||
)
|
||||
if widget_kind == "now_playing":
|
||||
sessions = [
|
||||
s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True)
|
||||
]
|
||||
rows = _map_sessions_to_activity_rows(sessions)
|
||||
return {"sessions": rows}
|
||||
except asyncio.TimeoutError:
|
||||
@@ -265,13 +370,75 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
logger.exception("failed to record ssh task timeout")
|
||||
|
||||
|
||||
class QbittorrentWidgetSource:
|
||||
"""Fetch qBittorrent data for totals, active, and speed widgets."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "qBittorrent widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "")
|
||||
username = str(service.secrets.get("username") or "")
|
||||
password = str(service.secrets.get("password") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
if not base_url or not username or not password:
|
||||
return {"error": "qBittorrent service is missing base_url, username, or password"}
|
||||
|
||||
client = QbittorrentClient(base_url, username, password, timeout)
|
||||
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
|
||||
server_state = data.get("server_state", {})
|
||||
torrents = data.get("torrents", {})
|
||||
|
||||
if widget_kind == "totals":
|
||||
by_state: dict[str, int] = {}
|
||||
for t in torrents.values():
|
||||
state = str(t.get("state", "unknown"))
|
||||
by_state[state] = by_state.get(state, 0) + 1
|
||||
return {"total": len(torrents), "by_state": by_state}
|
||||
|
||||
if widget_kind == "active":
|
||||
active = [
|
||||
{
|
||||
"name": t.get("name"),
|
||||
"state": t.get("state"),
|
||||
"size": t.get("size"),
|
||||
"progress": t.get("progress"),
|
||||
"dl_speed": t.get("dlspeed"),
|
||||
"up_speed": t.get("upspeed"),
|
||||
}
|
||||
for t in torrents.values()
|
||||
if str(t.get("state", "")) in {"downloading", "uploading"}
|
||||
]
|
||||
return {"torrents": active}
|
||||
|
||||
if widget_kind == "speed":
|
||||
dl = int(server_state.get("dl_info_speed", 0))
|
||||
up = int(server_state.get("up_info_speed", 0))
|
||||
ts = int(time.time())
|
||||
store = QbittorrentSampleStore()
|
||||
store.append(service.id, ts, dl, up)
|
||||
samples = store.window(service.id)
|
||||
series = [
|
||||
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
|
||||
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
|
||||
]
|
||||
return {"series": series}
|
||||
|
||||
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "qBittorrent data fetch timed out"}
|
||||
except Exception as exc:
|
||||
logger.exception("qbittorrent adapter failed")
|
||||
return {"error": f"qBittorrent fetch failed: {exc}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"qbittorrent": QbittorrentWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"ssh_tasks": SshTaskWidgetSource(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/workers
|
||||
|
||||
## role
|
||||
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
|
||||
Provides background worker subprocesses for asynchronously building and updating media indexes from external servers.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
|
||||
@@ -4,23 +4,23 @@ dir: backend/src/media_library_viewer_api/workers
|
||||
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
|
||||
Provides background worker subprocesses for asynchronously building and updating media indexes from external servers.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for worker entrypoints used in background task processing.
|
||||
- media_index_worker.py | Subprocess worker that builds a media index from Jellyfin libraries with progress tracking and cooperative cancellation support. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:run_build(final_index_path: str | Path, staging_index_path: str | Path) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:get_jellyfin_client, call:get_user_id, call:client.libraries, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index
|
||||
- media_index_worker.py | This file acts as a standalone subprocess worker that asynchronously builds and updates a media index from a Jellyfin server, allowing the main API to remain responsive. | exp: func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_cancel_requested(index: MediaIndex) → bool, call:index.status, func:_start_state(index: MediaIndex, pid: int, library_count: int) → None, call:_set_build_metadata, func:_progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) → None, call:_set_build_metadata, call:state.get, func:_resolve_jellyfin(service_id: str) → tuple[Any, str], call:get_settings_store, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:int, call:JellyfinClient, call:client.users, call:client.libraries, call:next, call:u.get, call:logger.info, call:logger.warning, raise:RuntimeError, func:run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id) → int, call:get_settings, call:configure_logging, call:logger.info, call:describe_settings, call:MediaIndex, call:os.getpid, call:time.perf_counter, call:Path, call:staging_path.unlink, call:_resolve_jellyfin, call:client.libraries, call:len, call:_start_state, call:build_media_index, call:_progress_callback, call:_cancel_requested, call:os.replace, call:completed_index.status, call:_set_build_metadata, call:logger.exception, call:str, call:staging_path.exists, func:main() → int, call:argparse.ArgumentParser, call:parser.add_argument, call:parser.parse_args, call:run_build | dep: argparse, logging, os, time, pathlib, typing, media_library_viewer_api.config, media_library_viewer_api.logging_utils, media_library_viewer_api.services.media_index, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies
|
||||
## arch
|
||||
Subprocess worker pattern with cooperative cancellation, designed to be spawned and monitored by a parent process for long-running media indexing tasks.
|
||||
Standalone subprocess worker pattern that decouples long-running data synchronization tasks from the main API process.
|
||||
## tags
|
||||
call:, metadata, set, index, call:get, media, progress, worker
|
||||
call:, metadata, set, index, jellyfin, settings, media, media_library_viewer_api
|
||||
## symbols
|
||||
- _set_build_metadata
|
||||
- _cancel_requested
|
||||
- _start_state
|
||||
- _progress_callback
|
||||
- _resolve_jellyfin
|
||||
- run_build
|
||||
- main
|
||||
- call:state.items
|
||||
- call:index.set_metadata
|
||||
## workflows
|
||||
- change workers behavior
|
||||
read: __init__.py, media_index_worker.py
|
||||
|
||||
@@ -14,7 +14,6 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
|
||||
from media_library_viewer_api.services.media_index import (
|
||||
MediaIndex,
|
||||
@@ -88,15 +87,70 @@ def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> No
|
||||
)
|
||||
|
||||
|
||||
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
|
||||
def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
|
||||
"""Resolve the Jellyfin client + user_id from the settings store.
|
||||
|
||||
In a subprocess we cannot use the FastAPI dependency layer (no request),
|
||||
so we query the settings store directly. When ``service_id`` is given,
|
||||
resolve that specific instance; otherwise fall back to first-enabled.
|
||||
"""
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.dependencies import _service_record, get_settings_store
|
||||
|
||||
store = get_settings_store()
|
||||
service = _service_record(store, "jellyfin", service_id or None)
|
||||
if service is None:
|
||||
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
|
||||
client = JellyfinClient(base_url, api_key, timeout)
|
||||
user_id = str(service.get("config", {}).get("user_id") or "")
|
||||
if not user_id:
|
||||
users = client.users()
|
||||
if not users:
|
||||
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
|
||||
user_id = users[0]["Id"]
|
||||
else:
|
||||
# Try the configured user_id directly. It might be the internal
|
||||
# Jellyfin Id (a long hash) — in that case libraries() succeeds
|
||||
# without an extra users() round-trip. Only if it fails do we
|
||||
# resolve it via the users API (the config field accepts usernames
|
||||
# like 'admin' too, but Jellyfin's API rejects them on /Users/<id>).
|
||||
try:
|
||||
client.libraries(user_id)
|
||||
except Exception:
|
||||
users = client.users()
|
||||
match = next((u for u in users if str(u.get("Name", "")) == user_id), None)
|
||||
if match:
|
||||
resolved = match["Id"]
|
||||
logger.info(
|
||||
"Resolved username '%s' to Jellyfin Id '%s'",
|
||||
user_id,
|
||||
resolved,
|
||||
)
|
||||
user_id = resolved
|
||||
elif users:
|
||||
user_id = users[0]["Id"]
|
||||
logger.warning(
|
||||
"user_id '%s' not found; falling back to first user",
|
||||
service.get("config", {}).get("user_id"),
|
||||
)
|
||||
return client, user_id
|
||||
|
||||
|
||||
def run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id: str = "") -> int:
|
||||
"""Run the media index build in a subprocess."""
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level)
|
||||
logger.info("Media index worker starting: %s", describe_settings(settings))
|
||||
client = get_jellyfin_client()
|
||||
user_id = get_user_id()
|
||||
libraries = client.libraries(user_id)
|
||||
|
||||
# Create the indexes BEFORE the try block so the except handler can write
|
||||
# error state to the DB. If _resolve_jellyfin or client.libraries fails,
|
||||
# the worker needs to record the error — otherwise the status stays
|
||||
# "queued" forever with no feedback.
|
||||
final_index = MediaIndex(final_index_path)
|
||||
staging_index = MediaIndex(staging_index_path)
|
||||
pid = os.getpid()
|
||||
@@ -104,10 +158,13 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
|
||||
|
||||
staging_path = Path(staging_index.db_path)
|
||||
staging_path.unlink(missing_ok=True)
|
||||
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
|
||||
_start_state(final_index, pid, len(libraries))
|
||||
|
||||
try:
|
||||
client, user_id = _resolve_jellyfin(service_id)
|
||||
libraries = client.libraries(user_id)
|
||||
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
|
||||
_start_state(final_index, pid, len(libraries))
|
||||
|
||||
count = build_media_index(
|
||||
client,
|
||||
user_id,
|
||||
@@ -117,6 +174,7 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
|
||||
fallback_prefix=settings.path_prefix,
|
||||
progress_callback=lambda state: _progress_callback(final_index, pid, state),
|
||||
should_cancel=lambda: _cancel_requested(final_index),
|
||||
service_id=service_id,
|
||||
)
|
||||
# Swap the staging database into place atomically.
|
||||
os.replace(staging_index.db_path, final_index.db_path)
|
||||
@@ -192,8 +250,9 @@ def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
|
||||
parser.add_argument("--index-path", required=True)
|
||||
parser.add_argument("--staging-path", required=True)
|
||||
parser.add_argument("--service-id", default="", help="Jellyfin service instance id")
|
||||
args = parser.parse_args()
|
||||
return run_build(args.index_path, args.staging_path)
|
||||
return run_build(args.index_path, args.staging_path, args.service_id)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/tests
|
||||
|
||||
## role
|
||||
Test suite providing unit and integration tests that validate API endpoints, configuration, external clients, utilities, and service logic across the backend.
|
||||
Comprehensive test suite providing unit and integration tests that validate the backend's API endpoints, external service clients, data persistence, and utility functions of a media library management dashboard.
|
||||
## parent
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
@@ -11,8 +11,10 @@ map: backend/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- test_api.py
|
||||
- test_authentik_client.py
|
||||
- test_backups.py
|
||||
- test_config.py
|
||||
- test_dashboards.py
|
||||
- test_domain_media.py
|
||||
- test_jellyseerr_client.py
|
||||
- test_jobs.py
|
||||
@@ -20,6 +22,9 @@ map: backend/.pi-map.md
|
||||
- test_media_index.py
|
||||
- test_observability.py
|
||||
- test_path_utils.py
|
||||
- test_prometheus_range.py
|
||||
- test_qbittorrent_client.py
|
||||
- test_service_data.py
|
||||
- test_services.py
|
||||
- test_ssh_client.py
|
||||
- test_targets.py
|
||||
@@ -30,9 +35,9 @@ index: backend/tests/.pi-map.index.md
|
||||
map: backend/tests/.pi-map.md
|
||||
## workflows
|
||||
- change tests behavior
|
||||
read: __init__.py, test_api.py, test_backups.py
|
||||
read: __init__.py, test_api.py, test_authentik_client.py
|
||||
- change tests CLI
|
||||
read: test_jellyseerr_client.py, test_ssh_client.py
|
||||
read: test_authentik_client.py, test_jellyseerr_client.py, test_qbittorrent_client.py
|
||||
- change tests config
|
||||
read: test_config.py
|
||||
## dirty
|
||||
|
||||
+15
-10
File diff suppressed because one or more lines are too long
+23
-64
@@ -26,6 +26,7 @@ from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
# Short alias for the monitoring router module under test.
|
||||
_MON = "media_library_viewer_api.routers.monitoring"
|
||||
_SVC = "media_library_viewer_api.services.service_resolution"
|
||||
|
||||
# --- Fixtures ---
|
||||
|
||||
@@ -362,7 +363,7 @@ class TestMediaIndexApi:
|
||||
assert data["status"] == "started"
|
||||
assert data["build_running"] is True
|
||||
assert data["build_stage"] == "queued"
|
||||
assert data["build_libraries_total"] == len(mock_jellyfin.libraries.return_value)
|
||||
assert data["build_libraries_total"] == 0
|
||||
assert data["build_pid"] == 4321
|
||||
start_worker.assert_called_once()
|
||||
finally:
|
||||
@@ -496,7 +497,7 @@ class TestMonitoring:
|
||||
|
||||
|
||||
class TestResolveServiceRecord:
|
||||
"""Unit tests for _resolve_service_record (service_id + first-enabled paths)."""
|
||||
"""Unit tests for resolve_service_record (service_id + first-enabled paths)."""
|
||||
|
||||
def _store(self, rows):
|
||||
store = MagicMock()
|
||||
@@ -509,31 +510,31 @@ class TestResolveServiceRecord:
|
||||
return store
|
||||
|
||||
def test_service_id_match_returns_record(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = _resolve_service_record(store, "alertmanager", "am1")
|
||||
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = resolve_service_record(store, "alertmanager", "am1")
|
||||
assert result == "RECORD"
|
||||
mock_build.assert_called_once_with(store, row)
|
||||
|
||||
def test_service_id_type_mismatch_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||
row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert _resolve_service_record(store, "alertmanager", "x1") is None
|
||||
assert resolve_service_record(store, "alertmanager", "x1") is None
|
||||
|
||||
def test_service_id_disabled_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert _resolve_service_record(store, "alertmanager", "am1") is None
|
||||
assert resolve_service_record(store, "alertmanager", "am1") is None
|
||||
|
||||
def test_no_service_id_returns_first_enabled(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
rows = [
|
||||
{
|
||||
@@ -554,16 +555,16 @@ class TestResolveServiceRecord:
|
||||
},
|
||||
]
|
||||
store = self._store(rows)
|
||||
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = _resolve_service_record(store, "alertmanager", None)
|
||||
with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
|
||||
result = resolve_service_record(store, "alertmanager", None)
|
||||
assert result == "RECORD"
|
||||
mock_build.assert_called_once_with(store, rows[1])
|
||||
|
||||
def test_no_service_id_and_none_enabled_returns_none(self):
|
||||
from media_library_viewer_api.routers.monitoring import _resolve_service_record
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
store = self._store([])
|
||||
assert _resolve_service_record(store, "alertmanager", None) is None
|
||||
assert resolve_service_record(store, "alertmanager", None) is None
|
||||
|
||||
|
||||
class TestSettingsMachines:
|
||||
@@ -624,7 +625,7 @@ class TestAlertmanager:
|
||||
def test_alerts_endpoint_when_unreachable(self, test_client):
|
||||
service = _am_service()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
@@ -651,7 +652,7 @@ class TestAlertmanager:
|
||||
}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alerts")
|
||||
@@ -669,7 +670,7 @@ class TestAlertmanager:
|
||||
resp.json.return_value = {"status": "success", "data": []}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
|
||||
):
|
||||
test_client.get("/api/monitoring/alerts")
|
||||
@@ -687,7 +688,7 @@ class TestAlertmanager:
|
||||
def test_alertmanager_status_when_unreachable(self, test_client):
|
||||
service = _am_service()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
@@ -707,7 +708,7 @@ class TestAlertmanager:
|
||||
}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/alertmanager-status")
|
||||
@@ -740,48 +741,6 @@ class TestAlertmanagerWebhook:
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
|
||||
class TestGrafanaStatus:
|
||||
def test_grafana_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "no_service_configured"
|
||||
|
||||
def test_grafana_status_when_unreachable(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "grafana_unreachable"
|
||||
assert data["name"] == "Grafana"
|
||||
|
||||
def test_grafana_status_returns_version(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "11.3.1"
|
||||
assert data["service_id"] == "g1"
|
||||
|
||||
|
||||
class TestPrometheusStatus:
|
||||
def test_prometheus_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
@@ -795,7 +754,7 @@ class TestPrometheusStatus:
|
||||
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
|
||||
)
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
@@ -814,7 +773,7 @@ class TestPrometheusStatus:
|
||||
build_info.raise_for_status = MagicMock()
|
||||
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
|
||||
with (
|
||||
patch(f"{_MON}._resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
|
||||
@@ -228,19 +228,70 @@ class TestMediaIndexQuery:
|
||||
|
||||
class TestMediaIndexReplace:
|
||||
def test_replace_clears_old(self, index):
|
||||
index.replace_items([
|
||||
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
])
|
||||
index.replace_items(
|
||||
[
|
||||
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
]
|
||||
)
|
||||
status = index.status()
|
||||
assert status.item_count == 1
|
||||
|
||||
index.replace_items([
|
||||
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
])
|
||||
index.replace_items(
|
||||
[
|
||||
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
]
|
||||
)
|
||||
status = index.status()
|
||||
assert status.item_count == 2
|
||||
|
||||
def test_replace_scoped_by_service_id_preserves_other_services(self, index):
|
||||
"""Regression test: building for one Jellyfin must not wipe another's rows.
|
||||
|
||||
Before the migration, ``replace_items`` did ``DELETE FROM media_items``
|
||||
(global clear). This test locks in the fix: a scoped replace preserves
|
||||
rows belonging to a different service_id.
|
||||
"""
|
||||
rows_a = [
|
||||
{"id": "a1", "title": "Alpha Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
{"id": "a2", "title": "Alpha Show", "type": "Episode", "library_id": "l2", "library_name": "L2"},
|
||||
]
|
||||
rows_b = [
|
||||
{"id": "b1", "title": "Beta Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
|
||||
]
|
||||
|
||||
index.replace_items(rows_a, service_id="svc-a")
|
||||
assert index.status().item_count == 2
|
||||
|
||||
# Replacing for svc-b must NOT wipe svc-a's rows.
|
||||
index.replace_items(rows_b, service_id="svc-b")
|
||||
assert index.status().item_count == 3 # 2 from svc-a + 1 from svc-b
|
||||
|
||||
# Querying svc-a returns only its rows.
|
||||
rows_a_result, total_a = index.query(
|
||||
library_ids=["l1", "l2"],
|
||||
media_types=["Movie", "Episode"],
|
||||
service_id="svc-a",
|
||||
)
|
||||
assert total_a == 2
|
||||
assert {r["id"] for r in rows_a_result} == {"a1", "a2"}
|
||||
|
||||
# Querying svc-b returns only its rows.
|
||||
rows_b_result, total_b = index.query(
|
||||
library_ids=["l1"],
|
||||
media_types=["Movie"],
|
||||
service_id="svc-b",
|
||||
)
|
||||
assert total_b == 1
|
||||
assert rows_b_result[0]["id"] == "b1"
|
||||
|
||||
# Querying with no service_id returns all rows (backward-compat).
|
||||
_, total_all = index.query(
|
||||
library_ids=["l1", "l2"],
|
||||
media_types=["Movie", "Episode"],
|
||||
)
|
||||
assert total_all == 3
|
||||
|
||||
|
||||
class TestMediaIndexMetadata:
|
||||
def test_set_and_read_metadata(self, index):
|
||||
@@ -288,15 +339,11 @@ class TestMediaIndexBuildPaths:
|
||||
self.calls.append(kwargs.get("start_index", 0))
|
||||
if kwargs.get("start_index", 0) == 0:
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
|
||||
@@ -332,15 +379,11 @@ class TestMediaIndexBuildPaths:
|
||||
self.calls.append(kwargs.get("start_index", 0))
|
||||
if kwargs.get("start_index", 0) == 0:
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
return {
|
||||
"Items": [
|
||||
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
|
||||
],
|
||||
"Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
|
||||
"TotalRecordCount": 2,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_prometheus_matrix,
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
|
||||
class TestStepForWindow:
|
||||
"""SC-104: every preset must yield 100–300 points."""
|
||||
|
||||
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
|
||||
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
|
||||
window = WINDOW_PRESETS[preset]
|
||||
step = step_for_window(window)
|
||||
# Clamped minimum.
|
||||
assert step >= 15
|
||||
point_count = window // step
|
||||
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
|
||||
|
||||
def test_floor_of_fifteen_seconds(self) -> None:
|
||||
# A tiny window that would otherwise produce a sub-15s step is clamped.
|
||||
assert step_for_window(60) == 15
|
||||
|
||||
def test_custom_target_points(self) -> None:
|
||||
# Targeting 100 points for 1h yields step 36 (3600/100).
|
||||
assert step_for_window(3_600, target_points=100) == 36
|
||||
|
||||
|
||||
class TestNormalizePrometheusMatrix:
|
||||
"""SC-102: label rule + null handling + dedup."""
|
||||
|
||||
def test_empty_matrix(self) -> None:
|
||||
assert normalize_prometheus_matrix([]) == []
|
||||
|
||||
def test_drops_dunder_labels_and_joins(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"},
|
||||
"values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert len(out) == 1
|
||||
assert out[0]["label"] == "instance=host:9100 mode=idle"
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1_700_000_000, "v": 12.5},
|
||||
{"t": 1_700_000_030, "v": 13.0},
|
||||
]
|
||||
|
||||
def test_falls_back_to_value_when_no_labels(self) -> None:
|
||||
result = [{"metric": {}, "values": [[100, "1"]]}]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["label"] == "value"
|
||||
|
||||
def test_dedup_collisions_with_suffix(self) -> None:
|
||||
# Two series with identical visible labels get a "(1)" suffix on the 2nd.
|
||||
result = [
|
||||
{"metric": {"job": "x"}, "values": [[1, "1"]]},
|
||||
{"metric": {"job": "x"}, "values": [[1, "2"]]},
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
labels = [s["label"] for s in out]
|
||||
assert labels == ["job=x", "job=x (1)"]
|
||||
|
||||
def test_non_numeric_sentinels_become_none(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"job": "x"},
|
||||
"values": [
|
||||
[1, "NaN"],
|
||||
[2, "+Inf"],
|
||||
[3, "-Inf"],
|
||||
[4, "3.5"],
|
||||
],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1, "v": None},
|
||||
{"t": 2, "v": None},
|
||||
{"t": 3, "v": None},
|
||||
{"t": 4, "v": 3.5},
|
||||
]
|
||||
|
||||
def test_malformed_values_are_ignored_not_raised(self) -> None:
|
||||
result = [
|
||||
{
|
||||
"metric": {"job": "x"},
|
||||
"values": [
|
||||
[1, "3.5"],
|
||||
["not-a-ts", "9"], # unusable timestamp → dropped
|
||||
[3, "junk-value"], # unparseable value → v: None
|
||||
],
|
||||
}
|
||||
]
|
||||
out = normalize_prometheus_matrix(result)
|
||||
assert out[0]["points"] == [
|
||||
{"t": 1, "v": 3.5},
|
||||
{"t": 3, "v": None},
|
||||
]
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Unit tests for the QbittorrentClient."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||
|
||||
|
||||
class QbittorrentClientTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = QbittorrentClient("https://qb.example.com", "admin", "secret", timeout=5)
|
||||
self.session = MagicMock()
|
||||
self.client._session = self.session
|
||||
|
||||
def _login_response(self, text: str = "Ok.") -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
|
||||
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = json_data
|
||||
resp.raise_for_status.return_value = None
|
||||
resp.status_code = status_code
|
||||
resp.text = ""
|
||||
return resp
|
||||
|
||||
def test_base_url_appends_api_v2(self) -> None:
|
||||
c = QbittorrentClient("https://qb.example.com", "u", "p")
|
||||
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||
|
||||
def test_base_url_keeps_existing_api_v2(self) -> None:
|
||||
c = QbittorrentClient("https://qb.example.com/api/v2", "u", "p")
|
||||
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||
|
||||
def test_base_url_strips_trailing_slash(self) -> None:
|
||||
c = QbittorrentClient("https://qb.example.com/", "u", "p")
|
||||
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
|
||||
|
||||
def test_empty_base_url_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
QbittorrentClient("", "u", "p")
|
||||
|
||||
def test_empty_username_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
QbittorrentClient("https://qb.example.com", "", "p")
|
||||
|
||||
def test_login_posts_credentials(self) -> None:
|
||||
self.session.post.return_value = self._login_response("Ok.")
|
||||
self.client._login()
|
||||
self.session.post.assert_called_once()
|
||||
call_args = self.session.post.call_args
|
||||
self.assertIn("/auth/login", call_args.args[0])
|
||||
self.assertEqual(call_args.kwargs["data"], {"username": "admin", "password": "secret"})
|
||||
self.assertTrue(self.client._logged_in)
|
||||
|
||||
def test_login_failure_raises_runtime_error(self) -> None:
|
||||
self.session.post.return_value = self._login_response("Fails.")
|
||||
with self.assertRaises(RuntimeError):
|
||||
self.client._login()
|
||||
|
||||
def test_get_auto_logs_in_on_first_call(self) -> None:
|
||||
"""First _get triggers login, then fetches data."""
|
||||
self.session.post.return_value = self._login_response("Ok.")
|
||||
self.session.get.return_value = self._get_response({"server_state": {}, "torrents": {}})
|
||||
|
||||
result = self.client._get("/sync/maindata")
|
||||
|
||||
self.session.post.assert_called_once() # login happened
|
||||
self.assertEqual(result, {"server_state": {}, "torrents": {}})
|
||||
|
||||
def test_cookie_reuse_does_not_re_login(self) -> None:
|
||||
"""After login, subsequent _get calls do NOT re-login."""
|
||||
self.client._logged_in = True # simulate already logged in
|
||||
self.session.get.return_value = self._get_response({"data": 1})
|
||||
|
||||
self.client._get("/some/path")
|
||||
|
||||
self.session.post.assert_not_called() # no re-login
|
||||
|
||||
def test_403_triggers_re_login(self) -> None:
|
||||
"""A 403 response triggers re-login and retries the GET."""
|
||||
self.client._logged_in = True # already logged in from a prior call
|
||||
forbidden = MagicMock()
|
||||
forbidden.status_code = 403
|
||||
ok = self._get_response({"server_state": {}, "torrents": {}})
|
||||
self.session.get.side_effect = [forbidden, ok]
|
||||
self.session.post.return_value = self._login_response("Ok.")
|
||||
|
||||
result = self.client._get("/sync/maindata")
|
||||
|
||||
self.assertEqual(self.session.get.call_count, 2) # initial + retry
|
||||
self.session.post.assert_called_once() # re-login happened
|
||||
self.assertEqual(result, {"server_state": {}, "torrents": {}})
|
||||
|
||||
def test_maindata_returns_full_payload(self) -> None:
|
||||
self.client._logged_in = True
|
||||
payload = {
|
||||
"server_state": {"dl_info_speed": 12345, "up_info_speed": 6789},
|
||||
"torrents": {
|
||||
"abc": {"name": "Movie.mkv", "state": "downloading", "progress": 0.5},
|
||||
"def": {"name": "Show.mkv", "state": "uploading", "progress": 1.0},
|
||||
},
|
||||
}
|
||||
self.session.get.return_value = self._get_response(payload)
|
||||
|
||||
result = self.client.maindata()
|
||||
|
||||
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
|
||||
self.assertEqual(len(result["torrents"]), 2)
|
||||
|
||||
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
|
||||
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
|
||||
"""A network error during login propagates as requests exception."""
|
||||
mock_session = MagicMock()
|
||||
mock_session_cls.return_value = mock_session
|
||||
bad_resp = MagicMock()
|
||||
bad_resp.raise_for_status.side_effect = requests.ConnectionError("refused")
|
||||
bad_resp.text = ""
|
||||
mock_session.post.return_value = bad_resp
|
||||
|
||||
client = QbittorrentClient("https://qb.example.com", "u", "p")
|
||||
with self.assertRaises(requests.ConnectionError):
|
||||
client._login()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for ServiceDataHarness lifecycle and QbittorrentSampleStore operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from media_library_viewer_api.services.qbittorrent_store import (
|
||||
MAX_SAMPLES,
|
||||
QBITTORRENT_CONCERN,
|
||||
QbittorrentSampleStore,
|
||||
)
|
||||
from media_library_viewer_api.services.service_data import (
|
||||
ServiceDataHarness,
|
||||
StorageConcern,
|
||||
)
|
||||
|
||||
# A throwaway concern used to test harness lifecycle in isolation.
|
||||
_TEST_CONCERN = StorageConcern(
|
||||
concern_key="test",
|
||||
db_filename="test.db",
|
||||
migrations=[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS test_items (
|
||||
id INTEGER PRIMARY KEY,
|
||||
service_id TEXT NOT NULL,
|
||||
value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_test_service ON test_items(service_id);
|
||||
"""
|
||||
],
|
||||
tables=["test_items"],
|
||||
)
|
||||
|
||||
|
||||
class TestServiceDataHarnessMigrations:
|
||||
def test_run_migrations_creates_tables(self, tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
harness.run_migrations()
|
||||
|
||||
with harness.connect("test") as conn:
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
|
||||
assert "test_items" in tables
|
||||
|
||||
def test_migrations_are_idempotent(self, tmp_path):
|
||||
"""Re-running migrations on an already-migrated DB must not crash."""
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
harness.run_migrations()
|
||||
harness.run_migrations() # should not raise
|
||||
|
||||
def test_alter_table_idempotency(self, tmp_path):
|
||||
"""ALTER TABLE ADD COLUMN must be silently skipped on re-run."""
|
||||
concern = StorageConcern(
|
||||
concern_key="alter_test",
|
||||
db_filename="alter.db",
|
||||
migrations=[
|
||||
"CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)",
|
||||
"ALTER TABLE items ADD COLUMN extra TEXT DEFAULT ''",
|
||||
],
|
||||
tables=["items"],
|
||||
)
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(concern)
|
||||
harness.run_migrations()
|
||||
harness.run_migrations() # second run: "duplicate column name" caught
|
||||
|
||||
with harness.connect("alter_test") as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(items)").fetchall()}
|
||||
assert "extra" in cols
|
||||
|
||||
|
||||
class TestServiceDataHarnessCascadeDelete:
|
||||
def test_cascade_delete_removes_only_matching_service(self, tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
harness.run_migrations()
|
||||
|
||||
with harness.connect("test") as conn:
|
||||
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (1, 'svc-a', 'a1')")
|
||||
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (2, 'svc-a', 'a2')")
|
||||
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (3, 'svc-b', 'b1')")
|
||||
conn.commit()
|
||||
|
||||
harness.cascade_delete("svc-a")
|
||||
|
||||
with harness.connect("test") as conn:
|
||||
remaining = conn.execute("SELECT service_id, value FROM test_items ORDER BY id").fetchall()
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0][0] == "svc-b"
|
||||
|
||||
def test_cascade_delete_skips_missing_concern_db(self, tmp_path):
|
||||
"""cascade_delete on a concern whose DB file doesn't exist should not crash."""
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(_TEST_CONCERN)
|
||||
# No run_migrations → DB file doesn't exist
|
||||
harness.cascade_delete("svc-x") # should not raise
|
||||
|
||||
|
||||
class TestQbittorrentSampleStore:
|
||||
@pytest.fixture()
|
||||
def store(self, tmp_path):
|
||||
harness = ServiceDataHarness(tmp_path)
|
||||
harness.register(QBITTORRENT_CONCERN)
|
||||
harness.run_migrations()
|
||||
return QbittorrentSampleStore(harness=harness)
|
||||
|
||||
def test_append_and_window(self, store):
|
||||
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
|
||||
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
|
||||
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
|
||||
|
||||
samples = store.window("svc-1")
|
||||
assert len(samples) == 3
|
||||
assert samples[0]["ts"] == 100
|
||||
assert samples[2]["ts"] == 300
|
||||
assert samples[1]["dl_speed"] == 600
|
||||
|
||||
def test_window_with_since_ts(self, store):
|
||||
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
|
||||
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
|
||||
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
|
||||
|
||||
samples = store.window("svc-1", since_ts=200)
|
||||
assert len(samples) == 2
|
||||
assert samples[0]["ts"] == 200
|
||||
|
||||
def test_prune_enforces_max_samples(self, store):
|
||||
for i in range(MAX_SAMPLES + 10):
|
||||
store.append("svc-1", ts=i, dl_speed=i, up_speed=i)
|
||||
|
||||
samples = store.window("svc-1")
|
||||
assert len(samples) == MAX_SAMPLES
|
||||
# The oldest 10 should have been pruned
|
||||
assert samples[0]["ts"] == 10
|
||||
assert samples[-1]["ts"] == MAX_SAMPLES + 9
|
||||
|
||||
def test_two_services_do_not_cross_contaminate(self, store):
|
||||
store.append("svc-a", ts=100, dl_speed=500, up_speed=50)
|
||||
store.append("svc-b", ts=200, dl_speed=600, up_speed=60)
|
||||
|
||||
a_samples = store.window("svc-a")
|
||||
b_samples = store.window("svc-b")
|
||||
|
||||
assert len(a_samples) == 1
|
||||
assert a_samples[0]["dl_speed"] == 500
|
||||
assert len(b_samples) == 1
|
||||
assert b_samples[0]["dl_speed"] == 600
|
||||
+119
-51
@@ -59,7 +59,6 @@ def client(tmp_path):
|
||||
|
||||
def test_registry_contains_eight_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
@@ -67,6 +66,7 @@ def test_registry_contains_eight_service_types():
|
||||
"ssh_tasks",
|
||||
"backups",
|
||||
"authentik",
|
||||
"qbittorrent",
|
||||
}
|
||||
|
||||
|
||||
@@ -99,10 +99,9 @@ def test_authentik_service_definition():
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
||||
assert get_service_definition("nextcloud").widget_kinds == []
|
||||
assert get_service_definition("authentik").widget_kinds == []
|
||||
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
|
||||
@@ -110,13 +109,13 @@ def test_definitions_declare_widget_kinds():
|
||||
|
||||
|
||||
def test_widget_kind_lookup():
|
||||
assert get_widget_kind("grafana", "link") is not None
|
||||
assert get_widget_kind("grafana", "missing") is None
|
||||
assert get_widget_kind("unknown", "link") is None
|
||||
assert get_widget_kind("prometheus", "metric") is not None
|
||||
assert get_widget_kind("prometheus", "missing") is None
|
||||
assert get_widget_kind("unknown", "metric") is None
|
||||
|
||||
|
||||
def test_service_config_schema_is_json_schema():
|
||||
schema = get_service_definition("grafana").config_schema
|
||||
schema = get_service_definition("prometheus").config_schema
|
||||
assert schema["type"] == "object"
|
||||
assert "base_url" in schema["properties"]
|
||||
|
||||
@@ -172,19 +171,19 @@ def test_list_service_types(client):
|
||||
"alertmanager",
|
||||
"authentik",
|
||||
"backups",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"qbittorrent",
|
||||
"ssh_tasks",
|
||||
}
|
||||
|
||||
|
||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
response = client.get("/api/services/types")
|
||||
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
|
||||
prom = next(item for item in response.json() if item["service_type"] == "prometheus")
|
||||
assert [sf["key"] for sf in prom["secret_fields"]] == ["api_key"]
|
||||
assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,11 +191,11 @@ def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _grafana_payload(**overrides):
|
||||
def _prometheus_payload(**overrides):
|
||||
payload = {
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "secret-token"},
|
||||
"enabled": True,
|
||||
}
|
||||
@@ -205,11 +204,11 @@ def _grafana_payload(**overrides):
|
||||
|
||||
|
||||
def test_create_and_list_service(client):
|
||||
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||
response = client.post("/api/services/instances", json=_prometheus_payload())
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_type"] == "grafana"
|
||||
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||
assert created["service_type"] == "prometheus"
|
||||
assert created["config"]["base_url"] == "https://prometheus.example.com"
|
||||
# Plaintext secrets are never returned.
|
||||
assert "secrets" not in created
|
||||
assert created["secrets_set"] == {"api_key": True}
|
||||
@@ -220,44 +219,44 @@ def test_create_and_list_service(client):
|
||||
|
||||
|
||||
def test_list_instances_filters_by_type(client):
|
||||
client.post("/api/services/instances", json=_grafana_payload())
|
||||
client.post("/api/services/instances", json=_prometheus_payload())
|
||||
client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "prometheus",
|
||||
"name": "Prom",
|
||||
"config": {"base_url": "http://prometheus:9090"},
|
||||
"service_type": "alertmanager",
|
||||
"name": "AM",
|
||||
"config": {"base_url": "http://am:9093"},
|
||||
},
|
||||
)
|
||||
response = client.get("/api/services/instances?service_type=grafana")
|
||||
response = client.get("/api/services/instances?service_type=prometheus")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["service_type"] == "grafana"
|
||||
assert response.json()[0]["service_type"] == "prometheus"
|
||||
|
||||
|
||||
def test_update_service_preserves_unsent_secrets(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
# Update without sending secrets; the existing key should remain set.
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Renamed Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||
"service_type": "prometheus",
|
||||
"name": "Renamed Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com", "timeout_seconds": 10},
|
||||
},
|
||||
).json()
|
||||
assert updated["name"] == "Renamed Grafana"
|
||||
assert updated["name"] == "Renamed Prometheus"
|
||||
assert updated["secrets_set"] == {"api_key": True}
|
||||
|
||||
|
||||
def test_update_service_can_clear_secret(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": ""},
|
||||
},
|
||||
).json()
|
||||
@@ -275,30 +274,28 @@ def test_unknown_service_type_rejected(client):
|
||||
def test_invalid_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"base_url": ""}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
# Force a real validation error via bad type.
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||
"bad_url", ["prometheus.example.com", "localhost:3000", "//bad.example.com", "ftp://bad.example.com"]
|
||||
)
|
||||
def test_service_base_url_requires_http_schema(bad_url):
|
||||
"""Every service base_url must include an http:// or https:// schema."""
|
||||
model = get_service_definition("grafana").config_model
|
||||
model = get_service_definition("prometheus").config_model
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
|
||||
)
|
||||
@pytest.mark.parametrize("service_type", ["prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"])
|
||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||
model = get_service_definition(service_type).config_model
|
||||
instance = model.model_validate({"base_url": "https://example.com"})
|
||||
@@ -309,9 +306,9 @@ def test_unknown_secret_field_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"password": "leak"},
|
||||
},
|
||||
)
|
||||
@@ -322,9 +319,9 @@ def test_credential_key_in_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||
"config": {"base_url": "https://prometheus.example.com", "api_key": "leak"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -333,22 +330,22 @@ def test_credential_key_in_config_rejected(client):
|
||||
def test_update_nonexistent_returns_404(client):
|
||||
response = client.put(
|
||||
"/api/services/instances/missing",
|
||||
json=_grafana_payload(id="missing"),
|
||||
json=_prometheus_payload(id="missing"),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_id_mismatch_returns_400(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json=_grafana_payload(id="other-id"),
|
||||
json=_prometheus_payload(id="other-id"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_service(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/services/instances").json() == []
|
||||
@@ -372,7 +369,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
"""
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||
{"service_type": "prometheus", "name": "Prometheus", "config": {"base_url": "u"}, "enabled": True}
|
||||
)
|
||||
|
||||
# Ensure the service_id column exists and seed a referencing widget.
|
||||
@@ -386,7 +383,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
enabled, sort_order, created_at, updated_at, service_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
("w1", "prometheus", "prometheus.metric", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
)
|
||||
|
||||
store.delete_service(service["id"])
|
||||
@@ -399,6 +396,77 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
assert int(remaining[0]) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Harness cascade-delete (Slice 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypatch):
|
||||
"""Deleting a service cascades to both qbittorrent samples and media items.
|
||||
|
||||
Proves end-to-end cascade across both harness-managed concerns, and that
|
||||
deleting one service preserves another service's data (multi-instance).
|
||||
"""
|
||||
monkeypatch.setenv("BACKEND_CACHE_DIR", str(tmp_path))
|
||||
|
||||
from media_library_viewer_api.services.media_index_impl import MediaIndex
|
||||
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.service_data import (
|
||||
get_service_data_harness,
|
||||
reset_service_data_harness,
|
||||
)
|
||||
|
||||
reset_service_data_harness()
|
||||
harness = get_service_data_harness()
|
||||
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
# --- qBit: create service, add samples, delete, verify gone ---
|
||||
qbit_a = store.upsert_service(
|
||||
{"service_type": "qbittorrent", "name": "qA", "config": {"base_url": "http://a"}, "enabled": True}
|
||||
)
|
||||
qbit_b = store.upsert_service(
|
||||
{"service_type": "qbittorrent", "name": "qB", "config": {"base_url": "http://b"}, "enabled": True}
|
||||
)
|
||||
sample_store = QbittorrentSampleStore(harness)
|
||||
sample_store.append(qbit_a["id"], ts=1000, dl_speed=500, up_speed=100)
|
||||
sample_store.append(qbit_b["id"], ts=1000, dl_speed=200, up_speed=50)
|
||||
|
||||
assert len(sample_store.window(qbit_a["id"])) == 1
|
||||
assert len(sample_store.window(qbit_b["id"])) == 1
|
||||
|
||||
store.delete_service(qbit_a["id"])
|
||||
|
||||
assert sample_store.window(qbit_a["id"]) == []
|
||||
assert len(sample_store.window(qbit_b["id"])) == 1 # B survives
|
||||
|
||||
# --- MediaIndex: create services, add items, delete, verify scoped ---
|
||||
jelly_a = store.upsert_service(
|
||||
{"service_type": "jellyfin", "name": "jA", "config": {"base_url": "http://ja"}, "enabled": True}
|
||||
)
|
||||
jelly_b = store.upsert_service(
|
||||
{"service_type": "jellyfin", "name": "jB", "config": {"base_url": "http://jb"}, "enabled": True}
|
||||
)
|
||||
index = MediaIndex(harness.db_path("media_index"))
|
||||
index.init_schema()
|
||||
index.replace_items([{"id": "m1", "title": "A1"}], service_id=jelly_a["id"])
|
||||
index.replace_items([{"id": "m2", "title": "B1"}], service_id=jelly_b["id"])
|
||||
|
||||
rows_a, total_a = index.query(service_id=jelly_a["id"])
|
||||
rows_b, total_b = index.query(service_id=jelly_b["id"])
|
||||
assert total_a == 1 and total_b == 1
|
||||
|
||||
store.delete_service(jelly_a["id"])
|
||||
|
||||
rows_a_after, total_a_after = index.query(service_id=jelly_a["id"])
|
||||
rows_b_after, total_b_after = index.query(service_id=jelly_b["id"])
|
||||
assert total_a_after == 0 # deleted
|
||||
assert total_b_after == 1 # survives
|
||||
|
||||
reset_service_data_harness()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service task run history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+762
-81
@@ -15,7 +15,7 @@ from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
AlertmanagerWidgetSource,
|
||||
BackupsWidgetSource,
|
||||
GrafanaWidgetSource,
|
||||
JellyfinWidgetSource,
|
||||
ServiceRecord,
|
||||
StaticWidgetSource,
|
||||
)
|
||||
@@ -41,12 +41,12 @@ def client(tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||
config = {"base_url": "https://grafana.example.com"}
|
||||
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
|
||||
config = {"base_url": "https://prometheus.example.com"}
|
||||
config.update(config_overrides)
|
||||
return client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
|
||||
json={"service_type": "prometheus", "name": name, "config": config, "enabled": True},
|
||||
).json()
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_create_backups_widget(client):
|
||||
|
||||
def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
|
||||
client.post(
|
||||
"/api/widgets/instances",
|
||||
@@ -102,9 +102,9 @@ def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "Dash",
|
||||
"config": {"dashboard_uid": "o"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -150,29 +150,29 @@ def test_credential_key_in_config_rejected(client):
|
||||
|
||||
|
||||
def test_create_service_bound_widget(client):
|
||||
service = _make_grafana_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "link"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "metric",
|
||||
"title": "Metrics",
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "metric"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "nonexistent_kind",
|
||||
"title": "x",
|
||||
"config": {},
|
||||
},
|
||||
@@ -185,33 +185,23 @@ def test_service_bound_widget_service_not_found_rejected(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": "missing",
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_service_bound_widget_invalid_config_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
|
||||
},
|
||||
)
|
||||
# Empty string passes Pydantic; force a real failure with a bad type.
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": 123},
|
||||
"config": {"promql": 123}, # bad type: promql must be a string
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -279,31 +269,15 @@ def test_fetch_backups_widget_data(client):
|
||||
assert "total_jobs" in response.json()["data"]
|
||||
|
||||
|
||||
def test_fetch_grafana_link_widget_data(client):
|
||||
service = _make_grafana_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||
},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||
|
||||
|
||||
def test_fetch_widget_service_not_found(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
# Deleting the service cascade-deletes its widgets, so the widget is gone.
|
||||
@@ -313,22 +287,22 @@ def test_fetch_widget_service_not_found(client):
|
||||
|
||||
|
||||
def test_fetch_widget_service_disabled(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
client.put(
|
||||
f"/api/services/instances/{service['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": service["name"],
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"enabled": False,
|
||||
},
|
||||
)
|
||||
@@ -346,23 +320,6 @@ def test_fetch_widget_not_found(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_builds_url():
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
|
||||
assert result["url"] == "http://g:3000/d/ov"
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
|
||||
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_missing_service():
|
||||
adapter = GrafanaWidgetSource()
|
||||
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_summarizes_alerts():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
@@ -494,3 +451,727 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
runs = store.list_service_task_runs(service_id=service["id"])
|
||||
assert len(runs) == 1
|
||||
assert runs[0]["status"] == "success"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New widget kind tests (jellyfin now_playing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_jellyfin_definition_has_now_playing_widget():
|
||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||
|
||||
definition = get_service_definition("jellyfin")
|
||||
kinds = {wk.kind for wk in definition.widget_kinds}
|
||||
assert "now_playing" in kinds
|
||||
assert "activity" in kinds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_runs_range_query():
|
||||
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"metric": {"__name__": "up", "instance": "h:9100"},
|
||||
"values": [[100, "1"], [130, "1"]],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
||||
|
||||
# query_range endpoint + window-derived start/end/step params.
|
||||
call = mock_get.call_args
|
||||
assert call.args[0].endswith("/api/v1/query_range")
|
||||
params = call.kwargs["params"]
|
||||
assert params["query"] == "up"
|
||||
assert {"start", "end", "step"}.issubset(params)
|
||||
# {series} shape with the shared normalization (label drops __name__).
|
||||
assert "series" in result
|
||||
assert result["series"][0]["label"] == "instance=h:9100"
|
||||
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_requires_promql():
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
|
||||
result = await adapter.fetch(service, "chart", {"promql": ""})
|
||||
assert result == {"error": "promql is required"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_degrades_on_http_error():
|
||||
"""SC-103: a connection error returns {error} rather than raising."""
|
||||
import requests as req_mod
|
||||
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
|
||||
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jellyfin_now_playing_filters_active_sessions():
|
||||
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
|
||||
adapter = JellyfinWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="jellyfin",
|
||||
name="jf",
|
||||
config={"base_url": "http://jf:8096"},
|
||||
secrets={"api_key": "k"},
|
||||
)
|
||||
playing_session = {
|
||||
"UserName": "alice",
|
||||
"NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
|
||||
"PlayState": {"IsPaused": False},
|
||||
"DeviceName": "Web",
|
||||
}
|
||||
paused_session = {
|
||||
"UserName": "bob",
|
||||
"NowPlayingItem": {"Name": "Show", "Type": "Episode"},
|
||||
"PlayState": {"IsPaused": True},
|
||||
"DeviceName": "TV",
|
||||
}
|
||||
idle_session = {
|
||||
"UserName": "carol",
|
||||
"PlayState": {"IsPaused": False},
|
||||
"DeviceName": "Phone",
|
||||
}
|
||||
mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
|
||||
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
|
||||
result = await adapter.fetch(service, "now_playing", {})
|
||||
sessions = result["sessions"]
|
||||
assert len(sessions) == 1
|
||||
assert sessions[0]["user"] == "alice"
|
||||
assert sessions[0]["state"] == "playing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jellyfin_activity_shows_all_sessions():
|
||||
"""activity (default) should include idle and paused sessions."""
|
||||
adapter = JellyfinWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="jellyfin",
|
||||
name="jf",
|
||||
config={"base_url": "http://jf:8096"},
|
||||
secrets={"api_key": "k"},
|
||||
)
|
||||
mock_client = SimpleNamespace(
|
||||
sessions=lambda: [
|
||||
{"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
|
||||
{"UserName": "bob", "PlayState": {"IsPaused": False}},
|
||||
]
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
|
||||
result = await adapter.fetch(service, "activity", {})
|
||||
assert len(result["sessions"]) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def widget_ref_client(monkeypatch):
|
||||
"""TestClient with an isolated SettingsStore + encryption key."""
|
||||
monkeypatch.setenv(
|
||||
"MANAGE_ENCRYPTION_KEY",
|
||||
Fernet.generate_key().decode(),
|
||||
)
|
||||
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
|
||||
|
||||
reset_encryption_key_cache()
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
|
||||
store.ensure_defaults()
|
||||
|
||||
def get_store_override():
|
||||
return store
|
||||
|
||||
app.dependency_overrides[get_settings_store] = get_store_override
|
||||
client = TestClient(app)
|
||||
yield client, store
|
||||
app.dependency_overrides.pop(get_settings_store, None)
|
||||
|
||||
|
||||
def test_widget_reference_lifecycle(widget_ref_client):
|
||||
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
# Create a service-bound widget (simulating one on a Prometheus service).
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "CPU IOWait",
|
||||
"config": {"promql": "rate(cpu[5m])", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Reference it on "main" dashboard.
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
"sort_order": 5,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
ref = resp.json()
|
||||
assert ref["dashboard_scope"] == "main"
|
||||
assert ref["widget_id"] == widget["id"]
|
||||
ref_id = ref["id"]
|
||||
|
||||
# List references for "main" — should include our widget.
|
||||
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
|
||||
assert resp.status_code == 200
|
||||
refs = resp.json()
|
||||
assert len(refs) == 1
|
||||
assert refs[0]["widget"]["title"] == "CPU IOWait"
|
||||
|
||||
# Delete the reference.
|
||||
resp = client.delete(f"/api/widgets/references/{ref_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "deleted"
|
||||
|
||||
# Reference is gone, original widget still exists.
|
||||
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
|
||||
assert len(resp.json()) == 0
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
|
||||
def test_widget_reference_detach(widget_ref_client):
|
||||
"""Detach clones the widget into a standalone instance and removes the reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "Memory",
|
||||
"config": {"promql": "mem", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
|
||||
# Reference on "main".
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "main",
|
||||
"widget_id": widget["id"],
|
||||
},
|
||||
)
|
||||
ref_id = resp.json()["id"]
|
||||
|
||||
# Detach.
|
||||
resp = client.post(f"/api/widgets/references/{ref_id}/detach")
|
||||
assert resp.status_code == 200
|
||||
cloned = resp.json()
|
||||
assert cloned["title"] == "Memory"
|
||||
assert cloned["widget_kind"] == "chart"
|
||||
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
|
||||
assert cloned["config"]["promql"] == "mem"
|
||||
assert cloned["id"] != widget["id"] # new independent widget
|
||||
|
||||
# Reference is gone.
|
||||
refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
|
||||
assert len(refs) == 0
|
||||
# Original still exists.
|
||||
assert store.get_widget(widget["id"]) is not None
|
||||
|
||||
|
||||
def test_widget_reference_update_sort_order(widget_ref_client):
|
||||
"""PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
widget_a = store.upsert_widget(
|
||||
{
|
||||
"service_id": None,
|
||||
"widget_kind": "static",
|
||||
"title": "A",
|
||||
"config": {"text": "a"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
)
|
||||
widget_b = store.upsert_widget(
|
||||
{
|
||||
"service_id": None,
|
||||
"widget_kind": "static",
|
||||
"title": "B",
|
||||
"config": {"text": "b"},
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Two references on the same dashboard scope.
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "named:test",
|
||||
"widget_id": widget_a["id"],
|
||||
"sort_order": 0,
|
||||
},
|
||||
)
|
||||
ref_a = resp.json()
|
||||
resp = client.post(
|
||||
"/api/widgets/references",
|
||||
json={
|
||||
"dashboard_scope": "named:test",
|
||||
"widget_id": widget_b["id"],
|
||||
"sort_order": 1,
|
||||
},
|
||||
)
|
||||
ref_b = resp.json()
|
||||
|
||||
# Swap sort orders via PUT (per-dashboard reorder).
|
||||
resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sort_order"] == 1
|
||||
|
||||
resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["sort_order"] == 0
|
||||
|
||||
# Widget instances themselves are unchanged.
|
||||
assert store.get_widget(widget_a["id"])["sort_order"] == 0
|
||||
assert store.get_widget(widget_b["id"])["sort_order"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_gauge_adapter_returns_scalar():
|
||||
"""SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
|
||||
result = await adapter.fetch(
|
||||
service,
|
||||
"gauge",
|
||||
{
|
||||
"promql": "cpu_usage",
|
||||
"warn_at": 0.8,
|
||||
"crit_at": 0.95,
|
||||
"unit": "%",
|
||||
},
|
||||
)
|
||||
call = mock_get.call_args
|
||||
assert call.args[0].endswith("/api/v1/query")
|
||||
assert call.kwargs["params"]["query"] == "cpu_usage"
|
||||
assert result["value"] == 0.75
|
||||
assert result["warn_at"] == 0.8
|
||||
assert result["crit_at"] == 0.95
|
||||
assert result["unit"] == "%"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_gauge_adapter_rejects_multi_series():
|
||||
"""SC-111: gauge must be scalar-only; multi-series returns error."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"instance": "a"}, "value": [100, "1"]},
|
||||
{"metric": {"instance": "b"}, "value": [100, "2"]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "gauge", {"promql": "up"})
|
||||
assert "error" in result
|
||||
assert "single-series" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_gauge_adapter_requires_promql():
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
result = await adapter.fetch(service, "gauge", {"promql": ""})
|
||||
assert result == {"error": "promql is required"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_computes_average():
|
||||
"""SC-112: mean kind averages non-null values over the window."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090", "timeout_seconds": 5},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"metric": {"__name__": "cpu"},
|
||||
"values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
|
||||
assert result["value"] == 2.0
|
||||
assert result["unit"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_rejects_multi_series():
|
||||
"""SC-114: mean must be scalar-only; multi-series returns error."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{"metric": {"instance": "a"}, "values": [[100, "1"]]},
|
||||
{"metric": {"instance": "b"}, "values": [[100, "2"]]},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
||||
assert "error" in result
|
||||
assert "single-series" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_skips_nan_values():
|
||||
"""SC-112: NaN / Inf values are excluded from the mean computation."""
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
payload = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {
|
||||
"result": [
|
||||
{
|
||||
"metric": {},
|
||||
"values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
|
||||
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
|
||||
# (2.0 + 4.0) / 2 = 3.0 (NaN excluded)
|
||||
assert result["value"] == 3.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_mean_adapter_requires_promql():
|
||||
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
|
||||
|
||||
adapter = PrometheusWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="prometheus",
|
||||
name="p",
|
||||
config={"base_url": "http://p:9090"},
|
||||
)
|
||||
result = await adapter.fetch(service, "mean", {"promql": ""})
|
||||
assert result == {"error": "promql is required"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# qBittorrent widget source adapter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fake_qbit_maindata():
|
||||
"""Return a mock maindata response (server_state + torrents dict)."""
|
||||
return {
|
||||
"server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
|
||||
"torrents": {
|
||||
"h1": {
|
||||
"name": "Movie.mkv",
|
||||
"state": "downloading",
|
||||
"size": 1000,
|
||||
"progress": 0.5,
|
||||
"dlspeed": 500,
|
||||
"upspeed": 10,
|
||||
},
|
||||
"h2": {
|
||||
"name": "Show.mkv",
|
||||
"state": "uploading",
|
||||
"size": 2000,
|
||||
"progress": 1.0,
|
||||
"dlspeed": 0,
|
||||
"upspeed": 100,
|
||||
},
|
||||
"h3": {
|
||||
"name": "Queued",
|
||||
"state": "queuedDL",
|
||||
"size": 3000,
|
||||
"progress": 0.0,
|
||||
"dlspeed": 0,
|
||||
"upspeed": 0,
|
||||
},
|
||||
"h4": {
|
||||
"name": "Paused",
|
||||
"state": "pausedDL",
|
||||
"size": 4000,
|
||||
"progress": 0.3,
|
||||
"dlspeed": 0,
|
||||
"upspeed": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_totals_counts_all_torrents():
|
||||
"""Totals kind returns count of all listed items + by_state breakdown."""
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="svc-1",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
|
||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||
result = await adapter.fetch(service, "totals", {})
|
||||
|
||||
assert result["total"] == 4
|
||||
assert result["by_state"]["downloading"] == 1
|
||||
assert result["by_state"]["uploading"] == 1
|
||||
assert result["by_state"]["queuedDL"] == 1
|
||||
assert result["by_state"]["pausedDL"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_active_filters_dl_ul_only():
|
||||
"""Active kind returns only downloading/uploading torrents (Q3)."""
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="svc-1",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
|
||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||
result = await adapter.fetch(service, "active", {})
|
||||
|
||||
active = result["torrents"]
|
||||
assert len(active) == 2
|
||||
names = [t["name"] for t in active]
|
||||
assert "Movie.mkv" in names
|
||||
assert "Show.mkv" in names
|
||||
# Queued and paused are excluded
|
||||
assert "Queued" not in names
|
||||
assert "Paused" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
|
||||
"""Speed kind appends a sample and returns {series} with two labeled series."""
|
||||
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
# Isolated harness so we don't pollute the real DB
|
||||
harness = ServiceDataHarness(base_dir=str(tmp_path))
|
||||
harness.register(QBITTORRENT_CONCERN)
|
||||
harness.run_migrations()
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="svc-speed",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with (
|
||||
patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
|
||||
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
|
||||
):
|
||||
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
|
||||
# Wire the mock store to a real isolated store
|
||||
real_store = QbittorrentSampleStore(harness)
|
||||
mock_store_cls.return_value = real_store
|
||||
result = await adapter.fetch(service, "speed", {})
|
||||
|
||||
assert "series" in result
|
||||
labels = [s["label"] for s in result["series"]]
|
||||
assert labels == ["download", "upload"]
|
||||
# The sample just appended should be present
|
||||
dl_points = result["series"][0]["points"]
|
||||
assert len(dl_points) >= 1
|
||||
# timestamps multiplied by 1000 for JS epoch
|
||||
assert dl_points[-1]["v"] == 500000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_adapter_missing_service():
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
result = await adapter.fetch(None, "totals", {})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_adapter_missing_credentials():
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080"},
|
||||
secrets={"username": "", "password": ""},
|
||||
)
|
||||
result = await adapter.fetch(service, "totals", {})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_qbittorrent_adapter_timeout():
|
||||
"""A timeout returns {error} rather than raising."""
|
||||
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
|
||||
|
||||
adapter = QbittorrentWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="qbittorrent",
|
||||
name="qbit",
|
||||
config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
|
||||
secrets={"username": "admin", "password": "pass"},
|
||||
)
|
||||
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _slow(*a, **kw):
|
||||
await _asyncio.sleep(10)
|
||||
|
||||
# Make to_thread hang so wait_for times out
|
||||
mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
|
||||
result = await adapter.fetch(service, "totals", {})
|
||||
assert "error" in result
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: docs
|
||||
|
||||
## role
|
||||
Documentation directory containing architecture, planning, and operational reference materials for the project.
|
||||
Documentation package providing architecture decisions, requirements, migration plans, and operational runbooks for the Manage application and its observability stack.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
|
||||
+4
-4
@@ -4,16 +4,16 @@ dir: docs
|
||||
index: docs/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Documentation directory containing architecture, planning, and operational reference materials for the project.
|
||||
Documentation package providing architecture decisions, requirements, migration plans, and operational runbooks for the Manage application and its observability stack.
|
||||
## files
|
||||
- MIGRATION_PLAN.md | This file documents the architecture, API design, and step-by-step migration plan for transitioning an application from a Streamlit monolith to a FastAPI and React SPA. | dep: FastAPI, React, Vite, TypeScript, pydantic-settings, @tanstack/react-query, ag-grid-react, recharts, tailwindcss
|
||||
- REQUIREMENTS.md | This file is a living requirements and decision log detailing the product goals, architecture, and feature specifications for a web application that manages a remote Jellyfin media library and inspects server files over SSH.
|
||||
- REQUIREMENTS.md | A living requirements and decision log documenting product goals, architecture choices, and detailed functional specifications for "Manage," a web application for browsing Jellyfin media libraries and inspecting remote media files over SSH. | dep: React, TypeScript, shadcn/ui, Tailwind CSS, TanStack Table, Vitest, Jellyfin API, Prometheus, Grafana, Alertmanager, SQLite
|
||||
- monitoring-logging-design.md | Design document detailing a self-hosted observability architecture (metrics, logs, dashboards, alerting) for integration with a platform called Manage. | dep: Prometheus, Grafana, Node Exporter, Grafana Loki, Grafana Alloy, Alertmanager, Authentik, Traefik
|
||||
- observability-runbooks.md | Provides operational runbooks, configuration, and maintenance procedures for deploying and managing a standalone observability stack. | dep: Prometheus, Grafana, Loki, Alloy, Alertmanager, Node Exporter, Docker Compose, Traefik
|
||||
## arch
|
||||
Flat collection of standalone Markdown documents covering requirements tracking, migration planning, and observability/operational runbooks.
|
||||
Flat collection of Markdown-based living design documents and runbooks serving as the project's single source of truth for planning, requirements, and operations.
|
||||
## tags
|
||||
design, react, observability, architecture, migration, plan, requirements, runbooks
|
||||
react, design, observability, architecture, migration, plan, requirements, prometheus
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: docs/superpowers
|
||||
|
||||
## role
|
||||
Documentation directory for advanced features, plugins, or capabilities (currently empty).
|
||||
Documentation directory for the project's "superpowers" feature set, concepts, or capabilities.
|
||||
## parent
|
||||
index: docs/.pi-map.index.md
|
||||
map: docs/.pi-map.md
|
||||
|
||||
@@ -4,10 +4,10 @@ dir: docs/superpowers
|
||||
index: docs/superpowers/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Documentation directory for advanced features, plugins, or capabilities (currently empty).
|
||||
Documentation directory for the project's "superpowers" feature set, concepts, or capabilities.
|
||||
## files
|
||||
## arch
|
||||
Flat file structure intended for Markdown or supplementary documentation resources.
|
||||
Flat file-based documentation structure with no code files present.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: docs/superpowers/plans
|
||||
|
||||
## role
|
||||
Documentation directory containing detailed implementation plans for specific project features or standalone systems.
|
||||
Documentation directory containing implementation plans for major system features and infrastructure improvements.
|
||||
## parent
|
||||
index: docs/superpowers/.pi-map.index.md
|
||||
map: docs/superpowers/.pi-map.md
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user