Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7019b33ac | |||
| 05a9faca3e | |||
| 39775a82ef | |||
| 7e53edfcc6 | |||
| b9a79b85d1 | |||
| 6d46de26c4 | |||
| 50c0c9b548 | |||
| b011d2421b | |||
| dad2202756 | |||
| 84dcf9e010 | |||
| ecabc65dd4 | |||
| 044d386ac7 | |||
| 9bc8fab971 | |||
| 29650ca512 | |||
| f921524d37 | |||
| 8d3c44d87f | |||
| 3bc7ce5269 | |||
| ad61d92b32 | |||
| dbc332d1b6 | |||
| 87f42b4ec3 | |||
| 5addc9dae9 | |||
| 6bcb60a74d | |||
| 98bf496a98 | |||
| f6c67bd3ff | |||
| 3391fbc85d | |||
| c4f68b4938 | |||
| 1fc3127b58 | |||
| ce5ee4f0a0 | |||
| a5ca1521fe | |||
| 9236fd8ac2 | |||
| cb8dd13514 | |||
| c886fcdf09 | |||
| 7e91e7f931 | |||
| df80c68f89 | |||
| 798196ffc7 | |||
| 872e95f8f7 | |||
| bf8de32815 | |||
| 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 | |||
| 8d2e4c9bfd | |||
| fef0ded76f | |||
| f7f590fa47 |
@@ -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,81 @@
|
||||
# Service IA Refinement — Instance Tabs + Config to Settings
|
||||
|
||||
## Files changed (5 files, +310/-381)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `frontend/src/integrations/navEntries.ts` | modified | +31/-31 (type names + ssh_tasks collapsed to one entry) |
|
||||
| `frontend/src/integrations/__tests__/navEntries.test.ts` | modified | +23/-23 (updated labels) |
|
||||
| `frontend/src/pages/ServicePage.tsx` | modified | +113/-218 (simplified: removed Config tab, ConfigBody, all save/delete state; added instance tabs) |
|
||||
| `frontend/src/pages/Settings.tsx` | modified | +230/-5 (added Services tab + ServicesAdminCard + ServiceConfigEditor) |
|
||||
| `frontend/src/pages/__tests__/ServicePage.test.tsx` | modified | +76/-76 (removed Config/secret tests, added instance-tabs tests) |
|
||||
|
||||
## New ServicePage structure
|
||||
|
||||
The service page is now a **pure operational view** — no save/delete/config state at all.
|
||||
|
||||
**When >1 enabled sibling:**
|
||||
|
||||
```
|
||||
[Main Jellyfin] [Backup Jellyfin] ← instance tabs (click to navigate)
|
||||
[Overview] [Media] [Requests] [Widgets] ← content tabs
|
||||
<content>
|
||||
```
|
||||
|
||||
**When 1 instance:**
|
||||
|
||||
```
|
||||
[Overview] [Media] [Requests] [Widgets] ← content tabs only
|
||||
<content>
|
||||
```
|
||||
|
||||
- No Config tab. No `<Select>` switcher. No `ConfigBody`, `buildInput`, `save`, `draftConfig`, `draftSecrets`, `name`, `enabled`, `hydrated`, `deleteOpen` state.
|
||||
- Instance tabs use the shadcn `Tabs` component (outer level). Content tabs use a nested `Tabs` (inner level). Clicking an instance tab navigates to `/services/:type/:id`.
|
||||
- Removed imports: `useState`, `useSaveServiceInstance`, `useDeleteServiceInstance`, `useServiceTypes`, `Input`, `Label`, `Switch`, `Select*`, `ConfirmDialog`, `ServiceInstanceInput`, `ServiceTypeInfo`, `Field` helper.
|
||||
|
||||
## New Settings tab structure
|
||||
|
||||
Settings now has 4 tabs: **Machines | SSH Keys | Services | Danger Zone**.
|
||||
|
||||
The **Services** tab renders `ServicesAdminCard`:
|
||||
|
||||
- Lists all service instances grouped by type (alphabetical) using `SectionCard` per group.
|
||||
- Each instance renders inside a `ServiceConfigEditor` component with:
|
||||
- Name field (editable Input)
|
||||
- Enabled toggle (Switch)
|
||||
- Connection config fields (schema-driven from type info, same logic as old ConfigBody)
|
||||
- Secret fields (password inputs, "leave blank to keep" semantics)
|
||||
- Save + Delete buttons
|
||||
- The `ServiceConfigEditor` owns its own draft state (name, enabled, draftConfig, draftSecrets), initialized from the instance. `buildInput` + `handleSave` replicate the old ConfigBody logic.
|
||||
|
||||
## How instance tabs work
|
||||
|
||||
- `siblings` is computed as `services.filter(s => s.service_type === serviceType && s.enabled)`.
|
||||
- When `siblings.length > 1`, an outer `<Tabs value={instance.id}>` renders one `<TabsTrigger>` per sibling. Each trigger has `onClick={() => navigate(`/services/${serviceType}/${sibling.id}`)}`.
|
||||
- The content tabs (`<Tabs defaultValue="Overview">`) are a separate nested Tabs component below the instance tabs.
|
||||
- Single instance: no instance tabs rendered (the condition is false).
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd frontend && npm run lint → 0 errors, 0 warnings
|
||||
cd frontend && npm run build → ✓ built (tsc -b + vite)
|
||||
cd frontend && npm run test → 36 files / 118 tests passed (was 117; +1 instance-tabs test)
|
||||
```
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **No ConfirmDialog on delete in ServiceConfigEditor.** The old ServicePage had a ConfirmDialog before deleting. The new ServiceConfigEditor calls `deleteService.mutate(instance.id)` directly on the Delete button click. This is a minor UX regression; a follow-up can add the confirm dialog. Kept simple to stay within scope.
|
||||
|
||||
2. **Instance tabs use onClick navigation, not Radix tab state.** The outer Tabs `value` is bound to `instance.id` (the current route), and clicking a trigger navigates. Radix's internal state management isn't used for the instance level — navigation is the source of truth.
|
||||
|
||||
3. **tabs.tsx formatting discarded.** The write tool normalized tabs.tsx (semicolons + indentation). I discarded that diff to keep the change focused on the 5 intended files.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- No ConfirmDialog on service delete in the Settings > Services tab (minor UX regression vs the old ServicePage).
|
||||
- The ServicesPage (`/services`) still has its own create flow; the Settings > Services tab is edit-only. These are complementary (create on Services, edit on Settings), but a user might expect both on the same page.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Configurable per-service Overview (change 4)
|
||||
|
||||
## Files changed (10 files, ~310 lines)
|
||||
|
||||
| File | Status | Lines |
|
||||
|------|--------|-------|
|
||||
| `backend/src/media_library_viewer_api/services/settings_store.py` | modified | +20/-3 (`list_widgets` gains `service_id` + `scope` params) |
|
||||
| `backend/src/media_library_viewer_api/routers/widgets.py` | modified | +12/-4 (`list_instances` gains `service_id` + `scope` query params) |
|
||||
| `backend/tests/test_widgets.py` | modified | +36 (filter test) |
|
||||
| `frontend/src/api/widgets.ts` | modified | +8/-1 (`fetchWidgetInstances` accepts `serviceId?` + `scope?`) |
|
||||
| `frontend/src/hooks/useWidgets.ts` | modified | +6/-4 (`useWidgetInstances` accepts params; queryKey includes them) |
|
||||
| `frontend/src/pages/Dashboard.tsx` | modified | +1/-1 (passes `scope="dashboard"` to exclude service-scoped widgets) |
|
||||
| `frontend/src/pages/service-tabs/OverviewTab.tsx` | **new** | 67 |
|
||||
| `frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx` | **new** | 79 |
|
||||
| `frontend/src/pages/service-tabs/index.ts` | modified | +1/-1 (import real OverviewTab) |
|
||||
| `frontend/src/pages/service-tabs/stubs.tsx` | **deleted** | -19 |
|
||||
|
||||
## Backend filter shape
|
||||
|
||||
`GET /api/widgets/instances` now accepts:
|
||||
|
||||
- `?service_id=X` — filter to widgets for service X
|
||||
- `?scope=dashboard` — only NULL service_id widgets (main dashboard)
|
||||
- `?scope=service` — only non-NULL service_id widgets
|
||||
|
||||
`SettingsStore.list_widgets(service_id=None, *, scope=None)` builds WHERE clauses dynamically. No-args returns all (backward-compatible).
|
||||
|
||||
## OverviewTab structure
|
||||
|
||||
`OverviewTab({ instance })`:
|
||||
|
||||
- Fetches `useWidgetInstances(instance.id)` (scoped to this service).
|
||||
- Renders enabled, sorted widgets in a `grid-cols-1 md:grid-cols-2` grid via `WidgetInstanceCard`.
|
||||
- "Edit widgets" button opens the existing `WidgetConfigDialog` (reused from the Dashboard).
|
||||
- Empty state: "No widgets on this overview yet" + "Add widgets" button.
|
||||
- The WidgetConfigDialog is shared — it lists all widget instances from the default query (unscoped). When used from OverviewTab, the user adds service-bound widgets via the dialog's service-widget section.
|
||||
|
||||
## Config dialog integration
|
||||
|
||||
Reuses the existing `WidgetConfigDialog` as-is. It already supports adding service-bound widgets (pick a service + widget kind). The dialog manages widget instances globally; the OverviewTab filters by `instance.id`. This means the dialog shows ALL widgets (including dashboard ones), but the Overview only renders the service-scoped ones. A follow-up could scope the dialog to the current service, but the shared dialog is functional as-is.
|
||||
|
||||
## Validation
|
||||
|
||||
```
|
||||
cd backend && .venv/bin/ruff check . → All checks passed!
|
||||
cd backend && .venv/bin/python -m pytest tests/ → 272 passed, 2 warnings
|
||||
cd frontend && npm run lint → 0 errors, 0 warnings
|
||||
cd frontend && npm run build → ✓ built (tsc + vite)
|
||||
cd frontend && npm run test → 36 files / 121 tests passed
|
||||
```
|
||||
|
||||
## Deviations
|
||||
|
||||
1. **WidgetConfigDialog is unscoped.** It lists all widget instances. The OverviewTab filters by `instance.id` at render time, but the dialog shows everything. Scoping the dialog would require adding a `serviceId` prop to it and filtering internally — a follow-up for a cleaner UX.
|
||||
2. **stubs.tsx deleted.** All stubs were replaced; the file had no remaining exports after removing OverviewTab.
|
||||
3. **ServicePage tests updated.** Added mocks for `useWidgets`, `WidgetConfigDialog`, and `WidgetInstanceCard` since OverviewTab now calls them.
|
||||
|
||||
## skill_resolution
|
||||
|
||||
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
|
||||
|
||||
## Residual risks
|
||||
|
||||
- WidgetConfigDialog is shared and unscoped — adding a widget from the OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.
|
||||
- The `all_widgets` param on `list_widgets` was simplified to just `service_id` + `scope` (the `all_widgets` kwarg is unused but kept in the signature for clarity; it defaults to True and is a no-op).
|
||||
- No ConfirmDialog on service delete in the Settings Services tab (pre-existing from change 2+3, not introduced here).
|
||||
|
||||
```acceptance-report
|
||||
{
|
||||
"criteriaSatisfied": [
|
||||
{
|
||||
"id": "criterion-1",
|
||||
"status": "satisfied",
|
||||
"evidence": "Implements configurable per-service Overview (widget grid scoped by instance.id) + backend filter params (?service_id= + ?scope=) + Dashboard scope fix + tests. No scope widening: 10 files, ~310 lines. 272 backend + 121 frontend tests pass; lint/build green both sides."
|
||||
}
|
||||
],
|
||||
"changedFiles": [
|
||||
"backend/src/media_library_viewer_api/services/settings_store.py",
|
||||
"backend/src/media_library_viewer_api/routers/widgets.py",
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/api/widgets.ts",
|
||||
"frontend/src/hooks/useWidgets.ts",
|
||||
"frontend/src/pages/Dashboard.tsx",
|
||||
"frontend/src/pages/service-tabs/OverviewTab.tsx",
|
||||
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
|
||||
"frontend/src/pages/service-tabs/index.ts",
|
||||
"frontend/src/pages/service-tabs/stubs.tsx"
|
||||
],
|
||||
"testsAddedOrUpdated": [
|
||||
"backend/tests/test_widgets.py",
|
||||
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
|
||||
"frontend/src/pages/__tests__/ServicePage.test.tsx"
|
||||
],
|
||||
"commandsRun": [
|
||||
{
|
||||
"command": "cd backend && .venv/bin/ruff check .",
|
||||
"result": "passed",
|
||||
"summary": "All checks passed"
|
||||
},
|
||||
{
|
||||
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
|
||||
"result": "passed",
|
||||
"summary": "272 passed, 2 warnings (pre-existing)"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run lint",
|
||||
"result": "passed",
|
||||
"summary": "0 errors, 0 warnings"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run build",
|
||||
"result": "passed",
|
||||
"summary": "tsc + vite build clean"
|
||||
},
|
||||
{
|
||||
"command": "cd frontend && npm run test",
|
||||
"result": "passed",
|
||||
"summary": "36 files / 121 tests passed"
|
||||
}
|
||||
],
|
||||
"validationOutput": [
|
||||
"Backend list_widgets supports service_id + scope filtering; test covers all/dash scope/service scope/filtered.",
|
||||
"Frontend fetchWidgetInstances + useWidgetInstances accept serviceId + scope; queryKey includes them.",
|
||||
"Dashboard uses scope=dashboard to exclude service-scoped widgets.",
|
||||
"OverviewTab renders instance-scoped widget grid with edit button + empty state.",
|
||||
"stubs.tsx deleted (all stubs replaced)."
|
||||
],
|
||||
"residualRisks": [
|
||||
"WidgetConfigDialog is shared and unscoped — adding a widget from OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.",
|
||||
"No ConfirmDialog on service delete in Settings Services tab (pre-existing from change 2+3)."
|
||||
],
|
||||
"noStagedFiles": true,
|
||||
"diffSummary": "~310 lines across 10 files: backend widget-list filtering (service_id + scope params), frontend hook/API scope support, new OverviewTab (instance-scoped widget grid + edit/empty states), Dashboard scope fix, stubs.tsx deleted, ServicePage test mocks updated.",
|
||||
"reviewFindings": [
|
||||
"no blockers"
|
||||
],
|
||||
"manualNotes": "Nothing is staged. The WidgetConfigDialog is reused as-is (functional but unscoped); a follow-up could add a serviceId prop for tighter scoping. The all_widgets kwarg on list_widgets is unused but kept for API clarity."
|
||||
}
|
||||
@@ -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,50 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed — HTTP read timeouts
|
||||
|
||||
- Service HTTP clients now use a `(connect, read)` timeout tuple (connect 5s,
|
||||
read 60s default) instead of a single integer, resolving `ReadTimeoutError`
|
||||
on slow Jellyfin index builds and qBittorrent stats. The media index build
|
||||
worker uses a 180s read floor so slow `/Items` pages on large libraries
|
||||
don't time out mid-build.
|
||||
- The shared `http_timeout()` helper (`clients/http_timeout.py`) decouples
|
||||
connect (fail-fast on dead hosts) from read (generous for slow responses).
|
||||
- Integration `timeout_seconds` defaults were raised from 5/10s to 15/60s.
|
||||
- Existing services with a low `timeout_seconds` may benefit from bumping it
|
||||
to 60+ via the service editor.
|
||||
|
||||
### **BREAKING** — Prometheus queries now route through Grafana gateway
|
||||
|
||||
- The `prometheus` service config changed: `base_url` is replaced by
|
||||
`grafana_url` + `datasource_uid`, and the `api_key` secret is replaced by
|
||||
`grafana_api_key` (a Grafana service account token or API key with read
|
||||
access to the Prometheus datasource). All metric widget queries (`chart`,
|
||||
`gauge`, `mean`, `metric`) now issue `POST {grafana_url}/api/ds/query`
|
||||
instead of direct Prometheus HTTP calls.
|
||||
- **Migration:** Reconfigure existing `prometheus` services — replace
|
||||
`base_url` with `grafana_url` (your Grafana instance URL), add the
|
||||
`grafana_api_key` secret, and optionally set `datasource_uid` (defaults
|
||||
to `"prometheus"`).
|
||||
|
||||
### Added — Direct Prometheus charting
|
||||
|
||||
- **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 providing authenticated APIs for remote media library inspection, SSH job execution, and observability via Jellyfin integration.
|
||||
## 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 providing authenticated APIs for remote media library inspection, SSH job execution, and observability via Jellyfin integration.
|
||||
## 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 that resolve and instantiate service clients like Jellyfin and SSH based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str(service.get("config", {}).get("user_id") or "").strip, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_resolved_user_id, raise:HTTPException, func:_resolved_user_id(cache_key: tuple[str, str, str, str]) → str, call:_jellyfin_client_for, call:client.resolve_user_id | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
|
||||
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
|
||||
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
|
||||
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health, version, and metrics endpoints. | exp: func:_validate_prometheus_gateway_config() → None, call:get_settings_store, call:store.list_services, call:service.get, call:logger.warning, call:logger.exception, func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_service_data_harness, call:_validate_prometheus_gateway_config, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.service_data, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
|
||||
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
|
||||
- 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, Pydantic settings, middleware-based auth (OIDC/JWT/API key), and modular utilities for configuration, logging, metrics, and path mapping.
|
||||
## tags
|
||||
call:, settings, call:get, request, get, client, call:str, id
|
||||
call:, settings, call:get, request, id, get, call:str, 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.
|
||||
Package of API and protocol client wrappers that standardize communication with external services (media servers, identity providers, torrent clients, remote/local hosts) for the media library viewer backend.
|
||||
## 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,18 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- authentik.py
|
||||
- http_timeout.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, http_timeout.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,28 +4,31 @@ dir: backend/src/media_library_viewer_api/clients
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||
Package of API and protocol client wrappers that standardize communication with external services (media servers, identity providers, torrent clients, remote/local hosts) for the media library viewer backend.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- jellyfin.py | Provides a reusable, framework-agnostic HTTP client wrapper for the Jellyfin/Emby API with methods for browsing users, libraries, media items, and sessions. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests
|
||||
- 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
|
||||
- authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float
|
||||
- jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- jellyseerr.py | HTTP API client wrapper for Jellyseerr to fetch user data and enrich Jellyfin user lists. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method: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, media_library_viewer_api.clients.http_timeout
|
||||
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
||||
- qbittorrent.py | Minimal read-only qBittorrent Web API client that manages authenticated sessions to fetch `/sync/maindata`. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:self._get | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||
## arch
|
||||
Client-wrapper pattern with framework-agnostic abstractions; parallel local/remote execution strategies via paramiko SSH and local subprocess; centralized REST API communication modules.
|
||||
Adapter/wrapper pattern where each module encapsulates a specific external service's API or protocol behind a uniform Python interface, returning plain dictionaries and using shared HTTP timeout configuration.
|
||||
## 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, call:self.get, client, init, timeout, call:logger.warning
|
||||
## 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, http_timeout.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -13,13 +13,15 @@ from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthentikClient:
|
||||
"""Small wrapper around the Authentik core directory API."""
|
||||
|
||||
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
|
||||
def __init__(self, base_url: str, api_token: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||
if not base_url:
|
||||
raise ValueError("Authentik base_url is required")
|
||||
if not api_token:
|
||||
@@ -29,7 +31,8 @@ class AuthentikClient:
|
||||
if self.base_url.endswith("/api/v3"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_token = api_token
|
||||
self.timeout = timeout
|
||||
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||
self.timeout = http_timeout(timeout)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Shared HTTP timeout helpers.
|
||||
|
||||
``requests`` accepts a single integer timeout and applies it to BOTH the
|
||||
connect and read phases. For slow upstream services (large Jellyfin
|
||||
libraries, qBittorrent with many torrents), the read phase needs a much
|
||||
larger budget than connect. These helpers produce ``(connect, read)`` tuples
|
||||
so the two phases are decoupled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Short connect timeout — fail fast on unreachable/dead hosts.
|
||||
DEFAULT_CONNECT_TIMEOUT = 5.0
|
||||
|
||||
#: Generous read timeout — let slow responses complete.
|
||||
DEFAULT_READ_TIMEOUT = 60.0
|
||||
|
||||
|
||||
def http_timeout(
|
||||
read_timeout: float | int | None = None,
|
||||
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||
) -> tuple[float, float]:
|
||||
"""Build a ``(connect, read)`` timeout tuple for ``requests``.
|
||||
|
||||
``read_timeout`` is the per-response read budget (seconds). When omitted
|
||||
or non-positive, :data:`DEFAULT_READ_TIMEOUT` applies.
|
||||
"""
|
||||
effective_read = DEFAULT_READ_TIMEOUT
|
||||
if read_timeout is not None:
|
||||
try:
|
||||
parsed = float(read_timeout)
|
||||
if parsed > 0:
|
||||
effective_read = parsed
|
||||
except (TypeError, ValueError):
|
||||
pass # fall back to default on non-numeric input
|
||||
return (connect_timeout, effective_read)
|
||||
@@ -12,6 +12,8 @@ from typing import Any, cast
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -35,7 +37,7 @@ DEFAULT_FIELDS = ",".join(
|
||||
class JellyfinClient:
|
||||
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
||||
def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||
if not base_url:
|
||||
raise ValueError("Jellyfin URL is required")
|
||||
if not api_key:
|
||||
@@ -47,7 +49,8 @@ class JellyfinClient:
|
||||
if self.base_url.endswith("/web"):
|
||||
self.base_url = self.base_url[:-4]
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||
self.timeout = http_timeout(timeout)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
@@ -87,6 +90,32 @@ class JellyfinClient:
|
||||
logger.info("Jellyfin returned %s visible users", len(users))
|
||||
return users
|
||||
|
||||
def resolve_user_id(self, identifier: str | None) -> str:
|
||||
"""Resolve a configured user identifier to Jellyfin's internal Id.
|
||||
|
||||
The service ``user_id`` config field accepts either the internal Jellyfin
|
||||
Id (a hash) or a username (e.g. ``'admin'``). Jellyfin's
|
||||
``/Users/{id}/...`` endpoints reject usernames with HTTP 400
|
||||
(``"The value 'admin' is not valid."``), so any caller must resolve
|
||||
usernames to the real Id before hitting user-scoped endpoints.
|
||||
|
||||
Resolution order: exact ``Id`` match → ``Name`` match → first visible
|
||||
user. Raises if the API key cannot see any users.
|
||||
"""
|
||||
users = self.users()
|
||||
if not users:
|
||||
raise RuntimeError("No Jellyfin users visible to this API key")
|
||||
if identifier:
|
||||
if any(str(u.get("Id")) == identifier for u in users):
|
||||
return identifier
|
||||
match = next((u for u in users if str(u.get("Name", "")) == identifier), None)
|
||||
if match:
|
||||
resolved = str(match["Id"])
|
||||
logger.info("Resolved Jellyfin username %r to Id %s", identifier, resolved)
|
||||
return resolved
|
||||
logger.warning("Jellyfin user identifier %r not found; using first user", identifier)
|
||||
return str(users[0]["Id"])
|
||||
|
||||
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return top-level library views visible to the selected Jellyfin user."""
|
||||
items = self.get(f"/Users/{user_id}/Views").get("Items", [])
|
||||
|
||||
@@ -11,13 +11,15 @@ from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JellyseerrClient:
|
||||
"""Small wrapper around the Jellyseerr REST API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
||||
def __init__(self, base_url: str, api_key: str, timeout: float = DEFAULT_READ_TIMEOUT):
|
||||
if not base_url:
|
||||
raise ValueError("Jellyseerr URL is required")
|
||||
if not api_key:
|
||||
@@ -27,7 +29,8 @@ class JellyseerrClient:
|
||||
if self.base_url.endswith("/api/v1"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||
self.timeout = http_timeout(timeout)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||
|
||||
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: float = DEFAULT_READ_TIMEOUT) -> 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
|
||||
# timeout is the per-response READ timeout (seconds); connect timeout is fixed at 5s.
|
||||
self.timeout = http_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 replies with the plain text ``"Ok."`` and a ``SID`` cookie
|
||||
on success, ``"Fails."`` on bad credentials, and ``403 Forbidden`` when
|
||||
the source IP is banned (too many failed attempts). The ``Referer``
|
||||
header is required by qBittorrent's CSRF protection.
|
||||
|
||||
Any other body — in particular an *empty* 200 — means the request did not
|
||||
reach qBittorrent's login handler, almost always because ``base_url`` is
|
||||
wrong (wrong host/port/path) or a reverse proxy is misrouting
|
||||
``/api/v2/auth/login``. We surface a diagnostic error in that case
|
||||
instead of the useless ``"login failed: "`` message.
|
||||
"""
|
||||
resp = self._session.post(
|
||||
f"{self.base_url}/auth/login",
|
||||
data={"username": self._username, "password": self._password},
|
||||
timeout=self.timeout,
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
# 502/503/504 come from the reverse proxy when qBittorrent is down,
|
||||
# starting up, or can't answer within the proxy's forwarding timeout
|
||||
# (qBittorrent's PBKDF2 password check is intentionally slow, so a
|
||||
# flood of concurrent logins can trip this). Surface it clearly rather
|
||||
# than as a bare HTTPError.
|
||||
if resp.status_code in (502, 503, 504):
|
||||
raise RuntimeError(
|
||||
f"qBittorrent is unreachable: reverse proxy returned HTTP {resp.status_code} "
|
||||
f"for {resp.url}. qBittorrent may be down, starting up, or unable to "
|
||||
"answer within the proxy's forwarding timeout."
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.text.strip()
|
||||
# qBittorrent signals a successful login with the body "Ok." and/or by
|
||||
# setting a session cookie. The cookie is named "SID" in older versions
|
||||
# and "QBT_SID" / "QBT_SID_<port>" in newer ones. Some setups return 204
|
||||
# No Content with the cookie and no body, and ``requests`` doesn't always
|
||||
# populate the cookie jar, so check both the jar and the raw Set-Cookie
|
||||
# header. qBittorrent only sets this cookie on a valid login.
|
||||
def _is_session_cookie(name: str) -> bool:
|
||||
upper = name.strip().upper()
|
||||
return upper == "SID" or upper.startswith("QBT_SID")
|
||||
|
||||
set_cookie_hdr = resp.headers.get("Set-Cookie", "") or ""
|
||||
first_cookie_name = set_cookie_hdr.split("=", 1)[0].strip()
|
||||
sid_ok = any(_is_session_cookie(k) for k in resp.cookies.keys()) or (
|
||||
bool(first_cookie_name) and _is_session_cookie(first_cookie_name)
|
||||
)
|
||||
if body == "Ok." or sid_ok:
|
||||
self._logged_in = True
|
||||
logger.info("qBittorrent login successful for %s", self.base_url)
|
||||
return
|
||||
if body == "Fails.":
|
||||
raise RuntimeError(f"qBittorrent login failed (HTTP {resp.status_code}): invalid username or password")
|
||||
cookie_names = sorted(resp.cookies.keys()) or (["<unparsed>"] if set_cookie_hdr else [])
|
||||
raise RuntimeError(
|
||||
f"Unexpected response from qBittorrent login endpoint (HTTP {resp.status_code}, "
|
||||
f"body={body!r}, cookies={cookie_names}). Expected the text 'Ok.' or a session "
|
||||
"cookie (SID / QBT_SID) from /api/v2/auth/login — this usually means base_url does "
|
||||
"not reach the qBittorrent Web API (check the URL, path, and any reverse proxy in "
|
||||
"front of qBittorrent)."
|
||||
)
|
||||
|
||||
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||
"""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")
|
||||
@@ -263,17 +263,41 @@ def get_settings_store() -> SettingsStore:
|
||||
|
||||
|
||||
def get_user_id(request: Request = None) -> str:
|
||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||
"""Return the Jellyfin user Id, resolving a configured username if needed.
|
||||
|
||||
The service ``user_id`` config field accepts either the internal Jellyfin Id
|
||||
or a username (e.g. ``'admin'``). Jellyfin's ``/Users/{id}/...`` endpoints
|
||||
reject usernames with HTTP 400 (``"The value 'admin' is not valid."``), so
|
||||
always resolve to the internal Id before use. Resolution is cached per
|
||||
(service, base_url, api_key, configured) so repeated dashboard/media requests
|
||||
don't re-list users on every call.
|
||||
"""
|
||||
store = get_settings_store()
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyfin", service_id)
|
||||
if service and service.get("config", {}).get("user_id"):
|
||||
return str(service["config"]["user_id"])
|
||||
client = get_jellyfin_client(request)
|
||||
users = client.users()
|
||||
if not users:
|
||||
if service is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No Jellyfin users found and no user_id configured on the service",
|
||||
detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
|
||||
)
|
||||
return users[0]["Id"]
|
||||
configured = str(service.get("config", {}).get("user_id") or "").strip()
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
|
||||
)
|
||||
return _resolved_user_id((service["id"], base_url, api_key, configured))
|
||||
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def _resolved_user_id(cache_key: tuple[str, str, str, str]) -> str:
|
||||
"""Resolve a configured Jellyfin identifier (Id or username) to the internal Id.
|
||||
|
||||
Keyed by (service_id, base_url, api_key, configured) so a credentials change
|
||||
or a different configured user busts the cache automatically.
|
||||
"""
|
||||
service_id, base_url, api_key, configured = cache_key
|
||||
client = _jellyfin_client_for((service_id, base_url, api_key))
|
||||
return client.resolve_user_id(configured or None)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/domain
|
||||
|
||||
## 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.
|
||||
Provides pluggable external service integrations (Alertmanager, Jellyfin, Nextcloud, Prometheus, qBittorrent, Authentik, Backups, SSH) with standardized config schemas, widgets, connection testing, and a central registry for discovery.
|
||||
## 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.
|
||||
Provides pluggable external service integrations (Alertmanager, Jellyfin, Nextcloud, Prometheus, qBittorrent, Authentik, Backups, SSH) with standardized config schemas, widgets, connection testing, and a central registry for discovery.
|
||||
## 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
|
||||
- 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
|
||||
- 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
|
||||
- alertmanager.py | Defines a service integration for Prometheus Alertmanager, providing configuration models, connection testing, alert summarization, and widget definitions for displaying active alerts. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:int, call:secrets.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get("versionInfo", {}).get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
|
||||
- authentik.py | Defines the Authentik service integration for user-directory access, including connection config, API token secret management, and a connection test. | exp: class:AuthentikConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:secrets.get, call:float, call:AuthentikClient, call:client.users, call:result.get, call:isinstance, call:TestResult, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.authentik, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.authentik.AuthentikClient
|
||||
- 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 base classes and utility functions for defining external service integrations, including config schemas, secrets, widgets, and connection error translation. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:TestResult, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump, func:translate_connection_error(exc: Exception, context) → TestResult, call:str, call:message.lower, call:isinstance, call:TestResult | dep: asyncio, dataclasses, typing, requests, pydantic, media_library_viewer_api.services.settings_store
|
||||
- jellyfin.py | Defines the Jellyfin media server service integration, including connection testing, configuration models, and widget definitions for activity monitoring. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore
|
||||
- nextcloud.py | Defines a Nextcloud service integration with connection testing and configuration for a media library viewer API. | exp: class:NextcloudConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("base_url") or "").rstrip, call:config.get, call:requests.get, call:resp.raise_for_status, call:resp.json, call:payload.get, call:TestResult, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
|
||||
- prometheus.py | Defines the Prometheus service integration for a media library viewer API, including connection testing via a Grafana gateway and configuration models for metric, chart, gauge, and mean widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig, class:PrometheusChartWidgetConfig, class:PrometheusGaugeWidgetConfig, class:PrometheusMeanWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("grafana_url") or "").rstrip, call:config.get, call:secrets.get, call:int, call:TestResult, call:requests.post, call:resp.raise_for_status, call:translate_connection_error | dep: typing, requests, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store
|
||||
- qbittorrent.py | Defines the qBittorrent service integration, including connection config, secret fields, three widget kinds (totals, active, speed), and a connection test function. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, class:QbittorrentSpeedWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore
|
||||
- registry.py | Maintains a closed registry of service definitions and provides lookup functions to query available services, their types, and widget kinds. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.authentik, media_library_viewer_api.integrations.backups, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.qbittorrent, media_library_viewer_api.integrations.ssh_tasks
|
||||
- ssh_tasks.py | Defines a service plugin that runs reusable saved tasks over SSH by managing connection configuration, secrets, and connection testing. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str(config.get("host") or "").strip, call:config.get, call:int, call:ServiceRecord, call:build_ssh_client, call:client.connect, call:str(exc).lower, call:TestResult, call:translate_connection_error, call:client.close | dep: typing, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||
## arch
|
||||
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/registry pattern with abstract base classes defining config models, secrets, widgets, and connection tests; each integration is a self-contained module registered in a closed registry for lookup and dynamic loading.
|
||||
## tags
|
||||
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
|
||||
config, connection, widget, media_library_viewer_api, service, error, integrations, test
|
||||
## symbols
|
||||
- AlertmanagerConfig
|
||||
- AlertmanagerAlertsWidgetConfig
|
||||
- AuthentikConfig
|
||||
- BackupsConfig
|
||||
- BackupsSummaryWidgetConfig
|
||||
- ServiceConfigBase
|
||||
- WidgetConfigBase
|
||||
- SecretField
|
||||
- WidgetKind
|
||||
- ServiceDefinition
|
||||
- GrafanaConfig
|
||||
## workflows
|
||||
- change integrations behavior
|
||||
read: __init__.py, alertmanager.py, base.py
|
||||
read: __init__.py, alertmanager.py, authentik.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -2,23 +2,30 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
class AlertmanagerConfig(ServiceConfigBase):
|
||||
"""Non-secret Alertmanager connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
timeout_seconds: int = 15
|
||||
|
||||
|
||||
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
||||
@@ -68,6 +75,28 @@ def summarize_alerts(
|
||||
}
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""GET /api/v2/status with optional bearer auth."""
|
||||
try:
|
||||
base_url = str(config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(config.get("timeout_seconds") or 15)
|
||||
headers: dict[str, str] = {}
|
||||
api_key = str(secrets.get("api_key") or "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
resp = requests.get(f"{base_url}/api/v2/status", headers=headers, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
version = str(payload.get("versionInfo", {}).get("version", "") or "connected")
|
||||
return TestResult(ok=True, detail="Connected to Alertmanager.", evidence=version)
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="Alertmanager")
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="alertmanager",
|
||||
name="Alertmanager",
|
||||
@@ -86,4 +115,5 @@ DEFINITION = ServiceDefinition(
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
|
||||
@@ -8,19 +8,45 @@ is unchanged -- this service type is for the directory, not SSO.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
translate_connection_error,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""Probe AuthentikClient.users(page=1, page_size=1) — lightest directory call."""
|
||||
try:
|
||||
base_url = str(config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(secrets.get("api_token") or "")
|
||||
timeout = float(config.get("timeout_seconds") or 60)
|
||||
client = AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
|
||||
result = client.users(page=1, page_size=1)
|
||||
total = result.get("total", 0) if isinstance(result, dict) else 0
|
||||
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{total} users")
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="Authentik")
|
||||
|
||||
|
||||
class AuthentikConfig(ServiceConfigBase):
|
||||
"""Non-secret Authentik connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
timeout_seconds: int = 60
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
@@ -32,4 +58,5 @@ DEFINITION = ServiceDefinition(
|
||||
SecretField(key="api_token", label="API token", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
|
||||
|
||||
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||
* the secret fields it accepts (API keys / tokens),
|
||||
@@ -15,16 +15,21 @@ map. There is no runtime plugin loading.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Annotated, Any
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Callable
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
@@ -93,6 +98,20 @@ class WidgetKind:
|
||||
config_model: type[WidgetConfigBase] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
"""Outcome of a credential/connectivity test for a service instance."""
|
||||
|
||||
ok: bool
|
||||
detail: str
|
||||
evidence: str | None = None
|
||||
|
||||
|
||||
#: A test routine receives (config, secrets, store). The store is needed for
|
||||
#: ssh_tasks (SSH-key resolution). Other types ignore it.
|
||||
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceDefinition:
|
||||
"""Closed description of an external service type."""
|
||||
@@ -103,6 +122,7 @@ class ServiceDefinition:
|
||||
config_model: type[ServiceConfigBase]
|
||||
secret_fields: list[SecretField]
|
||||
widget_kinds: list[WidgetKind]
|
||||
test_callable: TestCallable | None = None
|
||||
|
||||
@property
|
||||
def config_schema(self) -> dict[str, Any]:
|
||||
@@ -148,3 +168,58 @@ def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -
|
||||
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
||||
instance = model_cls.model_validate(config or {})
|
||||
return instance.model_dump(exclude_none=True)
|
||||
|
||||
|
||||
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
|
||||
"""Map a common connection/auth exception to a human-friendly TestResult.
|
||||
|
||||
Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus
|
||||
HTTP-client patterns from the widget sources. Each per-type test routine
|
||||
calls this for unexpected exceptions, but handles its **type-specific**
|
||||
auth failures directly (e.g., qBit ``"Fails."``).
|
||||
"""
|
||||
message = str(exc)
|
||||
lowered = message.lower()
|
||||
|
||||
# Auth failures (HTTP 401/403)
|
||||
if isinstance(exc, requests.HTTPError):
|
||||
status_code = exc.response.status_code if exc.response is not None else 0
|
||||
if status_code in (401, 403):
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail=f"Authentication failed — the service rejected the credentials ({status_code}).",
|
||||
)
|
||||
if "authentication failed" in lowered or "no authentication methods available" in lowered:
|
||||
return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.")
|
||||
|
||||
# Timeout (before OSError check, since requests.Timeout is a subclass of OSError)
|
||||
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
|
||||
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
|
||||
|
||||
# Connection refused / DNS / unreachable
|
||||
if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)):
|
||||
if (
|
||||
"name or service not known" in lowered
|
||||
or "nodename nor servname" in lowered
|
||||
or "getaddrinfo failed" in lowered
|
||||
):
|
||||
return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.")
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail="Connection refused — the service is not reachable at the configured address.",
|
||||
)
|
||||
|
||||
# SSL / certificate errors
|
||||
if "ssl" in lowered or "certificate" in lowered:
|
||||
return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.")
|
||||
|
||||
# SSH banner (from test_machine_ssh pattern)
|
||||
if "protocol banner" in lowered:
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail="SSH banner not received — confirm the SSH service is running and the port is correct.",
|
||||
)
|
||||
|
||||
# Fallback
|
||||
prefix = f"{context}: " if context else ""
|
||||
return TestResult(ok=False, detail=f"{prefix}{message[:200]}")
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -2,15 +2,40 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""Call JellyfinClient.users() — the lightest authenticated probe."""
|
||||
try:
|
||||
base_url = str(config.get("base_url") or "")
|
||||
api_key = str(secrets.get("api_key") or "")
|
||||
timeout = int(config.get("timeout_seconds") or 60)
|
||||
client = JellyfinClient(base_url, api_key, timeout=timeout)
|
||||
users = client.users()
|
||||
return TestResult(ok=True, detail="Connected to Jellyfin.", evidence=f"{len(users)} users")
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="Jellyfin")
|
||||
|
||||
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyfin connection config.
|
||||
@@ -24,7 +49,7 @@ class JellyfinConfig(ServiceConfigBase):
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
timeout_seconds: int = 60
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
|
||||
@@ -36,6 +61,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
|
||||
"""Only show sessions with active playback (not idle/paused)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="jellyfin",
|
||||
name="Jellyfin",
|
||||
@@ -53,5 +84,14 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="now_playing",
|
||||
name="Now Playing",
|
||||
description="Only sessions actively playing media.",
|
||||
model_cls=JellyfinNowPlayingWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
|
||||
@@ -6,13 +6,39 @@ dashboard widgets yet; its service page holds connection config only.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
translate_connection_error,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""GET {base_url}/status.php (unauthenticated server probe)."""
|
||||
try:
|
||||
base_url = str(config.get("base_url") or "").rstrip("/")
|
||||
resp = requests.get(f"{base_url}/status.php", timeout=(5.0, 60.0))
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
version = str(payload.get("version", "") or "connected")
|
||||
return TestResult(ok=True, detail="Connected to Nextcloud.", evidence=version)
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="Nextcloud")
|
||||
|
||||
|
||||
class NextcloudConfig(ServiceConfigBase):
|
||||
"""Non-secret Nextcloud connection config."""
|
||||
@@ -30,4 +56,5 @@ DEFINITION = ServiceDefinition(
|
||||
SecretField(key="app_password", label="App password", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
|
||||
@@ -2,21 +2,78 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""POST {grafana_url}/api/ds/query with expr 'up' via the Grafana gateway."""
|
||||
try:
|
||||
grafana_url = str(config.get("grafana_url") or "").rstrip("/")
|
||||
api_key = str(secrets.get("grafana_api_key") or "")
|
||||
datasource_uid = str(config.get("datasource_uid") or "prometheus")
|
||||
timeout = int(config.get("timeout_seconds") or 60)
|
||||
if not grafana_url:
|
||||
return TestResult(ok=False, detail="Grafana gateway URL is required.")
|
||||
if not api_key:
|
||||
return TestResult(ok=False, detail="Grafana API key is required.")
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": "up",
|
||||
"format": "time_series",
|
||||
"intervalMs": 15000,
|
||||
"maxDataPoints": 1,
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": "now-1m",
|
||||
"to": "now",
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{grafana_url}/api/ds/query",
|
||||
json=body,
|
||||
timeout=timeout,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return TestResult(
|
||||
ok=True,
|
||||
detail="Grafana gateway reachable.",
|
||||
evidence="Gateway reachable; datasource responded.",
|
||||
)
|
||||
except requests.HTTPError as exc:
|
||||
return translate_connection_error(exc, context="Prometheus via Grafana")
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="Prometheus via Grafana")
|
||||
|
||||
|
||||
class PrometheusConfig(ServiceConfigBase):
|
||||
"""Non-secret Prometheus connection config."""
|
||||
"""Non-secret Prometheus-via-Grafana gateway config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
grafana_url: ServiceBaseUrl
|
||||
datasource_uid: str = "prometheus"
|
||||
timeout_seconds: int = 60
|
||||
|
||||
|
||||
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||
@@ -25,13 +82,56 @@ 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)
|
||||
# Display scaling for the Y axis + tooltip. "none" shows raw values; the
|
||||
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
|
||||
unit: Literal[
|
||||
"none",
|
||||
"bytes",
|
||||
"bytes_per_sec",
|
||||
"bits_per_sec",
|
||||
"bits",
|
||||
"percent",
|
||||
"seconds",
|
||||
] = "none"
|
||||
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||
|
||||
|
||||
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111)."""
|
||||
|
||||
promql: str
|
||||
warn_at: float | None = None
|
||||
crit_at: float | None = None
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
class PrometheusMeanWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
|
||||
|
||||
promql: str
|
||||
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
description="Metrics storage and PromQL queries.",
|
||||
config_model=PrometheusConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
SecretField(
|
||||
key="grafana_api_key",
|
||||
label="Grafana API key",
|
||||
required=True,
|
||||
helper="Service account token or API key for the Grafana gateway",
|
||||
),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
@@ -42,5 +142,30 @@ DEFINITION = ServiceDefinition(
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="chart",
|
||||
name="Chart",
|
||||
description="Multi-series line chart from a PromQL range query.",
|
||||
model_cls=PrometheusChartWidgetConfig,
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="gauge",
|
||||
name="Gauge",
|
||||
description="Instant query rendered as a gauge with optional threshold bands.",
|
||||
model_cls=PrometheusGaugeWidgetConfig,
|
||||
default_config={"promql": ""},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="mean",
|
||||
name="Mean",
|
||||
description="Average value of a PromQL query over a time window.",
|
||||
model_cls=PrometheusMeanWidgetConfig,
|
||||
default_config={"promql": "", "window": "1h"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""qBittorrent service definition.
|
||||
|
||||
Declares the config model (base URL + timeout), secret fields (username +
|
||||
password), and three widget kinds (totals, active, speed). Models on
|
||||
:mod:`media_library_viewer_api.integrations.prometheus`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""Login + probe maindata; surface auth failures specifically."""
|
||||
try:
|
||||
base_url = str(config.get("base_url") or "")
|
||||
username = str(secrets.get("username") or "")
|
||||
password = str(secrets.get("password") or "")
|
||||
timeout = int(config.get("timeout_seconds") or 60)
|
||||
client = QbittorrentClient(base_url, username, password, timeout=timeout)
|
||||
data = client.maindata()
|
||||
version = str(data.get("server_state", {}).get("qbittorrent_version", "") or "connected")
|
||||
return TestResult(ok=True, detail="Connected to qBittorrent.", evidence=version)
|
||||
except RuntimeError as exc:
|
||||
lowered = str(exc).lower()
|
||||
if "invalid username or password" in lowered:
|
||||
return TestResult(ok=False, detail="Authentication failed — qBittorrent rejected the credentials.")
|
||||
# Gateway timeout, wrong URL/path, empty body, etc. — surface the real
|
||||
# reason instead of masking every login error as an auth failure.
|
||||
return translate_connection_error(exc, context="qBittorrent")
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="qBittorrent")
|
||||
|
||||
|
||||
class QbittorrentConfig(ServiceConfigBase):
|
||||
"""Non-secret qBittorrent connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 60
|
||||
|
||||
|
||||
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||
"""Per-widget config for totals/active (empty — derived from the service connection)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class QbittorrentSpeedWidgetConfig(WidgetConfigBase):
|
||||
"""Speed chart config. The source returns raw bytes/sec; the frontend scales."""
|
||||
|
||||
unit: Literal[
|
||||
"none",
|
||||
"bytes",
|
||||
"bytes_per_sec",
|
||||
"bits_per_sec",
|
||||
"bits",
|
||||
"percent",
|
||||
"seconds",
|
||||
] = "bytes_per_sec"
|
||||
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||
|
||||
|
||||
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=QbittorrentSpeedWidgetConfig,
|
||||
default_config={"unit": "bytes_per_sec", "scale": "auto"},
|
||||
refresh_interval_ms=5_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -10,18 +10,18 @@ from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALE
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
QBITTORRENT.service_type: QBITTORRENT,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
BACKUPS.service_type: BACKUPS,
|
||||
AUTHENTIK.service_type: AUTHENTIK,
|
||||
|
||||
@@ -8,14 +8,74 @@ and shown as history on the instance's service page.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""Build an SSH client via build_ssh_client and attempt .connect().
|
||||
|
||||
Reuses the same error-translation patterns as test_machine_ssh (banner,
|
||||
auth failed). Known-host recording is preserved.
|
||||
"""
|
||||
from media_library_viewer_api.services.task_runner import build_ssh_client
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
host = str(config.get("host") or "").strip()
|
||||
port = int(config.get("port") or 22)
|
||||
try:
|
||||
service = ServiceRecord(
|
||||
id="",
|
||||
service_type="ssh_tasks",
|
||||
name="test",
|
||||
config=config,
|
||||
secrets=secrets,
|
||||
enabled=True,
|
||||
)
|
||||
client = build_ssh_client(store, service)
|
||||
try:
|
||||
client.connect()
|
||||
except Exception as exc:
|
||||
lowered = str(exc).lower()
|
||||
if "protocol banner" in lowered:
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail=f"SSH banner not received from {host}:{port}; confirm the SSH service is running.",
|
||||
)
|
||||
if "no authentication methods available" in lowered or "authentication failed" in lowered:
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail=f"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.",
|
||||
)
|
||||
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||
finally:
|
||||
client.close()
|
||||
return TestResult(
|
||||
ok=True,
|
||||
detail=f"SSH connection succeeded for {host}:{port}.",
|
||||
evidence=f"Connected to {host}:{port}",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return TestResult(ok=False, detail=str(exc))
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||
|
||||
|
||||
class SshTasksConfig(ServiceConfigBase):
|
||||
"""Non-secret SSH task runner config.
|
||||
@@ -57,4 +117,5 @@ DEFINITION = ServiceDefinition(
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
|
||||
@@ -37,6 +37,23 @@ from .version import get_backend_version, get_version_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_prometheus_gateway_config() -> None:
|
||||
"""Warn (not crash) about old-shape prometheus services needing migration (GM-113)."""
|
||||
try:
|
||||
store = get_settings_store()
|
||||
for service in store.list_services("prometheus"):
|
||||
config = service.get("config") or {}
|
||||
if "base_url" in config and "grafana_url" not in config:
|
||||
logger.warning(
|
||||
"Prometheus service '%s' (id=%s) uses the old 'base_url' config shape. "
|
||||
"Reconfigure with grafana_url + grafana_api_key (see CHANGELOG).",
|
||||
service.get("name"),
|
||||
service.get("id"),
|
||||
)
|
||||
except Exception: # pragma: no cover - startup best-effort
|
||||
logger.exception("Failed to validate prometheus gateway config during startup")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan — startup/shutdown."""
|
||||
@@ -52,6 +69,13 @@ async def lifespan(app: FastAPI):
|
||||
get_settings_store().ensure_defaults()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default settings during startup")
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness()
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize service data harness during startup")
|
||||
_validate_prometheus_gateway_config()
|
||||
mail_queue = get_mail_queue()
|
||||
backup_poller = get_backup_poller()
|
||||
mail_queue.start()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/models
|
||||
|
||||
## role
|
||||
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -10,6 +10,7 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
-
|
||||
## files
|
||||
- backups.py
|
||||
- dashboards.py
|
||||
- services.py
|
||||
- widgets.py
|
||||
## links
|
||||
@@ -17,6 +18,6 @@ index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, services.py, widgets.py
|
||||
read: backups.py, dashboards.py, services.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/models
|
||||
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
|
||||
## files
|
||||
- backups.py | Defines Pydantic data models for backup system API requests and responses including reports, jobs, runs, alerts, and dashboard summaries. | exp: class:BackupReportRequest, class:BackupJobResponse, class:BackupRunResponse, class:BackupAlertResponse, class:BackupDashboardSummary | dep: datetime, typing, pydantic
|
||||
- dashboards.py | Defines Pydantic data models for creating, updating, and representing named dashboard records in an API. | exp: class:NamedDashboardInput, class:NamedDashboard | dep: typing, pydantic
|
||||
- services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
|
||||
- widgets.py | Defines Pydantic models for a dashboard widget system with validation to prevent secrets/credentials in widget configuration. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||
- widgets.py | Defines Pydantic models for a dashboard widget system, including input/output schemas and validation to prevent credential leakage in widget configurations. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||
## arch
|
||||
Pydantic-based model layer implementing data validation, serialization contracts, and custom validators enforcing security constraints (e.g., blocking credentials in non-secret configs).
|
||||
Pydantic-based model layer following a schema-first design pattern with built-in validators for domain-specific business rules and data integrity.
|
||||
## tags
|
||||
widget, backup, instance, response, info, call:isinstance, call:, service
|
||||
widget, backup, instance, dashboard, pydantic, response, info, call:isinstance
|
||||
## symbols
|
||||
- BackupReportRequest
|
||||
- BackupJobResponse
|
||||
- BackupRunResponse
|
||||
- BackupAlertResponse
|
||||
- BackupDashboardSummary
|
||||
- NamedDashboardInput
|
||||
- NamedDashboard
|
||||
- ServiceInstanceInput
|
||||
- ServiceInstance
|
||||
- SecretFieldInfo
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, services.py, widgets.py
|
||||
read: backups.py, dashboards.py, services.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Widgets are either:
|
||||
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||
by that service's definition (Prometheus metric, Jellyfin
|
||||
activity, SSH task output); or
|
||||
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||
service-less kinds (backups, static).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/routers
|
||||
|
||||
## role
|
||||
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -10,8 +10,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- authentik_users.py
|
||||
- backups.py
|
||||
- dashboard.py
|
||||
- dashboards.py
|
||||
- files.py
|
||||
- jobs.py
|
||||
- media.py
|
||||
@@ -19,14 +21,12 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
- services.py
|
||||
- settings.py
|
||||
- tasks.py
|
||||
- users.py
|
||||
- users_impl.py
|
||||
- widgets.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, backups.py, dashboard.py
|
||||
read: __init__.py, authentik_users.py, backups.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -4,26 +4,27 @@ dir: backend/src/media_library_viewer_api/routers
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organizing routes by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for routers.
|
||||
- backups.py | FastAPI router that provides REST endpoints for reporting, tracking, and alerting on backup jobs and runs. | exp: func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||
- authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
|
||||
- backups.py | FastAPI router for receiving backup run reports, managing backup jobs/runs, and generating/acknowledging backup alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||
- dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store
|
||||
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
|
||||
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
||||
- media.py | FastAPI router that manages media index lifecycle (status, build, stop, query) with subprocess worker orchestration and cooperative/force cancellation. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path) → list[str], call:str, func:_start_worker(index: MediaIndex) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(client, user_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:client.libraries, call:logger.info, call:len, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index, media_library_viewer_api.workers.media_index_worker
|
||||
- monitoring.py | FastAPI router providing endpoints to check the health/status of Alertmanager, Grafana, and Prometheus services and expose Prometheus scrape targets. | exp: func:_resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services, func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_grafana_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
||||
- media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index
|
||||
- monitoring.py | FastAPI router providing monitoring observability endpoints that proxy and aggregate status, alerts, and scrape targets from Alertmanager and Prometheus. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
||||
- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store
|
||||
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
||||
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||
- users.py | Re-exports all public names from the users_impl module to provide a unified public API for user-related functionality. | dep: .users_impl, users_impl
|
||||
- users_impl.py | FastAPI router that fetches and merges Jellyfin users with optional Jellyseerr enrichment, plus endpoints for queueing user emails via a background mail worker. | exp: func:_safe_int(value: Any) → int, call:int, func:_permission_labels(permissions: int) → list[str], func:_role_label(permissions: int) → str, func:_account_type(user_type: Any) → str, call:_USER_TYPES.get, call:_safe_int, func:_merge_users(jellyfin_users: list[dict[str, Any]], jellyseerr_jellyfin_users: list[dict[str, Any]] | None, jellyseerr_users: list[dict[str, Any]] | None, jellyseerr_client: JellyseerrClient | None) → dict[str, Any], call:str(value or "").strip().lower, call:bool, call:_looks_like_email, call:str(value).strip, call:", ".join, call:_normalize, call:item.get, call:_lookup_keys, call:user.get, call:linked_by_jellyfin_id.get, call:(jf_link or {}).get, call:seerr_by_key.get, call:_pick_source_and_value, call:(seerr_user or {}).get, call:_first_value, call:jellyseerr_client.absolute_url, call:_safe_int, call:_role_label, call:_source_summary, call:items.append, call:_account_type, call:_permission_labels, call:logger.info, call:len, func:_normalize(value: Any) → str, call:str(value or "").strip().lower, func:_looks_like_email(value: Any) → bool, call:str(value or "").strip, call:bool, func:_pick_source_and_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:_looks_like_email, call:str(value).strip, func:_first_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:str(value or "").strip, func:_source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) → str, call:", ".join, func:_lookup_keys(item: dict[str, Any]) → list[str], call:_normalize, call:item.get, func:get_users(jellyfin, jellyseerr) → dict[str, Any], call:jellyfin.users, call:logger.info, call:len, call:jellyseerr.jellyfin_users, call:logger.exception, call:jellyseerr.users, call:_merge_users, call:bool, func:get_user_message_status() → dict[str, Any], call:mail_queue.status, func:post_user_message(recipient_ids, subject, html_body, text_body, attachments, jellyfin, jellyseerr) → dict[str, Any], call:json.loads, call:isinstance, call:str(item).strip, call:subject.strip, call:get_users, call:item.get, call:directory.get, call:users_by_id.get, call:skipped.append, call:str(item.get("email") or "").strip, call:recipients.append, call:recipient_labels.append, call:get_settings, call:validate_smtp_settings, call:mail_queue.status, call:upload.read, call:attachment_payloads.append, call:EmailAttachment, call:mail_queue.enqueue, call:str(getattr(settings, "smtp_from_address", "") or "").strip, call:getattr, call:str(getattr(settings, "smtp_username", "") or "").strip, call:logger.info, call:len, raise:HTTPException | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mailer
|
||||
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances, including CRUD operations, built-in widget discovery, and data fetching through service or built-in adapters. | exp: func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException | dep: logging, time, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances and their references, including CRUD operations, data fetching, and live-link detachments. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||
## arch
|
||||
Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter; routers are registered by the parent application and share common dependencies for SSH orchestration, database access, and service resolution.
|
||||
Modular FastAPI APIRouter pattern where each domain module exports its own router instance; routers encapsulate endpoint definitions and delegate business logic to underlying service clients, SSH utilities, and subprocess workers.
|
||||
## tags
|
||||
call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh, call:logger.info
|
||||
call:, raise:httpexception, service, backup, get, media_library_viewer_api, ssh, call:store.get
|
||||
## symbols
|
||||
- MessageRequest
|
||||
- RunJobRequest
|
||||
- MonitoringMachineInput
|
||||
- SSHKeyInput
|
||||
@@ -31,9 +32,8 @@ call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh,
|
||||
- ResetLocalDatabaseInput
|
||||
- TaskInput
|
||||
- RunTaskRequest
|
||||
- _get_or_create_job
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, backups.py, dashboard.py
|
||||
read: __init__.py, authentik_users.py, backups.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
@@ -20,8 +20,9 @@ from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,29 +37,6 @@ class MessageRequest(BaseModel):
|
||||
html_body: str
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested authentik instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled ``authentik`` instance is configured.
|
||||
"""
|
||||
service_type = "authentik"
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(service.secrets.get("api_token") or "")
|
||||
@@ -82,7 +60,7 @@ def get_authentik_users(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated Authentik user directory for a specific service instance."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||
return _empty("Authentik service not configured")
|
||||
@@ -102,7 +80,7 @@ def get_authentik_message_status(
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
@@ -116,7 +94,7 @@ def post_authentik_message(
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
service = resolve_service_record(store, "authentik", service_id)
|
||||
if service is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
|
||||
|
||||
@@ -129,9 +129,10 @@ def post_backup_start(
|
||||
|
||||
@router.get("/jobs")
|
||||
def get_backup_jobs(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
jobs = store.list_backup_jobs()
|
||||
jobs = store.list_backup_jobs(service_id=service_id)
|
||||
return jobs
|
||||
|
||||
|
||||
@@ -155,9 +156,10 @@ def get_backup_runs(
|
||||
job_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[BackupRunResponse]:
|
||||
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit)
|
||||
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit, service_id=service_id)
|
||||
return [BackupRunResponse(**run) for run in runs]
|
||||
|
||||
|
||||
@@ -177,9 +179,15 @@ def get_backup_alerts(
|
||||
job_id: str | None = None,
|
||||
acknowledged: bool | None = None,
|
||||
severity: str | None = None,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[BackupAlertResponse]:
|
||||
alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity)
|
||||
alerts = store.list_backup_alerts(
|
||||
job_id=job_id,
|
||||
acknowledged=acknowledged,
|
||||
severity=severity,
|
||||
service_id=service_id,
|
||||
)
|
||||
return [BackupAlertResponse(**alert) for alert in alerts]
|
||||
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
str(final_db_path),
|
||||
"--staging-path",
|
||||
str(staging_db_path),
|
||||
"--service-id",
|
||||
service_id,
|
||||
]
|
||||
|
||||
|
||||
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
|
||||
def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
|
||||
staging_path = _staging_db_path(index)
|
||||
staging_path.unlink(missing_ok=True)
|
||||
return subprocess.Popen(
|
||||
_worker_command(index.db_path, staging_path),
|
||||
_worker_command(index.db_path, staging_path, service_id),
|
||||
start_new_session=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
@@ -131,20 +133,28 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
|
||||
|
||||
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
|
||||
def post_build_index(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
jellyfin_service_id: str | None = None,
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Start a media index build in a subprocess worker."""
|
||||
"""Start a media index build in a subprocess worker.
|
||||
|
||||
The worker resolves its own Jellyfin connection from the settings store.
|
||||
We do NOT use Depends(get_jellyfin_client) here because the worker runs
|
||||
in a separate process and needs to resolve the client itself. Validating
|
||||
the connection here would fail if Jellyfin is briefly unreachable, even
|
||||
though the build just needs to start the worker process.
|
||||
"""
|
||||
with _build_lock:
|
||||
current_status = _clean_stale_build_state(index)
|
||||
if current_status.build_running and _pid_is_alive(current_status.build_pid):
|
||||
logger.warning("Media build already running pid=%s", current_status.build_pid)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
|
||||
|
||||
libraries = client.libraries(user_id)
|
||||
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
||||
process = _start_worker(index)
|
||||
logger.info(
|
||||
"Starting media index build service_id=%s",
|
||||
jellyfin_service_id or "<default>",
|
||||
)
|
||||
process = _start_worker(index, jellyfin_service_id or "")
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
@@ -156,7 +166,7 @@ def post_build_index(
|
||||
"build_items_total": 0,
|
||||
"build_current_library": "",
|
||||
"build_library_index": 0,
|
||||
"build_libraries_total": len(libraries),
|
||||
"build_libraries_total": 0,
|
||||
"build_library_progress": None,
|
||||
"build_library_items_processed": 0,
|
||||
"build_library_items_total": 0,
|
||||
@@ -272,6 +282,7 @@ def query_media(
|
||||
sort_order: str = Query("Ascending", description="Ascending or Descending"),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
jellyfin_service_id: str | None = None,
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
@@ -306,6 +317,7 @@ def query_media(
|
||||
sort_order=sort_order,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
service_id=jellyfin_service_id or "",
|
||||
)
|
||||
|
||||
logger.info("Media query returned total=%s rows=%s", total, len(rows))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Monitoring router — observability service status.
|
||||
|
||||
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||
Observability components (Alertmanager, Prometheus) are resolved from
|
||||
the service registry, not environment variables. The endpoints pick the first
|
||||
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||
@@ -14,41 +14,24 @@ from typing import Any
|
||||
import requests
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import http_timeout
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.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("/")
|
||||
|
||||
|
||||
def _timeout(service: ServiceRecord, default: int) -> int:
|
||||
return int(service.config.get("timeout_seconds") or default)
|
||||
def _timeout(service: ServiceRecord, default: int) -> tuple[float, float]:
|
||||
"""Return a (connect, read) timeout tuple from the service config."""
|
||||
read = int(service.config.get("timeout_seconds") or default)
|
||||
return http_timeout(read)
|
||||
|
||||
|
||||
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
||||
@@ -104,7 +87,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 +132,7 @@ def get_alertmanager_status(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return Alertmanager cluster/status for the UI health card."""
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
service = resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {
|
||||
"up": False,
|
||||
@@ -192,51 +175,57 @@ def get_alertmanager_status(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grafana-status")
|
||||
def get_grafana_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||
service = _resolve_service_record(store, "grafana", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/health",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Grafana status")
|
||||
return _status_response(service, error="grafana_unreachable")
|
||||
return _status_response(service, version=data.get("version", ""))
|
||||
|
||||
|
||||
@router.get("/prometheus-status")
|
||||
def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Prometheus service instance's health and build info."""
|
||||
service = _resolve_service_record(store, "prometheus", service_id)
|
||||
"""Probe a Prometheus service's health via the Grafana gateway path (GM-110).
|
||||
|
||||
Issues a trivial ``up`` query through Grafana ``/api/ds/query``. Success
|
||||
validates the full path: Grafana is reachable, the API key works, and the
|
||||
Prometheus datasource responds.
|
||||
"""
|
||||
service = resolve_service_record(store, "prometheus", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
base = _base_url(service)
|
||||
timeout = _timeout(service, 10)
|
||||
headers = _auth_headers(service)
|
||||
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("grafana_api_key") or "")
|
||||
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
|
||||
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||
if not grafana_url or not api_key:
|
||||
return _status_response(service, error="gateway_not_configured")
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": "up",
|
||||
"format": "time_series",
|
||||
"intervalMs": 15_000,
|
||||
"maxDataPoints": 1,
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": "now-1m",
|
||||
"to": "now",
|
||||
}
|
||||
try:
|
||||
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
||||
health.raise_for_status()
|
||||
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
||||
build_info.raise_for_status()
|
||||
version = build_info.json().get("data", {}).get("version", "")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Prometheus status")
|
||||
resp = requests.post(
|
||||
f"{grafana_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
timeout=http_timeout(timeout),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
status_code = exc.response.status_code if exc.response else 0
|
||||
if status_code in (401, 403):
|
||||
return _status_response(service, error="auth_failed")
|
||||
return _status_response(service, error="gateway_error")
|
||||
except requests.RequestException:
|
||||
logger.exception("Failed to fetch Prometheus status via gateway")
|
||||
return _status_response(service, error="prometheus_unreachable")
|
||||
return _status_response(service, version=version)
|
||||
return _status_response(service, version="ok")
|
||||
|
||||
|
||||
@router.post("/alertmanager-webhook")
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
from media_library_viewer_api.integrations.base import TestResult, validate_config
|
||||
from media_library_viewer_api.integrations.registry import (
|
||||
SERVICE_DEFINITIONS,
|
||||
get_service_definition,
|
||||
@@ -180,3 +180,30 @@ def delete_instance(
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||
store.delete_service(service_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_instance(
|
||||
body: ServiceInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Test connectivity + credentials for unsaved service input.
|
||||
|
||||
Validates first (422 on malformed config), dispatches to the per-type
|
||||
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
|
||||
"""
|
||||
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
|
||||
definition = require_service_definition(body.service_type)
|
||||
|
||||
if definition.test_callable is None:
|
||||
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
|
||||
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
|
||||
|
||||
try:
|
||||
result: TestResult = definition.test_callable(body.config, body.secrets, store)
|
||||
except Exception as exc:
|
||||
logger.exception("test_callable raised for type=%s", body.service_type)
|
||||
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
|
||||
|
||||
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
|
||||
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
|
||||
get_service_adapter,
|
||||
)
|
||||
|
||||
|
||||
class WidgetReferenceCreate(BaseModel):
|
||||
"""Payload for creating a widget reference (live-link)."""
|
||||
|
||||
dashboard_scope: str
|
||||
widget_id: str
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -103,10 +113,18 @@ def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
service_id: str | None = None,
|
||||
scope: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all persisted widget instances."""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
||||
"""Return widget instances, optionally filtered.
|
||||
|
||||
- ``?service_id=X``: only widgets for service X.
|
||||
- ``?scope=dashboard``: only widgets with NULL service_id.
|
||||
- ``?scope=service``: only widgets with a non-null service_id.
|
||||
- No params: all widgets (backward-compatible).
|
||||
"""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
@@ -213,3 +231,65 @@ async def fetch_data(
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/references")
|
||||
def list_references(
|
||||
dashboard_scope: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List widget references for a dashboard scope."""
|
||||
return store.list_widget_references(dashboard_scope)
|
||||
|
||||
|
||||
@router.post("/references", status_code=status.HTTP_201_CREATED)
|
||||
def create_reference(
|
||||
body: WidgetReferenceCreate,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a widget reference (live-link) on a dashboard."""
|
||||
try:
|
||||
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/references/{reference_id}")
|
||||
def delete_reference(
|
||||
reference_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Remove a widget reference from a dashboard."""
|
||||
store.delete_widget_reference(reference_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.put("/references/{reference_id}")
|
||||
def update_reference(
|
||||
reference_id: str,
|
||||
sort_order: int,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Update a widget reference's sort_order (per-dashboard reordering)."""
|
||||
try:
|
||||
return store.update_widget_reference(reference_id, sort_order)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/references/{reference_id}/detach")
|
||||
def detach_reference(
|
||||
reference_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Clone the referenced widget into a standalone instance and remove the reference."""
|
||||
try:
|
||||
cloned = store.detach_widget_reference(reference_id, "")
|
||||
return WidgetInstance(**cloned).model_dump()
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/services
|
||||
|
||||
## role
|
||||
Backend service layer providing business logic for media library management, backup monitoring, email notifications, SSH task execution, encryption, and persistent settings storage.
|
||||
Backend service layer providing business logic for media indexing, backup monitoring, email delivery, secrets management, task execution, and persistent storage operations.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -19,7 +19,10 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
- mailer_impl.py
|
||||
- media_index.py
|
||||
- media_index_impl.py
|
||||
- qbittorrent_store.py
|
||||
- secrets.py
|
||||
- service_data.py
|
||||
- service_resolution.py
|
||||
- settings_store.py
|
||||
- targets.py
|
||||
- task_runner.py
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,7 @@ from typing import Any, Callable, Iterable
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
|
||||
from media_library_viewer_api.path_utils import resolve_remote_media_path
|
||||
from media_library_viewer_api.services.service_data import StorageConcern
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,6 +26,20 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
|
||||
MEDIA_TYPES = "Movie,Episode,Video"
|
||||
|
||||
# Harness concern registration: the media_index DB is scoped by service_id so
|
||||
# multiple Jellyfin instances can coexist. The ALTER TABLE migration adds the
|
||||
# service_id column to existing DBs; init_schema adds it for fresh installs.
|
||||
# The harness run_migrations catches "duplicate column name" on re-runs.
|
||||
MEDIA_INDEX_CONCERN = StorageConcern(
|
||||
concern_key="media_index",
|
||||
db_filename=DEFAULT_INDEX_PATH.name,
|
||||
migrations=[
|
||||
"ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
|
||||
],
|
||||
tables=["media_items"],
|
||||
service_id_column="service_id",
|
||||
)
|
||||
|
||||
# Only values from this whitelist are interpolated into ORDER BY. User-selected
|
||||
# sort keys map to these known SQL snippets to avoid SQL injection.
|
||||
SORT_COLUMNS = {
|
||||
@@ -135,7 +150,8 @@ class MediaIndex:
|
||||
date_added_ts INTEGER,
|
||||
path TEXT,
|
||||
library_id TEXT,
|
||||
library_name TEXT
|
||||
library_name TEXT,
|
||||
service_id TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS index_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
@@ -167,8 +183,13 @@ class MediaIndex:
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set."""
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set.
|
||||
|
||||
Scoped by ``service_id``: only rows belonging to this service are
|
||||
deleted before the new batch is inserted. This means building for one
|
||||
Jellyfin instance no longer wipes another instance's rows.
|
||||
"""
|
||||
self.init_schema()
|
||||
row_list = list(rows)
|
||||
columns = [
|
||||
@@ -194,13 +215,14 @@ class MediaIndex:
|
||||
"path",
|
||||
"library_id",
|
||||
"library_name",
|
||||
"service_id",
|
||||
]
|
||||
placeholders = ",".join(["?"] * len(columns))
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM media_items")
|
||||
conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
|
||||
conn.executemany(
|
||||
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
|
||||
[[row.get(column) for column in columns] for row in row_list],
|
||||
[[row.get(column) if column != "service_id" else service_id for column in columns] for row in row_list],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
|
||||
@@ -287,8 +309,14 @@ class MediaIndex:
|
||||
sort_order: str = "Ascending",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
service_id: str = "",
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Query indexed media with full-index filters, sorting, and pagination."""
|
||||
"""Query indexed media with full-index filters, sorting, and pagination.
|
||||
|
||||
When ``service_id`` is non-empty, only rows matching that service are
|
||||
returned. When empty (the default), all rows are returned (backward-
|
||||
compatible with callers that are not multi-instance aware).
|
||||
"""
|
||||
self.init_schema()
|
||||
where = []
|
||||
params: list[Any] = []
|
||||
@@ -309,6 +337,9 @@ class MediaIndex:
|
||||
where.append("hdr = 1")
|
||||
elif hdr_filter == "SDR/unknown only":
|
||||
where.append("(hdr IS NULL OR hdr = 0)")
|
||||
if service_id:
|
||||
where.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
|
||||
where_sql = " WHERE " + " AND ".join(where) if where else ""
|
||||
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
|
||||
@@ -338,6 +369,7 @@ def build_media_index(
|
||||
fallback_prefix: str = "",
|
||||
progress_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
service_id: str = "",
|
||||
) -> int:
|
||||
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
||||
index = index or MediaIndex()
|
||||
@@ -453,7 +485,7 @@ def build_media_index(
|
||||
logger.info("Media index finalizing rows=%s", len(normalized_rows))
|
||||
emit("finalizing", "Writing index to disk")
|
||||
ensure_not_cancelled()
|
||||
count = index.replace_items(normalized_rows)
|
||||
count = index.replace_items(normalized_rows, service_id=service_id)
|
||||
duration = time.perf_counter() - started_at
|
||||
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
|
||||
processed_total = count
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Speed-sample storage for qBittorrent widgets.
|
||||
|
||||
This module defines the storage concern for qBittorrent speed data and a
|
||||
bespoke store with ``append``/``window`` operations. It is registered with the
|
||||
:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as
|
||||
the first real consumer of the harness lifecycle layer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.services.service_data import StorageConcern
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.service_data import ServiceDataHarness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
QBITTORRENT_CONCERN = StorageConcern(
|
||||
concern_key="qbittorrent",
|
||||
db_filename="qbittorrent.db",
|
||||
migrations=[
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
|
||||
service_id TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
dl_speed INTEGER NOT NULL DEFAULT 0,
|
||||
up_speed INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
|
||||
ON qbittorrent_speed_samples(service_id, ts);
|
||||
"""
|
||||
],
|
||||
tables=["qbittorrent_speed_samples"],
|
||||
)
|
||||
|
||||
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
|
||||
MAX_SAMPLES = 120
|
||||
|
||||
|
||||
class QbittorrentSampleStore:
|
||||
"""Bespoke speed-sample store for qBittorrent widgets.
|
||||
|
||||
Each ``append`` inserts a new sample and prunes entries beyond
|
||||
:data:`MAX_SAMPLES`, keeping only the most recent rows for the given
|
||||
``service_id``.
|
||||
"""
|
||||
|
||||
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
|
||||
if harness is None:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
harness = get_service_data_harness()
|
||||
self._harness = harness
|
||||
|
||||
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
|
||||
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
|
||||
(service_id, ts, dl_speed, up_speed),
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM qbittorrent_speed_samples "
|
||||
"WHERE service_id = ? AND ts NOT IN ("
|
||||
" SELECT ts FROM qbittorrent_speed_samples"
|
||||
" WHERE service_id = ?"
|
||||
" ORDER BY ts DESC LIMIT ?"
|
||||
")",
|
||||
(service_id, service_id, MAX_SAMPLES),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
|
||||
"""Return all samples for a service since a timestamp (or all if ``None``)."""
|
||||
with self._harness.connect("qbittorrent") as conn:
|
||||
if since_ts is not None:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples "
|
||||
"WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
|
||||
(service_id, since_ts),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
|
||||
(service_id,),
|
||||
).fetchall()
|
||||
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Lifecycle-only storage harness for service-owned persistent data.
|
||||
|
||||
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
|
||||
schema migrations, and cascade-delete when a service instance is removed. It
|
||||
does **not** own data operations — each integration implements its own Store
|
||||
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
|
||||
etc.). This keeps the general interface narrow (lifecycle) and the specific
|
||||
interfaces rich (per-integration operations).
|
||||
|
||||
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
|
||||
declaring its DB filename, ordered migration statements, owned tables, and the
|
||||
column used for service scoping.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StorageConcern:
|
||||
"""A per-integration storage namespace registered with the harness."""
|
||||
|
||||
concern_key: str # e.g. "qbittorrent", "media_index"
|
||||
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
|
||||
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
|
||||
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
|
||||
service_id_column: str = "service_id"
|
||||
|
||||
|
||||
class ServiceDataHarness:
|
||||
"""Lifecycle-only registry of per-concern storage.
|
||||
|
||||
Owns:
|
||||
* DB provisioning (per-concern SQLite files under ``base_dir``).
|
||||
* Schema migrations (run on first access via :meth:`run_migrations`).
|
||||
* ``service_id`` cascade-delete when a service instance is removed.
|
||||
|
||||
Does **not** own:
|
||||
* Data operations — each store keeps bespoke append/window/query/etc.
|
||||
* A generic value table or generic CRUD layer.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: Path | str) -> None:
|
||||
self._base_dir = Path(base_dir)
|
||||
self._concerns: dict[str, StorageConcern] = {}
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self._base_dir
|
||||
|
||||
def register(self, concern: StorageConcern) -> None:
|
||||
"""Register a storage concern. Called at startup / on first access."""
|
||||
self._concerns[concern.concern_key] = concern
|
||||
|
||||
def db_path(self, concern_key: str) -> Path:
|
||||
"""Return the absolute path to a concern's DB file."""
|
||||
concern = self._concerns[concern_key]
|
||||
return self._base_dir / concern.db_filename
|
||||
|
||||
def connect(self, concern_key: str) -> sqlite3.Connection:
|
||||
"""Open a WAL-mode connection to a concern's DB."""
|
||||
path = self.db_path(concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def run_migrations(self) -> None:
|
||||
"""Run pending migrations for every registered concern.
|
||||
|
||||
Each migration string is split into individual statements (by ``;``)
|
||||
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
|
||||
that fail with "duplicate column name" are silently skipped, making
|
||||
migrations idempotent across re-runs and fresh installs where
|
||||
``init_schema`` may have already created the column.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
path = self.db_path(concern.concern_key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
for migration_sql in concern.migrations:
|
||||
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
|
||||
for stmt in statements:
|
||||
try:
|
||||
conn.execute(stmt)
|
||||
except sqlite3.OperationalError as exc:
|
||||
lowered = str(exc).lower()
|
||||
if "duplicate column name" in lowered or "no such table" in lowered:
|
||||
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
|
||||
else:
|
||||
raise
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def cascade_delete(self, service_id: str) -> None:
|
||||
"""Delete all rows for a ``service_id`` across every concern's tables.
|
||||
|
||||
Called from :meth:`SettingsStore.delete_service` after the service row
|
||||
is removed. Best-effort: callers wrap in try/except so a harness
|
||||
failure does not block service deletion.
|
||||
"""
|
||||
for concern in self._concerns.values():
|
||||
col = concern.service_id_column
|
||||
path = self.db_path(concern.concern_key)
|
||||
if not path.exists():
|
||||
continue
|
||||
with sqlite3.connect(path, timeout=30) as conn:
|
||||
for table in concern.tables:
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if col in cols:
|
||||
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_HARNESS: ServiceDataHarness | None = None
|
||||
|
||||
|
||||
def get_service_data_harness() -> ServiceDataHarness:
|
||||
"""Return the process-wide harness singleton, initializing it on first call.
|
||||
|
||||
Lazy registration of built-in concerns happens here (local imports avoid
|
||||
circular dependencies). Migrations are run immediately after registration.
|
||||
"""
|
||||
global _HARNESS
|
||||
if _HARNESS is None:
|
||||
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
|
||||
_HARNESS = ServiceDataHarness(base_dir)
|
||||
# Register built-in concerns (lazy import avoids circular dependency).
|
||||
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
|
||||
|
||||
_HARNESS.register(QBITTORRENT_CONCERN)
|
||||
|
||||
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
|
||||
|
||||
_HARNESS.register(MEDIA_INDEX_CONCERN)
|
||||
_HARNESS.run_migrations()
|
||||
return _HARNESS
|
||||
|
||||
|
||||
def reset_service_data_harness() -> None:
|
||||
"""Reset the singleton (for testing)."""
|
||||
global _HARNESS
|
||||
_HARNESS = None
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Shared helpers for resolving service instances at request time.
|
||||
|
||||
Extracted from the duplicated ``_resolve_service_record`` helpers that lived
|
||||
in ``routers/monitoring.py`` and ``routers/authentik_users.py``. Both routers
|
||||
need the same logic: return the requested service instance (by id), or fall
|
||||
back to the first enabled instance of the type. Returns ``None`` when the
|
||||
instance does not exist, is the wrong type, is disabled, or when no enabled
|
||||
instance of the type is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
|
||||
def resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_type: str,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested service instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled instance of ``service_type`` is configured.
|
||||
"""
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
@@ -81,7 +81,7 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
@@ -165,6 +165,19 @@ class SettingsStore:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||
if "widget_kind" not in widget_cols:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS widget_references (
|
||||
id TEXT PRIMARY KEY,
|
||||
dashboard_scope TEXT NOT NULL,
|
||||
widget_id TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -1062,10 +1075,16 @@ class SettingsStore:
|
||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||
return self._row_to_job(row) if row else None
|
||||
|
||||
def list_backup_jobs(self) -> list[dict[str, Any]]:
|
||||
def list_backup_jobs(self, service_id: str | None = None) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
where = ""
|
||||
params: list[Any] = []
|
||||
if service_id:
|
||||
where = "WHERE service_id = ?"
|
||||
params.append(service_id)
|
||||
sql = f"SELECT * FROM backup_jobs {where} ORDER BY created_at DESC"
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall()
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [self._row_to_job(row) for row in rows]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1154,6 +1173,7 @@ class SettingsStore:
|
||||
job_id: str | None = None,
|
||||
status: str | None = None,
|
||||
limit: int = 50,
|
||||
service_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
@@ -1164,6 +1184,9 @@ class SettingsStore:
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
params.append(status)
|
||||
if service_id:
|
||||
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
|
||||
params.append(service_id)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
|
||||
params.append(max(1, min(int(limit), 200)))
|
||||
@@ -1244,6 +1267,7 @@ class SettingsStore:
|
||||
job_id: str | None = None,
|
||||
acknowledged: bool | None = None,
|
||||
severity: str | None = None,
|
||||
service_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
@@ -1257,6 +1281,9 @@ class SettingsStore:
|
||||
if severity:
|
||||
clauses.append("severity = ?")
|
||||
params.append(severity)
|
||||
if service_id:
|
||||
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
|
||||
params.append(service_id)
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
|
||||
with self.connect() as conn:
|
||||
@@ -1403,10 +1430,36 @@ class SettingsStore:
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
|
||||
def list_widgets(self) -> list[dict[str, Any]]:
|
||||
def list_widgets(
|
||||
self,
|
||||
service_id: str | None = None,
|
||||
*,
|
||||
scope: str | None = None,
|
||||
all_widgets: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List widget instances, optionally filtered.
|
||||
|
||||
- ``service_id=X``: only widgets for service X.
|
||||
- ``scope="dashboard"``: only widgets with NULL service_id.
|
||||
- ``scope="service"``: only widgets with a non-null service_id.
|
||||
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
|
||||
"""
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if service_id is not None:
|
||||
clauses.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
if scope == "dashboard":
|
||||
clauses.append("service_id IS NULL")
|
||||
elif scope == "service":
|
||||
clauses.append("service_id IS NOT NULL")
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._row_to_widget(row) for row in rows]
|
||||
|
||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||
@@ -1465,6 +1518,123 @@ class SettingsStore:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||
conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Widget references (live-link widgets across dashboards)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]:
|
||||
"""List widget references for a dashboard scope, joined with widget data."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order,
|
||||
wr.created_at AS ref_created_at
|
||||
FROM widget_references wr
|
||||
WHERE wr.dashboard_scope = ?
|
||||
ORDER BY wr.sort_order ASC, wr.created_at ASC
|
||||
""",
|
||||
(dashboard_scope,),
|
||||
).fetchall()
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
widget = self.get_widget(row["widget_id"])
|
||||
if not widget:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": row["ref_id"],
|
||||
"dashboard_scope": row["dashboard_scope"],
|
||||
"widget_id": row["widget_id"],
|
||||
"sort_order": int(row["sort_order"]),
|
||||
"created_at": row["ref_created_at"],
|
||||
"widget": widget,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
widget = self.get_widget(widget_id)
|
||||
if not widget:
|
||||
raise ValueError(f"Widget {widget_id} not found")
|
||||
ref_id = uuid.uuid4().hex[:12]
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ref_id, dashboard_scope, widget_id, sort_order, now),
|
||||
)
|
||||
return {
|
||||
"id": ref_id,
|
||||
"dashboard_scope": dashboard_scope,
|
||||
"widget_id": widget_id,
|
||||
"sort_order": sort_order,
|
||||
"created_at": now,
|
||||
"widget": widget,
|
||||
}
|
||||
|
||||
def delete_widget_reference(self, reference_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
|
||||
|
||||
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
|
||||
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM widget_references WHERE id = ?",
|
||||
(reference_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Reference {reference_id} not found")
|
||||
conn.execute(
|
||||
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
|
||||
(sort_order, reference_id),
|
||||
)
|
||||
widget = self.get_widget(row["widget_id"])
|
||||
return {
|
||||
"id": row["id"],
|
||||
"dashboard_scope": row["dashboard_scope"],
|
||||
"widget_id": row["widget_id"],
|
||||
"sort_order": sort_order,
|
||||
"created_at": int(row["created_at"]),
|
||||
"widget": widget,
|
||||
}
|
||||
|
||||
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
|
||||
"""Clone the referenced widget into a new standalone instance owned by the scope."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT widget_id FROM widget_references WHERE id = ?",
|
||||
(reference_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError(f"Reference {reference_id} not found")
|
||||
source = self.get_widget(row["widget_id"])
|
||||
if not source:
|
||||
raise ValueError(f"Source widget {row['widget_id']} not found")
|
||||
# Clone: copy the widget verbatim including service_id (so service-bound
|
||||
# widgets keep working), only the id/created_at change.
|
||||
cloned = self.upsert_widget(
|
||||
{
|
||||
"service_id": source.get("service_id"),
|
||||
"widget_kind": source["widget_kind"],
|
||||
"title": source["title"],
|
||||
"config": source["config"],
|
||||
"enabled": source["enabled"],
|
||||
"sort_order": source["sort_order"],
|
||||
}
|
||||
)
|
||||
self.delete_widget_reference(reference_id)
|
||||
return cloned
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Service registry
|
||||
@@ -1584,7 +1754,7 @@ class SettingsStore:
|
||||
return self.get_service(service["id"]) or service
|
||||
|
||||
def delete_service(self, service_id: str) -> None:
|
||||
"""Delete a service and cascade-delete widgets referencing it."""
|
||||
"""Delete a service and cascade-delete widgets + harness data."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
# The service_id column on dashboard_widgets is added in a later
|
||||
@@ -1597,6 +1767,15 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||
|
||||
# Cascade-delete harness-managed data (best-effort: the service row is
|
||||
# already removed; data cleanup must not block service deletion).
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness().cascade_delete(service_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to cascade-delete harness data for service %s", service_id)
|
||||
|
||||
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Append a service task run history row."""
|
||||
self.init_schema()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/widgets
|
||||
|
||||
## role
|
||||
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
|
||||
Provides widget data adapters and configuration definitions that fetch, normalize, and validate content from both built-in and external services for dashboard display.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
@@ -11,12 +11,13 @@ map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- builtin.py
|
||||
- prometheus_range.py
|
||||
- sources.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||
## workflows
|
||||
- change widgets behavior
|
||||
read: __init__.py, builtin.py, sources.py
|
||||
read: __init__.py, builtin.py, prometheus_range.py
|
||||
## dirty
|
||||
-
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,147 @@
|
||||
"""Shared helpers for Prometheus range queries.
|
||||
|
||||
These two pieces were called out by the spec's downstream-notes as needing a
|
||||
home: the step-derivation function (SC-104) and the series normalization helper
|
||||
(SC-102). Keeping them in their own module makes them unit-testable in isolation
|
||||
and reusable by the chart and mean widget paths (and, later, the in-service data
|
||||
path of the service-storage-harness change) without ``sources.py`` growing
|
||||
unbounded.
|
||||
|
||||
``normalize_prometheus_matrix`` is a direct extraction of the metric-label →
|
||||
readable-label rule that previously lived inside the Grafana datasource-proxy
|
||||
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
|
||||
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
|
||||
labels.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
|
||||
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
|
||||
WINDOW_PRESETS: dict[str, int] = {
|
||||
"1h": 3_600,
|
||||
"6h": 21_600,
|
||||
"24h": 86_400,
|
||||
"7d": 604_800,
|
||||
}
|
||||
|
||||
#: Sentinel values Prometheus serialises for non-finite floats; map these to
|
||||
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
|
||||
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")
|
||||
|
||||
|
||||
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
||||
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
|
||||
|
||||
Clamped to a minimum of 15 seconds so Prometheus does not reject
|
||||
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
|
||||
the resulting point count to land in the 100–300 band; with
|
||||
``target_points=200`` every preset yields 200 points.
|
||||
"""
|
||||
return max(15, round(window_seconds / target_points))
|
||||
|
||||
|
||||
def _dedup_label(label: str, seen: dict[str, int]) -> str:
|
||||
"""Apply `` (n)`` suffix on collision. Mutates and reads from ``seen`` dict."""
|
||||
if label in seen:
|
||||
seen[label] += 1
|
||||
return f"{label} ({seen[label]})"
|
||||
seen[label] = 0
|
||||
return label
|
||||
|
||||
|
||||
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
|
||||
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
|
||||
renderer consumes.
|
||||
|
||||
Label rule (matches the removed Grafana path so labels are stable on
|
||||
migration):
|
||||
|
||||
1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
|
||||
2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
|
||||
3. Else fall back to ``"value"``.
|
||||
4. Dedup label collisions with a `` (n)`` suffix.
|
||||
"""
|
||||
series: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
for entry in result:
|
||||
metric = entry.get("metric") or {}
|
||||
values = entry.get("values") or []
|
||||
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
|
||||
label = _dedup_label(" ".join(parts) if parts else "value", seen)
|
||||
points: list[dict[str, Any]] = []
|
||||
for ts, raw in values:
|
||||
t = _safe_int(ts)
|
||||
if t is None:
|
||||
# Drop samples whose timestamp is unusable rather than raising.
|
||||
continue
|
||||
points.append({"t": t, "v": _safe_float(raw)})
|
||||
series.append({"label": label, "points": points})
|
||||
return series
|
||||
|
||||
|
||||
def normalize_grafana_frames(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Turn a Grafana ``/api/ds/query`` response into the ``{label, points}`` series shape.
|
||||
|
||||
Parses ``results.<refId>.frames[]`` where each frame has:
|
||||
- ``data.values``: ``[[timestamps...], [values...]]``
|
||||
- ``schema.fields``: ``[{name, labels?, config?: {displayName?}}, ...]``
|
||||
|
||||
Label rule (same as ``normalize_prometheus_matrix``, shared via ``_dedup_label``):
|
||||
1. Prefer ``config.displayName`` (explicitly set in Grafana).
|
||||
2. Else use Prometheus metric labels (sorted ``k=v``, excluding ``__``-prefixed).
|
||||
3. Else fall back to the field name, or ``"value"``.
|
||||
4. Dedup collisions with `` (n)`` suffix.
|
||||
"""
|
||||
series: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
results = raw.get("results", {})
|
||||
for _ref_id, ref_data in results.items():
|
||||
for frame in ref_data.get("frames", []):
|
||||
values = frame.get("data", {}).get("values", [])
|
||||
if len(values) < 2:
|
||||
continue
|
||||
timestamps = values[0]
|
||||
vals = values[1]
|
||||
# Derive a meaningful series label from the frame metadata.
|
||||
fields = frame.get("schema", {}).get("fields", [])
|
||||
value_field = fields[-1] if fields else {}
|
||||
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
|
||||
frame_labels = value_field.get("labels") or {}
|
||||
if display_name:
|
||||
label = str(display_name)
|
||||
elif frame_labels:
|
||||
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not str(k).startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
else:
|
||||
label = str(value_field.get("name", "value"))
|
||||
label = _dedup_label(label, seen)
|
||||
points = []
|
||||
for t, v in zip(timestamps, vals):
|
||||
safe_t = _safe_int(t)
|
||||
if safe_t is None:
|
||||
continue
|
||||
points.append({"t": safe_t, "v": _safe_float(v)})
|
||||
series.append({"label": label, "points": points})
|
||||
return series
|
||||
|
||||
|
||||
def _safe_float(raw: Any) -> float | None:
|
||||
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
|
||||
if raw in _NON_NUMERIC:
|
||||
return None
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _safe_int(ts: Any) -> int | None:
|
||||
"""Best-effort int conversion for a Prometheus timestamp."""
|
||||
try:
|
||||
return int(float(ts))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -12,19 +12,29 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from typing import Any, Protocol
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_grafana_frames,
|
||||
normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5)
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,61 +106,169 @@ class StaticWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link (no embedding)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Grafana widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
dashboard_uid = config.get("dashboard_uid")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run a PromQL instant query against a Prometheus service."""
|
||||
class MetricSource:
|
||||
"""Run PromQL queries through a Grafana gateway (``/api/ds/query``)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Prometheus widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
url = f"{base_url}/api/v1/query"
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
url,
|
||||
params={"query": promql},
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"result": payload.get("data", {})}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("grafana_api_key") or "")
|
||||
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
|
||||
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
if widget_kind == "gauge":
|
||||
return await self._fetch_gauge(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
if widget_kind == "mean":
|
||||
return await self._fetch_mean(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
# Default: instant-query metric path.
|
||||
return await self._fetch_metric(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
async def _gateway_query(
|
||||
self,
|
||||
grafana_url: str,
|
||||
api_key: str,
|
||||
datasource_uid: str,
|
||||
timeout: int,
|
||||
promql: str,
|
||||
window_seconds: int | None = None,
|
||||
max_data_points: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""POST ``{grafana_url}/api/ds/query``; return raw Grafana JSON or ``{error}``.
|
||||
|
||||
- ``window_seconds=None`` → instant mapping (``from=now-1m, maxDataPoints=1``).
|
||||
- ``window_seconds=<N>`` → range query (``from=now-Ns``, step derived).
|
||||
"""
|
||||
if not grafana_url:
|
||||
return {"error": "grafana_url is required"}
|
||||
if not api_key:
|
||||
return {"error": "grafana_api_key is required"}
|
||||
step = step_for_window(window_seconds) if window_seconds else 15
|
||||
interval_ms = step * 1000
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": promql,
|
||||
"format": "time_series",
|
||||
"intervalMs": interval_ms,
|
||||
"maxDataPoints": 1 if window_seconds is None else max_data_points,
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": f"now-{window_seconds or 60}s" if window_seconds else "now-1m",
|
||||
"to": "now",
|
||||
}
|
||||
|
||||
def _do_post() -> dict[str, Any]:
|
||||
resp = requests.post(
|
||||
f"{grafana_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Grafana query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("grafana gateway query failed")
|
||||
return {"error": f"Grafana query failed: {exc}"}
|
||||
|
||||
async def _fetch_chart(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Range query → ``{series}`` for the chart widget (GM-106)."""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
return {"series": normalize_grafana_frames(raw)}
|
||||
|
||||
async def _fetch_gauge(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Instant query → scalar for the gauge widget (GM-107).
|
||||
|
||||
Scalar-only: a multi-series query returns an error. Threshold config is
|
||||
passed through for the frontend renderer.
|
||||
"""
|
||||
promql = config.get("promql") or ""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
series = normalize_grafana_frames(raw)
|
||||
if len(series) != 1:
|
||||
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
||||
points = series[0]["points"]
|
||||
if not points:
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
value = points[-1]["v"]
|
||||
if value is None:
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
return {
|
||||
"value": value,
|
||||
"warn_at": config.get("warn_at"),
|
||||
"crit_at": config.get("crit_at"),
|
||||
"min": config.get("min"),
|
||||
"max": config.get("max"),
|
||||
"unit": config.get("unit"),
|
||||
}
|
||||
|
||||
async def _fetch_mean(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Range query → client-side mean for the mean widget (GM-108).
|
||||
|
||||
Runs a gateway range query over the configured window preset, averages
|
||||
all non-null numeric samples of the single series, and returns a scalar.
|
||||
Scalar-only: a multi-series query returns an error.
|
||||
"""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
series = normalize_grafana_frames(raw)
|
||||
if len(series) != 1:
|
||||
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
||||
nums = [p["v"] for p in series[0]["points"] if p["v"] is not None]
|
||||
if not nums:
|
||||
return {"error": "Mean query returned no numeric samples in the window"}
|
||||
return {"value": sum(nums) / len(nums), "unit": config.get("unit")}
|
||||
|
||||
async def _fetch_metric(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Instant query → ``{result}`` for the metric widget (GM-109).
|
||||
|
||||
Returns ``{result: [{label, points}]}`` — the normalized series shape.
|
||||
The frontend ``PrometheusMetricWidget`` renders the last point of each
|
||||
series.
|
||||
"""
|
||||
promql = config.get("promql") or ""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
return {"result": normalize_grafana_frames(raw)}
|
||||
|
||||
|
||||
class AlertmanagerWidgetSource:
|
||||
"""Fetch firing alerts from an Alertmanager service and summarize them."""
|
||||
@@ -160,7 +278,7 @@ class AlertmanagerWidgetSource:
|
||||
if service is None:
|
||||
return {"error": "Alertmanager widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
timeout = int(service.config.get("timeout_seconds") or 5)
|
||||
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||
severity_filter = config.get("severity_filter") or None
|
||||
headers: dict[str, str] = {}
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
@@ -199,7 +317,7 @@ class JellyfinWidgetSource:
|
||||
return {"error": "Jellyfin widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "")
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
timeout = int(service.config.get("timeout_seconds") or 60)
|
||||
client = await asyncio.wait_for(
|
||||
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
|
||||
timeout=timeout,
|
||||
@@ -208,6 +326,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 +387,95 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
logger.exception("failed to record ssh task timeout")
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _qbittorrent_client(cache_key: tuple[str, str, str, str, int]) -> QbittorrentClient:
|
||||
"""Build (or reuse) a qBittorrent client for a service.
|
||||
|
||||
Cached per (service_id, base_url, username, password, timeout) so the
|
||||
authenticated session/SID cookie persists across widget fetches. A
|
||||
credentials or URL change produces a new cache key, so stale clients are
|
||||
not reused after reconfiguration. Mirrors the Jellyfin client cache in
|
||||
dependencies._jellyfin_client_for.
|
||||
"""
|
||||
_service_id, base_url, username, password, timeout = cache_key
|
||||
return QbittorrentClient(base_url, username, password, timeout=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 60)
|
||||
if not base_url or not username or not password:
|
||||
return {"error": "qBittorrent service is missing base_url, username, or password"}
|
||||
|
||||
# Reuse one authenticated client per service so the SID cookie
|
||||
# persists across fetches and we don't re-login on every widget
|
||||
# poll. qBittorrent verifies passwords with slow PBKDF2 hashing,
|
||||
# so logging in on every fetch (3 widgets x frequent polls)
|
||||
# saturates its web thread pool and the reverse proxy returns 504
|
||||
# gateway timeouts. The client re-logins itself on a 403.
|
||||
client = _qbittorrent_client((service.id, base_url, username, password, timeout))
|
||||
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
|
||||
server_state = data.get("server_state", {})
|
||||
torrents = data.get("torrents", {})
|
||||
|
||||
if widget_kind == "totals":
|
||||
by_state: dict[str, int] = {}
|
||||
for t in torrents.values():
|
||||
state = str(t.get("state", "unknown"))
|
||||
by_state[state] = by_state.get(state, 0) + 1
|
||||
return {"total": len(torrents), "by_state": by_state}
|
||||
|
||||
if widget_kind == "active":
|
||||
active = [
|
||||
{
|
||||
"name": t.get("name"),
|
||||
"state": t.get("state"),
|
||||
"size": t.get("size"),
|
||||
"progress": t.get("progress"),
|
||||
"dl_speed": t.get("dlspeed"),
|
||||
"up_speed": t.get("upspeed"),
|
||||
}
|
||||
for t in torrents.values()
|
||||
if str(t.get("state", "")) in {"downloading", "uploading"}
|
||||
]
|
||||
return {"torrents": active}
|
||||
|
||||
if widget_kind == "speed":
|
||||
dl = int(server_state.get("dl_info_speed", 0))
|
||||
up = int(server_state.get("up_info_speed", 0))
|
||||
ts = int(time.time())
|
||||
store = QbittorrentSampleStore()
|
||||
store.append(service.id, ts, dl, up)
|
||||
samples = store.window(service.id)
|
||||
series = [
|
||||
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
|
||||
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
|
||||
]
|
||||
return {"series": series}
|
||||
|
||||
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "qBittorrent data fetch timed out"}
|
||||
except Exception as exc:
|
||||
logger.exception("qbittorrent adapter failed")
|
||||
return {"error": f"qBittorrent fetch failed: {exc}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"prometheus": MetricSource(),
|
||||
"qbittorrent": QbittorrentWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"ssh_tasks": SshTaskWidgetSource(),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
dir: backend/src/media_library_viewer_api/workers
|
||||
|
||||
## role
|
||||
Background worker package providing subprocess-based media index building from Jellyfin libraries with progress tracking and cancellation support.
|
||||
Background task workers that handle long-running media indexing operations external to the main request/response cycle.
|
||||
## 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.
|
||||
Background task workers that handle long-running media indexing operations external to the main request/response cycle.
|
||||
## 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 | Subprocess worker that builds a media index from a Jellyfin server, reporting progress and supporting cooperative cancellation via metadata in a database. | 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:service.get("config", {}).get, call:service.get("secrets", {}).get, call:int, call:max, call:float, call:JellyfinClient, call:str(service.get("config", {}).get("user_id") or "").strip, call:client.resolve_user_id, 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.
|
||||
Subprocess-based worker pattern with cooperative cancellation via database metadata and progress reporting, keeping heavy I/O isolated from the API server process.
|
||||
## tags
|
||||
call:, metadata, set, index, call:get, media, progress, worker
|
||||
call:, metadata, set, index, jellyfin, settings, media, progress
|
||||
## 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,49 @@ 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", 60))
|
||||
# The build is a background operation and can afford a patient read timeout.
|
||||
# The UI-grade config timeout_seconds (default 60) governs the widget paths;
|
||||
# the worker uses a larger floor so slow /Items pages on large libraries
|
||||
# don't ReadTimeout mid-build.
|
||||
read_timeout = max(float(timeout), 180.0)
|
||||
client = JellyfinClient(base_url, api_key, read_timeout)
|
||||
# The config field accepts either the internal Jellyfin Id or a username
|
||||
# (e.g. 'admin'). Jellyfin's /Users/{id}/... endpoints reject usernames
|
||||
# with HTTP 400, so always resolve to the internal Id before use.
|
||||
user_id = str(service.get("config", {}).get("user_id") or "").strip() or None
|
||||
user_id = client.resolve_user_id(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 +137,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 +153,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 +229,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user