Compare commits
189 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef4a6379c9 | |||
| d76ea49777 | |||
| 3871f24724 | |||
| 17976eab80 | |||
| 4562a9dfca | |||
| 37533dd219 | |||
| fe90feb1b7 | |||
| 230b4b8533 | |||
| 03aece02b8 | |||
| a541a4fd16 | |||
| 70511d97f9 | |||
| a9488af0b4 | |||
| eac9b5d33d | |||
| 70f4e5b6e1 | |||
| 45c295457a | |||
| 7665ef4d10 | |||
| 54851779fb | |||
| e757f4ba21 | |||
| 0b039529f6 | |||
| e25240c2f3 | |||
| b8cb29e330 | |||
| ba01ad7c0c | |||
| 7e4222ef00 | |||
| 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 | |||
| 01527ae4f0 | |||
| b583d5a365 | |||
| 32fa01cc12 | |||
| ac703eecd2 | |||
| d05de0aacd | |||
| 09b9c45665 | |||
| 32516f6e3b | |||
| 30f1b6e6db | |||
| 7808822a55 | |||
| e805c624b2 | |||
| f7b63fead5 | |||
| 2eb649eceb | |||
| 2076ab76fa | |||
| 2e3e7b3850 | |||
| c447dfe68d | |||
| 688a18af22 | |||
| 18ee77a4e4 | |||
| 3d331e4c72 | |||
| eebc86a52b | |||
| 56b919ea1f | |||
| 648320abfd | |||
| 7d252489de | |||
| 04319025de | |||
| 8bc209b27e | |||
| cbc2740e37 | |||
| 6919158012 | |||
| fd12e921fd | |||
| 7107815a5c | |||
| 38b2de54ff | |||
| c1610c93a1 | |||
| b1a66a1ab7 | |||
| b200025daa | |||
| 0c5698c903 | |||
| 14771ae990 | |||
| 7d49df3e7d | |||
| c13e274ca4 | |||
| d4f95b64d4 | |||
| 4d520ab0e3 | |||
| ca8927834e | |||
| a39dbf272c | |||
| 50eb76a10d | |||
| d7ad933b2a | |||
| 8c69911252 | |||
| 7b3e2ebace | |||
| cfb9977532 | |||
| 802a9202e9 | |||
| 7ab9b1ac59 | |||
| cbb703341e | |||
| a13f560df2 | |||
| 5eb49be697 | |||
| 8ff735d644 | |||
| d998e6ab0c | |||
| 9a6cbfae68 | |||
| c9c72be0b6 | |||
| 5ec35b4849 | |||
| 739ad38e29 | |||
| 1da67f38c7 | |||
| 41dddbccc0 | |||
| f6a86310cc | |||
| 10fd4ead4a | |||
| 2452e2e1e4 | |||
| fd534a816b | |||
| 8cdeadd6dd | |||
| d1819c0186 | |||
| 9459de5c07 | |||
| 9782280a03 | |||
| 0ad6a04053 | |||
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 | |||
| 2557185fb7 | |||
| e1356b20f1 | |||
| e6d333ef7b | |||
| 1cd8e926de | |||
| 1a52dfb087 | |||
| 9dfe62eb6f |
@@ -0,0 +1,20 @@
|
||||
# .claude (index)
|
||||
dir: .claude
|
||||
|
||||
## role
|
||||
Configuration and settings directory for Claude AI assistant integration within the project workspace.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- .claude/skills
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .claude
|
||||
dir: .claude
|
||||
|
||||
index: .claude/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Configuration and settings directory for Claude AI assistant integration within the project workspace.
|
||||
## files
|
||||
## arch
|
||||
Flat configuration directory following standard AI assistant tool conventions, typically containing permission rules, context files, and project-specific behavioral settings.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# .claude/skills (index)
|
||||
dir: .claude/skills
|
||||
|
||||
## role
|
||||
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
|
||||
## parent
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
## children
|
||||
- .claude/skills/sift-backlog
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
map: .claude/skills/sift-backlog/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .claude/skills
|
||||
dir: .claude/skills
|
||||
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Configuration directory storing reusable Claude AI skill definitions and behavioral instructions for the project.
|
||||
## files
|
||||
## arch
|
||||
Flat directory structure with markdown-based skill modules that define specialized assistant capabilities.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .claude/skills/sift-backlog (index)
|
||||
dir: .claude/skills/sift-backlog
|
||||
|
||||
## role
|
||||
Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
|
||||
## parent
|
||||
index: .claude/skills/.pi-map.index.md
|
||||
map: .claude/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
map: .claude/skills/sift-backlog/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .claude/skills/sift-backlog
|
||||
dir: .claude/skills/sift-backlog
|
||||
|
||||
index: .claude/skills/sift-backlog/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a structured workflow skill for Claude to triage, organize, and activate backlog tasks into actionable sprint plans using the `sf` CLI tool.
|
||||
## files
|
||||
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. | dep: sf CLI (task, plan, dependency, update subcommands)
|
||||
## arch
|
||||
Single-file declarative skill definition following a prompt-engineering pattern that encodes step-by-step procedures and decision rules for Claude to execute when invoked.
|
||||
## tags
|
||||
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -1,3 +1,7 @@
|
||||
# Manage environment template
|
||||
# Copy this file to .env, fill in the required values, and export them in your shell
|
||||
# before running docker compose. Compose files use interpolation, not env_file.
|
||||
|
||||
# App
|
||||
APP_VERSION=0.1.0
|
||||
APP_BUILD_INFO=dev
|
||||
@@ -23,6 +27,9 @@ PROMETHEUS_ENABLED=true
|
||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL=http://alertmanager:9093
|
||||
ALERTMANAGER_WEBHOOK_URL=
|
||||
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
|
||||
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||
BACKEND_CACHE_DIR=./backend-cache
|
||||
|
||||
# Auth
|
||||
@@ -42,6 +49,7 @@ VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
VITE_GRAFANA_URL=https://grafana.example.com
|
||||
VITE_PROMETHEUS_URL=https://prometheus.example.com
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.example.com
|
||||
|
||||
@@ -55,5 +55,3 @@ frontend/dist/
|
||||
.superpowers/
|
||||
# Local Pi runtime state
|
||||
.atl/
|
||||
.pi-map.md
|
||||
.pi-map.index.md
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# .opencode (index)
|
||||
dir: .opencode
|
||||
|
||||
## role
|
||||
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- .opencode/commands
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
map: .opencode/commands/.pi-map.md
|
||||
- .opencode/skills
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .opencode
|
||||
dir: .opencode
|
||||
|
||||
index: .opencode/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Configuration and settings package for the opencode tool, defining project-level or user-level preferences and behavior.
|
||||
## files
|
||||
## arch
|
||||
Flat directory structure with declarative configuration files; no executable code or architectural patterns involved.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,22 @@
|
||||
# .opencode/commands (index)
|
||||
dir: .opencode/commands
|
||||
|
||||
## role
|
||||
Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
|
||||
## parent
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- opsx-apply.md
|
||||
- opsx-archive.md
|
||||
- opsx-explore.md
|
||||
- opsx-propose.md
|
||||
## links
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
map: .opencode/commands/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,22 @@
|
||||
# .opencode/commands
|
||||
dir: .opencode/commands
|
||||
|
||||
index: .opencode/commands/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines structured AI assistant workflow commands for an OpenSpec-based software development lifecycle (explore, propose, apply, archive).
|
||||
## files
|
||||
- opsx-apply.md | Defines a workflow for implementing tasks from an OpenSpec change in a structured, iterative manner with pause points for blockers and ambiguity. | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development process, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), tasks.md
|
||||
- opsx-explore.md | Defines the explore mode stance for a thinking/discussion assistant that investigates problems and clarifies requirements without implementing code | dep: OpenSpec system (openspec CLI, change artifacts like proposal.md/design.md/tasks.md/spec.md)
|
||||
- opsx-propose.md | Defines a workflow for creating a new change with all required planning artifacts (proposal, design, tasks) in a single step using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, file system
|
||||
## arch
|
||||
Markdown-based command-definition pattern where each file encodes a discrete, step-by-step procedural prompt controlling assistant behavior for a specific development phase.
|
||||
## tags
|
||||
opsx, defines, tasks, md, workflow, openspec, openspec cli, askuserquestion tool
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,29 @@
|
||||
# .opencode/skills (index)
|
||||
dir: .opencode/skills
|
||||
|
||||
## role
|
||||
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
|
||||
## parent
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
## children
|
||||
- .opencode/skills/openspec-apply-change
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||
- .opencode/skills/openspec-archive-change
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||
- .opencode/skills/openspec-explore
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||
- .opencode/skills/openspec-propose
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# .opencode/skills
|
||||
dir: .opencode/skills
|
||||
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Custom skill/automation definitions for the opencode tooling framework, defining reusable capabilities or behaviors.
|
||||
## files
|
||||
## arch
|
||||
Configuration-driven skill registry with declarative definition files (no implementation code present in this directory).
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-apply-change (index)
|
||||
dir: .opencode/skills/openspec-apply-change
|
||||
|
||||
## role
|
||||
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-apply-change/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-apply-change
|
||||
dir: .opencode/skills/openspec-apply-change
|
||||
|
||||
index: .opencode/skills/openspec-apply-change/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a structured skill definition for implementing OpenSpec changes through a schema-driven workflow with progress tracking and contextual file reading.
|
||||
## files
|
||||
- SKILL.md | Defines a skill for implementing tasks from an OpenSpec change using a schema-driven workflow with progress tracking and contextual file reading. | dep: openspec CLI, AskUserQuestion tool
|
||||
## arch
|
||||
Declarative skill specification using markdown-based instructions, schema-driven task processing, and progressive context loading patterns.
|
||||
## tags
|
||||
skill, defines, implementing, tasks, openspec, change, schema, driven
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-archive-change (index)
|
||||
dir: .opencode/skills/openspec-archive-change
|
||||
|
||||
## role
|
||||
Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-archive-change/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-archive-change
|
||||
dir: .opencode/skills/openspec-archive-change
|
||||
|
||||
index: .opencode/skills/openspec-archive-change/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines a specialized skill within the openspec workflow that handles the archival process for completed changes, including validation checks, sync assessment, and user confirmation.
|
||||
## files
|
||||
- SKILL.md | Defines a skill for archiving a completed change in the openspec experimental workflow, including validation, sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool (subagent_type: general-purpose), openspec-sync-specs skill
|
||||
## arch
|
||||
Single-file declarative skill definition using a markdown-based pattern description format, structured as a procedural workflow with validation gates and conditional user interaction steps.
|
||||
## tags
|
||||
skill, openspec, sync, defines, archiving, completed, change, experimental
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-explore (index)
|
||||
dir: .opencode/skills/openspec-explore
|
||||
|
||||
## role
|
||||
Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-explore/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-explore
|
||||
dir: .opencode/skills/openspec-explore
|
||||
|
||||
index: .opencode/skills/openspec-explore/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a conversational "explore mode" skill definition that configures the OpenSpec CLI to act as a collaborative thinking partner for brainstorming, problem investigation, and requirements clarification without code implementation.
|
||||
## files
|
||||
- SKILL.md | Defines a conversational "explore mode" skill for the OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||
## arch
|
||||
Skill-definition pattern using a single Markdown manifest (SKILL.md) that declaratively specifies the assistant's behavioral instructions, interaction style, and operational constraints for the explore workflow.
|
||||
## tags
|
||||
skill, defines, conversational, explore, mode, openspec, cli, acts
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-propose (index)
|
||||
dir: .opencode/skills/openspec-propose
|
||||
|
||||
## role
|
||||
Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
|
||||
## parent
|
||||
index: .opencode/skills/.pi-map.index.md
|
||||
map: .opencode/skills/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- SKILL.md
|
||||
## links
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
map: .opencode/skills/openspec-propose/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .opencode/skills/openspec-propose
|
||||
dir: .opencode/skills/openspec-propose
|
||||
|
||||
index: .opencode/skills/openspec-propose/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides an AI assistant skill that automates the openspec proposal workflow, scaffolding directories and generating structured artifacts (proposals, designs, tasks) for new project changes.
|
||||
## files
|
||||
- SKILL.md | Defines an AI assistant skill that automates proposing new changes by scaffolding a directory, generating dependent artifacts (proposal, design, tasks), and tracking progress through a structured workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||
## arch
|
||||
Declarative skill-definition pattern using a markdown-based manifest (SKILL.md) that encodes a step-by-step procedural workflow, CLI commands, and file-system conventions for the AI to follow.
|
||||
## tags
|
||||
skill, defines, assistant, automates, proposing, new, changes, scaffolding
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,79 @@
|
||||
# . (index)
|
||||
dir: .
|
||||
|
||||
## Project Map Protocol
|
||||
|
||||
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||
3. Load indexes before rich maps during task-start navigation.
|
||||
4. Read the local rich map and actual source before editing.
|
||||
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||
6. If source and generated artifacts disagree, trust source.
|
||||
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||
8. After editing source, run `project_map_patch` for each changed file.
|
||||
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
|
||||
## parent
|
||||
-
|
||||
## children
|
||||
- .atl
|
||||
index: .atl/.pi-map.index.md
|
||||
map: .atl/.pi-map.md
|
||||
- .claude
|
||||
index: .claude/.pi-map.index.md
|
||||
map: .claude/.pi-map.md
|
||||
- .opencode
|
||||
index: .opencode/.pi-map.index.md
|
||||
map: .opencode/.pi-map.md
|
||||
- .pi
|
||||
index: .pi/.pi-map.index.md
|
||||
map: .pi/.pi-map.md
|
||||
- .pi-tmp
|
||||
index: .pi-tmp/.pi-map.index.md
|
||||
map: .pi-tmp/.pi-map.md
|
||||
- .ruff_cache
|
||||
index: .ruff_cache/.pi-map.index.md
|
||||
map: .ruff_cache/.pi-map.md
|
||||
- archive
|
||||
index: archive/.pi-map.index.md
|
||||
map: archive/.pi-map.md
|
||||
- backend
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
- docs
|
||||
index: docs/.pi-map.index.md
|
||||
map: docs/.pi-map.md
|
||||
- frontend
|
||||
index: frontend/.pi-map.index.md
|
||||
map: frontend/.pi-map.md
|
||||
- monitoring
|
||||
index: monitoring/.pi-map.index.md
|
||||
map: monitoring/.pi-map.md
|
||||
- openspec
|
||||
index: openspec/.pi-map.index.md
|
||||
map: openspec/.pi-map.md
|
||||
## files
|
||||
- .dockerignore
|
||||
- .env.example
|
||||
- .gitignore
|
||||
- AGENTS.md
|
||||
- CHANGELOG.md
|
||||
- CONTRIBUTING.md
|
||||
- LICENSE
|
||||
- README.md
|
||||
- context.md
|
||||
- docker-compose.dev.yml
|
||||
- docker-compose.observability.yml
|
||||
- docker-compose.yml
|
||||
- token-usage-output.txt
|
||||
## links
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# .
|
||||
dir: .
|
||||
|
||||
index: ./.pi-map.index.md
|
||||
|
||||
## Project Map Protocol
|
||||
|
||||
1. Read this protocol and the root `.pi-map.index.md` first.
|
||||
2. Use `index:` / `map:` references to open relevant directory indexes and maps.
|
||||
3. Load indexes before rich maps during task-start navigation.
|
||||
4. Read the local rich map and actual source before editing.
|
||||
5. Treat non-empty `## dirty` sections in either artifact as stale.
|
||||
6. If source and generated artifacts disagree, trust source.
|
||||
7. If map and index disagree, trust neither blindly; verify from source and regenerate the pair.
|
||||
8. After editing source, run `project_map_patch` for each changed file.
|
||||
9. Before broad architectural claims or final handoff, run `project_map_validate` when freshness matters.
|
||||
|
||||
Trust boundary: index routes, map orients, source decides.
|
||||
|
||||
## role
|
||||
Root project configuration and documentation directory for a media library/server operations management application with Jellyfin integration, SSH inspection, and observability capabilities.
|
||||
## files
|
||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
||||
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
||||
- .gitignore | Configures Git to ignore Python artifacts, virtual environments, secrets, editor files, frontend builds, and tool-specific metadata from version control.
|
||||
- AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
|
||||
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across releases.
|
||||
- CONTRIBUTING.md | Provides contribution guidelines and setup instructions for the Manage project's backend (FastAPI) and frontend (React) codebases. | dep: FastAPI, React, Vite, TypeScript, Ruff, pytest, Docker Compose, Tailwind CSS, TanStack Query
|
||||
- LICENSE | Provides the MIT open-source software license terms for the project
|
||||
- README.md | Project README documenting a media and server operations tool with Jellyfin integration, SSH file inspection, and server monitoring capabilities. | dep: FastAPI, React, TypeScript, Docker Compose, SQLite, Traefik, OIDC/Authentik, Jellyfin, Prometheus, Grafana, Alertmanager
|
||||
- context.md | Documentation file providing a historical and architectural overview of an observability stack (Prometheus, Grafana, Loki, Alertmanager) for a containerized media management application. | dep: Prometheus, Grafana, Loki, Alertmanager, Grafana Alloy, Node Exporter, Docker Compose, FastAPI
|
||||
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
|
||||
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
|
||||
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
|
||||
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
||||
## arch
|
||||
Full-stack containerized architecture using Docker Compose orchestration with a FastAPI/Uvicorn backend and Vite React frontend, Traefik reverse proxy with TLS/OIDC, and an optional observability stack (Prometheus, Grafana, Loki, Alertmanager, Alloy).
|
||||
## tags
|
||||
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -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)."
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
- Focused frontend typecheck: `npx tsc --noEmit`
|
||||
- Local dev stack: `docker compose -f docker-compose.dev.yml up --build`
|
||||
- Production stack: `docker compose up --build`
|
||||
- Solo landing: after review and verification, squash-land a feature branch with `bash scripts/land-branch.sh <feature-branch> "<conventional commit message>"`; do not commit directly on `main`.
|
||||
|
||||
## Repo-Specific Gotchas
|
||||
|
||||
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
# Changelog
|
||||
|
||||
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
|
||||
Prometheus instances in the UI on the Services page; all three are first-class
|
||||
service-registry entries with dashboard widgets (`active_alerts`, Grafana link,
|
||||
Prometheus metric).
|
||||
- New monitoring endpoints resolve the configured service instance and probe its
|
||||
health: `GET /api/monitoring/grafana-status`, `/prometheus-status`. The
|
||||
`/alerts` and `/alertmanager-status` endpoints now take an optional
|
||||
`service_id` and pick the first enabled alertmanager instance by default.
|
||||
- The Observability page discovers Grafana/Prometheus/Alertmanager from the
|
||||
registry and renders health cards; the dashboard `active_alerts` widget sums
|
||||
firing alerts by severity.
|
||||
|
||||
### Changed — Observability is now external only
|
||||
|
||||
- **Removed** all observability services from `docker-compose.yml` and
|
||||
`docker-compose.dev.yml`. They now deploy **only** the backend and frontend.
|
||||
The `monitoring` network and the `prometheus`/`loki`/`alloy`/`grafana`/
|
||||
`alertmanager`/`node-exporter` services and their named volumes were deleted,
|
||||
and the `GRAFANA_APP_HOST` Traefik rule was removed.
|
||||
- Manage now connects to **existing** Grafana/Prometheus/Alertmanager instances
|
||||
and never ships its own stack. The previous in-compose stack is preserved as
|
||||
an optional, deploy-it-yourself example in `docker-compose.observability.yml`
|
||||
(config under `monitoring/`, documented in `docs/observability-runbooks.md`).
|
||||
- Removed the now-orphaned combined `monitoring/prometheus/prometheus.yml`; the
|
||||
standalone stack uses `monitoring/prometheus/prometheus.standalone.yml`.
|
||||
- Removed the Prometheus file-SD bridge (`PROMETHEUS_FILE_SD_DIR` + the
|
||||
`write_prometheus_targets` file writer). External Prometheus instances now
|
||||
consume node-exporter targets via `http_sd_configs` against
|
||||
`GET /api/monitoring/prometheus-targets`. The webhook receiver is log-only.
|
||||
|
||||
### **BREAKING**
|
||||
|
||||
- Observability is configured entirely via the service registry; the backend
|
||||
`alertmanager_url`/`alertmanager_webhook_url` and frontend
|
||||
`VITE_GRAFANA_URL`/`VITE_PROMETHEUS_URL` environment variables, plus
|
||||
`PROMETHEUS_FILE_SD_DIR`, were **removed**. Re-create your Alertmanager /
|
||||
Grafana / Prometheus instances on the Services page after upgrading. The only
|
||||
observability env var remaining is `PROMETHEUS_ENABLED` (toggles Manage's own
|
||||
`/metrics` endpoint).
|
||||
|
||||
### Added — Service registry
|
||||
|
||||
- Runtime **service registry** persisted in the backend SQLite database. External
|
||||
services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are now
|
||||
configured in the app instead of via environment variables.
|
||||
- Services page (`/services`) to create, list, and delete service instances.
|
||||
- Service detail pages (`/services/:serviceType/:serviceId`) to edit name/enabled
|
||||
state, rotate secrets, and view the widgets a service provides.
|
||||
- Service definitions live as Pydantic modules in `backend/.../integrations/`,
|
||||
each declaring its config schema, secret fields, and widget kinds.
|
||||
- Multi-instance support: multiple Grafana/Jellyfin/etc. instances per type.
|
||||
- SSH task runner service records run history in a new `service_task_runs`
|
||||
table, shown on the runner's service page.
|
||||
|
||||
### Changed
|
||||
|
||||
- Dashboard widgets are now **service-bound** (reference a service instance +
|
||||
widget kind) or **built-in** (backups, static text). The "Add widget" flow is
|
||||
pick-service → pick-widget-kind → configure.
|
||||
- Deleting a service cascade-deletes widgets that reference it.
|
||||
|
||||
### Security
|
||||
|
||||
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
|
||||
Fernet.
|
||||
|
||||
### **BREAKING**
|
||||
|
||||
- Saved Actions (server tasks) now target `ssh_tasks` service instances instead
|
||||
of monitoring machines. The `default_machine_id` field on saved tasks was
|
||||
replaced with `default_service_id`; the legacy `saved_task_runs` table was
|
||||
dropped and run history now lives in `service_task_runs`. Re-create SSH task
|
||||
runner services on the Services page and re-link saved actions after
|
||||
upgrading.
|
||||
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
|
||||
with:
|
||||
|
||||
```bash
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
```
|
||||
|
||||
- The `GRAFANA_URL` and `PROMETHEUS_URL` backend environment variables were
|
||||
removed; Grafana/Prometheus URLs now live on service records configured in the
|
||||
UI. Re-create them on the Services page after upgrading.
|
||||
- The legacy widget/addon-pages model (`/addons/:addonId`,
|
||||
`/api/widgets/types`, `/api/widgets/sources`) was removed in favor of the
|
||||
service registry.
|
||||
- Default dashboard widget seeding was removed; a fresh install starts with an
|
||||
empty dashboard. Add widgets from the dashboard's edit dialog after
|
||||
configuring services.
|
||||
|
||||
### Notes / follow-ups
|
||||
|
||||
- ~~Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
|
||||
pages. Migrating those onto the service registry is a separate follow-up change.~~
|
||||
**Done (2026-06-23):** Jellyfin is no longer a machine service, and the dead
|
||||
machine-level `media_root`/`path_prefix` fields were removed. See the
|
||||
Jellyfin migration entry in `docs/REQUIREMENTS.md`.
|
||||
|
||||
## Follow-up #2 — remove dead machine `media_root`/`path_prefix` + Jellyfin service
|
||||
|
||||
Completes the Jellyfin migration onto the service registry. Jellyfin is no
|
||||
longer a machine `services` tag (`DEFAULT_SERVICES` is now `["monitoring",
|
||||
"files"]`), and the dead machine-level `media_root`/`path_prefix` fields were
|
||||
removed from the settings store, `MonitoringMachineInput`, frontend types, and
|
||||
the Settings UI. Jellyfin is configured exclusively as a service-registry
|
||||
instance. The global `REMOTE_MEDIA_ROOT`/`REMOTE_PATH_PREFIX` config properties
|
||||
and `path_utils.py` remain (files/media-index still use them for Jellyfin→SSH
|
||||
path resolution). Existing DB rows may still carry these keys in `config_json`;
|
||||
they are inert and get dropped on the next machine save.
|
||||
|
||||
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
|
||||
|
||||
With Jellyfin/Jellyseerr now resolved from the service registry, the machine-level
|
||||
Jellyfin/Jellyseerr fields are dead config. Removed from `dependencies.py` (dead
|
||||
`_jellyseerr_client_for`; `_resolve_machine` simplified to SSH-only),
|
||||
`services/settings_store.py`, `routers/settings.py` (`MachineInput`), frontend
|
||||
types, the `Settings.tsx` form, and frontend test fixtures. Existing DB rows may
|
||||
still carry these keys in `config_json`; they are inert and get dropped on the
|
||||
next machine save. No data migration required.
|
||||
+77
-20
@@ -1,55 +1,114 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for considering a contribution.
|
||||
Thanks for considering a contribution to Manage.
|
||||
|
||||
Manage is a media and server-operations dashboard built from two subprojects:
|
||||
|
||||
- **`backend/`** — FastAPI (Python 3.11) REST API using a `src/` layout.
|
||||
- **`frontend/`** — Vite + React + TypeScript SPA.
|
||||
- **`archive/`** — the original Streamlit prototype, preserved for reference only. Do **not** use it as a guide; the app is FastAPI + React now.
|
||||
|
||||
The authoritative contributor quick-reference is [`AGENTS.md`](./AGENTS.md). This document mirrors it for human contributors.
|
||||
|
||||
## Setup
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
Copy env template:
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
Then set real values in `.env` and run:
|
||||
### Local stack (optional)
|
||||
|
||||
For a full local dev stack with hot reload (auth disabled):
|
||||
|
||||
```bash
|
||||
streamlit run app.py
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
## Development guidelines
|
||||
The dev compose deploys only the backend and frontend; Manage never deploys an
|
||||
observability stack. For the optional standalone observability example, see
|
||||
`docker-compose.observability.yml` and `docs/observability-runbooks.md`.
|
||||
|
||||
## Development commands
|
||||
|
||||
Run backend checks from `backend/` and frontend checks from `frontend/`.
|
||||
|
||||
```bash
|
||||
# Backend: lint + tests
|
||||
cd backend && ruff check . && python -m pytest
|
||||
|
||||
# Run the API locally (if the package is installed as above)
|
||||
uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||
# Otherwise, without installing: PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||
|
||||
# Focused backend tests
|
||||
pytest tests/test_api.py
|
||||
pytest -k <expr>
|
||||
```
|
||||
|
||||
```bash
|
||||
# Frontend: dev server (proxies /api to http://localhost:8000)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# Frontend: lint + typecheck/build (build runs tsc -b + vite build) + tests
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run test
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Keep architecture boundaries clear:
|
||||
- `clients/` for external integrations
|
||||
- `domain/` for normalization/business logic
|
||||
- `services/` for app services/indexing
|
||||
- `ui/` for Streamlit rendering
|
||||
- `clients/` for external service transports (Jellyfin, Jellyseerr, SSH, local shell).
|
||||
- `integrations/` for service-registry definitions (config schema, secrets, widget kinds).
|
||||
- `domain/` for normalization/business logic.
|
||||
- `services/` for app services, indexing, persistence, and background workers.
|
||||
- `routers/` for FastAPI route handlers.
|
||||
- `models/` for Pydantic request/response schemas.
|
||||
- Prefer small, focused functions and explicit names.
|
||||
- Preserve safe SSH behavior and shell quoting — job templates must quote all interpolated values.
|
||||
- External services (Jellyfin, Grafana, Prometheus, Alertmanager, …) are configured at runtime via the **service registry** in the UI, not environment variables. The only observability env var is `PROMETHEUS_ENABLED` (Manage's own `/metrics` toggle).
|
||||
- Avoid introducing optional fallback paths unless required.
|
||||
- Prefer small, focused functions and explicit session-state keys.
|
||||
- Preserve safe SSH behavior and path quoting.
|
||||
|
||||
## Validation
|
||||
### Backend style
|
||||
|
||||
Before opening a merge request, run:
|
||||
Backend linting/format is Ruff (line length 120, Python 3.11); config lives in `backend/pyproject.toml`.
|
||||
|
||||
### Frontend style
|
||||
|
||||
The frontend uses **shadcn/ui + Tailwind CSS v4 + lucide-react + TanStack Query + TanStack Table**. Do not introduce MUI, Emotion, recharts, d3, or AG Grid — those were removed and are not coming back.
|
||||
|
||||
## Validation before opening a merge request
|
||||
|
||||
Before opening a merge request, run and ensure green:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python -m py_compile app.py src/media_library_viewer/*.py src/media_library_viewer/clients/*.py src/media_library_viewer/domain/*.py src/media_library_viewer/services/*.py src/media_library_viewer/ui/*.py
|
||||
cd backend && ruff check . && python -m pytest
|
||||
cd frontend && npm run lint && npm run build && npm run test
|
||||
```
|
||||
|
||||
If behavior, UX, or architecture changed, also update `docs/REQUIREMENTS.md`.
|
||||
|
||||
## Security / secrets
|
||||
|
||||
Never commit:
|
||||
|
||||
- `.env`
|
||||
- `.streamlit/secrets.toml`
|
||||
- private keys or API tokens
|
||||
- service secrets
|
||||
|
||||
Use `.env.example` for documented placeholders only.
|
||||
Service secrets are encrypted at rest with `MANAGE_ENCRYPTION_KEY` (required to start the backend). Use `.env.example` for documented placeholders only.
|
||||
|
||||
## Pull requests
|
||||
|
||||
@@ -57,6 +116,4 @@ Please include:
|
||||
|
||||
- what changed
|
||||
- why it changed
|
||||
- how it was tested
|
||||
|
||||
If behavior/requirements changed, also update `docs/REQUIREMENTS.md`.
|
||||
- how it was tested (commands run / tests added)
|
||||
|
||||
@@ -20,14 +20,15 @@ The project consists of two subprojects:
|
||||
|
||||
## Features
|
||||
|
||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
||||
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines
|
||||
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine
|
||||
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, Alertmanager alerts, SSH task output, static text) and shortcuts
|
||||
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
||||
- Service registry: configure Jellyfin, Jellyseerr, Alertmanager, Grafana, Prometheus, Nextcloud, and SSH task runner instances in the UI
|
||||
- Per-machine settings for SSH, monitoring targets, and file browsing
|
||||
- SQLite-indexed media table with full-library sort/filter
|
||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||
- Remote file browser with ffprobe preview and job execution
|
||||
- Jellyfin API integration for library metadata and user identity data
|
||||
- SSH-based file inspection and remote job templates
|
||||
- SSH-based file inspection and safe remote job templates
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -39,7 +40,11 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open the app at http://localhost:8080.
|
||||
Open the app at <http://localhost:8080>.
|
||||
|
||||
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
|
||||
|
||||
> **Observability is external.** Manage only ships its **backend** and **frontend**. It does **not** deploy Grafana, Prometheus, Loki, Alertmanager, Alloy, or Node Exporter. The backend exposes a `/metrics` endpoint and optional Alertmanager proxy endpoints so an *existing* observability deployment can scrape and consume them. For a ready-to-run example stack you can deploy alongside Manage, see [`docker-compose.observability.yml`](docker-compose.observability.yml) and [`docs/observability-runbooks.md`](docs/observability-runbooks.md).
|
||||
|
||||
Local development with hot reload:
|
||||
|
||||
@@ -47,9 +52,9 @@ Local development with hot reload:
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000.
|
||||
The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index.
|
||||
Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts.
|
||||
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
||||
|
||||
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
||||
|
||||
### Manual backend/frontend development
|
||||
|
||||
@@ -76,21 +81,25 @@ The Compose files use environment-variable interpolation. Export the required va
|
||||
Production-style example with shell exports:
|
||||
|
||||
```bash
|
||||
export BACKEND_APP_HOST=manage.example.com
|
||||
export BACKEND_APP_HOST=api.manage.example.com
|
||||
export FRONTEND_APP_HOST=manage.example.com
|
||||
export CERT_RESOLVER=letsencrypt
|
||||
export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/
|
||||
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||
export VITE_OIDC_CLIENT_ID=manage
|
||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/
|
||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
> Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
||||
> the app on the **Services** page — no env vars for them.
|
||||
|
||||
Inline one-liner example:
|
||||
|
||||
```bash
|
||||
BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
||||
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
||||
```
|
||||
|
||||
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
||||
@@ -124,27 +133,35 @@ SMTP_TIMEOUT=30
|
||||
|
||||
# Authentik / OIDC
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
||||
OIDC_AUDIENCE=media-library-viewer
|
||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||
OIDC_AUDIENCE=manage
|
||||
OIDC_JWKS_URL=
|
||||
OIDC_CLOCK_SKEW_SECONDS=30
|
||||
|
||||
# Frontend OIDC settings
|
||||
VITE_OIDC_ENABLED=true
|
||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
||||
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||
VITE_OIDC_CLIENT_ID=manage
|
||||
VITE_OIDC_SCOPE=openid profile email
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
|
||||
# Observability services (Grafana, Prometheus, Alertmanager) are configured in
|
||||
# the app on the Services page. The only observability env var is the optional
|
||||
# PROMETHEUS_ENABLED toggle (defaults on) for Manage's own /metrics endpoint.
|
||||
|
||||
# Required: master key encrypting service secrets (API keys/tokens) at rest.
|
||||
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||
```
|
||||
|
||||
## Remote server requirements
|
||||
|
||||
The remote server needs:
|
||||
|
||||
- Linux `/proc` and `/sys/block` for monitoring
|
||||
- `/bin/sh` (POSIX shell)
|
||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk`
|
||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
|
||||
- SSH access with a key configured in the app's Settings tab
|
||||
|
||||
The SSH client rejects unknown host keys. Connect manually once first:
|
||||
|
||||
@@ -155,17 +172,20 @@ ssh user@host
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
cd backend && PYTHONPATH=src python -m py_compile src/media_library_viewer_api/main.py
|
||||
# Backend (lint + tests)
|
||||
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest
|
||||
|
||||
# Frontend
|
||||
cd frontend && npx tsc --noEmit && npm run build
|
||||
# Frontend (lint + typecheck/build + tests)
|
||||
cd frontend && npm run lint && npm run build && npm run test
|
||||
```
|
||||
|
||||
Focused frontend typecheck: `npx tsc --noEmit`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
||||
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
||||
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
|
||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
|
||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. They deploy **only** the backend and frontend; Manage never deploys its own observability stack (see `docker-compose.observability.yml` for an optional standalone example).
|
||||
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
||||
- Grafana, Prometheus, and Alertmanager are configured as **service instances** in the app (Services page); their widget adapters resolve URLs from service records, and no observability URLs/credentials live in env vars. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`. When no alertmanager service is configured, the alert proxy endpoints return graceful "not configured" responses.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# archive (index)
|
||||
dir: archive
|
||||
|
||||
## role
|
||||
Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- archive/src
|
||||
index: archive/src/.pi-map.index.md
|
||||
map: archive/src/.pi-map.md
|
||||
- archive/tests
|
||||
index: archive/tests/.pi-map.index.md
|
||||
map: archive/tests/.pi-map.md
|
||||
## files
|
||||
- app.py
|
||||
- pyproject.toml
|
||||
- requirements.txt
|
||||
## links
|
||||
index: archive/.pi-map.index.md
|
||||
map: archive/.pi-map.md
|
||||
## workflows
|
||||
- change archive behavior
|
||||
read: app.py, pyproject.toml, requirements.txt
|
||||
- change archive config
|
||||
read: pyproject.toml
|
||||
- explore archive subdirectories
|
||||
index: archive/src/.pi-map.index.md, archive/tests/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,26 @@
|
||||
# archive
|
||||
dir: archive
|
||||
|
||||
index: archive/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Archived/legacy entrypoint and packaging configuration for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
## files
|
||||
- app.py | Provides a minimal Streamlit entrypoint that adds the src directory to Python's path and delegates to the actual application in media_library_viewer.app. | dep: sys, pathlib, media_library_viewer.app
|
||||
- pyproject.toml | Defines Python package metadata, dependencies, and tool configurations for a Streamlit-based Jellyfin media library browser with SSH remote file inspection. | dep: hatchling, streamlit, streamlit-aggrid, requests, paramiko, python-dotenv, pandas, ruff, pytest
|
||||
- requirements.txt | Installs the current package in editable/development mode using pip | dep: pip, setuptools
|
||||
## arch
|
||||
Thin bootstrap layer using path manipulation to delegate to a source module (src/), packaged with standard Python tooling (pyproject.toml) for dependency management and Streamlit deployment.
|
||||
## tags
|
||||
streamlit, app, python, media, library, package, pyproject, pip
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
- change archive behavior
|
||||
read: app.py, pyproject.toml, requirements.txt
|
||||
- change archive config
|
||||
read: pyproject.toml
|
||||
- explore archive subdirectories
|
||||
index: archive/src/.pi-map.index.md, archive/tests/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# archive/src (index)
|
||||
dir: archive/src
|
||||
|
||||
## role
|
||||
Insufficient information — no files provided in the directory listing to determine this package's role.
|
||||
## parent
|
||||
index: archive/.pi-map.index.md
|
||||
map: archive/.pi-map.md
|
||||
## children
|
||||
- archive/src/media_library_viewer
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: archive/src/.pi-map.index.md
|
||||
map: archive/src/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# archive/src
|
||||
dir: archive/src
|
||||
|
||||
index: archive/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Insufficient information — no files provided in the directory listing to determine this package's role.
|
||||
## files
|
||||
## arch
|
||||
Unable to assess — empty directory or missing file contents for architectural analysis.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,39 @@
|
||||
# archive/src/media_library_viewer (index)
|
||||
dir: archive/src/media_library_viewer
|
||||
|
||||
## role
|
||||
Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
|
||||
## parent
|
||||
index: archive/src/.pi-map.index.md
|
||||
map: archive/src/.pi-map.md
|
||||
## children
|
||||
- archive/src/media_library_viewer/clients
|
||||
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/clients/.pi-map.md
|
||||
- archive/src/media_library_viewer/domain
|
||||
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/domain/.pi-map.md
|
||||
- archive/src/media_library_viewer/services
|
||||
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/services/.pi-map.md
|
||||
- archive/src/media_library_viewer/ui
|
||||
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/ui/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- app.py
|
||||
- config.py
|
||||
- jobs.py
|
||||
- utils.py
|
||||
## links
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
## workflows
|
||||
- change media_library_viewer behavior
|
||||
read: __init__.py, app.py, config.py
|
||||
- change media_library_viewer config
|
||||
read: config.py
|
||||
- explore media_library_viewer subdirectories
|
||||
index: archive/src/media_library_viewer/clients/.pi-map.index.md, archive/src/media_library_viewer/domain/.pi-map.index.md, archive/src/media_library_viewer/services/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,35 @@
|
||||
# archive/src/media_library_viewer
|
||||
dir: archive/src/media_library_viewer
|
||||
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Streamlit-based UI package for browsing and monitoring a Jellyfin media library with SSH remote file management capabilities.
|
||||
## files
|
||||
- __init__.py | Package initialization file that defines the Media Library Viewer package metadata and exports the version string.
|
||||
- app.py | Streamlit UI entrypoint for a Media Library Viewer that connects to Jellyfin and SSH backends, providing dashboard, monitoring, media browsing, and file browser tabs with cached data and path resolution between systems. | exp: func:get_jellyfin_client(base_url: str, api_key: str) → JellyfinClient, call:JellyfinClient, func:cached_users(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).users, func:get_ssh_client(host: str, username: str, port: int, key_filename: str, password: str) → RemoteSSHClient, call:RemoteSSHClient, call:client.connect, func:cached_libraries(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).libraries, func:cached_media_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client(base_url, api_key).media_counts, func:cached_library_counts(base_url: str, api_key: str, user_id: str), call:get_jellyfin_client, call:client.libraries, call:client.library_item_counts, func:cached_active_sessions(base_url: str, api_key: str), call:get_jellyfin_client(base_url, api_key).active_sessions, func:cached_dir_listing(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.list_dir, call:json.loads, raise:RuntimeError, func:cached_ffprobe_preview(host: str, username: str, port: int, key_filename: str, password: str, path: str), call:get_ssh_client, call:ssh.ffprobe_json, func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:apply_remote_path_prefix, func:credentials_panel(), call:load_config, call:st.header, call:st.expander, call:st.text_input, call:st.number_input, call:int, func:main(), call:st.set_page_config, call:st.title, call:st.caption, call:credentials_panel, call:st.info, call:get_jellyfin_client, call:cached_users, call:st.error, call:user.get, call:st.selectbox, call:list, call:user_options.keys, call:st.tabs, call:render_now_playing, call:st.divider, call:render_resource_dashboard, call:render_media_overview, call:cached_libraries, call:set_file_browser_path, call:resolve_remote_media_path, call:render_media_tab, call:render_file_browser, call:get_ssh_client, call:render_ssh_tools, func:set_prefixed_file_browser_path(path: str, selected_path, reset_filters) → None, call:set_file_browser_path, call:resolve_remote_media_path | dep: json, posixpath, typing, media_library_viewer.clients.jellyfin, media_library_viewer.clients.ssh, media_library_viewer.config, media_library_viewer.ui.dashboard, media_library_viewer.ui.file_browser, media_library_viewer.ui.media, media_library_viewer.ui.preview, streamlit
|
||||
- config.py | Loads application configuration from environment variables and .env files using immutable dataclasses for Jellyfin and SSH settings. | exp: class:JellyfinConfig, class:SSHConfig, class:AppConfig, func:load_config() → AppConfig, call:AppConfig | dep: os, dataclasses, pathlib, dotenv
|
||||
- jobs.py | Defines safe, template-based remote SSH jobs with shell-quoted parameter rendering. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:ssh.run | dep: shlex, dataclasses, typing, media_library_viewer.clients.ssh, typing.Mapping
|
||||
- utils.py | Provides UI-independent formatting helpers and ffprobe output summarizers for video/audio/subtitle stream metadata. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||
## arch
|
||||
Tab-based modular frontend using immutable dataclass configuration, environment-driven settings, cached data access, template-based remote job execution, and separated utility functions for metadata formatting.
|
||||
## tags
|
||||
client, path, media, call:, jellyfin, call:get, ssh, cached
|
||||
## symbols
|
||||
- JellyfinConfig
|
||||
- SSHConfig
|
||||
- AppConfig
|
||||
- JobTemplate
|
||||
- get_jellyfin_client
|
||||
- cached_users
|
||||
- get_ssh_client
|
||||
- cached_libraries
|
||||
## workflows
|
||||
- change media_library_viewer behavior
|
||||
read: __init__.py, app.py, config.py
|
||||
- change media_library_viewer config
|
||||
read: config.py
|
||||
- explore media_library_viewer subdirectories
|
||||
index: archive/src/media_library_viewer/clients/.pi-map.index.md, archive/src/media_library_viewer/domain/.pi-map.index.md, archive/src/media_library_viewer/services/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,23 @@
|
||||
# archive/src/media_library_viewer/clients (index)
|
||||
dir: archive/src/media_library_viewer/clients
|
||||
|
||||
## role
|
||||
External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- jellyfin.py
|
||||
- resources.py
|
||||
- ssh.py
|
||||
## links
|
||||
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/clients/.pi-map.md
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, jellyfin.py, resources.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,30 @@
|
||||
# archive/src/media_library_viewer/clients
|
||||
dir: archive/src/media_library_viewer/clients
|
||||
|
||||
index: archive/src/media_library_viewer/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
External service and system integration clients providing media server API access and remote SSH-based system metrics collection for the media library viewer application.
|
||||
## files
|
||||
- __init__.py | Package initialization file that defines external service clients module boundaries and constraints
|
||||
- jellyfin.py | HTTP API client for Jellyfin/Emby media servers providing user, library, item, and session management with plain Python return types for frontend agnosticism. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → dict[str, Any], call:params.items, call:self.session.get, call:response.raise_for_status, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:isinstance, call:session.get, method:image_url(self, item_id: str, image_type) → str | dep: typing, requests
|
||||
- resources.py | Manages a lightweight POSIX shell-based remote system metrics collector that samples CPU, memory, network, and disk statistics via SSH and reads the resulting JSONL data. | exp: class:ResourceMonitorPaths, func:start_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:shlex.quote, call:int, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:stop_resource_collector(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:restart_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:stop_resource_collector, call:start_resource_collector, func:resource_collector_status(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:resource_collector_debug_info(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, func:read_resource_metrics(ssh: RemoteSSHClient, max_lines, paths) → list[dict[str, Any]], call:shlex.quote, call:int, call:ssh.run, call:result.stdout.splitlines, call:line.strip, call:rows.append, call:json.loads, raise:RuntimeError, func:disk_space(ssh: RemoteSSHClient, path) → dict[str, Any], call:shlex.quote, call:ssh.run, call:result.stdout.strip, call:json.loads, raise:RuntimeError | dep: json, shlex, dataclasses, typing, media_library_viewer.clients.ssh
|
||||
- ssh.py | Provides an SSH client wrapper around Paramiko for remote filesystem inspection and media analysis, ensuring POSIX shell compatibility regardless of the user's login shell. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, password, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:client.connect, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, posixpath, shlex, dataclasses, typing, paramiko
|
||||
## arch
|
||||
Modular client-per-service pattern with plain Python return types for frontend agnosticism, wrapping HTTP APIs (Jellyfin/Emby) and SSH/Paramiko connections with POSIX shell compatibility enforcement.
|
||||
## tags
|
||||
call:shlex.quote, resource, error, collector, call:ssh.run, raise:runtime, call:self.get, call:result.stdout.strip
|
||||
## symbols
|
||||
- JellyfinClient
|
||||
- ResourceMonitorPaths
|
||||
- CommandResult
|
||||
- RemoteSSHClient
|
||||
- __init__
|
||||
- get
|
||||
- users
|
||||
- libraries
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, jellyfin.py, resources.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,21 @@
|
||||
# archive/src/media_library_viewer/domain (index)
|
||||
dir: archive/src/media_library_viewer/domain
|
||||
|
||||
## role
|
||||
Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- media.py
|
||||
## links
|
||||
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/domain/.pi-map.md
|
||||
## workflows
|
||||
- change domain behavior
|
||||
read: __init__.py, media.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,28 @@
|
||||
# archive/src/media_library_viewer/domain
|
||||
dir: archive/src/media_library_viewer/domain
|
||||
|
||||
index: archive/src/media_library_viewer/domain/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides domain-level normalization logic that transforms inconsistent external Jellyfin API responses into stable, application-native data structures.
|
||||
## files
|
||||
- __init__.py | Serves as the package docstring for a domain-level helpers/normalization module that converts external data into stable app concepts.
|
||||
- media.py | Flattens inconsistent Jellyfin API item JSON into stable, normalized dictionaries for SQLite storage and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer.utils, pandas, media_library_viewer.utils (human_size, ticks_to_minutes)
|
||||
## arch
|
||||
Functional transformation layer using dictionary flattening and field mapping to decouple external API data shapes from internal storage (SQLite) and presentation (frontend) concerns.
|
||||
## tags
|
||||
media, call:str, date, added, item, call:item.get, streams, call:stream
|
||||
## symbols
|
||||
- first_media_source
|
||||
- media_streams
|
||||
- stream_value
|
||||
- is_hdr_item
|
||||
- format_date_added
|
||||
- timestamp_date_added
|
||||
- format_rate_bits_decimal
|
||||
- normalize_media_item
|
||||
## workflows
|
||||
- change domain behavior
|
||||
read: __init__.py, media.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,21 @@
|
||||
# archive/src/media_library_viewer/services (index)
|
||||
dir: archive/src/media_library_viewer/services
|
||||
|
||||
## role
|
||||
Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- media_index.py
|
||||
## links
|
||||
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/services/.pi-map.md
|
||||
## workflows
|
||||
- change services behavior
|
||||
read: __init__.py, media_index.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,28 @@
|
||||
# archive/src/media_library_viewer/services
|
||||
dir: archive/src/media_library_viewer/services
|
||||
|
||||
index: archive/src/media_library_viewer/services/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Application services layer that coordinates media library clients and domain logic into reusable, UI-agnostic operations like indexing and querying Jellyfin media metadata.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package and documents it as the application services layer for coordinating clients/domain logic into reusable operations.
|
||||
- media_index.py | Provides a UI-agnostic SQLite-backed media inventory service that indexes, queries, and manages Jellyfin media metadata with filtering, sorting, and pagination capabilities. | exp: class:MediaIndexStatus, class:MediaIndex, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, method:init_schema(self) → None, call:self.connect, call:conn.executescript, method:set_metadata(self, key: str, value: str | int | float) → None, call:self.init_schema, call:self.connect, call:conn.execute, call:str, method:replace_items(self, rows: Iterable[dict[str, Any]]) → int, call:self.init_schema, call:list, call:",".join, call:len, call:self.connect, call:conn.execute, call:conn.executemany, call:','.join, call:row.get, call:str, call:int, call:time.time, method:status(self) → MediaIndexStatus, call:self.db_path.exists, call:MediaIndexStatus, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone, call:str(updated_row[0]).isdigit, call:time.strftime, call:time.localtime, call:float, method:query(self, library_id, library_ids, media_types, search, hdr_filter, sort_key, sort_order, limit, offset) → tuple[list[dict[str, Any]], int], call:self.init_schema, call:where.append, call:",".join, call:len, call:params.extend, call:params.append, call:search.lower, call:" AND ".join, call:SORT_COLUMNS.get, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone, call:conn.execute( "SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?", [*params, int(limit), int(offset)], ).fetchall, call:display_media_row, call:dict, func:build_media_index(client: JellyfinClient, user_id: str, libraries: list[dict[str, Any]], index, page_size) → int, call:MediaIndex, call:time.perf_counter, call:library.get, call:client.items, call:response.get, call:normalized_rows.extend, call:normalize_media_item, call:len, call:int, call:index.replace_items, call:index.set_metadata | dep: sqlite3, time, dataclasses, pathlib, typing, media_library_viewer.clients.jellyfin, media_library_viewer.domain.media, media_library_viewer.clients.jellyfin.JellyfinClient, media_library_viewer.domain.media.display_media_row, media_library_viewer.domain.media.normalize_media_item
|
||||
## arch
|
||||
Service-oriented architecture with SQLite persistence, providing filtering, sorting, and pagination capabilities abstracted away from UI concerns.
|
||||
## tags
|
||||
media, call:conn.execute, index, call:self.connect, schema, call:int, init, status
|
||||
## symbols
|
||||
- MediaIndexStatus
|
||||
- MediaIndex
|
||||
- __init__
|
||||
- connect
|
||||
- init_schema
|
||||
- set_metadata
|
||||
- replace_items
|
||||
- status
|
||||
## workflows
|
||||
- change services behavior
|
||||
read: __init__.py, media_index.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,24 @@
|
||||
# archive/src/media_library_viewer/ui (index)
|
||||
dir: archive/src/media_library_viewer/ui
|
||||
|
||||
## role
|
||||
Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
|
||||
## parent
|
||||
index: archive/src/media_library_viewer/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- dashboard.py
|
||||
- file_browser.py
|
||||
- media.py
|
||||
- preview.py
|
||||
## links
|
||||
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||
map: archive/src/media_library_viewer/ui/.pi-map.md
|
||||
## workflows
|
||||
- change ui behavior
|
||||
read: __init__.py, dashboard.py, file_browser.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,31 @@
|
||||
# archive/src/media_library_viewer/ui
|
||||
dir: archive/src/media_library_viewer/ui
|
||||
|
||||
index: archive/src/media_library_viewer/ui/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Streamlit-based presentation layer for browsing, monitoring, and diagnosing a Jellyfin media server via SSH and a local SQLite index.
|
||||
## files
|
||||
- __init__.py | Package initialization file for Streamlit UI modules that documents the architectural pattern of splitting the application into separate render modules.
|
||||
- dashboard.py | Implements a Streamlit dashboard for monitoring a Jellyfin media server, displaying media library statistics, active playback sessions, and server resource metrics via SSH. | exp: func:format_rate_bytes(bytes_per_second: float | int | None) → str, call:human_size, func:rate_scale(max_value: float | int | None) → tuple[float, str], call:abs, call:float, func:scaled_rate_chart_df(chart_df: pd.DataFrame, columns: list[str], labels: list[str]) → tuple[pd.DataFrame, str], call:chart_df[columns].max(numeric_only=True).max, call:rate_scale, call:chart_df[columns].copy, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_overview(cached_media_counts, cached_library_counts, base_url: str, api_key: str, user_id: str) → None, call:st.subheader, call:cached_media_counts, call:st.warning, call:counts.get, call:st.columns, call:top_cols[0].metric, call:top_cols[1].metric, call:top_cols[2].metric, call:top_cols[3].metric, call:cached_library_counts, call:st.caption, call:st.markdown, call:e.get, call:st.container, call:m_cols[0].metric, call:m_cols[1].metric, func:render_now_playing(cached_active_sessions, base_url: str, api_key: str) → None, call:st.subheader, call:cached_active_sessions, call:st.warning, call:st.caption, call:session.get, call:bool, call:play_state.get, call:item.get, call:transcoding.get, call:transcode_type.append, call:rows.append, call:", ".join, call:st.dataframe, call:pd.DataFrame, func:render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed) → None, call:st.subheader, call:get_ssh_client, call:resource_collector_status, call:st.error, call:st.columns, call:control_col.caption, call:start_col.button, call:st.success, call:start_resource_collector, call:restart_col.button, call:restart_resource_collector, call:stop_col.button, call:st.info, call:stop_resource_collector, call:refresh_col.button, call:st.rerun, call:st.caption, call:read_resource_metrics, call:disk_space, call:float, call:str(space.get("used_pct", "0")).rstrip, call:space.get, call:disk_cols[0].metric, call:human_size, call:disk_cols[1].metric, call:disk_cols[2].metric, call:disk_cols[3].metric, call:st.progress, call:min, call:max, call:st.warning, call:st.expander, call:st.code, call:resource_collector_debug_info, call:pd.DataFrame, call:pd.to_numeric, call:df.dropna, call:pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert, call:time.time, call:len, call:st.write, call:raw_df['ts'].astype(float).max, call:st.dataframe, call:raw_df.tail, call:df.sort_values, call:df["cpu_pct"].mean, call:df["cpu_pct"].max, call:df["iowait_pct"].mean, call:df["iowait_pct"].max, call:df["mem_pct"].mean, call:df["mem_pct"].max, call:df["net_rx_bytes_per_sec"].mean, call:df["net_rx_bytes_per_sec"].max, call:df["net_tx_bytes_per_sec"].mean, call:df["net_tx_bytes_per_sec"].max, call:df["disk_read_bps"].mean, call:df["disk_read_bps"].max, call:df["disk_write_bps"].mean, call:df["disk_write_bps"].max, call:metric_cols[0].metric, call:metric_cols[0].caption, call:metric_cols[1].metric, call:latest.get, call:metric_cols[1].caption, call:metric_cols[2].metric, call:metric_cols[2].caption, call:metric_cols[3].metric, call:format_rate_bytes, call:metric_cols[3].caption, call:metric_cols[4].metric, call:metric_cols[4].caption, call:metric_cols[5].metric, call:metric_cols[5].caption, call:metric_cols[6].metric, call:metric_cols[6].caption, call:df.set_index, call:st.markdown, call:st.line_chart, call:scaled_rate_chart_df | dep: time, typing, media_library_viewer.clients.resources, media_library_viewer.utils, pandas, streamlit
|
||||
- file_browser.py | Renders an interactive SSH remote file browser UI in Streamlit with filtering, sorting, pagination, and directory navigation using ag-grid. | exp: func:reset_file_browser_filters() → None, call:st.session_state.pop, func:set_file_browser_path(path: str, selected_path, reset_filters) → None, func:aggrid_selected_rows(response: dict) → list[dict], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args: tuple, initial_path: str) → str, call:st.subheader, call:st.session_state.pop, call:reset_file_browser_filters, call:st.session_state.get, call:st.columns, call:status_col.caption, call:selected_col.caption, call:path_col.text_input, call:set_file_browser_path, call:st.rerun, call:refresh_col.button, call:cached_dir_listing.clear, call:st.error, call:PurePosixPath(name).suffix.lower, call:str, call:display_rows.append, call:int, call:human_size, call:float, call:timestamp_to_local, call:len, call:sum, call:st.caption, call:st.container, call:filter_col.selectbox, call:search_col.text_input, call:sorted, call:ext_col.selectbox, call:sort_col.selectbox, call:order_col.toggle, call:page_size_col.selectbox, call:search_term.lower, call:r["name"].lower, call:filtered_rows.sort, call:max, call:page_col.number_input, call:summary_col.caption, call:min, call:visible_rows.append, call:visible_rows.extend, call:st.info, call:st.expander, call:st.write, call:pd.DataFrame, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:aggrid_selected_rows, call:picked_row.get | dep: json, pathlib, typing, st_aggrid, media_library_viewer.utils, streamlit, pandas
|
||||
- media.py | Renders a Streamlit UI tab for browsing and filtering a local SQLite-backed media index with ag-grid table display and automatic file browser synchronization. | exp: func:aggrid_selected_rows(response: dict[str, Any]) → list[dict[str, Any]], call:response.get, call:isinstance, call:selected_rows.to_dict, call:list, func:format_elapsed(seconds: float | int | None) → str, call:float, call:int, func:render_media_tab(client, user_id: str, libraries: list[dict[str, Any]], set_file_browser_path: Callable[[str, str | None, bool], None]) → None, call:st.subheader, call:st.caption, call:MediaIndex, call:index.status, call:st.columns, call:status_parts.append, call:format_elapsed, call:status_col.caption, call:" | ".join, call:status_col.warning, call:build_col.button, call:st.spinner, call:build_media_index, call:st.success, call:st.rerun, call:refresh_col.button, call:st.info, call:filter_col.multiselect, call:list, call:library_options.keys, call:type_col.multiselect, call:search_col.text_input, call:page_size_col.selectbox, call:page_col.number_input, call:sort_col.selectbox, call:sort_options.keys, call:order_col.selectbox, call:hdr_col.selectbox, call:index.query, call:int, call:len, call:pd.DataFrame(rows)[columns].fillna, call:st.session_state.get, call:GridOptionsBuilder.from_dataframe, call:grid_builder.configure_default_column, call:grid_builder.configure_column, call:grid_builder.configure_selection, call:grid_builder.build, call:JsCode, call:AgGrid, call:min, call:aggrid_selected_rows, call:selected_rows[0].get, call:set_file_browser_path, call:str, call:PurePosixPath, call:st.expander, call:st.write | dep: pathlib, typing, st_aggrid, media_library_viewer.services.media_index, pandas, streamlit
|
||||
- preview.py | Renders a Streamlit UI for previewing selected media file metadata via ffprobe and executing remote SSH diagnostic tools/jobs. | exp: func:render_ffprobe_sections(ffprobe_data: dict[str, Any]) → None, call:ffprobe_format_summary, call:summarize_video_streams, call:summarize_audio_streams, call:summarize_subtitle_streams, call:st.markdown, call:st.dataframe, call:pd.DataFrame, call:st.caption, func:render_selected_file_preview(ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:st.container, call:st.markdown, call:st.caption, call:is_known_video_file, call:st.columns, call:refresh_col.button, call:cached_ffprobe_preview.clear, call:st.rerun, call:st.spinner, call:status_col.error, call:status_col.success, call:render_ffprobe_sections, call:st.expander, call:st.json, func:render_ssh_tools(ssh, ssh_args: tuple, selected_path: str | None, cached_ffprobe_preview: Callable[..., dict[str, Any]]) → None, call:render_selected_file_preview, call:st.subheader, call:st.tabs, call:st.button, call:ssh.ffprobe_json, call:render_ffprobe_sections, call:st.expander, call:st.dataframe, call:pd.DataFrame, call:summarize_streams, call:st.json, call:st.error, call:str, call:ssh.stat_path, call:st.code, call:st.warning, call:st.selectbox, call:list, call:JOB_TEMPLATES.keys, call:st.caption, call:JOB_TEMPLATES[job_key].render, call:run_job, call:st.write | dep: typing, media_library_viewer.jobs, media_library_viewer.utils, pandas, streamlit
|
||||
## arch
|
||||
Modular page-by-page rendering pattern where each module is a self-contained Streamlit view, integrated through shared session state for cross-component synchronization.
|
||||
## tags
|
||||
call:metric, call:grid, render, call:st.caption, col.button, browser, col.selectbox, media
|
||||
## symbols
|
||||
- format_rate_bytes
|
||||
- rate_scale
|
||||
- scaled_rate_chart_df
|
||||
- format_elapsed
|
||||
- render_media_overview
|
||||
- render_now_playing
|
||||
- render_resource_dashboard
|
||||
- reset_file_browser_filters
|
||||
## workflows
|
||||
- change ui behavior
|
||||
read: __init__.py, dashboard.py, file_browser.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# archive/tests (index)
|
||||
dir: archive/tests
|
||||
|
||||
## role
|
||||
Empty placeholder directory retained for historical/archived test files that are no longer actively used.
|
||||
## parent
|
||||
index: archive/.pi-map.index.md
|
||||
map: archive/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- .gitkeep
|
||||
## links
|
||||
index: archive/tests/.pi-map.index.md
|
||||
map: archive/tests/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# archive/tests
|
||||
dir: archive/tests
|
||||
|
||||
index: archive/tests/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Empty placeholder directory retained for historical/archived test files that are no longer actively used.
|
||||
## files
|
||||
- .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
## arch
|
||||
No active code; contains only a `.gitkeep` placeholder file (with an unrelated description) to preserve the directory structure in version control.
|
||||
## tags
|
||||
tmux, swaps, position, two, panes, within, window, windows
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,32 @@
|
||||
# backend (index)
|
||||
dir: backend
|
||||
|
||||
## role
|
||||
FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
## parent
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## children
|
||||
- backend/.pytest_cache
|
||||
index: backend/.pytest_cache/.pi-map.index.md
|
||||
map: backend/.pytest_cache/.pi-map.md
|
||||
- backend/.ruff_cache
|
||||
index: backend/.ruff_cache/.pi-map.index.md
|
||||
map: backend/.ruff_cache/.pi-map.md
|
||||
- backend/src
|
||||
index: backend/src/.pi-map.index.md
|
||||
map: backend/src/.pi-map.md
|
||||
- backend/tests
|
||||
index: backend/tests/.pi-map.index.md
|
||||
map: backend/tests/.pi-map.md
|
||||
## files
|
||||
- Dockerfile
|
||||
- README.md
|
||||
- pyproject.toml
|
||||
## links
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,21 @@
|
||||
# backend
|
||||
dir: backend
|
||||
|
||||
index: backend/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend service providing JWT-protected REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
|
||||
## files
|
||||
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
||||
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
||||
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
||||
## arch
|
||||
Layered API architecture using FastAPI with Uvicorn ASGI server, containerized via Docker, configured through pyproject.toml with standardized linting and testing pipelines.
|
||||
## tags
|
||||
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+36
-21
@@ -13,29 +13,46 @@ backend/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI app entrypoint
|
||||
│ ├── config.py # pydantic-settings config
|
||||
│ ├── auth.py # OIDC/JWT + API key auth
|
||||
│ ├── dependencies.py # Dependency injection
|
||||
│ ├── observability.py # Prometheus metrics + request IDs
|
||||
│ ├── logging_utils.py # Structured JSON/text logging
|
||||
│ ├── path_utils.py # Jellyfin→SSH path resolution
|
||||
│ ├── jobs.py # Job templates
|
||||
│ ├── utils.py # Formatting helpers
|
||||
│ ├── routers/
|
||||
│ │ ├── backups.py
|
||||
│ │ ├── dashboard.py
|
||||
│ │ ├── monitoring.py
|
||||
│ │ ├── media.py
|
||||
│ │ ├── users.py
|
||||
│ │ ├── settings.py
|
||||
│ │ ├── files.py
|
||||
│ │ └── jobs.py
|
||||
│ │ ├── jobs.py
|
||||
│ │ ├── media.py
|
||||
│ │ ├── monitoring.py
|
||||
│ │ ├── services.py
|
||||
│ │ ├── settings.py
|
||||
│ │ ├── tasks.py
|
||||
│ │ ├── users.py (+ users_impl.py)
|
||||
│ │ └── widgets.py
|
||||
│ ├── clients/
|
||||
│ │ ├── jellyfin.py
|
||||
│ │ ├── jellyseerr.py
|
||||
│ │ ├── local.py
|
||||
│ │ ├── resources.py
|
||||
│ │ └── ssh.py
|
||||
│ ├── integrations/ # Service-registry definitions
|
||||
│ ├── domain/
|
||||
│ │ └── media.py
|
||||
│ └── services/
|
||||
│ ├── media_index.py
|
||||
│ └── settings_store.py
|
||||
│ ├── models/ # Pydantic request/response models
|
||||
│ ├── services/
|
||||
│ │ ├── media_index.py (+ _impl.py)
|
||||
│ │ ├── settings_store.py
|
||||
│ │ ├── secrets.py # Fernet encryption at rest
|
||||
│ │ ├── targets.py # Node Exporter target discovery
|
||||
│ │ ├── task_runner.py
|
||||
│ │ ├── mail_queue.py (+ mailer.py/_impl.py)
|
||||
│ │ ├── backup_alert_engine.py (+ backup_poller.py)
|
||||
│ │ ├── known_hosts.py
|
||||
│ │ └── db_maintenance.py
|
||||
│ ├── widgets/ # Widget sources (dashboard data adapters)
|
||||
│ └── workers/ # Background workers (media index)
|
||||
└── tests/
|
||||
```
|
||||
|
||||
@@ -99,7 +116,7 @@ Or with PYTHONPATH if not installed:
|
||||
PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
API docs available at: http://localhost:8000/docs
|
||||
API docs available at: <http://localhost:8000/docs>
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -123,16 +140,16 @@ export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
2. After the API is running, open the app, go to **Settings**, and add machine entries:
|
||||
1. After the API is running, open the app, go to **Settings**, and add machine entries:
|
||||
- **Local**: monitors the API host itself without SSH.
|
||||
- **SSH**: monitors another machine using a host, username, and a private key pasted directly into the machine settings, with an optional passphrase.
|
||||
- The machine editor groups Connection, Monitoring / Files, Jellyfin, Jellyseerr, and Notes under separate headings so each service area is easier to scan.
|
||||
- Saving a monitoring machine now validates the banner/auth flow, records the first trusted host key into the backend-managed `known_hosts` file, and starts the collector so charts populate without a separate manual step.
|
||||
- Saving a monitoring machine validates the banner/auth flow and records the first trusted host key into the backend-managed `known_hosts` file.
|
||||
- If the host cannot be reached or authenticated, the save flow surfaces the SSH error directly in the dialog.
|
||||
- Use **Validate SSH + trust host** in the machine editor before saving if you want to test the banner/auth flow explicitly.
|
||||
- The first successful SSH connection uses trust-on-first-use: the backend records that machine's host key into its managed `known_hosts` file automatically, then continues verifying it strictly on later connects.
|
||||
|
||||
3. Open **Monitoring** to see one section per configured machine. Each section uses its own collector state, disk path, metrics queries, and recent action history, which are populated automatically by the backend poller.
|
||||
2. Open **Observability** to see Alertmanager alerts, Prometheus scrape targets, and Grafana deep-links for configured machines. Alertmanager, Grafana, and Prometheus are configured as service instances on the **Services** page; system metrics (disk, CPU, memory) are owned by the external observability stack (Prometheus + node_exporter + Grafana), not by the Manage backend.
|
||||
|
||||
For local development, `docker compose -f docker-compose.dev.yml up --build` does not require an SSH key unless you configure remote SSH machines in the Settings tab.
|
||||
|
||||
@@ -142,14 +159,12 @@ For local development, `docker compose -f docker-compose.dev.yml up --build` doe
|
||||
- `GET /api/dashboard/libraries` — Per-library breakdown
|
||||
- `GET /api/dashboard/now-playing` — Active playback sessions
|
||||
- `GET /api/monitoring/machines` — Persistent monitoring machine definitions
|
||||
- `GET /api/monitoring/status?machine_id=` — Collector status for a machine
|
||||
- `GET /api/monitoring/metrics?machine_id=` — Resource samples (last hour)
|
||||
- `GET /api/monitoring/disk?machine_id=` — Disk space
|
||||
- `POST /api/monitoring/start|stop|restart?machine_id=` — Collector controls
|
||||
- `GET /api/monitoring/diagnostics?machine_id=` — Collector debug info
|
||||
- `GET /api/monitoring/poller` — Backend poller status and configuration
|
||||
- `GET /api/monitoring/machines/{machine_id}/actions` — Recent machine action history
|
||||
- `GET /api/dashboard/monitoring` — Dashboard-wide per-machine monitoring summary table with 10-minute averages and min/max subtext
|
||||
- `GET /api/monitoring/prometheus-targets` — Prometheus scrape targets for remote Node Exporters (consumed by external Prometheus via `http_sd_configs`)
|
||||
- `GET /api/monitoring/alerts` — Active Alertmanager alerts summary (resolves the configured alertmanager service)
|
||||
- `GET /api/monitoring/alertmanager-status` — Alertmanager cluster/status
|
||||
- `GET /api/monitoring/grafana-status` — Grafana service health
|
||||
- `GET /api/monitoring/prometheus-status` — Prometheus service health
|
||||
- `POST /api/monitoring/alertmanager-webhook` — Receive Alertmanager webhooks (log-only)
|
||||
- `GET /api/settings/machines` — Manage machine definitions
|
||||
- `GET /api/media/status` — Index status
|
||||
- `POST /api/media/build` — Rebuild index
|
||||
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"python-multipart>=0.0.9",
|
||||
"prometheus-client>=0.21",
|
||||
"python-json-logger>=2.0",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# backend/src (index)
|
||||
dir: backend/src
|
||||
|
||||
## role
|
||||
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
|
||||
## parent
|
||||
index: backend/.pi-map.index.md
|
||||
map: backend/.pi-map.md
|
||||
## children
|
||||
- backend/src/media_library_viewer_api
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## files
|
||||
## links
|
||||
index: backend/src/.pi-map.index.md
|
||||
map: backend/src/.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,18 @@
|
||||
# backend/src
|
||||
dir: backend/src
|
||||
|
||||
index: backend/src/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Core backend application source directory containing server-side business logic, API routes, models, and configuration.
|
||||
## files
|
||||
## arch
|
||||
Cannot be fully determined as no files are listed in the directory; likely follows standard Node.js/Python backend patterns (e.g., MVC, layered architecture) depending on framework used.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,57 @@
|
||||
# backend/src/media_library_viewer_api (index)
|
||||
dir: backend/src/media_library_viewer_api
|
||||
|
||||
## role
|
||||
FastAPI backend 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
|
||||
## children
|
||||
- backend/src/media_library_viewer_api/clients
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/domain
|
||||
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/domain/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/integrations
|
||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/models
|
||||
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/routers
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/services
|
||||
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/services/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/widgets
|
||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/workers
|
||||
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/workers/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- auth.py
|
||||
- config.py
|
||||
- dependencies.py
|
||||
- jobs.py
|
||||
- logging_utils.py
|
||||
- main.py
|
||||
- observability.py
|
||||
- path_utils.py
|
||||
- utils.py
|
||||
- version.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## workflows
|
||||
- change media_library_viewer_api behavior
|
||||
read: __init__.py, auth.py, config.py
|
||||
- change media_library_viewer_api config
|
||||
read: config.py
|
||||
- explore media_library_viewer_api subdirectories
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,41 @@
|
||||
# backend/src/media_library_viewer_api
|
||||
dir: backend/src/media_library_viewer_api
|
||||
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend 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 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, 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, 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, id, get, call:str, client
|
||||
## symbols
|
||||
- Settings
|
||||
- JobTemplate
|
||||
- _normalize_issuer_url
|
||||
- get_oidc_metadata
|
||||
- get_jwk_client
|
||||
- _split_audience
|
||||
- validate_auth_settings
|
||||
- validate_bearer_jwt
|
||||
## workflows
|
||||
- change media_library_viewer_api behavior
|
||||
read: __init__.py, auth.py, config.py
|
||||
- change media_library_viewer_api config
|
||||
read: config.py
|
||||
- explore media_library_viewer_api subdirectories
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,27 @@
|
||||
# backend/src/media_library_viewer_api/clients (index)
|
||||
dir: backend/src/media_library_viewer_api/clients
|
||||
|
||||
## role
|
||||
Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- authentik.py
|
||||
- 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, authentik.py, http_timeout.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,34 @@
|
||||
# backend/src/media_library_viewer_api/clients
|
||||
dir: backend/src/media_library_viewer_api/clients
|
||||
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Collection of external service API clients and protocol wrappers that standardize communication with media servers, identity providers, torrent clients, and remote/local filesystems.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- 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 for Jellyseerr that fetches and enriches Jellyfin user and request metadata. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:_resolve_title(self, media_type: Any, tmdb_id: Any) → str, call:str, call:self.get, call:data.get, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:mapped.append, call:_label, call:(media or {}).get, method:open_requests(self, max_per_filter) → list[dict[str, Any]], call:self.get, call:isinstance, call:payload.get, call:r.get, call:media.get, call:self._resolve_title, call:results.append, call:_label, call:(media or {}).get, call:len, call:results.sort, call:logger.info, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses
|
||||
- qbittorrent.py | Minimal read-only qBittorrent Web API client that authenticates via username/password and fetches/merges incremental sync/maindata snapshots with caching, locking, and exponential backoff. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout
|
||||
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||
## arch
|
||||
Adapter/wrapper pattern around `requests` HTTP and SSH/paramiko protocols, with each client encapsulating authentication, data fetching, and response normalization into plain Python dictionaries.
|
||||
## tags
|
||||
call:logger.info, call:self.get, call:self., error, call:logger.debug, call:logger.warning, call:isinstance, client
|
||||
## symbols
|
||||
- AuthentikClient
|
||||
- JellyfinClient
|
||||
- JellyseerrClient
|
||||
- CommandResult
|
||||
- LocalCommandClient
|
||||
- QbittorrentClient
|
||||
- RemoteSSHClient
|
||||
- __init__
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, authentik.py, http_timeout.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Read-only Authentik directory client.
|
||||
|
||||
The client normalizes the subset of Authentik core data that Manage displays.
|
||||
It deliberately does not fetch individual users or expose policy/provider data.
|
||||
"""
|
||||
|
||||
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__)
|
||||
|
||||
_MAX_COLLECTION_ITEMS = 10_000
|
||||
_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _text(value: Any) -> str:
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def _identifier(item: dict[str, Any]) -> str:
|
||||
for key in ("pk", "id", "uuid"):
|
||||
value = _text(item.get(key))
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _page_total(payload: dict[str, Any], fallback: int) -> int:
|
||||
pagination = payload.get("pagination")
|
||||
if isinstance(pagination, dict):
|
||||
try:
|
||||
return max(0, int(pagination.get("count") or fallback))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return fallback
|
||||
|
||||
|
||||
class AuthentikClient:
|
||||
"""Small wrapper around Authentik's read-only core API."""
|
||||
|
||||
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:
|
||||
raise ValueError("Authentik API token is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/api/v3"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_token = api_token
|
||||
self.timeout = http_timeout(timeout)
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"Authorization": f"Bearer {api_token}", "Accept": "application/json"})
|
||||
|
||||
def get(self, path: str, **params: Any) -> Any:
|
||||
"""GET an Authentik endpoint and include useful response text on errors."""
|
||||
clean_params = {key: value for key, value in params.items() if value is not None and value != ""}
|
||||
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(f"{self.base_url}/api/v3{path}", params=clean_params, timeout=self.timeout)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
logger.warning("Authentik GET %s failed status=%s url=%s", path, response.status_code, response.url)
|
||||
raise requests.HTTPError(f"{response.status_code} for {response.url}: {detail}", response=response) from exc
|
||||
return response.json()
|
||||
|
||||
def users(self, search: str | None = None, page: int = 1, page_size: int = 50) -> dict[str, Any]:
|
||||
"""Return one raw user page for the directory and messaging surfaces."""
|
||||
payload = self.get("/core/users/", search=search, page=page, page_size=page_size)
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
results = payload.get("results")
|
||||
items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||
return {"items": items, "total": _page_total(payload, len(items)), "page": page, "page_size": page_size}
|
||||
|
||||
def _collection(self, path: str, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
|
||||
"""Read a paginated core collection with a hard cap and loop protection."""
|
||||
try:
|
||||
requested = max(1, min(int(limit), _MAX_COLLECTION_ITEMS))
|
||||
except (TypeError, ValueError):
|
||||
requested = _MAX_COLLECTION_ITEMS
|
||||
items: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
total = 0
|
||||
while len(items) < requested:
|
||||
payload = self.get(path, page=page, page_size=min(_PAGE_SIZE, requested - len(items)))
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("Authentik %s payload was not a dict: %s", path, type(payload).__name__)
|
||||
break
|
||||
results = payload.get("results")
|
||||
page_items = [item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||
total = _page_total(payload, len(items) + len(page_items))
|
||||
items.extend(page_items[: requested - len(items)])
|
||||
if not page_items or len(items) >= total:
|
||||
break
|
||||
page += 1
|
||||
if page > 100: # defensive limit for malformed pagination responses
|
||||
logger.warning("Authentik %s pagination stopped after 100 pages", path)
|
||||
break
|
||||
return {"items": items, "total": total or len(items)}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_group(item: dict[str, Any]) -> dict[str, str] | None:
|
||||
group_id = _identifier(item)
|
||||
if not group_id:
|
||||
return None
|
||||
name = _text(item.get("name") or item.get("display_name") or item.get("slug"))
|
||||
return {"id": group_id, "name": name or f"Unnamed group ({group_id})"}
|
||||
|
||||
@staticmethod
|
||||
def _normalize_application(item: dict[str, Any]) -> dict[str, str]:
|
||||
app_id = _identifier(item)
|
||||
return {
|
||||
"id": app_id,
|
||||
"name": _text(item.get("name") or item.get("slug") or item.get("meta_name")) or "Unnamed application",
|
||||
"slug": _text(item.get("slug")),
|
||||
"launch_url": _text(item.get("launch_url") or item.get("meta_launch_url")),
|
||||
}
|
||||
|
||||
def groups(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
|
||||
"""Return normalized groups; only display-safe identifiers and names are retained."""
|
||||
raw = self._collection("/core/groups/", limit)
|
||||
items = [normalized for item in raw["items"] if (normalized := self._normalize_group(item)) is not None]
|
||||
return {"items": items, "total": raw["total"]}
|
||||
|
||||
def applications(self, limit: int = _MAX_COLLECTION_ITEMS) -> dict[str, Any]:
|
||||
"""Return normalized applications without provider, policy, or secret fields."""
|
||||
raw = self._collection("/core/applications/", limit)
|
||||
return {"items": [self._normalize_application(item) for item in raw["items"]], "total": raw["total"]}
|
||||
|
||||
@staticmethod
|
||||
def _group_references(user: dict[str, Any]) -> list[str]:
|
||||
"""Extract group ids from release-dependent user reference shapes."""
|
||||
raw = user.get("groups", user.get("group", []))
|
||||
if not isinstance(raw, list):
|
||||
raw = [raw] if raw is not None else []
|
||||
ids: list[str] = []
|
||||
for reference in raw:
|
||||
if isinstance(reference, dict):
|
||||
group_id = _identifier(reference)
|
||||
else:
|
||||
group_id = _text(reference)
|
||||
if group_id and group_id not in ids:
|
||||
ids.append(group_id)
|
||||
return ids
|
||||
|
||||
def access_summaries(
|
||||
self,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize user group references and privileged flags without N+1 user reads.
|
||||
|
||||
This is directory metadata only: group membership plus the explicit
|
||||
``is_superuser`` and ``is_staff`` fields. It does not evaluate policies
|
||||
or claim to calculate effective authorization.
|
||||
"""
|
||||
users = self.users(search=search, page=page, page_size=page_size)
|
||||
groups = self.groups()
|
||||
group_names = {group["id"]: group["name"] for group in groups["items"]}
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for user in users["items"]:
|
||||
group_ids = self._group_references(user)
|
||||
summaries.append(
|
||||
{
|
||||
"id": _identifier(user),
|
||||
"username": _text(user.get("username")),
|
||||
"name": _text(user.get("name")),
|
||||
"email": _text(user.get("email")),
|
||||
"is_active": bool(user.get("is_active", True)),
|
||||
"is_superuser": bool(user.get("is_superuser", False)),
|
||||
"is_staff": bool(user.get("is_staff", False)),
|
||||
"groups": [
|
||||
{
|
||||
"id": group_id,
|
||||
"name": group_names.get(group_id, f"Unknown group ({group_id})"),
|
||||
"known": group_id in group_names,
|
||||
}
|
||||
for group_id in group_ids
|
||||
],
|
||||
}
|
||||
)
|
||||
return {"items": summaries, "total": users["total"], "page": users["page"], "page_size": users["page_size"]}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Shared HTTP timeout helpers.
|
||||
|
||||
``requests`` accepts a single integer timeout and applies it to BOTH the
|
||||
connect and read phases. For slow upstream services (large Jellyfin
|
||||
libraries, qBittorrent with many torrents), the read phase needs a much
|
||||
larger budget than connect. These helpers produce ``(connect, read)`` tuples
|
||||
so the two phases are decoupled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Short connect timeout — fail fast on unreachable/dead hosts.
|
||||
DEFAULT_CONNECT_TIMEOUT = 5.0
|
||||
|
||||
#: Generous read timeout — let slow responses complete.
|
||||
DEFAULT_READ_TIMEOUT = 60.0
|
||||
|
||||
|
||||
def http_timeout(
|
||||
read_timeout: float | int | None = None,
|
||||
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
||||
) -> tuple[float, float]:
|
||||
"""Build a ``(connect, read)`` timeout tuple for ``requests``.
|
||||
|
||||
``read_timeout`` is the per-response read budget (seconds). When omitted
|
||||
or non-positive, :data:`DEFAULT_READ_TIMEOUT` applies.
|
||||
"""
|
||||
effective_read = DEFAULT_READ_TIMEOUT
|
||||
if read_timeout is not None:
|
||||
try:
|
||||
parsed = float(read_timeout)
|
||||
if parsed > 0:
|
||||
effective_read = parsed
|
||||
except (TypeError, ValueError):
|
||||
pass # fall back to default on non-numeric input
|
||||
return (connect_timeout, effective_read)
|
||||
@@ -12,6 +12,8 @@ from typing import Any, cast
|
||||
|
||||
import requests
|
||||
|
||||
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,33 @@ from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Jellyseerr numeric status enums (see Overseerr/Jellyseerr source).
|
||||
_REQUEST_STATUS: dict[int, str] = {1: "pending", 2: "approved", 3: "declined"}
|
||||
_MEDIA_STATUS: dict[int, str] = {
|
||||
1: "unknown",
|
||||
2: "pending",
|
||||
3: "processing",
|
||||
4: "partially_available",
|
||||
5: "available",
|
||||
}
|
||||
_REQUEST_TYPE: dict[int, str] = {1: "movie", 2: "tv"}
|
||||
|
||||
|
||||
def _label(value: Any, table: dict[int, str]) -> str:
|
||||
try:
|
||||
return table.get(int(value), str(value))
|
||||
except (TypeError, ValueError):
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
|
||||
class JellyseerrClient:
|
||||
"""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 +47,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(
|
||||
{
|
||||
@@ -35,6 +56,7 @@ class JellyseerrClient:
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
self._title_cache: dict[tuple[str, str], str] = {}
|
||||
|
||||
def get(self, path: str, **params: Any) -> Any:
|
||||
"""GET a Jellyseerr endpoint and include useful response text on errors."""
|
||||
@@ -63,6 +85,26 @@ class JellyseerrClient:
|
||||
path = f"/{path}"
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def _resolve_title(self, media_type: Any, tmdb_id: Any) -> str:
|
||||
"""Resolve a media title via /movie/{tmdbId} or /tv/{tmdbId}, cached.
|
||||
|
||||
Jellyseerr's /request list doesn't include titles; they live on the
|
||||
Movie/Series records. Cached per (type, tmdbId) so repeated polls reuse.
|
||||
"""
|
||||
if not tmdb_id:
|
||||
return ""
|
||||
key = (str(media_type or ""), str(tmdb_id))
|
||||
if key in self._title_cache:
|
||||
return self._title_cache[key]
|
||||
try:
|
||||
is_tv = str(media_type) in ("2", "tv")
|
||||
data = self.get(f"/{'tv' if is_tv else 'movie'}/{tmdb_id}")
|
||||
title = str(data.get("name" if is_tv else "title") or "")
|
||||
except Exception:
|
||||
title = ""
|
||||
self._title_cache[key] = title
|
||||
return title
|
||||
|
||||
def jellyfin_users(self) -> list[dict[str, Any]]:
|
||||
"""Return Jellyfin-linked users known to Jellyseerr.
|
||||
|
||||
@@ -133,3 +175,102 @@ class JellyseerrClient:
|
||||
|
||||
logger.info("Jellyseerr returned %s users", len(results))
|
||||
return results
|
||||
|
||||
def request_count(self) -> dict[str, int]:
|
||||
"""Return normalized request counts from /api/v1/request/count.
|
||||
|
||||
Jellyseerr reports pending/approved/declined/processing/available/total.
|
||||
Missing keys default to 0 so callers can rely on a stable shape.
|
||||
"""
|
||||
payload = self.get("/request/count")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
keys = ("total", "pending", "approved", "declined", "processing", "available")
|
||||
counts = {k: int(payload.get(k) or 0) for k in keys}
|
||||
logger.info(
|
||||
"Jellyseerr request counts total=%s pending=%s processing=%s",
|
||||
counts["total"],
|
||||
counts["pending"],
|
||||
counts["processing"],
|
||||
)
|
||||
return counts
|
||||
|
||||
def recent_requests(self, take: int = 20) -> list[dict[str, Any]]:
|
||||
"""Return the most recently modified requests with resolved titles."""
|
||||
take = max(1, min(int(take), 100))
|
||||
payload = self.get("/request", sort="modified", skip=0, take=take)
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
results = payload.get("results") or []
|
||||
items = [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
|
||||
mapped: list[dict[str, Any]] = []
|
||||
for r in items:
|
||||
media = r.get("media") or {}
|
||||
tmdb_id = media.get("tmdbId")
|
||||
name = r.get("title") or media.get("title") or media.get("name") or ""
|
||||
if not name and tmdb_id:
|
||||
name = self._resolve_title(r.get("type"), tmdb_id)
|
||||
if not name:
|
||||
name = media.get("externalServiceSlug") or ""
|
||||
mapped.append(
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"type": _label(r.get("type"), _REQUEST_TYPE),
|
||||
"name": name or "—",
|
||||
"status": _label(r.get("status"), _REQUEST_STATUS),
|
||||
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
|
||||
"created_at": r.get("createdAt"),
|
||||
}
|
||||
)
|
||||
return mapped
|
||||
|
||||
def open_requests(self, max_per_filter: int = 100) -> list[dict[str, Any]]:
|
||||
"""Return open (pending + approved) requests with resolved titles.
|
||||
|
||||
Fetches pending and approved requests via Jellyseerr's filter param
|
||||
(not all 800+ historical requests), then resolves titles from
|
||||
/movie/{tmdbId} or /tv/{tmdbId}. Titles are cached on the client so
|
||||
subsequent polls are instant.
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
take = 50
|
||||
for filter_val in ("pending", "approved"):
|
||||
skip = 0
|
||||
while skip < max_per_filter:
|
||||
payload = self.get("/request", filter=filter_val, sort="added", skip=skip, take=take)
|
||||
if not isinstance(payload, dict):
|
||||
break
|
||||
page = payload.get("results") or []
|
||||
items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else []
|
||||
for r in items:
|
||||
media = r.get("media") or {}
|
||||
tmdb_id = media.get("tmdbId") or r.get("tmdbId")
|
||||
# Diagnostic: log the first request's shape once so we can verify tmdbId.
|
||||
if not results and filter_val == "pending":
|
||||
logger.info(
|
||||
"Jellyseerr request sample: keys=%s media_keys=%s tmdbId=%s",
|
||||
sorted(r.keys()),
|
||||
sorted(media.keys()) if isinstance(media, dict) else "N/A",
|
||||
tmdb_id,
|
||||
)
|
||||
name = r.get("title") or media.get("title") or media.get("name") or ""
|
||||
if not name and tmdb_id:
|
||||
name = self._resolve_title(r.get("type"), tmdb_id)
|
||||
if not name:
|
||||
name = media.get("externalServiceSlug") or ""
|
||||
results.append(
|
||||
{
|
||||
"id": r.get("id"),
|
||||
"type": _label(r.get("type"), _REQUEST_TYPE),
|
||||
"name": name or "—",
|
||||
"status": _label(r.get("status"), _REQUEST_STATUS),
|
||||
"media_status": _label((media or {}).get("status"), _MEDIA_STATUS),
|
||||
"created_at": r.get("createdAt"),
|
||||
}
|
||||
)
|
||||
if len(items) < take:
|
||||
break
|
||||
skip += len(items)
|
||||
results.sort(key=lambda r: r.get("created_at") or 0, reverse=True)
|
||||
logger.info("Jellyseerr returned %s open requests (with titles)", len(results))
|
||||
return results
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""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
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.clients.http_timeout import DEFAULT_READ_TIMEOUT, http_timeout
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# qBittorrent's built-in web server is effectively single-threaded; collapse
|
||||
# concurrent widget polls onto one fetch and back off when it struggles.
|
||||
MAINDATA_CACHE_TTL = 3.0 # seconds a snapshot is served without re-hitting qBittorrent
|
||||
MAINDATA_BACKOFF_MAX = 30.0 # cap exponential backoff after repeated failures
|
||||
|
||||
|
||||
class QbittorrentClient:
|
||||
"""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
|
||||
# /sync/maindata is the only hot endpoint. Maintain a rid-merged
|
||||
# snapshot (incremental updates -> small payloads), a short-TTL cache
|
||||
# + lock so concurrent widgets share one fetch, and back off when
|
||||
# qBittorrent is struggling rather than piling on (its web server is
|
||||
# single-threaded and otherwise hangs the Web UI for everyone).
|
||||
self._rid: int | None = None
|
||||
self._snapshot: dict[str, Any] = {
|
||||
"server_state": {},
|
||||
"torrents": {},
|
||||
"categories": {},
|
||||
"tags": [],
|
||||
"trackers": [],
|
||||
}
|
||||
self._maindata_lock = threading.Lock()
|
||||
self._maindata_fetched_at: float = 0.0
|
||||
self._maindata_ttl: float = MAINDATA_CACHE_TTL
|
||||
self._backoff_until: float = 0.0
|
||||
self._consecutive_failures = 0
|
||||
|
||||
def _login(self) -> None:
|
||||
"""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]:
|
||||
"""Return the current ``/sync/maindata`` snapshot.
|
||||
|
||||
Uses qBittorrent's incremental ``rid`` protocol (first call is a full
|
||||
update, subsequent calls send the last rid and get a small diff that is
|
||||
merged into the cached snapshot), so payloads stay small. A short-TTL
|
||||
cache + lock collapses concurrent widget polls onto a single fetch, and
|
||||
on repeated failures the client backs off instead of hammering
|
||||
qBittorrent's single-threaded web server (serving the last good
|
||||
snapshot when available).
|
||||
|
||||
Returns a dict with ``server_state`` and ``torrents``.
|
||||
"""
|
||||
now = time.time()
|
||||
with self._maindata_lock:
|
||||
# Serve a fresh-enough cached snapshot without re-hitting qBittorrent.
|
||||
if self._snapshot.get("torrents") and (now - self._maindata_fetched_at) < self._maindata_ttl:
|
||||
return self._copy_snapshot()
|
||||
# While backing off, don't pile on; serve stale or raise.
|
||||
if now < self._backoff_until:
|
||||
if self._snapshot.get("torrents"):
|
||||
return self._copy_snapshot()
|
||||
raise RuntimeError(
|
||||
"qBittorrent maindata unavailable (backing off after repeated failures)"
|
||||
)
|
||||
try:
|
||||
update = self._fetch_maindata_incremental()
|
||||
self._apply_update(update)
|
||||
except Exception as exc:
|
||||
self._consecutive_failures += 1
|
||||
delay = min(2 ** self._consecutive_failures, MAINDATA_BACKOFF_MAX)
|
||||
self._backoff_until = time.time() + delay
|
||||
logger.warning(
|
||||
"qBittorrent maindata fetch failed (#%s); backing off %.0fs: %s",
|
||||
self._consecutive_failures,
|
||||
delay,
|
||||
exc,
|
||||
)
|
||||
if self._snapshot.get("torrents"):
|
||||
return self._copy_snapshot()
|
||||
raise RuntimeError(f"qBittorrent maindata failed: {exc}") from exc
|
||||
self._maindata_fetched_at = time.time()
|
||||
self._consecutive_failures = 0
|
||||
self._backoff_until = 0.0
|
||||
return self._copy_snapshot()
|
||||
|
||||
def _fetch_maindata_incremental(self) -> dict[str, Any]:
|
||||
"""GET /sync/maindata, sending the last rid for an incremental update."""
|
||||
params: dict[str, Any] = {}
|
||||
if self._rid is not None:
|
||||
params["rid"] = self._rid
|
||||
return self._get("/sync/maindata", **params)
|
||||
|
||||
def _apply_update(self, update: dict[str, Any]) -> None:
|
||||
"""Merge a full or partial maindata update into the cached snapshot."""
|
||||
is_full = bool(update.get("full_update")) or self._rid is None
|
||||
self._rid = update.get("rid", self._rid)
|
||||
snap = self._snapshot
|
||||
if is_full:
|
||||
snap.clear()
|
||||
snap["server_state"] = dict(update.get("server_state") or {})
|
||||
snap["torrents"] = dict(update.get("torrents") or {})
|
||||
snap["categories"] = dict(update.get("categories") or {})
|
||||
snap["tags"] = list(update.get("tags") or [])
|
||||
snap["trackers"] = list(update.get("trackers") or [])
|
||||
return
|
||||
# Partial update — merge the diff.
|
||||
server_state = update.get("server_state")
|
||||
if isinstance(server_state, dict):
|
||||
snap["server_state"].update(server_state)
|
||||
changed = update.get("torrents")
|
||||
if isinstance(changed, dict):
|
||||
for hash_, fields in changed.items():
|
||||
if fields is None:
|
||||
snap["torrents"].pop(hash_, None)
|
||||
else:
|
||||
snap["torrents"][hash_] = fields
|
||||
for hash_ in update.get("torrents_removed") or []:
|
||||
snap["torrents"].pop(hash_, None)
|
||||
categories = update.get("categories")
|
||||
if isinstance(categories, dict):
|
||||
snap["categories"].update(categories)
|
||||
for name in update.get("categories_removed") or []:
|
||||
snap["categories"].pop(name, None)
|
||||
if "tags" in update:
|
||||
snap["tags"] = list(update.get("tags") or [])
|
||||
if "trackers" in update:
|
||||
snap["trackers"] = list(update.get("trackers") or [])
|
||||
|
||||
def _copy_snapshot(self) -> dict[str, Any]:
|
||||
"""Return a shallow, race-safe copy of the current snapshot."""
|
||||
snap = self._snapshot
|
||||
return {
|
||||
"rid": self._rid,
|
||||
"server_state": dict(snap.get("server_state") or {}),
|
||||
"torrents": dict(snap.get("torrents") or {}),
|
||||
"categories": dict(snap.get("categories") or {}),
|
||||
"tags": list(snap.get("tags") or []),
|
||||
"trackers": list(snap.get("trackers") or []),
|
||||
}
|
||||
@@ -54,9 +54,6 @@ class Settings(BaseSettings):
|
||||
|
||||
# Observability
|
||||
prometheus_enabled: bool = True
|
||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
||||
alertmanager_url: str = "http://alertmanager:9093"
|
||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
||||
|
||||
# Remote paths
|
||||
remote_media_root: str = ""
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Dependency injection for FastAPI.
|
||||
|
||||
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
|
||||
context. The selected machine can be chosen with a ``machine_id`` query
|
||||
parameter; otherwise the backend falls back to the first enabled machine that
|
||||
matches the requested service.
|
||||
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
||||
remote-machine SSH clients via FastAPI's request context.
|
||||
|
||||
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
|
||||
parameter (resolved against the service registry); the backend falls back to
|
||||
the first enabled ``jellyfin``/``jellyseerr`` service instance.
|
||||
- SSH/Files transport is selected with an enabled ``remote_machine`` ``service_id``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,10 +18,7 @@ from typing import Any
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
@@ -27,11 +27,46 @@ from media_library_viewer_api.services.settings_store import get_settings_store
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _request_machine_id(request: Request | None) -> str | None:
|
||||
def _request_remote_machine_service_id(request: Request | None) -> str | None:
|
||||
if request is None:
|
||||
return None
|
||||
machine_id = request.query_params.get("machine_id")
|
||||
return machine_id or None
|
||||
service_id = request.query_params.get("service_id")
|
||||
return service_id or None
|
||||
|
||||
|
||||
def _request_jellyfin_service_id(request: Request | None) -> str | None:
|
||||
if request is None:
|
||||
return None
|
||||
service_id = request.query_params.get("jellyfin_service_id")
|
||||
return service_id or None
|
||||
|
||||
|
||||
def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None:
|
||||
"""Return a service row for a type, preferring the requested id.
|
||||
|
||||
The row carries an in-memory decrypted ``secrets`` dict. Returns None if no
|
||||
enabled instance of the type exists.
|
||||
"""
|
||||
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||
|
||||
row = None
|
||||
if service_id:
|
||||
candidate = store.get_service(service_id)
|
||||
if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True):
|
||||
row = candidate
|
||||
if row is None:
|
||||
instances = [s for s in store.list_services(service_type) if s.get("enabled", True)]
|
||||
row = instances[0] if instances else None
|
||||
if row is None:
|
||||
return None
|
||||
decrypted = {}
|
||||
blob = row.get("secrets") or {}
|
||||
if blob:
|
||||
try:
|
||||
decrypted = decrypt_secrets(blob)
|
||||
except Exception:
|
||||
logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id"))
|
||||
return {**row, "secrets": decrypted}
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
@@ -43,201 +78,41 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||
return JellyfinClient(url, api_key)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
|
||||
machine_id, url = cache_key
|
||||
if not url:
|
||||
return None
|
||||
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
|
||||
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
|
||||
if not api_key:
|
||||
return None
|
||||
logger.info(
|
||||
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
|
||||
)
|
||||
return JellyseerrClient(url, api_key)
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _ssh_client_for(
|
||||
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
||||
) -> RemoteSSHClient:
|
||||
machine_id, host, username, port, key_filename, password, private_key, private_key_passphrase, known_hosts_path = (
|
||||
cache_key
|
||||
)
|
||||
logger.info(
|
||||
"Creating SSH client machine_id=%s host=%s user=%s port=%s key=%s password=%s private_key=%s passphrase=%s",
|
||||
machine_id or "<default>",
|
||||
host or "<unset>",
|
||||
username or "<unset>",
|
||||
port,
|
||||
key_filename or "<unset>",
|
||||
"set" if password else "missing",
|
||||
"set" if private_key else "missing",
|
||||
"set" if private_key_passphrase else "missing",
|
||||
)
|
||||
client = RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=port,
|
||||
key_filename=key_filename or None,
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=private_key_passphrase or None,
|
||||
password=password or None,
|
||||
known_hosts_path=known_hosts_path or None,
|
||||
)
|
||||
try:
|
||||
client.connect()
|
||||
except RuntimeError as exc:
|
||||
message = str(exc)
|
||||
lowered = message.lower()
|
||||
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
|
||||
if "banner" in lowered:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=(
|
||||
f"SSH banner not received from {host}:{port}. "
|
||||
"Confirm the host, port, and firewall; the backend could not complete the SSH handshake."
|
||||
),
|
||||
) from exc
|
||||
if "authentication failed" in lowered or "no authentication methods available" in lowered:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail=(
|
||||
f"SSH authentication failed for {host}:{port}. "
|
||||
"Check the selected key, passphrase, username, or password."
|
||||
),
|
||||
) from exc
|
||||
raise HTTPException(status_code=502, detail=message) from exc
|
||||
except Exception:
|
||||
logger.exception("Failed to establish SSH connection to %s", host or "<unset>")
|
||||
raise
|
||||
return client
|
||||
|
||||
|
||||
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
||||
def get_jellyfin_client(request: Request) -> JellyfinClient:
|
||||
"""Return a Jellyfin client for the selected enabled service instance."""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
if machine_id:
|
||||
machine = store.get_machine(machine_id)
|
||||
if machine and (service in machine.get("services", []) or service == "ssh"):
|
||||
return machine
|
||||
return machine
|
||||
if service == "jellyfin":
|
||||
machines = store.list_machines_for_service("jellyfin")
|
||||
elif service == "jellyseerr":
|
||||
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
|
||||
elif service == "ssh":
|
||||
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
||||
else:
|
||||
machines = store.list_machines_for_service(service)
|
||||
return machines[0] if machines else None
|
||||
|
||||
|
||||
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||
"""Return a Jellyfin client for the selected machine."""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
resolved = _resolve_machine("jellyfin", request)
|
||||
if resolved:
|
||||
machine = store.get_machine_config(resolved["id"])
|
||||
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"):
|
||||
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
|
||||
return _jellyfin_client_for(cache_key)
|
||||
|
||||
raise RuntimeError(
|
||||
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
|
||||
)
|
||||
|
||||
|
||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
resolved = _resolve_machine("jellyseerr", request)
|
||||
if resolved:
|
||||
machine = store.get_machine_config(resolved["id"])
|
||||
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
|
||||
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
|
||||
|
||||
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)")
|
||||
return None
|
||||
|
||||
|
||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||
"""Build a RemoteSSHClient from a machine config dict."""
|
||||
store = store or get_settings_store()
|
||||
known_hosts_path = get_settings().ssh_known_hosts_file
|
||||
key_data = None
|
||||
key_passphrase = None
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
key_data = ssh_key.get("private_key") or None
|
||||
key_passphrase = ssh_key.get("passphrase") or None
|
||||
if not key_data and machine.get("ssh_private_key"):
|
||||
key_data = machine.get("ssh_private_key") or None
|
||||
key_passphrase = machine.get("ssh_private_key_passphrase") or None
|
||||
cache_key = (
|
||||
machine["id"],
|
||||
machine["host"],
|
||||
machine["username"],
|
||||
int(machine.get("port") or 22),
|
||||
f"{machine.get('key_directory')}/{machine.get('key_name')}"
|
||||
if machine.get("key_directory") and machine.get("key_name")
|
||||
else "",
|
||||
machine.get("password") or None,
|
||||
key_data,
|
||||
key_passphrase,
|
||||
str(known_hosts_path),
|
||||
)
|
||||
return _ssh_client_for(cache_key)
|
||||
|
||||
|
||||
def get_ssh_client(request: Request = None):
|
||||
"""Return a command client for the selected machine or legacy env fallback."""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
machine_ref = _resolve_machine("ssh", request)
|
||||
machine = store.get_machine_config(machine_ref["id"]) if machine_ref else None
|
||||
if machine and str(machine.get("mode") or "local").strip().lower() == "local":
|
||||
logger.info("Creating LocalCommandClient machine_id=%s", machine["id"])
|
||||
return LocalCommandClient()
|
||||
if machine and machine.get("host") and machine.get("username"):
|
||||
return _ssh_client_from_machine_config(machine, store)
|
||||
|
||||
settings = get_settings()
|
||||
logger.info(
|
||||
"Creating SSH client from legacy env host=%s user=%s port=%s key_dir=%s key_name=%s password=%s",
|
||||
settings.ssh_host or "<unset>",
|
||||
settings.ssh_username or "<unset>",
|
||||
settings.ssh_port,
|
||||
settings.ssh_key_directory or "<unset>",
|
||||
settings.ssh_key_name or "<unset>",
|
||||
"set" if settings.ssh_password else "missing",
|
||||
)
|
||||
if not settings.ssh_key_path:
|
||||
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
|
||||
return _ssh_client_for(
|
||||
(
|
||||
"legacy",
|
||||
settings.ssh_host,
|
||||
settings.ssh_username,
|
||||
settings.ssh_port,
|
||||
settings.ssh_key_path,
|
||||
settings.ssh_password or None,
|
||||
None,
|
||||
None,
|
||||
str(settings.ssh_known_hosts_file),
|
||||
service = _service_record(store, "jellyfin", _request_jellyfin_service_id(request))
|
||||
if service is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
|
||||
)
|
||||
)
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Jellyfin service is missing base_url or api_key. Edit it on the Services page.",
|
||||
)
|
||||
return _jellyfin_client_for((service["id"], base_url, api_key))
|
||||
|
||||
|
||||
def get_ssh_client(request: Request) -> RemoteSSHClient:
|
||||
"""Return SSH transport for the requested enabled remote-machine service."""
|
||||
from media_library_viewer_api.services.task_runner import build_ssh_client
|
||||
from media_library_viewer_api.widgets.sources import build_service_record
|
||||
|
||||
store = get_settings_store()
|
||||
service_id = _request_remote_machine_service_id(request)
|
||||
if not service_id:
|
||||
raise HTTPException(status_code=400, detail="service_id is required for remote file and job operations")
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != "remote_machine" or not row.get("enabled", True):
|
||||
raise HTTPException(status_code=404, detail="Enabled remote machine service not found")
|
||||
try:
|
||||
return build_ssh_client(store, build_service_record(store, row))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
def get_mail_queue() -> MailQueue:
|
||||
@@ -250,19 +125,42 @@ def get_settings_store() -> SettingsStore:
|
||||
return _get_settings_store()
|
||||
|
||||
|
||||
def get_user_id(request: Request = None) -> str:
|
||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||
def get_user_id(request: Request) -> str:
|
||||
"""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()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
resolved = _resolve_machine("jellyfin", request)
|
||||
if resolved:
|
||||
machine = store.get_machine_config(resolved["id"])
|
||||
if machine and machine.get("jellyfin_user_id"):
|
||||
return str(machine["jellyfin_user_id"])
|
||||
client = get_jellyfin_client(request)
|
||||
users = client.users()
|
||||
if not users:
|
||||
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
|
||||
return users[0]["Id"]
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyfin", service_id)
|
||||
if service is None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No Jellyfin service is configured. Add a Jellyfin service on the Services page.",
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# backend/src/media_library_viewer_api/domain (index)
|
||||
dir: backend/src/media_library_viewer_api/domain
|
||||
|
||||
## role
|
||||
Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- dashboard.py
|
||||
- media.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/domain/.pi-map.md
|
||||
## workflows
|
||||
- change domain behavior
|
||||
read: __init__.py, dashboard.py, media.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,29 @@
|
||||
# backend/src/media_library_viewer_api/domain
|
||||
dir: backend/src/media_library_viewer_api/domain
|
||||
|
||||
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides domain logic for normalizing media API data and building dashboard summaries for the media library viewer application.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- dashboard.py | Provides domain helper functions for building dashboard data, specifically normalizing Jellyfin session activity rows and computing backup job summaries. | exp: func:_map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) → list[dict[str, Any]], call:session.get, call:bool, call:item.get, call:play_state.get, call:transcoding.get, call:transcode_type.append, call:results.append, call:", ".join, func:build_backup_dashboard_summary(store: SettingsStore) → BackupDashboardSummary, call:store.list_backup_jobs, call:len, call:int, call:time.time, call:store.list_backup_runs, call:recent_runs.append, call:sum, call:store.list_backup_alerts, call:failed_runs.append, call:max, call:BackupDashboardSummary, call:round | dep: time, typing, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||
- media.py | Flattens inconsistent Jellyfin API JSON into normalized dictionaries for SQLite indexing and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer_api.utils, pandas
|
||||
## arch
|
||||
Functional utility module pattern with pure helper functions that transform external API JSON into normalized domain objects.
|
||||
## tags
|
||||
media, backup, call:item.get, call:str, date, added, dashboard, call:store.list
|
||||
## symbols
|
||||
- _map_sessions_to_activity_rows
|
||||
- build_backup_dashboard_summary
|
||||
- first_media_source
|
||||
- media_streams
|
||||
- stream_value
|
||||
- is_hdr_item
|
||||
- format_date_added
|
||||
- timestamp_date_added
|
||||
## workflows
|
||||
- change domain behavior
|
||||
read: __init__.py, dashboard.py, media.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Dashboard domain helpers shared between routers and widget adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown"))
|
||||
if has_item
|
||||
else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
else:
|
||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type: list[str] = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary:
|
||||
"""Compute the backup summary shown on the dashboard."""
|
||||
jobs = store.list_backup_jobs()
|
||||
total_jobs = len(jobs)
|
||||
|
||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||
recent_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||
if runs and runs[0]["started_at"] >= cutoff:
|
||||
recent_runs.append(runs[0])
|
||||
|
||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||
|
||||
alerts = store.list_backup_alerts(acknowledged=False)
|
||||
active_alerts = len(alerts)
|
||||
|
||||
failed_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||
if runs:
|
||||
failed_runs.append(runs[0])
|
||||
|
||||
last_failed_at = None
|
||||
if failed_runs:
|
||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||
|
||||
return BackupDashboardSummary(
|
||||
total_jobs=total_jobs,
|
||||
success_rate_24h=round(success_rate, 1),
|
||||
active_alerts=active_alerts,
|
||||
last_failed_at=last_failed_at,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
# backend/src/media_library_viewer_api/integrations (index)
|
||||
dir: backend/src/media_library_viewer_api/integrations
|
||||
|
||||
## role
|
||||
Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- alertmanager.py
|
||||
- authentik.py
|
||||
- backups.py
|
||||
- base.py
|
||||
- jellyfin.py
|
||||
- nextcloud.py
|
||||
- prometheus.py
|
||||
- qbittorrent.py
|
||||
- registry.py
|
||||
- ssh_tasks.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
|
||||
## workflows
|
||||
- change integrations behavior
|
||||
read: __init__.py, alertmanager.py, authentik.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,37 @@
|
||||
# backend/src/media_library_viewer_api/integrations
|
||||
dir: backend/src/media_library_viewer_api/integrations
|
||||
|
||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides a pluggable integration layer for connecting to and monitoring external self-hosted services (e.g., Jellyfin, Prometheus, qBittorrent, Nextcloud) with unified config schemas, connection testing, and widget definitions.
|
||||
## files
|
||||
- __init__.py | Defines a closed registry module for service integrations.
|
||||
- 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 service integration configuration, connection testing, and widget definitions for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig, class:JellyfinNowPlayingWidgetConfig, class:JellyfinRequestStatWidgetConfig, class:JellyfinRequestsOverviewWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:str, call:config.get, call:secrets.get, call:int, call:JellyfinClient, call:client.users, call:TestResult, call:len, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.jellyfin.JellyfinClient, media_library_viewer_api.services.settings_store.SettingsStore
|
||||
- nextcloud.py | Defines 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 models, secret fields, widget definitions (totals, active, speed), and a connection test function. | exp: class:QbittorrentConfig, class:QbittorrentWidgetConfig, class:QbittorrentSpeedWidgetConfig, func:test_connection(config: dict[str, Any], secrets: dict[str, str], store: SettingsStore) → TestResult, call:config.get, call:secrets.get, call:int, call:QbittorrentClient, call:client.maindata, call:data.get("server_state", {}).get, call:TestResult, call:str(exc).lower, call:translate_connection_error | dep: typing, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.integrations.base, media_library_viewer_api.services.settings_store, media_library_viewer_api.clients.qbittorrent.QbittorrentClient, media_library_viewer_api.services.settings_store.SettingsStore
|
||||
- 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-based plugin pattern with a shared base class defining standard interfaces (config models, secrets, widgets, connection tests) that each service integration implements and registers with a central closed registry for dynamic discovery.
|
||||
## tags
|
||||
config, connection, widget, media_library_viewer_api, service, error, integrations, test
|
||||
## symbols
|
||||
- AlertmanagerConfig
|
||||
- AlertmanagerAlertsWidgetConfig
|
||||
- AuthentikConfig
|
||||
- BackupsConfig
|
||||
- BackupsSummaryWidgetConfig
|
||||
- ServiceConfigBase
|
||||
- WidgetConfigBase
|
||||
- SecretField
|
||||
## workflows
|
||||
- change integrations behavior
|
||||
read: __init__.py, alertmanager.py, authentik.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1 @@
|
||||
"""Closed registry of service integrations."""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Alertmanager service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import requests
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
class AlertmanagerConfig(ServiceConfigBase):
|
||||
"""Non-secret Alertmanager connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 15
|
||||
|
||||
|
||||
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
||||
"""Active-alerts summary for an Alertmanager instance."""
|
||||
|
||||
severity_filter: str | None = None
|
||||
|
||||
|
||||
def summarize_alerts(
|
||||
alerts: list[dict[str, Any]],
|
||||
*,
|
||||
severity_filter: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a UI-friendly summary from an Alertmanager ``/api/v1/alerts`` list.
|
||||
|
||||
Reshapes the raw alert objects into a stable summary (``total``,
|
||||
``by_severity``, top-50 ``alerts``). When ``severity_filter`` is given, only
|
||||
alerts whose ``labels.severity`` matches are counted.
|
||||
"""
|
||||
by_severity: dict[str, int] = {}
|
||||
open_alerts: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
severity = labels.get("severity", "unknown")
|
||||
if severity_filter and severity != severity_filter:
|
||||
continue
|
||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||
open_alerts.append(
|
||||
{
|
||||
"name": labels.get("alertname", "unknown"),
|
||||
"severity": severity,
|
||||
"category": labels.get("category", ""),
|
||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||
"summary": annotations.get("summary", ""),
|
||||
"description": annotations.get("description", ""),
|
||||
"active_since": alert.get("startsAt"),
|
||||
"state": alert.get("status", "firing"),
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||
return {
|
||||
"total": len(open_alerts),
|
||||
"by_severity": by_severity,
|
||||
"alerts": open_alerts[:50],
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
description="Alertmanager alerts and status.",
|
||||
config_model=AlertmanagerConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="active_alerts",
|
||||
name="Active alerts",
|
||||
description="Firing alerts summary from Alertmanager.",
|
||||
model_cls=AlertmanagerAlertsWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Authentik service definition for read-only directory and access metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
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:
|
||||
"""Probe the least-expensive Authentik directory endpoint."""
|
||||
try:
|
||||
client = AuthentikClient(
|
||||
base_url=str(config.get("base_url") or "").rstrip("/"),
|
||||
api_token=str(secrets.get("api_token") or ""),
|
||||
timeout=float(config.get("timeout_seconds") or 60),
|
||||
)
|
||||
result = client.users(page=1, page_size=1)
|
||||
return TestResult(ok=True, detail="Connected to Authentik.", evidence=f"{result.get('total', 0)} users")
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context="Authentik")
|
||||
|
||||
|
||||
class AuthentikConfig(ServiceConfigBase):
|
||||
"""Non-secret Authentik connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = Field(default=60, ge=1, le=300)
|
||||
|
||||
|
||||
class AuthentikListWidgetConfig(WidgetConfigBase):
|
||||
"""Bounded display count for read-only Authentik list widgets."""
|
||||
|
||||
limit: int = Field(default=10, ge=1, le=50)
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="authentik",
|
||||
name="Authentik",
|
||||
description="Read-only user directory, groups, and application access metadata.",
|
||||
config_model=AuthentikConfig,
|
||||
secret_fields=[SecretField(key="api_token", label="API token", required=True)],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="access_summary",
|
||||
name="User access summary",
|
||||
description="User group memberships and explicit staff/superuser status; not effective authorization.",
|
||||
model_cls=AuthentikListWidgetConfig,
|
||||
default_config={"limit": 10},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="groups",
|
||||
name="Groups",
|
||||
description="Read-only Authentik group list.",
|
||||
model_cls=AuthentikListWidgetConfig,
|
||||
default_config={"limit": 10},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="applications",
|
||||
name="Applications",
|
||||
description="Read-only Authentik application list.",
|
||||
model_cls=AuthentikListWidgetConfig,
|
||||
default_config={"limit": 10},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Backups service definition.
|
||||
|
||||
Backups is modeled as a service type so it can be configured, named, and
|
||||
multi-instanced like other services. Reports arrive via the existing REST
|
||||
report endpoint; the ``ingestion_label`` disambiguates multi-instance
|
||||
ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class BackupsConfig(ServiceConfigBase):
|
||||
"""Non-secret Backups connection config."""
|
||||
|
||||
ingestion_label: str = "default"
|
||||
|
||||
|
||||
class BackupsSummaryWidgetConfig(WidgetConfigBase):
|
||||
"""Backup dashboard summary (jobs, runs, alerts)."""
|
||||
|
||||
# No user-overridable fields; the widget reads the internal backup tables.
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="backups",
|
||||
name="Backups",
|
||||
description="Backup job monitoring, run history, and alerting.",
|
||||
config_model=BackupsConfig,
|
||||
secret_fields=[],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="summary",
|
||||
name="Summary",
|
||||
description="Backup job summary and active alerts.",
|
||||
model_cls=BackupsSummaryWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
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),
|
||||
* the widget kinds it can contribute to the dashboard (each with its own
|
||||
Pydantic-derived config schema).
|
||||
|
||||
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
|
||||
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
|
||||
map. There is no runtime plugin loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
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. ``example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("base_url must be a string starting with http:// or https://")
|
||||
text = value.strip()
|
||||
if not text:
|
||||
raise ValueError("base_url must not be empty")
|
||||
lowered = text.lower()
|
||||
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
||||
raise ValueError("base_url must start with http:// or https:// (include the schema)")
|
||||
return text
|
||||
|
||||
|
||||
#: Shared annotated type for service ``base_url`` fields. applying the validator
|
||||
#: uniformly across every integration so missing schemas are rejected at the
|
||||
#: config boundary with a helpful message.
|
||||
ServiceBaseUrl = Annotated[
|
||||
str,
|
||||
Field(description="Absolute URL including the http:// or https:// schema."),
|
||||
BeforeValidator(_validate_service_base_url),
|
||||
]
|
||||
|
||||
|
||||
class ServiceConfigBase(BaseModel):
|
||||
"""Base for per-service non-secret config models.
|
||||
|
||||
Subclass this in each integration module and declare the connection fields.
|
||||
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||
|
||||
Connection URLs should use the :data:`ServiceBaseUrl` type so the
|
||||
``http(s)://`` schema is enforced consistently across integrations.
|
||||
"""
|
||||
|
||||
|
||||
class WidgetConfigBase(BaseModel):
|
||||
"""Base for per-widget config models.
|
||||
|
||||
Subclass this for each widget kind a service provides. Widget configs never
|
||||
hold secrets; credentials live on the parent service record.
|
||||
"""
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SecretField:
|
||||
"""A secret field stored encrypted on the service record."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
required: bool = False
|
||||
helper: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WidgetKind:
|
||||
"""A widget kind contributed by a service definition."""
|
||||
|
||||
kind: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
default_config: dict[str, Any] = field(default_factory=dict)
|
||||
refresh_interval_ms: int = 0
|
||||
config_model: type[WidgetConfigBase] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestResult:
|
||||
"""Outcome of a credential/connectivity test for a service instance."""
|
||||
|
||||
ok: bool
|
||||
detail: str
|
||||
evidence: str | None = None
|
||||
|
||||
|
||||
#: A test routine receives (config, secrets, store). The store is needed for
|
||||
#: remote_machine (SSH-key resolution). Other types ignore it.
|
||||
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceDefinition:
|
||||
"""Closed description of an external service type."""
|
||||
|
||||
service_type: str
|
||||
name: str
|
||||
description: str
|
||||
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]:
|
||||
"""JSON schema for the service's non-secret config."""
|
||||
return self.config_model.model_json_schema()
|
||||
|
||||
@property
|
||||
def secret_keys(self) -> set[str]:
|
||||
return {sf.key for sf in self.secret_fields}
|
||||
|
||||
def widget_kind(self, kind: str) -> WidgetKind | None:
|
||||
for wk in self.widget_kinds:
|
||||
if wk.kind == kind:
|
||||
return wk
|
||||
return None
|
||||
|
||||
|
||||
def widget_kind(
|
||||
kind: str,
|
||||
name: str,
|
||||
description: str,
|
||||
model_cls: type[WidgetConfigBase],
|
||||
*,
|
||||
default_config: dict[str, Any] | None = None,
|
||||
refresh_interval_ms: int = 0,
|
||||
) -> WidgetKind:
|
||||
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
|
||||
schema = model_cls.model_json_schema()
|
||||
# Strip Pydantic's title noise so the exposed schema stays clean.
|
||||
schema.pop("title", None)
|
||||
return WidgetKind(
|
||||
kind=kind,
|
||||
name=name,
|
||||
description=description,
|
||||
config_schema=schema,
|
||||
default_config=dict(default_config or {}),
|
||||
refresh_interval_ms=refresh_interval_ms,
|
||||
config_model=model_cls,
|
||||
)
|
||||
|
||||
|
||||
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""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]}")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Jellyfin service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
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.
|
||||
|
||||
The optional ``jellyseerr_url`` field pairs a Jellyseerr companion with this
|
||||
Jellyfin instance; the matching ``jellyseerr_api_key`` is a secret field on
|
||||
the service. When both are set, the Jellyfin service page renders a Requests
|
||||
tab backed by Jellyseerr.
|
||||
"""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 60
|
||||
jellyseerr_url: str = ""
|
||||
|
||||
|
||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
"""Live Jellyfin session activity."""
|
||||
|
||||
# No user-overridable fields; the service record carries user_id.
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
|
||||
"""Only show sessions with active playback (not idle/paused)."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class JellyfinRequestStatWidgetConfig(WidgetConfigBase):
|
||||
"""A single Jellyseerr request stat (e.g. pending / approved / total)."""
|
||||
|
||||
stat: Literal[
|
||||
"total",
|
||||
"pending",
|
||||
"approved",
|
||||
"declined",
|
||||
"processing",
|
||||
"available",
|
||||
] = "pending"
|
||||
|
||||
|
||||
class JellyfinRequestsOverviewWidgetConfig(WidgetConfigBase):
|
||||
"""Grid of all Jellyseerr request stats + a recent-requests list."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="jellyfin",
|
||||
name="Jellyfin",
|
||||
description="Media server with live session activity.",
|
||||
config_model=JellyfinConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", required=True),
|
||||
SecretField(
|
||||
key="jellyseerr_api_key",
|
||||
label="Jellyseerr API key",
|
||||
required=False,
|
||||
helper="Enables the Requests tab + request-stats widgets (optional).",
|
||||
),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="activity",
|
||||
name="Activity",
|
||||
description="Live sessions and idle users.",
|
||||
model_cls=JellyfinActivityWidgetConfig,
|
||||
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,
|
||||
),
|
||||
widget_kind(
|
||||
kind="stat",
|
||||
name="Request stat",
|
||||
description="A single Jellyseerr request statistic (e.g. pending requests).",
|
||||
model_cls=JellyfinRequestStatWidgetConfig,
|
||||
default_config={"stat": "pending"},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
widget_kind(
|
||||
kind="stats_overview",
|
||||
name="Requests overview",
|
||||
description="All Jellyseerr request stats plus a recent-requests list.",
|
||||
model_cls=JellyfinRequestsOverviewWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Nextcloud service definition.
|
||||
|
||||
Nextcloud is included as a proof-of-concept third-party service. It has no
|
||||
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."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
username: str = ""
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="nextcloud",
|
||||
name="Nextcloud",
|
||||
description="Self-hosted files and collaboration.",
|
||||
config_model=NextcloudConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="app_password", label="App password", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Prometheus service definition."""
|
||||
|
||||
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-via-Grafana gateway config."""
|
||||
|
||||
grafana_url: ServiceBaseUrl
|
||||
datasource_uid: str = "prometheus"
|
||||
timeout_seconds: int = 60
|
||||
|
||||
|
||||
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL instant query rendered as a metric."""
|
||||
|
||||
promql: str
|
||||
|
||||
|
||||
class PrometheusChartWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
|
||||
|
||||
promql: str
|
||||
window: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
|
||||
# Display scaling for the Y axis + tooltip. "none" shows raw values; the
|
||||
# others auto/force a decimal-prefix unit (kB/MB/GB, kbps/Mbps, etc.).
|
||||
unit: Literal[
|
||||
"none",
|
||||
"bytes",
|
||||
"bytes_per_sec",
|
||||
"bits_per_sec",
|
||||
"bits",
|
||||
"percent",
|
||||
"seconds",
|
||||
] = "none"
|
||||
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||
|
||||
|
||||
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
|
||||
"""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: Literal["5m", "15m", "30m", "1h", "3h", "6h", "12h", "24h", "2d", "7d", "14d", "30d"] = "1h"
|
||||
unit: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
description="Metrics storage and PromQL queries.",
|
||||
config_model=PrometheusConfig,
|
||||
secret_fields=[
|
||||
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(
|
||||
kind="metric",
|
||||
name="Metric",
|
||||
description="Instant query result rendered as a metric.",
|
||||
model_cls=PrometheusMetricWidgetConfig,
|
||||
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,145 @@
|
||||
"""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 pydantic import Field, field_validator
|
||||
|
||||
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 and sampling config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = Field(default=60, ge=1, le=300)
|
||||
polling_enabled: bool = Field(default=True, description="Collect speed samples without an open dashboard")
|
||||
poll_interval_seconds: int = Field(default=15, ge=5, le=300, description="Seconds between speed samples")
|
||||
sample_retention_seconds: int = Field(
|
||||
default=1_800,
|
||||
ge=60,
|
||||
le=86_400,
|
||||
description="How long speed samples remain available",
|
||||
)
|
||||
sample_max_rows: int = Field(
|
||||
default=1_200,
|
||||
ge=60,
|
||||
le=1_200,
|
||||
description="Maximum speed samples retained per service",
|
||||
)
|
||||
|
||||
|
||||
class QbittorrentWidgetConfig(WidgetConfigBase):
|
||||
"""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."""
|
||||
|
||||
window_seconds: int | Literal["all"] = 1_800
|
||||
unit: Literal[
|
||||
"none",
|
||||
"bytes",
|
||||
"bytes_per_sec",
|
||||
"bits_per_sec",
|
||||
"bits",
|
||||
"percent",
|
||||
"seconds",
|
||||
] = "bytes_per_sec"
|
||||
scale: Literal["auto", "k", "m", "g", "t"] = "auto"
|
||||
|
||||
@field_validator("window_seconds")
|
||||
@classmethod
|
||||
def validate_window_seconds(cls, value: int | str) -> int | str:
|
||||
"""Allow all retained samples while bounding explicit numeric windows."""
|
||||
if value == "all":
|
||||
return value
|
||||
if not isinstance(value, int) or not 60 <= value <= 86_400:
|
||||
raise ValueError("window_seconds must be between 60 and 86400, or 'all'")
|
||||
return value
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
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="All active download/upload work, including queued and stalled transfers.",
|
||||
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={"window_seconds": 1_800, "unit": "bytes_per_sec", "scale": "auto"},
|
||||
refresh_interval_ms=15_000,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Closed registry of service definitions.
|
||||
|
||||
Adding a brand-new service still requires a backend deploy and a module here.
|
||||
There is no runtime plugin loading.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.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.remote_machine import DEFINITION as REMOTE_MACHINE
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
QBITTORRENT.service_type: QBITTORRENT,
|
||||
REMOTE_MACHINE.service_type: REMOTE_MACHINE,
|
||||
BACKUPS.service_type: BACKUPS,
|
||||
AUTHENTIK.service_type: AUTHENTIK,
|
||||
}
|
||||
|
||||
|
||||
def list_service_types() -> list[str]:
|
||||
"""Return all registered service type names (sorted for stable output)."""
|
||||
return sorted(SERVICE_DEFINITIONS)
|
||||
|
||||
|
||||
def get_service_definition(service_type: str) -> ServiceDefinition | None:
|
||||
"""Return the definition for a service type, or ``None`` if unknown."""
|
||||
return SERVICE_DEFINITIONS.get(service_type)
|
||||
|
||||
|
||||
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None:
|
||||
"""Return a widget kind declared by a service definition, or ``None``."""
|
||||
definition = get_service_definition(service_type)
|
||||
if definition is None:
|
||||
return None
|
||||
return definition.widget_kind(widget_kind)
|
||||
|
||||
|
||||
def require_service_definition(service_type: str) -> ServiceDefinition:
|
||||
"""Return the definition or raise ``ValueError`` for an unknown type."""
|
||||
definition = get_service_definition(service_type)
|
||||
if definition is None:
|
||||
raise ValueError(f"Unknown service type: {service_type}")
|
||||
return definition
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Remote machine service definition.
|
||||
|
||||
An ``remote_machine`` instance is an SSH endpoint that can run reusable saved tasks.
|
||||
Tasks themselves stay in the global saved-task registry; the instance only owns
|
||||
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||
and shown as history on the instance's service page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
TestResult,
|
||||
WidgetConfigBase,
|
||||
translate_connection_error,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def test_connection(
|
||||
config: dict[str, Any],
|
||||
secrets: dict[str, str],
|
||||
store: SettingsStore,
|
||||
) -> TestResult:
|
||||
"""Build an SSH client via build_ssh_client and attempt .connect().
|
||||
|
||||
Reuses the same error-translation patterns as test_machine_ssh (banner,
|
||||
auth failed). Known-host recording is preserved.
|
||||
"""
|
||||
from media_library_viewer_api.services.task_runner import build_ssh_client
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
host = str(config.get("host") or "").strip()
|
||||
port = int(config.get("port") or 22)
|
||||
try:
|
||||
service = ServiceRecord(
|
||||
id="",
|
||||
service_type="remote_machine",
|
||||
name="test",
|
||||
config=config,
|
||||
secrets=secrets,
|
||||
enabled=True,
|
||||
)
|
||||
client = build_ssh_client(store, service)
|
||||
try:
|
||||
client.connect()
|
||||
except Exception as exc:
|
||||
lowered = str(exc).lower()
|
||||
if "protocol banner" in lowered:
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail=f"SSH banner not received from {host}:{port}; confirm the SSH service is running.",
|
||||
)
|
||||
if "no authentication methods available" in lowered or "authentication failed" in lowered:
|
||||
return TestResult(
|
||||
ok=False,
|
||||
detail=f"SSH authentication failed for {host}:{port}; check the SSH key, passphrase, or username.",
|
||||
)
|
||||
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||
finally:
|
||||
client.close()
|
||||
return TestResult(
|
||||
ok=True,
|
||||
detail=f"SSH connection succeeded for {host}:{port}.",
|
||||
evidence=f"Connected to {host}:{port}",
|
||||
)
|
||||
except ValueError as exc:
|
||||
return TestResult(ok=False, detail=str(exc))
|
||||
except Exception as exc:
|
||||
return translate_connection_error(exc, context=f"SSH {host}:{port}")
|
||||
|
||||
|
||||
class RemoteMachineConfig(ServiceConfigBase):
|
||||
"""Non-secret Remote machine config.
|
||||
|
||||
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
||||
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||
"""
|
||||
|
||||
host: str
|
||||
port: int = 22
|
||||
username: str = ""
|
||||
ssh_key_id: str = ""
|
||||
timeout_seconds: int = 30
|
||||
|
||||
|
||||
class RemoteMachineTaskOutputWidgetConfig(WidgetConfigBase):
|
||||
"""Output of a saved task run on this instance."""
|
||||
|
||||
task_id: str
|
||||
# service_id is implicit (the widget's service); allow overriding per-widget.
|
||||
service_id: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="remote_machine",
|
||||
name="Remote machine",
|
||||
description="SSH transport for files and reusable actions.",
|
||||
config_model=RemoteMachineConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||
SecretField(key="password", label="SSH password", helper="Optional"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="task_output",
|
||||
name="Task output",
|
||||
description="Output of a saved task run.",
|
||||
model_cls=RemoteMachineTaskOutputWidgetConfig,
|
||||
default_config={"task_id": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
test_callable=test_connection,
|
||||
)
|
||||
@@ -52,42 +52,6 @@ JOB_TEMPLATES: dict[str, JobTemplate] = {
|
||||
description="Lists empty directories under the selected path. Does not delete anything.",
|
||||
command_template="find {path} -type d -empty -print",
|
||||
),
|
||||
"install_node_exporter": JobTemplate(
|
||||
name="Install Node Exporter",
|
||||
description="Downloads and installs prometheus-node-exporter via package manager (apt/dnf/yum/zypper).",
|
||||
command_template=(
|
||||
"set -e; "
|
||||
"if command -v apt-get >/dev/null 2>&1; then "
|
||||
"sudo apt-get update && sudo apt-get install -y prometheus-node-exporter; "
|
||||
"elif command -v dnf >/dev/null 2>&1; then "
|
||||
"sudo dnf install -y prometheus-node-exporter; "
|
||||
"elif command -v yum >/dev/null 2>&1; then "
|
||||
"sudo yum install -y prometheus-node-exporter; "
|
||||
"elif command -v zypper >/dev/null 2>&1; then "
|
||||
"sudo zypper install -y prometheus-node-exporter; "
|
||||
"else echo 'No supported package manager found' >&2; exit 1; "
|
||||
"fi; "
|
||||
"sudo systemctl enable --now prometheus-node-exporter; "
|
||||
"echo installed at {path}"
|
||||
),
|
||||
),
|
||||
"restart_node_exporter": JobTemplate(
|
||||
name="Restart Node Exporter",
|
||||
description="Restarts the prometheus-node-exporter systemd service.",
|
||||
command_template="sudo systemctl restart prometheus-node-exporter; echo restarted at {path}",
|
||||
),
|
||||
"node_exporter_status": JobTemplate(
|
||||
name="Node Exporter status",
|
||||
description="Checks whether prometheus-node-exporter is installed, enabled, and running.",
|
||||
command_template=(
|
||||
"systemctl status prometheus-node-exporter --no-pager || true; "
|
||||
"echo '---'; "
|
||||
"command -v node_exporter >/dev/null 2>&1 "
|
||||
"&& node_exporter --version 2>&1 | head -1 "
|
||||
"|| echo 'node_exporter binary not found'; "
|
||||
"echo checked {path}"
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,40 +21,72 @@ from media_library_viewer_api.observability import (
|
||||
record_request,
|
||||
set_current_request_id,
|
||||
)
|
||||
from media_library_viewer_api.routers import (
|
||||
authentik_users as authentik_users_router,
|
||||
)
|
||||
from media_library_viewer_api.routers import backups as backups_router
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
|
||||
from media_library_viewer_api.routers import dashboards as dashboards_router
|
||||
from media_library_viewer_api.routers import jellyseerr as jellyseerr_router
|
||||
from media_library_viewer_api.routers import scheduler as scheduler_router # type: ignore[reportAttributeAccessIssue]
|
||||
from media_library_viewer_api.routers import services as services_router
|
||||
from media_library_viewer_api.routers import widgets as widgets_router
|
||||
from media_library_viewer_api.routers.settings import router as settings_router
|
||||
|
||||
from .services.backup_poller import get_backup_poller
|
||||
from .services.scheduler import get_scheduler # type: ignore[reportMissingImports]
|
||||
from .version import get_backend_version, get_version_info
|
||||
|
||||
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."""
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level, settings.log_format)
|
||||
validate_auth_settings(settings)
|
||||
from media_library_viewer_api.services.secrets import validate_encryption_key
|
||||
|
||||
validate_encryption_key()
|
||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||
try:
|
||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
||||
|
||||
write_prometheus_targets(get_settings_store())
|
||||
except Exception:
|
||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
||||
try:
|
||||
get_settings_store().ensure_defaults()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default settings during startup")
|
||||
try:
|
||||
from media_library_viewer_api.services.service_data import get_service_data_harness
|
||||
|
||||
get_service_data_harness()
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize service data harness during startup")
|
||||
_validate_prometheus_gateway_config()
|
||||
mail_queue = get_mail_queue()
|
||||
backup_poller = get_backup_poller()
|
||||
scheduler = get_scheduler()
|
||||
mail_queue.start()
|
||||
backup_poller.start()
|
||||
scheduler.start()
|
||||
yield
|
||||
scheduler.stop()
|
||||
backup_poller.stop()
|
||||
mail_queue.stop()
|
||||
logger.info("Backend shutdown complete")
|
||||
@@ -138,11 +170,15 @@ app.include_router(monitoring.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
app.include_router(scheduler_router.router)
|
||||
app.include_router(dashboards_router.router)
|
||||
app.include_router(jellyseerr_router.router)
|
||||
app.include_router(services_router.router)
|
||||
app.include_router(authentik_users_router.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -167,4 +203,4 @@ def metrics() -> Response:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# backend/src/media_library_viewer_api/models (index)
|
||||
dir: backend/src/media_library_viewer_api/models
|
||||
|
||||
## role
|
||||
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- backups.py
|
||||
- dashboards.py
|
||||
- services.py
|
||||
- widgets.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, dashboards.py, services.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,30 @@
|
||||
# backend/src/media_library_viewer_api/models
|
||||
dir: backend/src/media_library_viewer_api/models
|
||||
|
||||
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Defines Pydantic data models (schemas) for API request/response validation and serialization across backup, dashboard, service, and widget domains.
|
||||
## files
|
||||
- backups.py | Defines Pydantic data models for backup system API requests and responses including reports, jobs, runs, alerts, and dashboard summaries. | exp: class:BackupReportRequest, class:BackupJobResponse, class:BackupRunResponse, class:BackupAlertResponse, class:BackupDashboardSummary | dep: datetime, typing, pydantic
|
||||
- dashboards.py | Defines Pydantic data models for creating, updating, and representing named dashboard records in an API. | exp: class:NamedDashboardInput, class:NamedDashboard | dep: typing, pydantic
|
||||
- services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
|
||||
- widgets.py | Defines Pydantic models for a dashboard widget system, including input/output schemas and validation to prevent credential leakage in widget configurations. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||
## arch
|
||||
Pydantic-based model layer following a schema-first design pattern with built-in validators for domain-specific business rules and data integrity.
|
||||
## tags
|
||||
widget, backup, instance, dashboard, pydantic, response, info, call:isinstance
|
||||
## symbols
|
||||
- BackupReportRequest
|
||||
- BackupJobResponse
|
||||
- BackupRunResponse
|
||||
- BackupAlertResponse
|
||||
- BackupDashboardSummary
|
||||
- NamedDashboardInput
|
||||
- NamedDashboard
|
||||
- ServiceInstanceInput
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, dashboards.py, services.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Pydantic models for the named-dashboards API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NamedDashboardInput(BaseModel):
|
||||
"""Input for create/update of a named dashboard."""
|
||||
|
||||
id: str | None = None
|
||||
label: str = Field(default="Dashboard")
|
||||
slug: str | None = None
|
||||
sort_order: int = 0
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NamedDashboard(BaseModel):
|
||||
"""A named dashboard record."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
slug: str
|
||||
sort_order: int
|
||||
payload: dict[str, Any]
|
||||
created_at: int
|
||||
updated_at: int
|
||||
@@ -0,0 +1,70 @@
|
||||
"""API models for backend scheduled actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SchedulerStatus(BaseModel):
|
||||
service_id: str
|
||||
action_key: str
|
||||
worker_running: bool
|
||||
enabled: bool
|
||||
running: bool = False
|
||||
poll_interval_seconds: int = Field(ge=5, le=300)
|
||||
sample_retention_seconds: int = Field(ge=60, le=86_400)
|
||||
sample_max_rows: int = Field(ge=60, le=1_200)
|
||||
next_run_at: int | None = None
|
||||
last_attempt_at: int | None = None
|
||||
last_success_at: int | None = None
|
||||
last_error: str = ""
|
||||
consecutive_failures: int = 0
|
||||
backoff_until: int | None = None
|
||||
is_stale: bool = False
|
||||
|
||||
|
||||
class SchedulerRun(BaseModel):
|
||||
id: str
|
||||
service_id: str
|
||||
action_key: str
|
||||
trigger: Literal["schedule", "manual"]
|
||||
started_at: int
|
||||
finished_at: int | None = None
|
||||
status: Literal["running", "success", "failure", "cancelled"]
|
||||
attempt: int = 0
|
||||
duration_ms: int | None = None
|
||||
error: str = ""
|
||||
created_at: int
|
||||
|
||||
|
||||
class SchedulerRunsResponse(BaseModel):
|
||||
items: list[SchedulerRun]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class SchedulerSample(BaseModel):
|
||||
ts: int
|
||||
dl_speed: int
|
||||
up_speed: int
|
||||
|
||||
|
||||
class SchedulerSamplesResponse(BaseModel):
|
||||
service_id: str
|
||||
window_seconds: int | None
|
||||
all_values: bool = False
|
||||
samples: list[SchedulerSample]
|
||||
|
||||
|
||||
class SchedulerManualRunResponse(BaseModel):
|
||||
run: SchedulerRun
|
||||
status: SchedulerStatus
|
||||
|
||||
|
||||
class SchedulerActionResult(BaseModel):
|
||||
"""Internal-friendly result payload exposed for diagnostics/tests."""
|
||||
|
||||
data: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Pydantic models for the service registry API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Reject credential keys in non-secret service config.
|
||||
|
||||
Secrets are sent in the separate ``secrets`` mapping; the plain ``config``
|
||||
object must never hold them.
|
||||
"""
|
||||
forbidden = {
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"passphrase",
|
||||
"credential",
|
||||
}
|
||||
|
||||
def _check(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if key.lower() in forbidden:
|
||||
raise ValueError(f"Credential key '{key}' is not allowed in service config")
|
||||
_check(child)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_check(item)
|
||||
|
||||
_check(config)
|
||||
return config
|
||||
|
||||
|
||||
class ServiceInstanceInput(BaseModel):
|
||||
"""Payload for creating or updating a service instance."""
|
||||
|
||||
id: str | None = None
|
||||
service_type: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
secrets: dict[str, str] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("config")
|
||||
@classmethod
|
||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
return _validate_config_keys(value or {})
|
||||
|
||||
|
||||
class ServiceInstance(BaseModel):
|
||||
"""Persisted service instance returned by the API (no plaintext secrets)."""
|
||||
|
||||
id: str
|
||||
service_type: str
|
||||
name: str
|
||||
config: dict[str, Any]
|
||||
secrets_set: dict[str, bool]
|
||||
enabled: bool
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class SecretFieldInfo(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
required: bool = False
|
||||
helper: str | None = None
|
||||
|
||||
|
||||
class WidgetKindInfo(BaseModel):
|
||||
kind: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
default_config: dict[str, Any]
|
||||
refresh_interval_ms: int
|
||||
|
||||
|
||||
class ServiceTypeInfo(BaseModel):
|
||||
"""Metadata about a registered service type."""
|
||||
|
||||
service_type: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
secret_fields: list[SecretFieldInfo]
|
||||
widget_kinds: list[WidgetKindInfo]
|
||||
@@ -1,8 +1,18 @@
|
||||
"""Pydantic models for the dashboard widget system."""
|
||||
"""Pydantic models for the dashboard widget system.
|
||||
|
||||
Widgets are either:
|
||||
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
FORBIDDEN_CONFIG_KEYS = {
|
||||
"password",
|
||||
@@ -47,8 +57,8 @@ def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||
class _WidgetInstanceBase(BaseModel):
|
||||
"""Shared fields between input and output widget models."""
|
||||
|
||||
addon_id: str
|
||||
widget_type: str
|
||||
service_id: str | None = None
|
||||
widget_kind: str = Field(..., min_length=1)
|
||||
title: str = Field(..., min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
@@ -59,6 +69,13 @@ class _WidgetInstanceBase(BaseModel):
|
||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
return _validate_config_keys(value or {})
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_kind(self) -> "_WidgetInstanceBase":
|
||||
# The kind must be non-empty (Field enforces it); service_id may be None
|
||||
# for built-ins. Deeper validation happens in the router against the
|
||||
# service definition / built-in registry.
|
||||
return self
|
||||
|
||||
|
||||
class WidgetInstanceInput(_WidgetInstanceBase):
|
||||
"""Payload for creating or updating a widget instance."""
|
||||
@@ -74,22 +91,21 @@ class WidgetInstance(_WidgetInstanceBase):
|
||||
updated_at: int
|
||||
|
||||
|
||||
class WidgetTypeInfo(BaseModel):
|
||||
"""Metadata about a built-in widget type."""
|
||||
class BuiltinWidgetKindInfo(BaseModel):
|
||||
"""Metadata about a built-in (service-less) widget kind."""
|
||||
|
||||
addon_id: str
|
||||
widget_type: str
|
||||
kind: str
|
||||
name: str
|
||||
description: str
|
||||
source_type: str
|
||||
config_schema: dict[str, Any]
|
||||
default_config: dict[str, Any]
|
||||
refresh_interval_ms: int
|
||||
|
||||
|
||||
class WidgetDataResponse(BaseModel):
|
||||
"""Response from the per-widget data endpoint."""
|
||||
|
||||
widget_id: str
|
||||
widget_type: str
|
||||
data: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
fetched_at: int
|
||||
|
||||
@@ -77,6 +77,28 @@ MAIL_QUEUE_SIZE = Counter(
|
||||
["status"],
|
||||
)
|
||||
|
||||
SCHEDULED_ACTIONS_TOTAL = Counter(
|
||||
"manage_scheduled_actions_total",
|
||||
"Total typed scheduled action attempts",
|
||||
["service_id", "action", "status"],
|
||||
)
|
||||
SCHEDULED_ACTION_DURATION = Histogram(
|
||||
"manage_scheduled_action_duration_seconds",
|
||||
"Typed scheduled action duration",
|
||||
["action"],
|
||||
buckets=(0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0),
|
||||
)
|
||||
SCHEDULED_ACTION_LAST_SUCCESS = Gauge(
|
||||
"manage_scheduled_action_last_success_timestamp",
|
||||
"Unix timestamp of the last successful typed scheduled action",
|
||||
["service_id", "action"],
|
||||
)
|
||||
SCHEDULED_ACTION_FAILURES = Gauge(
|
||||
"manage_scheduled_action_consecutive_failures",
|
||||
"Current consecutive failure count for a typed scheduled action",
|
||||
["service_id", "action"],
|
||||
)
|
||||
|
||||
|
||||
def set_current_request_id(request_id: str | None) -> None:
|
||||
"""Set the context-local request id."""
|
||||
@@ -147,6 +169,25 @@ def record_mail_queue(status: str) -> None:
|
||||
MAIL_QUEUE_SIZE.labels(status=status).inc()
|
||||
|
||||
|
||||
def record_scheduled_action(
|
||||
service_id: str,
|
||||
action: str,
|
||||
status: str,
|
||||
duration_seconds: float | None = None,
|
||||
success: bool = False,
|
||||
consecutive_failures: int = 0,
|
||||
) -> None:
|
||||
"""Record secret-safe metrics for a typed scheduled action."""
|
||||
safe_service = service_id or "unknown"
|
||||
safe_action = action or "unknown"
|
||||
SCHEDULED_ACTIONS_TOTAL.labels(service_id=safe_service, action=safe_action, status=status).inc()
|
||||
SCHEDULED_ACTION_FAILURES.labels(service_id=safe_service, action=safe_action).set(consecutive_failures)
|
||||
if duration_seconds is not None:
|
||||
SCHEDULED_ACTION_DURATION.labels(action=safe_action).observe(duration_seconds)
|
||||
if success:
|
||||
SCHEDULED_ACTION_LAST_SUCCESS.labels(service_id=safe_service, action=safe_action).set_to_current_time()
|
||||
|
||||
|
||||
def log_extra(request: Request | None = None, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Build a standard extra dict for structured logging."""
|
||||
extra: dict[str, Any] = {"request_id": get_request_id(request)}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# backend/src/media_library_viewer_api/routers (index)
|
||||
dir: backend/src/media_library_viewer_api/routers
|
||||
|
||||
## role
|
||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- authentik_users.py
|
||||
- backups.py
|
||||
- dashboard.py
|
||||
- dashboards.py
|
||||
- files.py
|
||||
- jellyseerr.py
|
||||
- jobs.py
|
||||
- media.py
|
||||
- monitoring.py
|
||||
- services.py
|
||||
- settings.py
|
||||
- tasks.py
|
||||
- widgets.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, authentik_users.py, backups.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,40 @@
|
||||
# backend/src/media_library_viewer_api/routers
|
||||
dir: backend/src/media_library_viewer_api/routers
|
||||
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets).
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for routers.
|
||||
- authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources
|
||||
- backups.py | FastAPI router providing REST endpoints for reporting, querying, and managing backup jobs, runs, and alerts. | exp: func:_resolve_backup_service_id(store: SettingsStore, explicit) → str, call:store.list_services, call:svc.get, func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, service_id, store, _auth) → BackupRunResponse, call:_resolve_backup_service_id, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(service_id, store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, service_id, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, service_id, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||
- dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||
- 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
|
||||
- jellyseerr.py | FastAPI router providing Jellyseerr request stats and recent requests endpoints for the Jellyfin page. | exp: func:_serialize(result) → dict, func:get_jellyseerr_stats(jellyfin_service_id, store) → dict, call:resolve_service_record, call:get_stats_provider, call:provider.fetch_stats, call:logger.exception, call:_serialize, raise:HTTPException, func:get_jellyseerr_requests(jellyfin_service_id, store) → dict, call:resolve_service_record, call:fetch_jellyseer_requests, call:logger.exception, raise:HTTPException | dep: logging, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets, media_library_viewer_api.widgets.jellyseerr_stats, media_library_viewer_api.widgets.stats_provider
|
||||
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
||||
- 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 observability endpoints for monitoring machines, Alertmanager alerts/status, Prometheus targets/status, and webhook ingestion. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → tuple[float, float], call:int, call:service.config.get, call:http_timeout, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:requests.post, call:http_timeout, call:resp.raise_for_status, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.clients.http_timeout, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager, fastapi.APIRouter
|
||||
- services.py | Provides REST API endpoints for listing service types and CRUD-managing service instances, including a test endpoint that validates connectivity and credentials without persisting them. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException, func:test_instance(body: ServiceInstanceInput, store) → dict[str, Any], call:_validate_input, call:require_service_definition, call:dict, call:store.get_service, call:existing.get, call:decrypt_secrets, call:logger.exception, call:secrets.get, call:stored.get, call:logger.info, call:definition.test_callable, call:TestResult | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
||||
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||
- widgets.py | Provides a FastAPI REST API for CRUD operations on dashboard widget instances and widget references (live-links), including data fetching through registered adapters. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_stats_adapter, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||
## arch
|
||||
Modular router-per-domain pattern where each file exposes a FastAPI APIRouter for a specific feature area; routers delegate business logic to service clients and adapters, using dependency injection for SSH/database access and standard Pydantic models for request/response validation.
|
||||
## tags
|
||||
call:, service, raise:httpexception, media_library_viewer_api, get, backup, call:store.get, ssh
|
||||
## symbols
|
||||
- MessageRequest
|
||||
- RunJobRequest
|
||||
- MonitoringMachineInput
|
||||
- SSHKeyInput
|
||||
- SSHKeyGenerateInput
|
||||
- ResetLocalDatabaseInput
|
||||
- TaskInput
|
||||
- RunTaskRequest
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, authentik_users.py, backups.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Read-only Authentik directory, access metadata, and messaging router.
|
||||
|
||||
Directory data is service-scoped and fails gracefully so the service page can
|
||||
render a useful empty/error state when Authentik is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
"""Compose-request body for the existing Authentik messaging endpoint."""
|
||||
|
||||
recipient_emails: list[str]
|
||||
subject: str
|
||||
html_body: str
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
try:
|
||||
timeout = float(service.config.get("timeout_seconds") or 10)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 10.0
|
||||
return AuthentikClient(
|
||||
base_url=str(service.config.get("base_url") or "").rstrip("/"),
|
||||
api_token=str(service.secrets.get("api_token") or ""),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _empty_directory(error: str) -> dict[str, Any]:
|
||||
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
|
||||
|
||||
|
||||
def _empty_collection(error: str) -> dict[str, Any]:
|
||||
return {"items": [], "total": 0, "error": error}
|
||||
|
||||
|
||||
def _service_or_error(store: SettingsStore, service_id: str) -> ServiceRecord | None:
|
||||
return resolve_service_record(store, "authentik", service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/users")
|
||||
def get_authentik_users(
|
||||
service_id: str,
|
||||
search: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated raw directory users for the existing messaging surface."""
|
||||
service = _service_or_error(store, service_id)
|
||||
if service is None:
|
||||
return _empty_directory("Authentik service not configured")
|
||||
try:
|
||||
return _build_client(service).users(search=search, page=page, page_size=page_size)
|
||||
except Exception:
|
||||
logger.exception("Authentik users query failed for service %s", service_id)
|
||||
return _empty_directory("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/access-summary")
|
||||
def get_authentik_access_summary(
|
||||
service_id: str,
|
||||
search: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""User groups plus explicit staff/superuser flags, not effective permissions."""
|
||||
service = _service_or_error(store, service_id)
|
||||
if service is None:
|
||||
return _empty_directory("Authentik service not configured")
|
||||
try:
|
||||
return _build_client(service).access_summaries(search=search, page=page, page_size=page_size)
|
||||
except Exception:
|
||||
logger.exception("Authentik access summary query failed for service %s", service_id)
|
||||
return _empty_directory("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/groups")
|
||||
def get_authentik_groups(
|
||||
service_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Display-safe, service-scoped Authentik group list."""
|
||||
service = _service_or_error(store, service_id)
|
||||
if service is None:
|
||||
return _empty_collection("Authentik service not configured")
|
||||
try:
|
||||
return _build_client(service).groups(limit=limit)
|
||||
except Exception:
|
||||
logger.exception("Authentik groups query failed for service %s", service_id)
|
||||
return _empty_collection("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/applications")
|
||||
def get_authentik_applications(
|
||||
service_id: str,
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Display-safe Authentik applications without provider or policy details."""
|
||||
service = _service_or_error(store, service_id)
|
||||
if service is None:
|
||||
return _empty_collection("Authentik service not configured")
|
||||
try:
|
||||
return _build_client(service).applications(limit=limit)
|
||||
except Exception:
|
||||
logger.exception("Authentik applications query failed for service %s", service_id)
|
||||
return _empty_collection("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/message/status")
|
||||
def get_authentik_message_status(
|
||||
service_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
if _service_or_error(store, service_id) is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/{service_id}/message")
|
||||
def post_authentik_message(
|
||||
service_id: str,
|
||||
body: MessageRequest,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
if _service_or_error(store, service_id) is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
recipients = [recipient.strip() for recipient in body.recipient_emails if recipient.strip()]
|
||||
if not recipients:
|
||||
return {"status": "error", "error": "No recipients with valid email addresses."}
|
||||
settings = get_settings()
|
||||
try:
|
||||
validate_smtp_settings(settings)
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=body.subject,
|
||||
html_body=body.html_body,
|
||||
)
|
||||
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
|
||||
return {"status": "queued", "request_id": request_id, "recipient_count": len(recipients)}
|
||||
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
|
||||
router = APIRouter(prefix="/api/backups", tags=["backups"])
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
|
||||
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
|
||||
"""Return the service_id for backup attribution.
|
||||
|
||||
First-wins: if no explicit service_id is given, pick the first enabled
|
||||
``backups`` service instance (spec R6.1). Returns an empty string when
|
||||
none is configured (backward-compatible with pre-service reports).
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
candidates = store.list_services("backups")
|
||||
for svc in candidates:
|
||||
if svc.get("enabled"):
|
||||
return svc["id"]
|
||||
return ""
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
|
||||
job = store.get_backup_job_by_name(report.name)
|
||||
if not job:
|
||||
job = store.upsert_backup_job(
|
||||
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
elif report.schedule_interval_seconds:
|
||||
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
job = store.get_backup_job(job["id"])
|
||||
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
@router.post("/report")
|
||||
def post_backup_report(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
# Check for duplicate (same job + started_at within 1s)
|
||||
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
|
||||
@@ -88,10 +108,12 @@ def post_backup_report(
|
||||
@router.post("/report/start")
|
||||
def post_backup_start(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
run_data = {
|
||||
"job_id": job["id"],
|
||||
@@ -107,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
|
||||
|
||||
|
||||
@@ -133,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]
|
||||
|
||||
|
||||
@@ -155,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]
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
|
||||
get_settings_store,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
@@ -85,50 +88,6 @@ def delete_shortcut(
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
else:
|
||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type: list[str] = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/activity")
|
||||
def get_activity(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
@@ -154,38 +113,4 @@ def get_now_playing(
|
||||
def get_backup_dashboard(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> BackupDashboardSummary:
|
||||
jobs = store.list_backup_jobs()
|
||||
total_jobs = len(jobs)
|
||||
|
||||
# Calculate 24h success rate
|
||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||
recent_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||
if runs and runs[0]["started_at"] >= cutoff:
|
||||
recent_runs.append(runs[0])
|
||||
|
||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||
|
||||
# Active alerts
|
||||
alerts = store.list_backup_alerts(acknowledged=False)
|
||||
active_alerts = len(alerts)
|
||||
|
||||
# Last failed
|
||||
failed_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||
if runs:
|
||||
failed_runs.append(runs[0])
|
||||
|
||||
last_failed_at = None
|
||||
if failed_runs:
|
||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||
|
||||
return BackupDashboardSummary(
|
||||
total_jobs=total_jobs,
|
||||
success_rate_24h=round(success_rate, 1),
|
||||
active_alerts=active_alerts,
|
||||
last_failed_at=last_failed_at,
|
||||
)
|
||||
return build_backup_dashboard_summary(store)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Named dashboards CRUD router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
|
||||
rows = store.list_dashboards()
|
||||
return [NamedDashboard(**row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/slug/{slug}")
|
||||
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.get_dashboard_by_slug(slug)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.upsert_dashboard(body.model_dump())
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}")
|
||||
def update_dashboard(
|
||||
dashboard_id: str,
|
||||
body: NamedDashboardInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> NamedDashboard:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
if body.id and body.id != dashboard_id:
|
||||
raise HTTPException(status_code=400, detail="ID mismatch")
|
||||
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.delete("/{dashboard_id}")
|
||||
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
store.delete_dashboard(dashboard_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Jellyseerr request stats — powers the Requests tab on the Jellyfin page.
|
||||
|
||||
Jellyseerr is an optional companion of the Jellyfin service. This router
|
||||
resolves the Jellyfin service instance (by ``jellyfin_service_id`` or the first
|
||||
enabled one) and delegates to the registered Jellyseerr stats provider, which
|
||||
shares its short-TTL cache with the ``stat`` / ``stats_overview`` widgets so
|
||||
the tab and the widgets don't each hit Jellyseerr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — ensure provider registration
|
||||
from media_library_viewer_api.widgets.jellyseerr_stats import fetch_jellyseer_requests
|
||||
from media_library_viewer_api.widgets.stats_provider import get_stats_provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/jellyseerr", tags=["jellyseerr"])
|
||||
|
||||
|
||||
def _serialize(result) -> dict:
|
||||
return {
|
||||
"stats": [{"key": s.key, "label": s.label, "value": s.value} for s in result.stats],
|
||||
"recent": result.recent,
|
||||
"detail": result.detail,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def get_jellyseerr_stats(
|
||||
jellyfin_service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict:
|
||||
"""Return Jellyseerr request counts + a recent-requests list."""
|
||||
service = resolve_service_record(store, "jellyfin", jellyfin_service_id)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="No Jellyfin service is configured.")
|
||||
provider = get_stats_provider("jellyfin")
|
||||
if provider is None: # pragma: no cover - registered at import
|
||||
raise HTTPException(status_code=503, detail="Jellyseerr stats provider is not available.")
|
||||
try:
|
||||
result = provider.fetch_stats(service)
|
||||
except Exception as exc: # pragma: no cover - provider guards internally
|
||||
logger.exception("Jellyseerr stats endpoint failed")
|
||||
raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc
|
||||
return _serialize(result)
|
||||
|
||||
|
||||
@router.get("/requests")
|
||||
def get_jellyseerr_requests(
|
||||
jellyfin_service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict:
|
||||
"""Return Jellyseerr requests for the Requests tab table (filter/sort client-side)."""
|
||||
service = resolve_service_record(store, "jellyfin", jellyfin_service_id)
|
||||
if service is None:
|
||||
raise HTTPException(status_code=503, detail="No Jellyfin service is configured.")
|
||||
try:
|
||||
requests = fetch_jellyseer_requests(service)
|
||||
except Exception as exc: # pragma: no cover - client guards internally
|
||||
logger.exception("Jellyseerr requests endpoint failed")
|
||||
raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc
|
||||
return {"requests": requests}
|
||||
@@ -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))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user