Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -0,0 +1,20 @@
|
||||
# .claude (index)
|
||||
dir: .claude
|
||||
|
||||
## role
|
||||
Configuration directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
|
||||
## 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 directory for the Claude AI assistant, storing project-specific settings, instructions, and behavioral guidelines.
|
||||
## files
|
||||
## arch
|
||||
Flat configuration structure containing markdown/YAML files that define custom commands, project context, and operational rules for Claude's interactions with the codebase.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,20 @@
|
||||
# .claude/skills (index)
|
||||
dir: .claude/skills
|
||||
|
||||
## role
|
||||
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
|
||||
## 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
|
||||
Directory containing custom skill definitions and capability instructions for the Claude AI assistant integration.
|
||||
## files
|
||||
## arch
|
||||
Flat configuration file structure defining modular skill behaviors and prompts used to extend Claude's domain-specific abilities.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,19 @@
|
||||
# .claude/skills/sift-backlog (index)
|
||||
dir: .claude/skills/sift-backlog
|
||||
|
||||
## role
|
||||
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
|
||||
## 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
|
||||
Defines a Claude skill workflow for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool.
|
||||
## files
|
||||
- SKILL.md | Defines a workflow skill for triaging, organizing, and activating backlog tasks into actionable plans using the `sf` CLI tool. | dep: sf CLI (task, plan, dependency, update subcommands)
|
||||
## arch
|
||||
Single-file declarative skill definition following a prompt-driven workflow pattern with structured triage and activation instructions for Claude to execute.
|
||||
## tags
|
||||
skill, defines, workflow, triaging, organizing, activating, backlog, tasks
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+8
-2
@@ -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,8 +27,9 @@ PROMETHEUS_ENABLED=true
|
||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL=http://alertmanager:9093
|
||||
ALERTMANAGER_WEBHOOK_URL=
|
||||
GRAFANA_URL=http://grafana:3000
|
||||
PROMETHEUS_URL=http://prometheus:9090
|
||||
# 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
|
||||
@@ -44,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 directory for the opencode tool, managing project-specific settings and preferences.
|
||||
## 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 directory for the opencode tool, managing project-specific settings and preferences.
|
||||
## files
|
||||
## arch
|
||||
Flat directory structure containing configuration files that define opencode behavior for the associated project.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,22 @@
|
||||
# .opencode/commands (index)
|
||||
dir: .opencode/commands
|
||||
|
||||
## role
|
||||
Defines slash-command workflows and assistant personas for an OpenSpec-based development process (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 slash-command workflows and assistant personas for an OpenSpec-based development process (explore, propose, apply, archive).
|
||||
## files
|
||||
- opsx-apply.md | Defines a workflow for implementing tasks from an OpenSpec change in a structured, iterative manner with pause points for blockers and ambiguity. | dep: openspec CLI, AskUserQuestion tool, filesystem access
|
||||
- opsx-archive.md | Defines a workflow for archiving completed changes in an experimental openspec-based development process, including validation, spec sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool, Skill tool, filesystem (mkdir, mv), tasks.md
|
||||
- opsx-explore.md | Defines the explore mode stance for a thinking/discussion assistant that investigates problems and clarifies requirements without implementing code | dep: OpenSpec system (openspec CLI, change artifacts like proposal.md/design.md/tasks.md/spec.md)
|
||||
- opsx-propose.md | Defines a workflow for creating a new change with all required planning artifacts (proposal, design, tasks) in a single step using the openspec CLI tool. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool, file system
|
||||
## arch
|
||||
Markdown-based declarative templates serving as structured prompts/playbooks that guide an AI assistant through specific operational phases of a spec-driven lifecycle.
|
||||
## 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
|
||||
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
|
||||
## 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
|
||||
Directory for defining custom agent skills, capabilities, and behavioral instructions within the opencode configuration framework.
|
||||
## files
|
||||
## arch
|
||||
Configuration-based skill definition directory; skills are declared as individual files consumed by the opencode agent runtime to extend or specialize assistant behavior.
|
||||
## 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.
|
||||
## 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.
|
||||
## files
|
||||
- SKILL.md | Defines a skill for implementing tasks from an OpenSpec change using a schema-driven workflow with progress tracking and contextual file reading. | dep: openspec CLI, AskUserQuestion tool
|
||||
## arch
|
||||
Documentation-based skill specification using markdown with defined workflow steps, schema references, and contextual file reading rules.
|
||||
## 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
|
||||
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
|
||||
## 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
|
||||
Provides a structured skill definition for archiving completed changes in the openspec experimental workflow with validation and user confirmation steps.
|
||||
## files
|
||||
- SKILL.md | Defines a skill for archiving a completed change in the openspec experimental workflow, including validation, sync assessment, and user confirmation steps. | dep: openspec CLI, AskUserQuestion tool, Task tool (subagent_type: general-purpose), openspec-sync-specs skill
|
||||
## arch
|
||||
Single-document declarative skill specification following a procedural checklist pattern (validate, assess sync, confirm) designed for an AI agent to execute.
|
||||
## 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 for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
|
||||
## 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 for the OpenSpec CLI that serves as a thinking partner for brainstorming ideas, investigating problems, and clarifying requirements.
|
||||
## files
|
||||
- SKILL.md | Defines a conversational "explore mode" skill for the OpenSpec CLI that acts as a thinking partner for exploring ideas, investigating problems, and clarifying requirements without implementing code. | dep: openspec CLI
|
||||
## arch
|
||||
Skill-definition pattern using a single Markdown file (SKILL.md) that declaratively specifies the assistant's behavioral constraints, workflow, and operational guidelines.
|
||||
## 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 by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
|
||||
## 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 by scaffolding directories and generating structured artifacts (proposals, designs, tasks).
|
||||
## files
|
||||
- SKILL.md | Defines an AI assistant skill that automates proposing new changes by scaffolding a directory, generating dependent artifacts (proposal, design, tasks), and tracking progress through a structured workflow using the openspec CLI. | dep: openspec CLI, AskUserQuestion tool, TodoWrite tool
|
||||
## arch
|
||||
Skill-definition pattern using a declarative markdown document (SKILL.md) that encodes a step-by-step procedural workflow with CLI integration conventions.
|
||||
## tags
|
||||
skill, defines, assistant, automates, proposing, new, changes, scaffolding
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,77 @@
|
||||
# . (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 orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||
## 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
|
||||
- .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
|
||||
- swap-pane
|
||||
- token-usage-output.txt
|
||||
## links
|
||||
index: ./.pi-map.index.md
|
||||
map: ./.pi-map.md
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
# .
|
||||
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 orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
|
||||
## files
|
||||
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
|
||||
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
|
||||
- .gitignore | Configures Git to ignore Python artifacts, virtual environments, secrets, editor files, frontend builds, and tool-specific metadata from version control.
|
||||
- AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
|
||||
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across recent versions.
|
||||
- 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
|
||||
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
|
||||
## arch
|
||||
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
|
||||
## tags
|
||||
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -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."
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
Archive of an earlier project structure 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
|
||||
Archive of an earlier project structure for a Streamlit-based Jellyfin media library browser with SSH remote file inspection capabilities.
|
||||
## files
|
||||
- app.py | Provides a minimal Streamlit entrypoint that adds the src directory to Python's path and delegates to the actual application in media_library_viewer.app. | dep: sys, pathlib, media_library_viewer.app
|
||||
- pyproject.toml | Defines Python package metadata, dependencies, and tool configurations for a Streamlit-based Jellyfin media library browser with SSH remote file inspection. | dep: hatchling, streamlit, streamlit-aggrid, requests, paramiko, python-dotenv, pandas, ruff, pytest
|
||||
- requirements.txt | Installs the current package in editable/development mode using pip | dep: pip, setuptools
|
||||
## arch
|
||||
Thin entrypoint pattern using a bootstrap app.py that manipulates sys.path to delegate execution to a nested media_library_viewer package, managed via standard Python packaging (pyproject.toml).
|
||||
## 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
|
||||
No files provided — directory appears to be empty or contents were not included, so the package's role cannot be determined.
|
||||
## 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
|
||||
No files provided — directory appears to be empty or contents were not included, so the package's role cannot be determined.
|
||||
## files
|
||||
## arch
|
||||
Cannot be assessed due to missing file contents; please provide the file listing for analysis.
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,39 @@
|
||||
# archive/src/media_library_viewer (index)
|
||||
dir: archive/src/media_library_viewer
|
||||
|
||||
## role
|
||||
Streamlit-based media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
|
||||
## 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 media library viewer that provides a unified dashboard for browsing and monitoring Jellyfin media alongside remote SSH file systems.
|
||||
## 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
|
||||
Layered Streamlit application using immutable dataclass configuration, template-based remote job execution, cached data access, and separated utility functions following a tab-based modular UI pattern.
|
||||
## 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 layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
|
||||
## 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 layer providing HTTP API clients for Jellyfin/Emby media servers and SSH-based remote system metrics collection.
|
||||
## files
|
||||
- __init__.py | Package initialization file that defines external service clients module boundaries and constraints
|
||||
- jellyfin.py | HTTP API client for Jellyfin/Emby media servers providing user, library, item, and session management with plain Python return types for frontend agnosticism. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → dict[str, Any], call:params.items, call:self.session.get, call:response.raise_for_status, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:isinstance, call:session.get, method:image_url(self, item_id: str, image_type) → str | dep: typing, requests
|
||||
- resources.py | Manages a lightweight POSIX shell-based remote system metrics collector that samples CPU, memory, network, and disk statistics via SSH and reads the resulting JSONL data. | exp: class:ResourceMonitorPaths, func:start_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:shlex.quote, call:int, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:stop_resource_collector(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:restart_resource_collector(ssh: RemoteSSHClient, interval_seconds, retention_seconds, max_lines, paths) → str, call:stop_resource_collector, call:start_resource_collector, func:resource_collector_status(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, call:result.stdout.strip, raise:RuntimeError, func:resource_collector_debug_info(ssh: RemoteSSHClient, paths) → str, call:shlex.quote, call:ssh.run, func:read_resource_metrics(ssh: RemoteSSHClient, max_lines, paths) → list[dict[str, Any]], call:shlex.quote, call:int, call:ssh.run, call:result.stdout.splitlines, call:line.strip, call:rows.append, call:json.loads, raise:RuntimeError, func:disk_space(ssh: RemoteSSHClient, path) → dict[str, Any], call:shlex.quote, call:ssh.run, call:result.stdout.strip, call:json.loads, raise:RuntimeError | dep: json, shlex, dataclasses, typing, media_library_viewer.clients.ssh
|
||||
- ssh.py | Provides an SSH client wrapper around Paramiko for remote filesystem inspection and media analysis, ensuring POSIX shell compatibility regardless of the user's login shell. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, password, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:client.connect, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, posixpath, shlex, dataclasses, typing, paramiko
|
||||
## arch
|
||||
Client-wrapper pattern with each module encapsulating a specific integration concern (Jellyfin HTTP API, SSH filesystem access, remote resource monitoring), returning plain Python types for frontend agnosticism.
|
||||
## 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 Jellyfin API responses into stable, flattened data structures for storage and display.
|
||||
## 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 Jellyfin API responses into stable, flattened data structures for storage and display.
|
||||
## files
|
||||
- __init__.py | Serves as the package docstring for a domain-level helpers/normalization module that converts external data into stable app concepts.
|
||||
- media.py | Flattens inconsistent Jellyfin API item JSON into stable, normalized dictionaries for SQLite storage and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer.utils, pandas, media_library_viewer.utils (human_size, ticks_to_minutes)
|
||||
## arch
|
||||
Functional transformation layer pattern mapping raw external API JSON directly into normalized flat dictionaries without intermediate ORM or complex object hierarchies.
|
||||
## 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 service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
|
||||
## 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 service layer that coordinates domain logic and external clients into reusable, UI-agnostic media library operations.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package and documents it as the application services layer for coordinating clients/domain logic into reusable operations.
|
||||
- media_index.py | Provides a UI-agnostic SQLite-backed media inventory service that indexes, queries, and manages Jellyfin media metadata with filtering, sorting, and pagination capabilities. | exp: class:MediaIndexStatus, class:MediaIndex, method:__init__(self, db_path), call:Path, call:self.db_path.parent.mkdir, method:connect(self) → sqlite3.Connection, call:sqlite3.connect, method:init_schema(self) → None, call:self.connect, call:conn.executescript, method:set_metadata(self, key: str, value: str | int | float) → None, call:self.init_schema, call:self.connect, call:conn.execute, call:str, method:replace_items(self, rows: Iterable[dict[str, Any]]) → int, call:self.init_schema, call:list, call:",".join, call:len, call:self.connect, call:conn.execute, call:conn.executemany, call:','.join, call:row.get, call:str, call:int, call:time.time, method:status(self) → MediaIndexStatus, call:self.db_path.exists, call:MediaIndexStatus, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone, call:conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone, call:str(updated_row[0]).isdigit, call:time.strftime, call:time.localtime, call:float, method:query(self, library_id, library_ids, media_types, search, hdr_filter, sort_key, sort_order, limit, offset) → tuple[list[dict[str, Any]], int], call:self.init_schema, call:where.append, call:",".join, call:len, call:params.extend, call:params.append, call:search.lower, call:" AND ".join, call:SORT_COLUMNS.get, call:self.connect, call:int, call:conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone, call:conn.execute( "SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?", [*params, int(limit), int(offset)], ).fetchall, call:display_media_row, call:dict, func:build_media_index(client: JellyfinClient, user_id: str, libraries: list[dict[str, Any]], index, page_size) → int, call:MediaIndex, call:time.perf_counter, call:library.get, call:client.items, call:response.get, call:normalized_rows.extend, call:normalize_media_item, call:len, call:int, call:index.replace_items, call:index.set_metadata | dep: sqlite3, time, dataclasses, pathlib, typing, media_library_viewer.clients.jellyfin, media_library_viewer.domain.media, media_library_viewer.clients.jellyfin.JellyfinClient, media_library_viewer.domain.media.display_media_row, media_library_viewer.domain.media.normalize_media_item
|
||||
## arch
|
||||
Service-oriented pattern with SQLite-backed indexing, query filtering, and pagination encapsulated behind a single cohesive media index service module.
|
||||
## 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 UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
|
||||
## 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 UI rendering layer for the media library viewer application, providing dashboard monitoring, file browsing, media indexing, and preview capabilities.
|
||||
## 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
|
||||
Module-based render pattern where each UI tab/view is isolated in its own module, sharing session state for cross-component synchronization (e.g., file browser auto-sync) and leveraging ag-grid for interactive data tables.
|
||||
## 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
|
||||
Legacy or archived test directory currently containing only a placeholder file with no active test code.
|
||||
## 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
|
||||
Legacy or archived test directory currently containing only a placeholder file with no active test code.
|
||||
## files
|
||||
- .gitkeep | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
## arch
|
||||
Empty placeholder structure using a `.gitkeep` file to preserve the directory in version control for potential future use.
|
||||
## 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 Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||
## 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 Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
|
||||
## files
|
||||
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
|
||||
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
|
||||
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
|
||||
## arch
|
||||
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
|
||||
## 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
|
||||
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||
## 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
|
||||
Root source directory serving as the main entry point and organizational container for the backend application.
|
||||
## files
|
||||
## arch
|
||||
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
|
||||
## tags
|
||||
-
|
||||
## symbols
|
||||
-
|
||||
## workflows
|
||||
-
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,57 @@
|
||||
# backend/src/media_library_viewer_api (index)
|
||||
dir: backend/src/media_library_viewer_api
|
||||
|
||||
## role
|
||||
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||
## parent
|
||||
index: backend/src/.pi-map.index.md
|
||||
map: backend/src/.pi-map.md
|
||||
## children
|
||||
- backend/src/media_library_viewer_api/clients
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/domain
|
||||
index: backend/src/media_library_viewer_api/domain/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/domain/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/integrations
|
||||
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/integrations/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/models
|
||||
index: backend/src/media_library_viewer_api/models/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/models/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/routers
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/services
|
||||
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/services/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/widgets
|
||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||
- backend/src/media_library_viewer_api/workers
|
||||
index: backend/src/media_library_viewer_api/workers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/workers/.pi-map.md
|
||||
## files
|
||||
- __init__.py
|
||||
- auth.py
|
||||
- config.py
|
||||
- dependencies.py
|
||||
- jobs.py
|
||||
- logging_utils.py
|
||||
- main.py
|
||||
- observability.py
|
||||
- path_utils.py
|
||||
- utils.py
|
||||
- version.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## workflows
|
||||
- change media_library_viewer_api behavior
|
||||
read: __init__.py, auth.py, config.py
|
||||
- change media_library_viewer_api config
|
||||
read: config.py
|
||||
- explore media_library_viewer_api subdirectories
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md, backend/src/media_library_viewer_api/domain/.pi-map.index.md, backend/src/media_library_viewer_api/integrations/.pi-map.index.md
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,41 @@
|
||||
# backend/src/media_library_viewer_api
|
||||
dir: backend/src/media_library_viewer_api
|
||||
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
|
||||
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
|
||||
- dependencies.py | Provides FastAPI dependency injection functions for resolving and caching service clients (Jellyfin, Jellyseerr, SSH/Local) and settings based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
|
||||
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
|
||||
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
|
||||
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
|
||||
- observability.py | Provides Prometheus metrics collection, request ID generation/correlation, and structured logging helpers for application observability. | exp: func:set_current_request_id(request_id: str | None) → None, call:_current_request_id.set, func:get_current_request_id() → str | None, call:_current_request_id.get, func:generate_request_id() → str, call:uuid.uuid4, func:get_request_id(request) → str, call:request.headers.get, call:header.strip, call:_current_request_id.get, call:generate_request_id, call:_current_request_id.set, func:metrics_payload() → tuple[bytes, str], call:generate_latest, func:record_request(request: Request, response: Response, duration_seconds: float) → None, call:str, call:REQUESTS_TOTAL.labels(method=method, path=path, status_code=status).inc, call:REQUEST_DURATION.labels(method=method, path=path).observe, func:record_ssh_command(machine_id: str, action: str, status: str, duration_seconds: float) → None, call:SSH_COMMANDS_TOTAL.labels(machine_id=machine_id or "unknown", action=action, status=status).inc, call:SSH_COMMAND_DURATION.labels(machine_id=machine_id or "unknown", action=action).observe, func:record_media_index_build(status: str, duration_seconds) → None, call:MEDIA_INDEX_BUILDS_TOTAL.labels(status=status).inc, call:MEDIA_INDEX_BUILD_DURATION.observe, func:record_backup_run(job_name: str, status: str, success) → None, call:BACKUP_RUNS_TOTAL.labels(job_name=job_name, status=status).inc, call:BACKUP_RUNS_LAST_SUCCESS.labels(job_name=job_name).set_to_current_time, func:record_mail_queue(status: str) → None, call:MAIL_QUEUE_SIZE.labels(status=status).inc, func:log_extra(request, **kwargs: Any) → dict[str, Any], call:get_request_id, call:extra.update | dep: uuid, contextvars, typing, fastapi, prometheus_client
|
||||
- path_utils.py | Maps Jellyfin media paths to SSH-accessible paths using media root anchoring or fallback prefixing. | exp: func:apply_remote_path_prefix(path: str, prefix: str) → str, call:(prefix or "").strip, call:normalized_prefix.rstrip, call:path.startswith, call:posixpath.normpath, call:logger.debug, call:posixpath.join, func:map_path_to_media_root(path: str, media_root: str) → str, call:(media_root or "").strip, call:posixpath.normpath, call:str(path).split, call:"/".join, call:path_absolute.startswith, call:logger.debug, call:posixpath.basename, call:raw_parts.index, call:posixpath.join, func:resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) → str, call:map_path_to_media_root, call:logger.debug, call:apply_remote_path_prefix | dep: logging, posixpath
|
||||
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
|
||||
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
|
||||
## arch
|
||||
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
|
||||
## tags
|
||||
call:, settings, call:get, request, get, client, call:str, id
|
||||
## 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,24 @@
|
||||
# backend/src/media_library_viewer_api/clients (index)
|
||||
dir: backend/src/media_library_viewer_api/clients
|
||||
|
||||
## role
|
||||
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||
## 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
|
||||
- jellyfin.py
|
||||
- jellyseerr.py
|
||||
- local.py
|
||||
- ssh.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/clients/.pi-map.md
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, jellyfin.py, jellyseerr.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,31 @@
|
||||
# backend/src/media_library_viewer_api/clients
|
||||
dir: backend/src/media_library_viewer_api/clients
|
||||
|
||||
index: backend/src/media_library_viewer_api/clients/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides HTTP and command execution client wrappers for integrating with external media services (Jellyfin, Jellyseerr) and performing remote/local filesystem inspection.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- jellyfin.py | Provides a reusable, framework-agnostic HTTP client wrapper for the Jellyfin/Emby API with methods for browsing users, libraries, media items, and sessions. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests
|
||||
- jellyseerr.py | HTTP client wrapper for the Jellyseerr REST API to fetch user data and enrich Jellyfin user information | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info | dep: logging, typing, requests
|
||||
- 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
|
||||
- ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts
|
||||
## arch
|
||||
Client-wrapper pattern with framework-agnostic abstractions; parallel local/remote execution strategies via paramiko SSH and local subprocess; centralized REST API communication modules.
|
||||
## tags
|
||||
call:logger.info, call:logger.debug, call:self.get, call:shlex.quote, error, host, call:self.run, init
|
||||
## symbols
|
||||
- JellyfinClient
|
||||
- JellyseerrClient
|
||||
- CommandResult
|
||||
- LocalCommandClient
|
||||
- RemoteSSHClient
|
||||
- __init__
|
||||
- get
|
||||
- users
|
||||
## workflows
|
||||
- change clients behavior
|
||||
read: __init__.py, jellyfin.py, jellyseerr.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Authentik directory API client.
|
||||
|
||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
||||
page). This client wraps the Authentik REST API for browsing the user directory
|
||||
with pagination and search. OIDC authentication is unchanged — this client is
|
||||
for the directory, not SSO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthentikClient:
|
||||
"""Small wrapper around the Authentik core directory API."""
|
||||
|
||||
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
|
||||
if not base_url:
|
||||
raise ValueError("Authentik base_url is required")
|
||||
if not api_token:
|
||||
raise ValueError("Authentik API token is required")
|
||||
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/api/v3"):
|
||||
self.base_url = self.base_url[:-7]
|
||||
self.api_token = api_token
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {api_token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def get(self, path: str, **params: Any) -> Any:
|
||||
"""GET an Authentik endpoint and include useful response text on errors."""
|
||||
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
|
||||
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
|
||||
response = self.session.get(
|
||||
f"{self.base_url}/api/v3{path}",
|
||||
params=clean_params,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
logger.warning(
|
||||
"Authentik GET %s failed status=%s url=%s",
|
||||
path,
|
||||
response.status_code,
|
||||
response.url,
|
||||
)
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} for {response.url}: {detail}",
|
||||
response=response,
|
||||
) from exc
|
||||
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
|
||||
return response.json()
|
||||
|
||||
def users(
|
||||
self,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a normalized page of Authentik users.
|
||||
|
||||
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
|
||||
Authentik response into ``{items, total, page, page_size}``. Each item
|
||||
is the raw Authentik user dict (pk, username, name, email, avatar, …)
|
||||
so the frontend can pick the fields it needs.
|
||||
"""
|
||||
payload = self.get(
|
||||
"/core/users/",
|
||||
search=search,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
results = payload.get("results")
|
||||
items: list[dict[str, Any]] = (
|
||||
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
|
||||
)
|
||||
|
||||
pagination = payload.get("pagination") or {}
|
||||
total = 0
|
||||
if isinstance(pagination, dict):
|
||||
try:
|
||||
total = int(pagination.get("count") or 0)
|
||||
except (TypeError, ValueError):
|
||||
total = 0
|
||||
|
||||
logger.info(
|
||||
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
|
||||
page,
|
||||
page_size,
|
||||
len(items),
|
||||
total,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
@@ -54,11 +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
|
||||
grafana_url: str = "http://grafana:3000"
|
||||
prometheus_url: str = "http://prometheus:9090"
|
||||
|
||||
# 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
|
||||
machine-specific 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 ``machine_id`` as before.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,7 +18,6 @@ from typing import Any
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
@@ -34,6 +36,41 @@ def _request_machine_id(request: Request | None) -> str | None:
|
||||
return machine_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)
|
||||
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||
machine_id, url, api_key = cache_key
|
||||
@@ -43,21 +80,6 @@ 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],
|
||||
@@ -116,6 +138,10 @@ def _ssh_client_for(
|
||||
|
||||
|
||||
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
||||
"""Resolve an SSH/Files machine for the given transport service.
|
||||
|
||||
Jellyfin/Jellyseerr are resolved against the service registry, not here.
|
||||
"""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
if machine_id:
|
||||
@@ -123,11 +149,7 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
||||
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":
|
||||
if service == "ssh":
|
||||
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
||||
else:
|
||||
machines = store.list_machines_for_service(service)
|
||||
@@ -135,37 +157,24 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
||||
|
||||
|
||||
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||
"""Return a Jellyfin client for the selected machine."""
|
||||
"""Return a Jellyfin client for the selected Jellyfin service instance."""
|
||||
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
|
||||
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.",
|
||||
)
|
||||
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.",
|
||||
)
|
||||
cache_key = (service["id"], base_url, api_key)
|
||||
return _jellyfin_client_for(cache_key)
|
||||
|
||||
|
||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||
@@ -224,7 +233,10 @@ def get_ssh_client(request: Request = None):
|
||||
"set" if settings.ssh_password else "missing",
|
||||
)
|
||||
if not settings.ssh_key_path:
|
||||
raise RuntimeError("No SSH machine is configured and SSH key settings must be configured")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No SSH machine is configured and SSH key settings must be configured",
|
||||
)
|
||||
return _ssh_client_for(
|
||||
(
|
||||
"legacy",
|
||||
@@ -253,16 +265,15 @@ def get_settings_store() -> SettingsStore:
|
||||
def get_user_id(request: Request = None) -> str:
|
||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||
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"])
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyfin", service_id)
|
||||
if service and service.get("config", {}).get("user_id"):
|
||||
return str(service["config"]["user_id"])
|
||||
client = get_jellyfin_client(request)
|
||||
users = client.users()
|
||||
if not users:
|
||||
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="No Jellyfin users found and no user_id configured on the service",
|
||||
)
|
||||
return users[0]["Id"]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# backend/src/media_library_viewer_api/domain (index)
|
||||
dir: backend/src/media_library_viewer_api/domain
|
||||
|
||||
## role
|
||||
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
|
||||
## 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
|
||||
Domain layer providing data normalization and transformation helpers for Jellyfin media data and dashboard summaries.
|
||||
## files
|
||||
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
|
||||
- dashboard.py | Provides domain helper functions for building dashboard data, specifically normalizing Jellyfin session activity rows and computing backup job summaries. | exp: func:_map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) → list[dict[str, Any]], call:session.get, call:bool, call:item.get, call:play_state.get, call:transcoding.get, call:transcode_type.append, call:results.append, call:", ".join, func:build_backup_dashboard_summary(store: SettingsStore) → BackupDashboardSummary, call:store.list_backup_jobs, call:len, call:int, call:time.time, call:store.list_backup_runs, call:recent_runs.append, call:sum, call:store.list_backup_alerts, call:failed_runs.append, call:max, call:BackupDashboardSummary, call:round | dep: time, typing, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store
|
||||
- media.py | Flattens inconsistent Jellyfin API JSON into normalized dictionaries for SQLite indexing and frontend display. | exp: func:first_media_source(item: dict[str, Any]) → dict[str, Any], call:item.get, func:media_streams(item: dict[str, Any], stream_type) → list[dict[str, Any]], call:item.get, call:streams.extend, call:source.get, call:str(stream.get("Type") or stream.get("codec_type") or "").lower, call:stream.get, call:stream_type.lower, func:stream_value(stream: dict[str, Any], *keys: str) → Any, func:is_hdr_item(item: dict[str, Any]) → bool, call:media_streams, call:stream_value, call:" ".join, call:str(value).lower, call:any, func:format_date_added(value: str | None) → str, call:pd.to_datetime(value).strftime, call:str, func:timestamp_date_added(value: str | None) → int | None, call:int, call:pd.to_datetime(value).timestamp, func:format_rate_bits_decimal(bits_per_second: float | int | str | None) → str, call:float, call:str, func:normalize_media_item(item: dict[str, Any], library_id, library_name) → dict[str, Any], call:first_media_source, call:media_streams, call:source.get, call:item.get, call:stream_value, call:is_hdr_item, call:int, call:ticks_to_minutes, call:human_size, call:format_rate_bits_decimal, call:video.get, call:format_date_added, call:timestamp_date_added, func:display_media_row(row: dict[str, Any]) → dict[str, Any], call:row.get, call:human_size, call:format_rate_bits_decimal | dep: typing, media_library_viewer_api.utils, pandas
|
||||
## arch
|
||||
Stateless functional modules that transform inconsistent upstream API JSON into normalized dictionaries for persistence and display.
|
||||
## 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,29 @@
|
||||
# backend/src/media_library_viewer_api/integrations (index)
|
||||
dir: backend/src/media_library_viewer_api/integrations
|
||||
|
||||
## role
|
||||
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||
## 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
|
||||
- base.py
|
||||
- grafana.py
|
||||
- jellyfin.py
|
||||
- jellyseerr.py
|
||||
- nextcloud.py
|
||||
- prometheus.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, base.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,36 @@
|
||||
# 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 plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
|
||||
## files
|
||||
- __init__.py | Defines a closed registry module for service integrations.
|
||||
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
|
||||
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
|
||||
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
|
||||
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
|
||||
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
|
||||
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
|
||||
## arch
|
||||
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
|
||||
## tags
|
||||
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
|
||||
## symbols
|
||||
- AlertmanagerConfig
|
||||
- AlertmanagerAlertsWidgetConfig
|
||||
- ServiceConfigBase
|
||||
- WidgetConfigBase
|
||||
- SecretField
|
||||
- WidgetKind
|
||||
- ServiceDefinition
|
||||
- GrafanaConfig
|
||||
## workflows
|
||||
- change integrations behavior
|
||||
read: __init__.py, alertmanager.py, base.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1 @@
|
||||
"""Closed registry of service integrations."""
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Alertmanager service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class AlertmanagerConfig(ServiceConfigBase):
|
||||
"""Non-secret Alertmanager connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class AlertmanagerAlertsWidgetConfig(WidgetConfigBase):
|
||||
"""Active-alerts summary for an Alertmanager instance."""
|
||||
|
||||
severity_filter: str | None = None
|
||||
|
||||
|
||||
def summarize_alerts(
|
||||
alerts: list[dict[str, Any]],
|
||||
*,
|
||||
severity_filter: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a UI-friendly summary from an Alertmanager ``/api/v1/alerts`` list.
|
||||
|
||||
Reshapes the raw alert objects into a stable summary (``total``,
|
||||
``by_severity``, top-50 ``alerts``). When ``severity_filter`` is given, only
|
||||
alerts whose ``labels.severity`` matches are counted.
|
||||
"""
|
||||
by_severity: dict[str, int] = {}
|
||||
open_alerts: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
severity = labels.get("severity", "unknown")
|
||||
if severity_filter and severity != severity_filter:
|
||||
continue
|
||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||
open_alerts.append(
|
||||
{
|
||||
"name": labels.get("alertname", "unknown"),
|
||||
"severity": severity,
|
||||
"category": labels.get("category", ""),
|
||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||
"summary": annotations.get("summary", ""),
|
||||
"description": annotations.get("description", ""),
|
||||
"active_since": alert.get("startsAt"),
|
||||
"state": alert.get("status", "firing"),
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||
return {
|
||||
"total": len(open_alerts),
|
||||
"by_severity": by_severity,
|
||||
"alerts": open_alerts[:50],
|
||||
}
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="alertmanager",
|
||||
name="Alertmanager",
|
||||
description="Alertmanager alerts and status.",
|
||||
config_model=AlertmanagerConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="active_alerts",
|
||||
name="Active alerts",
|
||||
description="Firing alerts summary from Alertmanager.",
|
||||
model_cls=AlertmanagerAlertsWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Authentik service definition.
|
||||
|
||||
Authentik is the user-directory source (replacing the Jellyfin-backed Users
|
||||
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
|
||||
on the Authentik service page (Users + Messaging tabs). OIDC authentication
|
||||
is unchanged -- this service type is for the directory, not SSO.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
|
||||
|
||||
class AuthentikConfig(ServiceConfigBase):
|
||||
"""Non-secret Authentik connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="authentik",
|
||||
name="Authentik",
|
||||
description="User directory and identity provider integration.",
|
||||
config_model=AuthentikConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_token", label="API token", required=True),
|
||||
],
|
||||
widget_kinds=[],
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Backups service definition.
|
||||
|
||||
Backups is modeled as a service type so it can be configured, named, and
|
||||
multi-instanced like other services. Reports arrive via the existing REST
|
||||
report endpoint; the ``ingestion_label`` disambiguates multi-instance
|
||||
ingestion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class BackupsConfig(ServiceConfigBase):
|
||||
"""Non-secret Backups connection config."""
|
||||
|
||||
ingestion_label: str = "default"
|
||||
|
||||
|
||||
class BackupsSummaryWidgetConfig(WidgetConfigBase):
|
||||
"""Backup dashboard summary (jobs, runs, alerts)."""
|
||||
|
||||
# No user-overridable fields; the widget reads the internal backup tables.
|
||||
pass
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="backups",
|
||||
name="Backups",
|
||||
description="Backup job monitoring, run history, and alerting.",
|
||||
config_model=BackupsConfig,
|
||||
secret_fields=[],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="summary",
|
||||
name="Summary",
|
||||
description="Backup job summary and active alerts.",
|
||||
model_cls=BackupsSummaryWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
|
||||
* 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
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import BaseModel, BeforeValidator, Field
|
||||
|
||||
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
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 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]
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Jellyfin service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class JellyfinConfig(ServiceConfigBase):
|
||||
"""Non-secret Jellyfin connection config.
|
||||
|
||||
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
|
||||
paired Jellyseerr companion config, absorbed from the former standalone
|
||||
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
|
||||
When both are set, the Jellyfin service page renders a Requests tab backed
|
||||
by Jellyseerr.
|
||||
"""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
user_id: str = ""
|
||||
timeout_seconds: int = 10
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
|
||||
|
||||
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||
"""Live Jellyfin session activity."""
|
||||
|
||||
# No user-overridable fields; the service record carries user_id.
|
||||
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),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="activity",
|
||||
name="Activity",
|
||||
description="Live sessions and idle users.",
|
||||
model_cls=JellyfinActivityWidgetConfig,
|
||||
default_config={},
|
||||
refresh_interval_ms=30_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""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 media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
)
|
||||
|
||||
|
||||
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=[],
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Prometheus service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class PrometheusConfig(ServiceConfigBase):
|
||||
"""Non-secret Prometheus connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||
"""A PromQL instant query rendered as a metric."""
|
||||
|
||||
promql: str
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="prometheus",
|
||||
name="Prometheus",
|
||||
description="Metrics storage and PromQL queries.",
|
||||
config_model=PrometheusConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
],
|
||||
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,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -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.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||
SSH_TASKS.service_type: SSH_TASKS,
|
||||
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,60 @@
|
||||
"""SSH task runner service definition.
|
||||
|
||||
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
|
||||
Tasks themselves stay in the global saved-task registry; the instance only owns
|
||||
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||
and shown as history on the instance's service page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class SshTasksConfig(ServiceConfigBase):
|
||||
"""Non-secret SSH task runner config.
|
||||
|
||||
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
||||
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||
"""
|
||||
|
||||
host: str
|
||||
port: int = 22
|
||||
username: str = ""
|
||||
ssh_key_id: str = ""
|
||||
timeout_seconds: int = 30
|
||||
|
||||
|
||||
class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
||||
"""Output of a saved task run on this instance."""
|
||||
|
||||
task_id: str
|
||||
# service_id is implicit (the widget's service); allow overriding per-widget.
|
||||
service_id: str | None = None
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="ssh_tasks",
|
||||
name="SSH task runner",
|
||||
description="Run reusable saved tasks over SSH and keep run history.",
|
||||
config_model=SshTasksConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="task_output",
|
||||
name="Task output",
|
||||
description="Output of a saved task run.",
|
||||
model_cls=SshTaskOutputWidgetConfig,
|
||||
default_config={"task_id": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -21,8 +21,13 @@ from media_library_viewer_api.observability import (
|
||||
record_request,
|
||||
set_current_request_id,
|
||||
)
|
||||
from media_library_viewer_api.routers import (
|
||||
authentik_users as authentik_users_router,
|
||||
)
|
||||
from media_library_viewer_api.routers import backups as backups_router
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
|
||||
from media_library_viewer_api.routers import dashboards as dashboards_router
|
||||
from media_library_viewer_api.routers import services as services_router
|
||||
from media_library_viewer_api.routers import widgets as widgets_router
|
||||
from media_library_viewer_api.routers.settings import router as settings_router
|
||||
|
||||
@@ -38,14 +43,11 @@ async def lifespan(app: FastAPI):
|
||||
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:
|
||||
@@ -138,11 +140,13 @@ app.include_router(monitoring.router)
|
||||
app.include_router(media.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
app.include_router(dashboards_router.router)
|
||||
app.include_router(services_router.router)
|
||||
app.include_router(authentik_users_router.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# backend/src/media_library_viewer_api/models (index)
|
||||
dir: backend/src/media_library_viewer_api/models
|
||||
|
||||
## role
|
||||
Defines Pydantic data models for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||
## 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
|
||||
- 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, services.py, widgets.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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 for request/response validation across backup management, service registry, and dashboard widget APIs.
|
||||
## 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
|
||||
- services.py | Defines Pydantic models for a service registry API, including validation to prevent credential keys in non-secret configuration. | exp: class:ServiceInstanceInput, class:ServiceInstance, class:SecretFieldInfo, class:WidgetKindInfo, class:ServiceTypeInfo, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError, func:_check(value: Any) → None, call:isinstance, call:value.items, call:key.lower, call:_check, raise:ValueError | dep: typing, pydantic
|
||||
- widgets.py | Defines Pydantic models for a dashboard widget system with validation to prevent secrets/credentials in widget configuration. | exp: class:_WidgetInstanceBase, class:WidgetInstanceInput, class:WidgetInstance, class:BuiltinWidgetKindInfo, class:WidgetDataResponse, func:_looks_secret(value: Any) → bool, call:isinstance, call:value.strip, call:value.lower, call:value.startswith, call:len, call:lowered.isalnum, func:_validate_config_keys(config: dict[str, Any]) → dict[str, Any], call:config.items, call:key.lower, call:_looks_secret, call:isinstance, call:_validate_config_keys, raise:ValueError | dep: typing, pydantic
|
||||
## arch
|
||||
Pydantic-based model layer implementing data validation, serialization contracts, and custom validators enforcing security constraints (e.g., blocking credentials in non-secret configs).
|
||||
## tags
|
||||
widget, backup, instance, response, info, call:isinstance, call:, service
|
||||
## symbols
|
||||
- BackupReportRequest
|
||||
- BackupJobResponse
|
||||
- BackupRunResponse
|
||||
- BackupAlertResponse
|
||||
- BackupDashboardSummary
|
||||
- ServiceInstanceInput
|
||||
- ServiceInstance
|
||||
- SecretFieldInfo
|
||||
## workflows
|
||||
- change models behavior
|
||||
read: backups.py, services.py, widgets.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,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 (Grafana link, 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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# backend/src/media_library_viewer_api/routers (index)
|
||||
dir: backend/src/media_library_viewer_api/routers
|
||||
|
||||
## role
|
||||
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||
## 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
|
||||
- backups.py
|
||||
- dashboard.py
|
||||
- files.py
|
||||
- jobs.py
|
||||
- media.py
|
||||
- monitoring.py
|
||||
- services.py
|
||||
- settings.py
|
||||
- tasks.py
|
||||
- users.py
|
||||
- users_impl.py
|
||||
- widgets.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/routers/.pi-map.md
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, backups.py, dashboard.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,39 @@
|
||||
# backend/src/media_library_viewer_api/routers
|
||||
dir: backend/src/media_library_viewer_api/routers
|
||||
|
||||
index: backend/src/media_library_viewer_api/routers/.pi-map.index.md
|
||||
|
||||
## role
|
||||
FastAPI router package that exposes all REST API endpoints for the media library viewer backend, organized by domain (files, media, jobs, backups, dashboard, monitoring, services, settings, tasks, users, widgets).
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for routers.
|
||||
- backups.py | FastAPI router that provides REST endpoints for reporting, tracking, and alerting on backup jobs and runs. | exp: func:_get_or_create_job(store: SettingsStore, report: BackupReportRequest) → dict[str, Any], call:store.get_backup_job_by_name, call:store.upsert_backup_job, call:store.get_backup_job, func:post_backup_report(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:store.list_backup_runs, call:int, call:report.started_at.timestamp, call:abs, call:BackupRunResponse, call:report.ended_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:generate_alerts_for_run, call:store.create_backup_alert, call:store.resolve_backup_alerts_for_job, call:run.pop, func:post_backup_start(report: BackupReportRequest, store, _auth) → BackupRunResponse, call:_get_or_create_job, call:int, call:report.started_at.timestamp, call:store.create_backup_run, call:record_backup_run, call:run.pop, call:BackupRunResponse, func:get_backup_jobs(store) → list[dict[str, Any]], call:store.list_backup_jobs, func:get_backup_job(job_id: str, store) → dict[str, Any], call:store.get_backup_job, call:store.list_backup_runs, raise:HTTPException, func:get_backup_runs(job_id, status, limit, store) → list[BackupRunResponse], call:store.list_backup_runs, call:BackupRunResponse, func:get_backup_run(run_id: str, store) → BackupRunResponse, call:store.get_backup_run, call:BackupRunResponse, raise:HTTPException, func:get_backup_alerts(job_id, acknowledged, severity, store) → list[BackupAlertResponse], call:store.list_backup_alerts, call:BackupAlertResponse, func:acknowledge_backup_alert(alert_id: str, store) → BackupAlertResponse, call:store.acknowledge_backup_alert, call:BackupAlertResponse, raise:HTTPException | dep: typing, fastapi, ..auth, ..models.backups, ..observability, ..services.backup_alert_engine, ..services.settings_store
|
||||
- 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
|
||||
- files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils
|
||||
- jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs
|
||||
- media.py | FastAPI router that manages media index lifecycle (status, build, stop, query) with subprocess worker orchestration and cooperative/force cancellation. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path) → list[str], call:str, func:_start_worker(index: MediaIndex) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(client, user_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:client.libraries, call:logger.info, call:len, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index, media_library_viewer_api.workers.media_index_worker
|
||||
- monitoring.py | FastAPI router providing endpoints to check the health/status of Alertmanager, Grafana, and Prometheus services and expose Prometheus scrape targets. | exp: func:_resolve_service_record(store: SettingsStore, service_type: str, service_id) → ServiceRecord | None, call:store.get_service, call:row.get, call:build_service_record, call:store.list_services, func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → int, call:int, call:service.config.get, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_grafana_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:_resolve_service_record, call:_status_response, call:_base_url, call:_timeout, call:_auth_headers, call:requests.get, call:health.raise_for_status, call:build_info.raise_for_status, call:build_info.json().get("data", {}).get, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager
|
||||
- services.py | Provides REST API endpoints for managing service instances in a service registry, including listing service types and CRUD operations for instances while ensuring plaintext secrets are never exposed. | exp: func:_to_type_info(service_type: str) → ServiceTypeInfo, call:require_service_definition, call:ServiceTypeInfo, call:SecretFieldInfo, call:WidgetKindInfo, func:_to_instance(row: dict[str, Any]) → ServiceInstance, call:get_service_definition, call:set, call:row.get, call:bool, call:ServiceInstance, func:_validate_input(body: ServiceInstanceInput) → None, call:get_service_definition, call:validate_config, call:set, raise:HTTPException, func:list_types() → list[ServiceTypeInfo], call:_to_type_info, call:sorted, func:list_instances(service_type, store) → list[ServiceInstance], call:store.list_services, call:_to_instance, func:create_instance(body: ServiceInstanceInput, store) → ServiceInstance, call:_validate_input, call:store.upsert_service, call:_to_instance, func:update_instance(service_id: str, body: ServiceInstanceInput, store) → ServiceInstance, call:store.get_service, call:_validate_input, call:store.upsert_service, call:_to_instance, raise:HTTPException, func:delete_instance(service_id: str, store) → dict[str, str], call:store.get_service, call:store.delete_service, raise:HTTPException | dep: logging, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.services, media_library_viewer_api.services.settings_store
|
||||
- settings.py | FastAPI router for managing machine definitions, SSH keys, SSH connection validation, and local database resets. | exp: class:MonitoringMachineInput, class:SSHKeyInput, class:SSHKeyGenerateInput, class:ResetLocalDatabaseInput, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, func:_resolve_ssh_client(machine: MonitoringMachineInput, store: SettingsStore) → tuple[RemoteSSHClient, str, int], call:machine.host.strip, call:machine.username.strip, call:int, call:store.get_ssh_key, call:str, call:ssh_key.get, call:get_settings, call:RemoteSSHClient, raise:HTTPException, func:_raise_ssh_validation_error(host: str, port: int, exc: Exception) → None, call:str, call:message.lower, raise:HTTPException, func:_validate_saved_machine_ssh(machine: MonitoringMachineInput, store: SettingsStore) → None, call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:client.connect, call:_raise_ssh_validation_error, call:client.close, func:test_machine_ssh(machine: MonitoringMachineInput, store) → dict[str, Any], call:str(machine.mode or "").strip().lower, call:_resolve_ssh_client, call:get_settings, call:has_known_host, call:client.connect, call:message.lower, call:client.close, raise:HTTPException, func:post_machine(machine: MonitoringMachineInput, store) → dict[str, Any], call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, func:put_machine(machine_id: str, machine: MonitoringMachineInput, store) → dict[str, Any], call:store.get_machine, call:store.upsert_machine, call:machine.model_dump, call:MonitoringMachineInput.model_validate, call:_validate_saved_machine_ssh, raise:HTTPException, func:delete_machine(machine_id: str, store) → dict[str, str], call:store.get_machine, call:store.delete_machine, raise:HTTPException, func:generate_ssh_key(payload: SSHKeyGenerateInput) → dict[str, Any], call:paramiko.RSAKey.generate, call:StringIO, call:key.write_private_key, call:private_buffer.getvalue, call:key.get_name, call:key.get_base64, call:":".join, call:key.get_fingerprint, func:get_ssh_keys(store) → list[dict[str, Any]], call:store.list_ssh_keys, func:post_ssh_key(key: SSHKeyInput, store) → dict[str, Any], call:store.upsert_ssh_key, call:key.model_dump, func:put_ssh_key(key_id: str, key: SSHKeyInput, store) → dict[str, Any], call:store.get_ssh_key, call:store.upsert_ssh_key, call:key.model_dump, raise:HTTPException, func:delete_ssh_key(key_id: str, store) → dict[str, str], call:store.get_ssh_key, call:store.delete_ssh_key, raise:HTTPException, func:reset_local_database(payload: ResetLocalDatabaseInput, store) → dict[str, Any], call:payload.confirm_phrase.strip().upper, call:remove_sqlite_database, call:MediaIndex, call:bool, raise:HTTPException | dep: logging, io, typing, paramiko, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.db_maintenance, media_library_viewer_api.services.known_hosts, media_library_viewer_api.services.media_index, media_library_viewer_api.services.settings_store
|
||||
- tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources
|
||||
- users.py | Re-exports all public names from the users_impl module to provide a unified public API for user-related functionality. | dep: .users_impl, users_impl
|
||||
- users_impl.py | FastAPI router that fetches and merges Jellyfin users with optional Jellyseerr enrichment, plus endpoints for queueing user emails via a background mail worker. | exp: func:_safe_int(value: Any) → int, call:int, func:_permission_labels(permissions: int) → list[str], func:_role_label(permissions: int) → str, func:_account_type(user_type: Any) → str, call:_USER_TYPES.get, call:_safe_int, func:_merge_users(jellyfin_users: list[dict[str, Any]], jellyseerr_jellyfin_users: list[dict[str, Any]] | None, jellyseerr_users: list[dict[str, Any]] | None, jellyseerr_client: JellyseerrClient | None) → dict[str, Any], call:str(value or "").strip().lower, call:bool, call:_looks_like_email, call:str(value).strip, call:", ".join, call:_normalize, call:item.get, call:_lookup_keys, call:user.get, call:linked_by_jellyfin_id.get, call:(jf_link or {}).get, call:seerr_by_key.get, call:_pick_source_and_value, call:(seerr_user or {}).get, call:_first_value, call:jellyseerr_client.absolute_url, call:_safe_int, call:_role_label, call:_source_summary, call:items.append, call:_account_type, call:_permission_labels, call:logger.info, call:len, func:_normalize(value: Any) → str, call:str(value or "").strip().lower, func:_looks_like_email(value: Any) → bool, call:str(value or "").strip, call:bool, func:_pick_source_and_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:_looks_like_email, call:str(value).strip, func:_first_value(candidates: list[tuple[str, Any]]) → tuple[str, str], call:str(value or "").strip, func:_source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) → str, call:", ".join, func:_lookup_keys(item: dict[str, Any]) → list[str], call:_normalize, call:item.get, func:get_users(jellyfin, jellyseerr) → dict[str, Any], call:jellyfin.users, call:logger.info, call:len, call:jellyseerr.jellyfin_users, call:logger.exception, call:jellyseerr.users, call:_merge_users, call:bool, func:get_user_message_status() → dict[str, Any], call:mail_queue.status, func:post_user_message(recipient_ids, subject, html_body, text_body, attachments, jellyfin, jellyseerr) → dict[str, Any], call:json.loads, call:isinstance, call:str(item).strip, call:subject.strip, call:get_users, call:item.get, call:directory.get, call:users_by_id.get, call:skipped.append, call:str(item.get("email") or "").strip, call:recipients.append, call:recipient_labels.append, call:get_settings, call:validate_smtp_settings, call:mail_queue.status, call:upload.read, call:attachment_payloads.append, call:EmailAttachment, call:mail_queue.enqueue, call:str(getattr(settings, "smtp_from_address", "") or "").strip, call:getattr, call:str(getattr(settings, "smtp_username", "") or "").strip, call:logger.info, call:len, raise:HTTPException | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mailer
|
||||
- widgets.py | Provides a FastAPI REST API for managing dashboard widget instances, including CRUD operations, built-in widget discovery, and data fetching through service or built-in adapters. | exp: func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException | dep: logging, time, typing, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources
|
||||
## arch
|
||||
Modular router-per-domain pattern where each file defines an isolated FastAPI APIRouter; routers are registered by the parent application and share common dependencies for SSH orchestration, database access, and service resolution.
|
||||
## tags
|
||||
call:, service, raise:httpexception, get, backup, media_library_viewer_api, ssh, call:logger.info
|
||||
## symbols
|
||||
- RunJobRequest
|
||||
- MonitoringMachineInput
|
||||
- SSHKeyInput
|
||||
- SSHKeyGenerateInput
|
||||
- ResetLocalDatabaseInput
|
||||
- TaskInput
|
||||
- RunTaskRequest
|
||||
- _get_or_create_job
|
||||
## workflows
|
||||
- change routers behavior
|
||||
read: __init__.py, backups.py, dashboard.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Authentik directory + messaging router.
|
||||
|
||||
Resolves an ``authentik`` service instance from the registry, builds an
|
||||
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
|
||||
proxies paginated directory queries plus message-compose (email enqueue).
|
||||
Graceful "not configured" / "unreachable" payloads (matching the monitoring
|
||||
router's pattern) so the UI always renders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from media_library_viewer_api.clients.authentik import AuthentikClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mailer import validate_smtp_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
|
||||
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
"""Compose-request body for the Authentik messaging endpoint."""
|
||||
|
||||
recipient_emails: list[str]
|
||||
subject: str
|
||||
html_body: str
|
||||
|
||||
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore,
|
||||
service_id: str | None = None,
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested authentik instance, else the first enabled one.
|
||||
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled ``authentik`` instance is configured.
|
||||
"""
|
||||
service_type = "authentik"
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _build_client(service: ServiceRecord) -> AuthentikClient:
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_token = str(service.secrets.get("api_token") or "")
|
||||
try:
|
||||
timeout = float(service.config.get("timeout_seconds") or 10)
|
||||
except (TypeError, ValueError):
|
||||
timeout = 10.0
|
||||
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
|
||||
|
||||
|
||||
def _empty(error: str) -> dict[str, Any]:
|
||||
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
|
||||
|
||||
|
||||
@router.get("/{service_id}/users")
|
||||
def get_authentik_users(
|
||||
service_id: str,
|
||||
search: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Paginated Authentik user directory for a specific service instance."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
|
||||
return _empty("Authentik service not configured")
|
||||
|
||||
try:
|
||||
client = _build_client(service)
|
||||
return client.users(search=search, page=page, page_size=page_size)
|
||||
except Exception:
|
||||
logger.exception("Authentik users query failed for service %s", service_id)
|
||||
return _empty("Authentik is unreachable")
|
||||
|
||||
|
||||
@router.get("/{service_id}/message/status")
|
||||
def get_authentik_message_status(
|
||||
service_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Mail-queue status snapshot for the Authentik messaging tab."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/{service_id}/message")
|
||||
def post_authentik_message(
|
||||
service_id: str,
|
||||
body: MessageRequest,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
mail_queue: MailQueue = Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
|
||||
service = _resolve_service_record(store, service_id)
|
||||
if service is None:
|
||||
return {"status": "error", "error": "Authentik service not configured"}
|
||||
|
||||
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
|
||||
if not recipients:
|
||||
return {"status": "error", "error": "No recipients with valid email addresses."}
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
validate_smtp_settings(settings)
|
||||
except ValueError as exc:
|
||||
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
|
||||
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=body.subject,
|
||||
html_body=body.html_body,
|
||||
)
|
||||
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
|
||||
return {
|
||||
"status": "queued",
|
||||
"request_id": request_id,
|
||||
"recipient_count": len(recipients),
|
||||
}
|
||||
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
|
||||
router = APIRouter(prefix="/api/backups", tags=["backups"])
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
|
||||
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
|
||||
"""Return the service_id for backup attribution.
|
||||
|
||||
First-wins: if no explicit service_id is given, pick the first enabled
|
||||
``backups`` service instance (spec R6.1). Returns an empty string when
|
||||
none is configured (backward-compatible with pre-service reports).
|
||||
"""
|
||||
if explicit:
|
||||
return explicit
|
||||
candidates = store.list_services("backups")
|
||||
for svc in candidates:
|
||||
if svc.get("enabled"):
|
||||
return svc["id"]
|
||||
return ""
|
||||
|
||||
|
||||
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
|
||||
job = store.get_backup_job_by_name(report.name)
|
||||
if not job:
|
||||
job = store.upsert_backup_job(
|
||||
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
elif report.schedule_interval_seconds:
|
||||
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
"source": report.source,
|
||||
"target": report.target,
|
||||
"schedule_interval_seconds": report.schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
)
|
||||
job = store.get_backup_job(job["id"])
|
||||
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
|
||||
@router.post("/report")
|
||||
def post_backup_report(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
# Check for duplicate (same job + started_at within 1s)
|
||||
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
|
||||
@@ -88,10 +108,12 @@ def post_backup_report(
|
||||
@router.post("/report/start")
|
||||
def post_backup_start(
|
||||
report: BackupReportRequest,
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
_auth: str = Depends(require_api_key),
|
||||
) -> BackupRunResponse:
|
||||
job = _get_or_create_job(store, report)
|
||||
resolved_service_id = _resolve_backup_service_id(store, service_id)
|
||||
job = _get_or_create_job(store, report, resolved_service_id)
|
||||
|
||||
run_data = {
|
||||
"job_id": job["id"],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Named dashboards CRUD router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
|
||||
rows = store.list_dashboards()
|
||||
return [NamedDashboard(**row) for row in rows]
|
||||
|
||||
|
||||
@router.get("/slug/{slug}")
|
||||
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.get_dashboard_by_slug(slug)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
|
||||
row = store.upsert_dashboard(body.model_dump())
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.put("/{dashboard_id}")
|
||||
def update_dashboard(
|
||||
dashboard_id: str,
|
||||
body: NamedDashboardInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> NamedDashboard:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
if body.id and body.id != dashboard_id:
|
||||
raise HTTPException(status_code=400, detail="ID mismatch")
|
||||
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
|
||||
return NamedDashboard(**row)
|
||||
|
||||
|
||||
@router.delete("/{dashboard_id}")
|
||||
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
|
||||
if not store.get_dashboard(dashboard_id):
|
||||
raise HTTPException(status_code=404, detail="Dashboard not found")
|
||||
store.delete_dashboard(dashboard_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -1,64 +1,76 @@
|
||||
"""Monitoring router — observability stack status (Alertmanager + Prometheus)."""
|
||||
"""Monitoring router — observability service status.
|
||||
|
||||
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||
the service registry, not environment variables. The endpoints pick the first
|
||||
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _alertmanager_client() -> Any:
|
||||
"""Return a simple HTTP client for the configured Alertmanager URL."""
|
||||
import requests
|
||||
def _resolve_service_record(
|
||||
store: SettingsStore, service_type: str, service_id: str | None = None
|
||||
) -> ServiceRecord | None:
|
||||
"""Return the requested service instance, else the first enabled one.
|
||||
|
||||
settings = get_settings()
|
||||
return requests.Session(), settings.alertmanager_url
|
||||
Returns ``None`` when the instance does not exist / is the wrong type, or
|
||||
when no enabled instance of ``service_type`` is configured.
|
||||
"""
|
||||
if service_id:
|
||||
row = store.get_service(service_id)
|
||||
if not row or row.get("service_type") != service_type:
|
||||
return None
|
||||
if not row.get("enabled", True):
|
||||
return None
|
||||
return build_service_record(store, row)
|
||||
for row in store.list_services(service_type):
|
||||
if row.get("enabled", True):
|
||||
return build_service_record(store, row)
|
||||
return None
|
||||
|
||||
|
||||
def _webhook_client() -> Any:
|
||||
"""Return a simple HTTP client for the optional webhook receiver URL."""
|
||||
import requests
|
||||
def _base_url(service: ServiceRecord) -> str:
|
||||
return str(service.config.get("base_url") or "").rstrip("/")
|
||||
|
||||
settings = get_settings()
|
||||
return requests.Session(), settings.alertmanager_webhook_url
|
||||
|
||||
def _timeout(service: ServiceRecord, default: int) -> int:
|
||||
return int(service.config.get("timeout_seconds") or default)
|
||||
|
||||
|
||||
def _auth_headers(service: ServiceRecord) -> dict[str, str]:
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
return {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
||||
|
||||
|
||||
def _status_response(service: ServiceRecord | None, *, version: str = "", error: str | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"up": error is None,
|
||||
"version": version or "",
|
||||
"service_id": service.id if service else "",
|
||||
"name": service.name if service else "",
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Build a UI-friendly summary from Alertmanager /api/v1/alerts payload."""
|
||||
by_severity: dict[str, int] = {}
|
||||
open_alerts: list[dict[str, Any]] = []
|
||||
for alert in alerts:
|
||||
labels = alert.get("labels") or {}
|
||||
annotations = alert.get("annotations") or {}
|
||||
severity = labels.get("severity", "unknown")
|
||||
by_severity[severity] = by_severity.get(severity, 0) + 1
|
||||
open_alerts.append(
|
||||
{
|
||||
"name": labels.get("alertname", "unknown"),
|
||||
"severity": severity,
|
||||
"category": labels.get("category", ""),
|
||||
"job_name": labels.get("job_name", labels.get("job", "")),
|
||||
"summary": annotations.get("summary", ""),
|
||||
"description": annotations.get("description", ""),
|
||||
"active_since": alert.get("startsAt"),
|
||||
"state": alert.get("status", "firing"),
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
open_alerts.sort(key=lambda a: (a["severity"] not in {"critical", "warning"}, a["severity"], a["name"]))
|
||||
return {
|
||||
"total": len(open_alerts),
|
||||
"by_severity": by_severity,
|
||||
"alerts": open_alerts[:50],
|
||||
}
|
||||
from media_library_viewer_api.integrations.alertmanager import summarize_alerts
|
||||
|
||||
return summarize_alerts(alerts)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||
@@ -72,11 +84,9 @@ def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dic
|
||||
|
||||
@router.get("/prometheus-targets")
|
||||
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
"""Return Prometheus file-SD targets for remote Node Exporters.
|
||||
"""Return Prometheus scrape targets for remote Node Exporters.
|
||||
|
||||
The backend writes these targets to a JSON file that Prometheus reads via
|
||||
file_sd_configs. This endpoint returns the same list live from the store so
|
||||
the UI can preview which machines will be scraped.
|
||||
External Prometheus instances consume this list via ``http_sd_configs``.
|
||||
"""
|
||||
targets = build_node_exporter_targets(store)
|
||||
logger.info("Prometheus targets requested count=%s", len(targets))
|
||||
@@ -84,47 +94,90 @@ def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -
|
||||
|
||||
|
||||
@router.get("/alerts")
|
||||
def get_alertmanager_alerts() -> dict[str, Any]:
|
||||
def get_alertmanager_alerts(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return a summary of active Alertmanager alerts for the UI.
|
||||
|
||||
Proxies the Alertmanager `/api/v1/alerts` endpoint and reshapes the payload
|
||||
into a stable, UI-friendly format. If Alertmanager is unreachable, the
|
||||
endpoint returns an empty summary and logs the failure so the UI can still
|
||||
render a health card instead of an error page.
|
||||
Resolves an ``alertmanager`` service instance from the registry. When none
|
||||
is configured the endpoint returns an empty summary with an
|
||||
``alertmanager_not_configured`` error so the UI can render a health card.
|
||||
"""
|
||||
session, base_url = _alertmanager_client()
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
|
||||
try:
|
||||
response = session.get(f"{base_url}/api/v1/alerts", timeout=5)
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/v1/alerts",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Alertmanager alerts from %s", base_url)
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_unreachable"}
|
||||
logger.exception("Failed to fetch Alertmanager alerts")
|
||||
return {
|
||||
"total": 0,
|
||||
"by_severity": {},
|
||||
"alerts": [],
|
||||
"error": "alertmanager_unreachable",
|
||||
"service_id": service.id,
|
||||
"name": service.name,
|
||||
}
|
||||
|
||||
if data.get("status") != "success":
|
||||
return {"total": 0, "by_severity": {}, "alerts": [], "error": data.get("error", "unknown")}
|
||||
return {
|
||||
"total": 0,
|
||||
"by_severity": {},
|
||||
"alerts": [],
|
||||
"error": data.get("error", "unknown"),
|
||||
"service_id": service.id,
|
||||
"name": service.name,
|
||||
}
|
||||
|
||||
summary = _summary_from_alerts(data.get("data", []))
|
||||
summary["service_id"] = service.id
|
||||
summary["name"] = service.name
|
||||
logger.info("Alertmanager alerts requested total=%s", summary["total"])
|
||||
return summary
|
||||
|
||||
|
||||
@router.get("/alertmanager-status")
|
||||
def get_alertmanager_status() -> dict[str, Any]:
|
||||
"""Return Alertmanager cluster/status for the UI health card.
|
||||
|
||||
Uses the Alertmanager `/api/v2/status` endpoint and exposes only the high-
|
||||
level fields the UI needs: uptime, version, and whether the cluster is
|
||||
healthy.
|
||||
"""
|
||||
session, base_url = _alertmanager_client()
|
||||
def get_alertmanager_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return Alertmanager cluster/status for the UI health card."""
|
||||
service = _resolve_service_record(store, "alertmanager", service_id)
|
||||
if service is None:
|
||||
return {
|
||||
"up": False,
|
||||
"version": "",
|
||||
"uptime": "",
|
||||
"name": "",
|
||||
"peers": [],
|
||||
"error": "alertmanager_not_configured",
|
||||
}
|
||||
try:
|
||||
response = session.get(f"{base_url}/api/v2/status", timeout=5)
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/v2/status",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Alertmanager status from %s", base_url)
|
||||
return {"up": False, "version": "", "uptime": ""}
|
||||
logger.exception("Failed to fetch Alertmanager status")
|
||||
return {
|
||||
"up": False,
|
||||
"version": "",
|
||||
"uptime": "",
|
||||
"name": service.name,
|
||||
"peers": [],
|
||||
"service_id": service.id,
|
||||
"error": "alertmanager_unreachable",
|
||||
}
|
||||
|
||||
cluster = data.get("cluster") or {}
|
||||
status = data.get("clusterStatus") or {}
|
||||
@@ -132,20 +185,68 @@ def get_alertmanager_status() -> dict[str, Any]:
|
||||
"up": True,
|
||||
"version": data.get("versionInfo", {}).get("version", ""),
|
||||
"uptime": status.get("createdAt", ""),
|
||||
"name": "",
|
||||
"name": service.name,
|
||||
"peers": [p.get("name", "") for p in cluster.get("peers", [])],
|
||||
"service_id": service.id,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grafana-status")
|
||||
def get_grafana_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||
service = _resolve_service_record(store, "grafana", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/health",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Grafana status")
|
||||
return _status_response(service, error="grafana_unreachable")
|
||||
return _status_response(service, version=data.get("version", ""))
|
||||
|
||||
|
||||
@router.get("/prometheus-status")
|
||||
def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Prometheus service instance's health and build info."""
|
||||
service = _resolve_service_record(store, "prometheus", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
base = _base_url(service)
|
||||
timeout = _timeout(service, 10)
|
||||
headers = _auth_headers(service)
|
||||
try:
|
||||
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
||||
health.raise_for_status()
|
||||
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
||||
build_info.raise_for_status()
|
||||
version = build_info.json().get("data", {}).get("version", "")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Prometheus status")
|
||||
return _status_response(service, error="prometheus_unreachable")
|
||||
return _status_response(service, version=version)
|
||||
|
||||
|
||||
@router.post("/alertmanager-webhook")
|
||||
def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[str, str]:
|
||||
"""Receive alerts from Alertmanager and optionally forward to a webhook URL.
|
||||
"""Receive alerts from Alertmanager and log them for audit/debug.
|
||||
|
||||
This endpoint is the receiver referenced by the optional `webhook_configs`
|
||||
block in Alertmanager. It logs the payload for audit/debug purposes and, if
|
||||
`ALERTMANAGER_WEBHOOK_URL` is configured, forwards the alert JSON verbatim.
|
||||
Forwarding is best-effort: a failure to reach the downstream webhook does
|
||||
not fail this endpoint, so Alertmanager sees a successful delivery.
|
||||
This endpoint is the receiver referenced by the optional ``webhook_configs``
|
||||
block in Alertmanager. It is log-only: received payloads are recorded but not
|
||||
forwarded anywhere. (The previous outbound relay to ``ALERTMANAGER_WEBHOOK_URL``
|
||||
was removed when observability became service-registry configured.)
|
||||
"""
|
||||
alerts = payload.get("alerts", [])
|
||||
logger.info(
|
||||
@@ -153,16 +254,4 @@ def receive_alertmanager_webhook(payload: dict[str, Any] = Body(...)) -> dict[st
|
||||
len(alerts),
|
||||
payload.get("status", "unknown"),
|
||||
)
|
||||
|
||||
session, webhook_url = _webhook_client()
|
||||
if webhook_url:
|
||||
try:
|
||||
response = session.post(webhook_url, json=payload, timeout=10)
|
||||
response.raise_for_status()
|
||||
logger.info("Forwarded Alertmanager webhook to %s", webhook_url)
|
||||
except Exception:
|
||||
logger.exception("Failed to forward Alertmanager webhook to %s", webhook_url)
|
||||
else:
|
||||
logger.debug("No ALERTMANAGER_WEBHOOK_URL configured; webhook stored in logs only")
|
||||
|
||||
return {"status": "received"}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""REST API for the service registry.
|
||||
|
||||
Service instances hold non-secret config and encrypted secrets. Plaintext
|
||||
secrets are never returned; only the boolean ``secrets_set`` map is exposed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
from media_library_viewer_api.integrations.registry import (
|
||||
SERVICE_DEFINITIONS,
|
||||
get_service_definition,
|
||||
require_service_definition,
|
||||
)
|
||||
from media_library_viewer_api.models.services import (
|
||||
SecretFieldInfo,
|
||||
ServiceInstance,
|
||||
ServiceInstanceInput,
|
||||
ServiceTypeInfo,
|
||||
WidgetKindInfo,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
router = APIRouter(prefix="/api/services", tags=["services"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _to_type_info(service_type: str) -> ServiceTypeInfo:
|
||||
definition = require_service_definition(service_type)
|
||||
return ServiceTypeInfo(
|
||||
service_type=definition.service_type,
|
||||
name=definition.name,
|
||||
description=definition.description,
|
||||
config_schema=definition.config_schema,
|
||||
secret_fields=[
|
||||
SecretFieldInfo(
|
||||
key=sf.key,
|
||||
label=sf.label,
|
||||
required=sf.required,
|
||||
helper=sf.helper,
|
||||
)
|
||||
for sf in definition.secret_fields
|
||||
],
|
||||
widget_kinds=[
|
||||
WidgetKindInfo(
|
||||
kind=wk.kind,
|
||||
name=wk.name,
|
||||
description=wk.description,
|
||||
config_schema=wk.config_schema,
|
||||
default_config=wk.default_config,
|
||||
refresh_interval_ms=wk.refresh_interval_ms,
|
||||
)
|
||||
for wk in definition.widget_kinds
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _to_instance(row: dict[str, Any]) -> ServiceInstance:
|
||||
"""Build an API response model, surfacing only secret 'set' flags."""
|
||||
definition = get_service_definition(row["service_type"])
|
||||
known_secrets = definition.secret_keys if definition else set()
|
||||
secrets_blob = row.get("secrets") or {}
|
||||
secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets}
|
||||
return ServiceInstance(
|
||||
id=row["id"],
|
||||
service_type=row["service_type"],
|
||||
name=row["name"],
|
||||
config=row.get("config") or {},
|
||||
secrets_set=secrets_set,
|
||||
enabled=row["enabled"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def _validate_input(body: ServiceInstanceInput) -> None:
|
||||
"""Validate service_type, config, and secret keys against the definition."""
|
||||
definition = get_service_definition(body.service_type)
|
||||
if definition is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown service type: {body.service_type}",
|
||||
)
|
||||
try:
|
||||
validate_config(definition.config_model, body.config)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid service config: {exc}",
|
||||
) from exc
|
||||
unknown_secrets = set(body.secrets) - definition.secret_keys
|
||||
if unknown_secrets:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def list_types() -> list[ServiceTypeInfo]:
|
||||
"""Return metadata for every registered service type."""
|
||||
return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)]
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
service_type: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[ServiceInstance]:
|
||||
"""Return all persisted service instances (no plaintext secrets)."""
|
||||
rows = store.list_services(service_type)
|
||||
return [_to_instance(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
def create_instance(
|
||||
body: ServiceInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> ServiceInstance:
|
||||
"""Create a new service instance."""
|
||||
_validate_input(body)
|
||||
row = store.upsert_service(
|
||||
{
|
||||
"id": body.id,
|
||||
"service_type": body.service_type,
|
||||
"name": body.name,
|
||||
"config": body.config,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
secret_values=body.secrets,
|
||||
)
|
||||
return _to_instance(row)
|
||||
|
||||
|
||||
@router.put("/instances/{service_id}")
|
||||
def update_instance(
|
||||
service_id: str,
|
||||
body: ServiceInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> ServiceInstance:
|
||||
"""Update an existing service instance."""
|
||||
existing = store.get_service(service_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||
if body.id is not None and body.id != service_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="ID in path does not match ID in body",
|
||||
)
|
||||
_validate_input(body)
|
||||
row = store.upsert_service(
|
||||
{
|
||||
"id": service_id,
|
||||
"service_type": body.service_type,
|
||||
"name": body.name,
|
||||
"config": body.config,
|
||||
"enabled": body.enabled,
|
||||
},
|
||||
secret_values=body.secrets,
|
||||
service_id=service_id,
|
||||
)
|
||||
return _to_instance(row)
|
||||
|
||||
|
||||
@router.delete("/instances/{service_id}")
|
||||
def delete_instance(
|
||||
service_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Delete a service instance (cascade-deletes widgets referencing it)."""
|
||||
existing = store.get_service(service_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||
store.delete_service(service_id)
|
||||
return {"status": "deleted"}
|
||||
@@ -17,7 +17,6 @@ from media_library_viewer_api.services.db_maintenance import remove_sqlite_datab
|
||||
from media_library_viewer_api.services.known_hosts import has_known_host
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import write_prometheus_targets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,13 +40,6 @@ class MonitoringMachineInput(BaseModel):
|
||||
ssh_private_key: str = ""
|
||||
ssh_private_key_passphrase: str = ""
|
||||
password: str = ""
|
||||
media_root: str = ""
|
||||
path_prefix: str = ""
|
||||
jellyfin_url: str = ""
|
||||
jellyfin_user_id: str = ""
|
||||
jellyfin_api_key: str = ""
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@@ -126,14 +118,6 @@ def _validate_saved_machine_ssh(machine: MonitoringMachineInput, store: Settings
|
||||
client.close()
|
||||
|
||||
|
||||
def _write_prometheus_targets(store: SettingsStore) -> None:
|
||||
"""Regenerate Prometheus file-SD targets after machine changes."""
|
||||
try:
|
||||
write_prometheus_targets(store)
|
||||
except Exception:
|
||||
logger.exception("Failed to write Prometheus file-SD targets")
|
||||
|
||||
|
||||
@router.post("/machines/test-ssh")
|
||||
def test_machine_ssh(
|
||||
machine: MonitoringMachineInput,
|
||||
@@ -194,7 +178,6 @@ def post_machine(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||
_write_prometheus_targets(store)
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
return saved
|
||||
@@ -209,7 +192,6 @@ def put_machine(
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||
_write_prometheus_targets(store)
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
return saved
|
||||
@@ -220,7 +202,6 @@ def delete_machine(machine_id: str, store: SettingsStore = Depends(get_settings_
|
||||
if not store.get_machine(machine_id):
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
store.delete_machine(machine_id)
|
||||
_write_prometheus_targets(store)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
|
||||
@@ -3,18 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,7 +24,7 @@ class TaskInput(BaseModel):
|
||||
task_type: str = Field(default="shell", description="shell or python")
|
||||
content: str = Field(default="")
|
||||
enabled: bool = True
|
||||
default_machine_id: str = ""
|
||||
default_service_id: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@@ -35,61 +32,31 @@ class RunTaskRequest(BaseModel):
|
||||
task_id: str
|
||||
|
||||
|
||||
def _machine_label(machine: dict[str, Any] | None) -> str:
|
||||
if not machine:
|
||||
def _service_label(service: dict[str, Any] | None) -> str:
|
||||
if not service:
|
||||
return ""
|
||||
return str(machine.get("name") or machine.get("host") or machine.get("id") or "")
|
||||
return str(service.get("name") or service.get("id") or "")
|
||||
|
||||
|
||||
def _resolve_machine_for_task(
|
||||
def _resolve_service_for_task(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
machine_id: str | None,
|
||||
service_id: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if machine_id:
|
||||
return store.get_machine_config(machine_id) or store.get_machine(machine_id)
|
||||
default_machine_id = str(task.get("default_machine_id") or "").strip()
|
||||
if default_machine_id:
|
||||
return store.get_machine_config(default_machine_id) or store.get_machine(default_machine_id)
|
||||
machines = [machine for machine in store.list_machines() if machine.get("enabled")]
|
||||
return machines[0] if machines else None
|
||||
if service_id:
|
||||
return store.get_service(service_id)
|
||||
default_service_id = str(task.get("default_service_id") or "").strip()
|
||||
if default_service_id:
|
||||
return store.get_service(default_service_id)
|
||||
services = [svc for svc in store.list_services("ssh_tasks") if svc.get("enabled")]
|
||||
return services[0] if services else None
|
||||
|
||||
|
||||
def _client_for_machine(store: SettingsStore, machine: dict[str, Any]):
|
||||
mode = str(machine.get("mode") or "local").lower()
|
||||
if mode == "local":
|
||||
return LocalCommandClient()
|
||||
def _service_row_to_record(service_row: dict[str, Any]) -> ServiceRecord:
|
||||
"""Build a ServiceRecord from a raw settings_store service row."""
|
||||
from media_library_viewer_api.services.settings_store import get_settings_store
|
||||
|
||||
host = str(machine.get("host") or "").strip()
|
||||
username = str(machine.get("username") or "").strip()
|
||||
if not host or not username:
|
||||
raise HTTPException(status_code=400, detail="SSH machine is missing host or username")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
private_key = str(machine.get("ssh_private_key") or "")
|
||||
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
|
||||
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
private_key = str(ssh_key.get("private_key") or private_key)
|
||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
||||
|
||||
key_filename = ""
|
||||
if machine.get("key_directory") and machine.get("key_name"):
|
||||
key_filename = f"{machine.get('key_directory')}/{machine.get('key_name')}"
|
||||
|
||||
return RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=int(machine.get("port") or 22),
|
||||
key_filename=key_filename or None,
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=passphrase or None,
|
||||
password=str(machine.get("password") or "") or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
)
|
||||
return build_service_record(get_settings_store(), service_row)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -125,14 +92,14 @@ def list_task_runs(
|
||||
) -> dict[str, Any]:
|
||||
if not store.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
runs = store.list_task_runs(task_id, limit=limit)
|
||||
runs = store.list_service_task_runs(task_id=task_id, limit=limit)
|
||||
return {"items": runs, "total": len(runs)}
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
def run_task(
|
||||
request: RunTaskRequest,
|
||||
machine_id: str | None = Query(default=None),
|
||||
service_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
task = store.get_task(request.task_id)
|
||||
@@ -141,68 +108,22 @@ def run_task(
|
||||
if not task.get("enabled", True):
|
||||
raise HTTPException(status_code=400, detail="Task is disabled")
|
||||
|
||||
machine = _resolve_machine_for_task(store, task, machine_id)
|
||||
if not machine:
|
||||
raise HTTPException(status_code=400, detail="No machine is available for this action")
|
||||
service_row = _resolve_service_for_task(store, task, service_id)
|
||||
if not service_row:
|
||||
raise HTTPException(status_code=400, detail="No SSH task service is available for this action")
|
||||
if not service_row.get("enabled", True):
|
||||
raise HTTPException(status_code=400, detail="Selected SSH task service is disabled")
|
||||
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
command = f"python3 -c {shlex.quote(command)}"
|
||||
elif task_type != "shell":
|
||||
raise HTTPException(status_code=400, detail=f"Unknown task type: {task_type}")
|
||||
service = _service_row_to_record(service_row)
|
||||
result = run_saved_task(store, task, service)
|
||||
|
||||
client = _client_for_machine(store, machine)
|
||||
start = time.perf_counter()
|
||||
machine_name = _machine_label(machine)
|
||||
try:
|
||||
result = client.run(command, timeout=1200)
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
status_text = "success" if result.exit_status == 0 else "error"
|
||||
store.record_task_run(
|
||||
task,
|
||||
status_text,
|
||||
machine_id=str(machine.get("id") or ""),
|
||||
machine_name=machine_name,
|
||||
task_type=task_type,
|
||||
duration_ms=int((time.perf_counter() - start) * 1000),
|
||||
stdout_tail=stdout[-4000:],
|
||||
stderr_tail=stderr[-4000:],
|
||||
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
||||
)
|
||||
return {
|
||||
"task_id": task["id"],
|
||||
"task_name": task["name"],
|
||||
"machine_id": str(machine.get("id") or ""),
|
||||
"machine_name": machine_name,
|
||||
"task_type": task_type,
|
||||
"exit_status": result.exit_status,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
}
|
||||
except Exception as exc:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
error_text = str(exc)
|
||||
store.record_task_run(
|
||||
task,
|
||||
"error",
|
||||
machine_id=str(machine.get("id") or ""),
|
||||
machine_name=machine_name,
|
||||
task_type=task_type,
|
||||
duration_ms=duration_ms,
|
||||
stdout_tail="",
|
||||
stderr_tail=error_text[-4000:],
|
||||
error=error_text,
|
||||
)
|
||||
logger.exception("Task execution failed task_id=%s", task["id"])
|
||||
return {
|
||||
"task_id": task["id"],
|
||||
"task_name": task["name"],
|
||||
"machine_id": str(machine.get("id") or ""),
|
||||
"machine_name": machine_name,
|
||||
"task_type": task_type,
|
||||
"exit_status": 1,
|
||||
"stdout": "",
|
||||
"stderr": error_text,
|
||||
}
|
||||
return {
|
||||
"task_id": task["id"],
|
||||
"task_name": task["name"],
|
||||
"service_id": service.id,
|
||||
"service_name": _service_label(service_row),
|
||||
"task_type": task.get("task_type", "shell"),
|
||||
"exit_status": result.exit_status,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
from .users_impl import * # noqa: F401,F403
|
||||
@@ -1,389 +0,0 @@
|
||||
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import (
|
||||
get_jellyfin_client,
|
||||
get_jellyseerr_client,
|
||||
get_mail_queue,
|
||||
)
|
||||
from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||
|
||||
|
||||
_PERMISSION_FLAGS = [
|
||||
(2, "admin"),
|
||||
(4, "manage_settings"),
|
||||
(8, "manage_users"),
|
||||
(16, "manage_requests"),
|
||||
(32, "request"),
|
||||
(64, "vote"),
|
||||
(128, "auto_approve"),
|
||||
(256, "auto_approve_movie"),
|
||||
(512, "auto_approve_tv"),
|
||||
(1024, "request_4k"),
|
||||
(2048, "request_4k_movie"),
|
||||
(4096, "request_4k_tv"),
|
||||
(8192, "request_advanced"),
|
||||
(16384, "request_view"),
|
||||
(32768, "auto_approve_4k"),
|
||||
(65536, "auto_approve_4k_movie"),
|
||||
(131072, "auto_approve_4k_tv"),
|
||||
(262144, "request_movie"),
|
||||
(524288, "request_tv"),
|
||||
(1048576, "manage_issues"),
|
||||
(2097152, "view_issues"),
|
||||
]
|
||||
|
||||
_USER_TYPES = {
|
||||
1: "plex",
|
||||
2: "local",
|
||||
3: "jellyfin",
|
||||
4: "emby",
|
||||
}
|
||||
|
||||
|
||||
def _safe_int(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _permission_labels(permissions: int) -> list[str]:
|
||||
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
|
||||
return labels or ["none"]
|
||||
|
||||
|
||||
def _role_label(permissions: int) -> str:
|
||||
if permissions & 2:
|
||||
return "admin"
|
||||
if permissions & (4 | 8 | 16):
|
||||
return "manager"
|
||||
if permissions & (32 | 64 | 128):
|
||||
return "requester"
|
||||
return "user"
|
||||
|
||||
|
||||
def _account_type(user_type: Any) -> str:
|
||||
return _USER_TYPES.get(_safe_int(user_type), "unknown")
|
||||
|
||||
|
||||
def _merge_users(
|
||||
jellyfin_users: list[dict[str, Any]],
|
||||
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
|
||||
jellyseerr_users: list[dict[str, Any]] | None,
|
||||
jellyseerr_client: JellyseerrClient | None,
|
||||
) -> dict[str, Any]:
|
||||
def _normalize(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
def _looks_like_email(value: Any) -> bool:
|
||||
text = str(value or "").strip()
|
||||
return bool(text and "@" in text and " " not in text)
|
||||
|
||||
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
|
||||
for source, value in candidates:
|
||||
if _looks_like_email(value):
|
||||
return source, str(value).strip()
|
||||
return "", ""
|
||||
|
||||
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
|
||||
for source, value in candidates:
|
||||
text = str(value or "").strip()
|
||||
if text:
|
||||
return source, text
|
||||
return "", ""
|
||||
|
||||
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
|
||||
return ", ".join(
|
||||
[
|
||||
f"name={name_source or 'none'}",
|
||||
f"email={email_source or 'none'}",
|
||||
f"avatar={avatar_source or 'none'}",
|
||||
f"access={access_source or 'none'}",
|
||||
]
|
||||
)
|
||||
|
||||
def _lookup_keys(item: dict[str, Any]) -> list[str]:
|
||||
return [
|
||||
_normalize(item.get("id")),
|
||||
_normalize(item.get("Id")),
|
||||
_normalize(item.get("userId")),
|
||||
_normalize(item.get("user_id")),
|
||||
_normalize(item.get("jellyfinUserId")),
|
||||
_normalize(item.get("jellyfin_user_id")),
|
||||
_normalize(item.get("jellyfinUsername")),
|
||||
_normalize(item.get("jellyfin_username")),
|
||||
_normalize(item.get("username")),
|
||||
_normalize(item.get("displayName")),
|
||||
_normalize(item.get("display_name")),
|
||||
]
|
||||
|
||||
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
|
||||
for item in jellyseerr_jellyfin_users or []:
|
||||
for key in (
|
||||
item.get("id"),
|
||||
item.get("Id"),
|
||||
item.get("userId"),
|
||||
item.get("user_id"),
|
||||
item.get("jellyfinUserId"),
|
||||
item.get("jellyfin_user_id"),
|
||||
):
|
||||
normalized = _normalize(key)
|
||||
if normalized:
|
||||
linked_by_jellyfin_id[normalized] = item
|
||||
|
||||
seerr_by_key: dict[str, dict[str, Any]] = {}
|
||||
for item in jellyseerr_users or []:
|
||||
for key in _lookup_keys(item):
|
||||
if key:
|
||||
seerr_by_key[key] = item
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
enriched_count = 0
|
||||
for user in jellyfin_users:
|
||||
jellyfin_id = str(user.get("Id") or user.get("id") or "")
|
||||
jellyfin_name = str(user.get("Name") or user.get("name") or "")
|
||||
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
|
||||
|
||||
seerr_user = None
|
||||
for candidate in [
|
||||
jellyfin_name,
|
||||
(jf_link or {}).get("jellyfinUsername"),
|
||||
(jf_link or {}).get("jellyfin_username"),
|
||||
(jf_link or {}).get("username"),
|
||||
(jf_link or {}).get("displayName"),
|
||||
(jf_link or {}).get("display_name"),
|
||||
]:
|
||||
seerr_user = seerr_by_key.get(_normalize(candidate))
|
||||
if seerr_user:
|
||||
break
|
||||
|
||||
email_source, email = _pick_source_and_value(
|
||||
[
|
||||
("jellyseerr:user", (seerr_user or {}).get("email")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
|
||||
]
|
||||
)
|
||||
avatar_source, avatar = _first_value(
|
||||
[
|
||||
("jellyseerr:user", (seerr_user or {}).get("avatar")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
|
||||
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
|
||||
]
|
||||
)
|
||||
if avatar and jellyseerr_client:
|
||||
avatar = jellyseerr_client.absolute_url(avatar)
|
||||
|
||||
permissions = _safe_int((seerr_user or {}).get("permissions"))
|
||||
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
|
||||
role = _role_label(permissions)
|
||||
access_source = "jellyseerr:user" if seerr_user else ""
|
||||
name_source = "jellyfin"
|
||||
summary = _source_summary(name_source, email_source, avatar_source, access_source)
|
||||
|
||||
if seerr_user or jf_link:
|
||||
enriched_count += 1
|
||||
|
||||
items.append(
|
||||
{
|
||||
"jellyfin_id": jellyfin_id,
|
||||
"username": jellyfin_name,
|
||||
"display_name": jellyfin_name,
|
||||
"email": email,
|
||||
"email_source": email_source,
|
||||
"avatar": avatar,
|
||||
"avatar_source": avatar_source,
|
||||
"contactable": bool(email),
|
||||
"source": summary,
|
||||
"source_summary": summary,
|
||||
"name_source": name_source,
|
||||
"access_source": access_source,
|
||||
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
|
||||
or None,
|
||||
"jellyseerr_username": str(
|
||||
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
|
||||
),
|
||||
"user_type": user_type or None,
|
||||
"user_type_label": _account_type(user_type),
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
"permissions_label": ", ".join(_permission_labels(permissions)),
|
||||
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
|
||||
len(jellyfin_users),
|
||||
len(jellyseerr_jellyfin_users or []),
|
||||
len(jellyseerr_users or []),
|
||||
enriched_count,
|
||||
)
|
||||
return {
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
"jellyseerr_configured": jellyseerr_client is not None,
|
||||
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
|
||||
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
|
||||
"jellyseerr_user_count": len(jellyseerr_users or []),
|
||||
"enriched_count": enriched_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_users(
|
||||
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
|
||||
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
|
||||
) -> dict[str, Any]:
|
||||
"""Return the known users, enriched with Jellyseerr data when available."""
|
||||
jellyfin_users = jellyfin.users()
|
||||
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
|
||||
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
|
||||
jellyseerr_users: list[dict[str, Any]] | None = None
|
||||
jellyseerr_error = ""
|
||||
if jellyseerr:
|
||||
try:
|
||||
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
|
||||
except Exception as exc: # pragma: no cover - network fallback
|
||||
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
|
||||
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
|
||||
try:
|
||||
jellyseerr_users = jellyseerr.users()
|
||||
except Exception as exc: # pragma: no cover - network fallback
|
||||
logger.exception("Jellyseerr user list fetch failed")
|
||||
jellyseerr_error = (
|
||||
f"{jellyseerr_error}; " if jellyseerr_error else ""
|
||||
) + f"Jellyseerr user list fetch failed: {exc}"
|
||||
|
||||
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
|
||||
result["jellyseerr_error"] = jellyseerr_error
|
||||
logger.info(
|
||||
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
|
||||
result["total"],
|
||||
result["jellyseerr_configured"],
|
||||
result["jellyseerr_available"],
|
||||
result["enriched_count"],
|
||||
bool(jellyseerr_error),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/message/status")
|
||||
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
|
||||
"""Return the current background email queue status."""
|
||||
return mail_queue.status()
|
||||
|
||||
|
||||
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
|
||||
async def post_user_message(
|
||||
recipient_ids: str = Form(...),
|
||||
subject: str = Form(...),
|
||||
html_body: str = Form(""),
|
||||
text_body: str = Form(""),
|
||||
attachments: list[UploadFile] | None = File(default=None),
|
||||
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
|
||||
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
|
||||
mail_queue=Depends(get_mail_queue),
|
||||
) -> dict[str, Any]:
|
||||
"""Queue a single email to the selected users without blocking the API."""
|
||||
try:
|
||||
requested_ids = json.loads(recipient_ids)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(requested_ids, list):
|
||||
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
|
||||
|
||||
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
|
||||
if not cleaned_ids:
|
||||
raise HTTPException(status_code=400, detail="At least one recipient is required")
|
||||
|
||||
subject = subject.strip()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=400, detail="Subject is required")
|
||||
|
||||
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
|
||||
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
|
||||
|
||||
recipients: list[str] = []
|
||||
recipient_labels: list[str] = []
|
||||
skipped: list[dict[str, str]] = []
|
||||
for user_id in cleaned_ids:
|
||||
item = users_by_id.get(user_id)
|
||||
if not item:
|
||||
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
|
||||
continue
|
||||
email = str(item.get("email") or "").strip()
|
||||
if not email:
|
||||
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
|
||||
continue
|
||||
recipients.append(email)
|
||||
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
|
||||
|
||||
if not recipients:
|
||||
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
|
||||
|
||||
settings = get_settings()
|
||||
validate_smtp_settings(settings)
|
||||
|
||||
queue_status = mail_queue.status()
|
||||
if not queue_status["worker_running"]:
|
||||
raise HTTPException(status_code=503, detail="Email queue worker is not running")
|
||||
|
||||
attachment_payloads: list[EmailAttachment] = []
|
||||
for upload in attachments or []:
|
||||
data = await upload.read()
|
||||
if not data:
|
||||
continue
|
||||
attachment_payloads.append(
|
||||
EmailAttachment(
|
||||
filename=upload.filename or "attachment",
|
||||
content_type=upload.content_type or "application/octet-stream",
|
||||
data=data,
|
||||
)
|
||||
)
|
||||
|
||||
request_id = mail_queue.enqueue(
|
||||
settings=settings,
|
||||
recipients=recipients,
|
||||
subject=subject,
|
||||
html_body=html_body,
|
||||
text_body=text_body,
|
||||
attachments=attachment_payloads,
|
||||
)
|
||||
from_address = (
|
||||
str(getattr(settings, "smtp_from_address", "") or "").strip()
|
||||
or str(getattr(settings, "smtp_username", "") or "").strip()
|
||||
)
|
||||
logger.info(
|
||||
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
|
||||
request_id,
|
||||
subject,
|
||||
len(recipients),
|
||||
len(attachment_payloads),
|
||||
len(skipped),
|
||||
)
|
||||
return {
|
||||
"status": "queued",
|
||||
"request_id": request_id,
|
||||
"from_address": from_address,
|
||||
"recipient_count": len(recipients),
|
||||
"attachment_count": len(attachment_payloads),
|
||||
"subject": subject,
|
||||
"recipient_labels": recipient_labels,
|
||||
"skipped": skipped,
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
"""REST API for dashboard widget instances and registry metadata."""
|
||||
"""REST API for dashboard widget instances.
|
||||
|
||||
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
|
||||
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
|
||||
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
@@ -7,76 +14,107 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||
from media_library_viewer_api.models.widgets import (
|
||||
BuiltinWidgetKindInfo,
|
||||
WidgetDataResponse,
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.registry import (
|
||||
get_widget_info,
|
||||
list_source_types,
|
||||
list_widget_types,
|
||||
validate_config,
|
||||
from media_library_viewer_api.widgets.builtin import (
|
||||
BUILTIN_WIDGET_KINDS,
|
||||
is_builtin_kind,
|
||||
validate_builtin_config,
|
||||
)
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
build_service_record,
|
||||
get_builtin_adapter,
|
||||
get_service_adapter,
|
||||
)
|
||||
from media_library_viewer_api.widgets.sources import get_source_adapter
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
|
||||
"""Validate widget_kind + config against the service definition or built-ins."""
|
||||
if body.service_id:
|
||||
service = store.get_service(body.service_id)
|
||||
if not service:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Service {body.service_id} not found",
|
||||
)
|
||||
definition = get_service_definition(service["service_type"])
|
||||
if definition is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown service type: {service['service_type']}",
|
||||
)
|
||||
widget_kind = definition.widget_kind(body.widget_kind)
|
||||
if widget_kind is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
|
||||
)
|
||||
if widget_kind.config_model is not None:
|
||||
try:
|
||||
validate_config(widget_kind.config_model, body.config)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid widget config: {exc}",
|
||||
) from exc
|
||||
else:
|
||||
if not is_builtin_kind(body.widget_kind):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
|
||||
),
|
||||
)
|
||||
try:
|
||||
validate_builtin_config(body.widget_kind, body.config)
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Invalid widget config: {exc}",
|
||||
) from exc
|
||||
|
||||
info = WIDGET_REGISTRY.get(widget_type)
|
||||
if not info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unknown widget type: {widget_type}",
|
||||
|
||||
@router.get("/builtin")
|
||||
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
||||
"""Return metadata for service-less built-in widget kinds."""
|
||||
return [
|
||||
BuiltinWidgetKindInfo(
|
||||
kind=wk.kind,
|
||||
name=wk.name,
|
||||
description=wk.description,
|
||||
config_schema=wk.config_schema,
|
||||
default_config=wk.default_config,
|
||||
refresh_interval_ms=wk.refresh_interval_ms,
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
def _validate_widget_input(body: WidgetInstanceInput) -> None:
|
||||
"""Validate widget_type/addon_id match and config schema."""
|
||||
info = _registry_for_type(body.widget_type)
|
||||
expected_addon = info["addon_id"]
|
||||
if body.addon_id != expected_addon:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=(
|
||||
f"Widget type '{body.widget_type}' belongs to addon "
|
||||
f"'{expected_addon}', not '{body.addon_id}'"
|
||||
),
|
||||
)
|
||||
try:
|
||||
validate_config(body.widget_type, body.config)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=str(exc),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
def list_sources() -> list[str]:
|
||||
"""Return all registered widget source types."""
|
||||
return list_source_types()
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def list_types() -> list[dict[str, Any]]:
|
||||
"""Return metadata for all registered widget types."""
|
||||
return [info.model_dump() for info in list_widget_types()]
|
||||
for wk in BUILTIN_WIDGET_KINDS.values()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
service_id: str | None = None,
|
||||
scope: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all persisted widget instances."""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
||||
"""Return widget instances, optionally filtered.
|
||||
|
||||
- ``?service_id=X``: only widgets for service X.
|
||||
- ``?scope=dashboard``: only widgets with NULL service_id.
|
||||
- ``?scope=service``: only widgets with a non-null service_id.
|
||||
- No params: all widgets (backward-compatible).
|
||||
"""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
@@ -85,7 +123,7 @@ def create_instance(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new widget instance."""
|
||||
_validate_widget_input(body)
|
||||
_validate_widget_input(body, store)
|
||||
widget = store.upsert_widget(body.model_dump())
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
@@ -105,7 +143,7 @@ def update_instance(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="ID in path does not match ID in body",
|
||||
)
|
||||
_validate_widget_input(body)
|
||||
_validate_widget_input(body, store)
|
||||
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
@@ -133,31 +171,44 @@ async def fetch_data(
|
||||
if not widget:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
|
||||
widget_type = widget["widget_type"]
|
||||
info = get_widget_info(widget_type)
|
||||
if info is None:
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
widget_type=widget_type,
|
||||
data=None,
|
||||
error=f"Unknown widget type: {widget_type}",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
service_id = widget.get("service_id")
|
||||
widget_kind = widget.get("widget_kind") or ""
|
||||
|
||||
adapter = get_source_adapter(info.source_type)
|
||||
if adapter is None:
|
||||
# Defensive: registry should prevent this, but return a safe error.
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
widget_type=widget_type,
|
||||
data=None,
|
||||
error=f"No adapter registered for source type: {info.source_type}",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
service: Any = None
|
||||
if service_id:
|
||||
service_row = store.get_service(service_id)
|
||||
if not service_row:
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
error=f"Service {service_id} not found",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
if not service_row.get("enabled", True):
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
error="Service is disabled",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
adapter = get_service_adapter(service_row["service_type"])
|
||||
if adapter is None:
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
error=f"No adapter for service type {service_row['service_type']}",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
service = build_service_record(store, service_row)
|
||||
else:
|
||||
adapter = get_builtin_adapter(widget_kind)
|
||||
if adapter is None:
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
error=f"Unknown built-in widget kind: {widget_kind}",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
|
||||
try:
|
||||
data = await adapter.fetch(widget["config"])
|
||||
except Exception as exc:
|
||||
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -166,7 +217,6 @@ async def fetch_data(
|
||||
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
widget_type=widget_type,
|
||||
data=data if "error" not in data else None,
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# backend/src/media_library_viewer_api/services (index)
|
||||
dir: backend/src/media_library_viewer_api/services
|
||||
|
||||
## role
|
||||
Backend service layer providing business logic for media library management, backup monitoring, email notifications, SSH task execution, encryption, and persistent settings storage.
|
||||
## parent
|
||||
index: backend/src/media_library_viewer_api/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/.pi-map.md
|
||||
## children
|
||||
-
|
||||
## files
|
||||
- __init__.py
|
||||
- backup_alert_engine.py
|
||||
- backup_poller.py
|
||||
- db_maintenance.py
|
||||
- known_hosts.py
|
||||
- mail_queue.py
|
||||
- mailer.py
|
||||
- mailer_impl.py
|
||||
- media_index.py
|
||||
- media_index_impl.py
|
||||
- secrets.py
|
||||
- settings_store.py
|
||||
- targets.py
|
||||
- task_runner.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/services/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/services/.pi-map.md
|
||||
## workflows
|
||||
- change services behavior
|
||||
read: __init__.py, backup_alert_engine.py, backup_poller.py
|
||||
## dirty
|
||||
-
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,93 @@
|
||||
"""Encryption-at-rest for service secrets.
|
||||
|
||||
Service API keys / tokens are stored encrypted in the ``services.secrets_json``
|
||||
column. Encryption uses Fernet (symmetric authenticated encryption) with a single
|
||||
master key provided via the ``MANAGE_ENCRYPTION_KEY`` environment variable.
|
||||
|
||||
* The key **must** be a urlsafe base64-encoded 32-byte value (Fernet format).
|
||||
* The key is **always required** — there is no development fallback, so secrets
|
||||
are never accidentally stored in plaintext.
|
||||
* Secrets are encrypted field-by-field; the ``"which secrets are set"`` metadata
|
||||
can be derived from the ciphertext blob without decrypting.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
ENCRYPTION_KEY_ENV = "MANAGE_ENCRYPTION_KEY"
|
||||
|
||||
|
||||
class EncryptionKeyError(RuntimeError):
|
||||
"""Raised when the encryption key is missing or invalid."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_encryption_key() -> bytes:
|
||||
"""Return the raw Fernet key, or raise if missing/invalid.
|
||||
|
||||
The result is cached for the process lifetime. Tests should call
|
||||
:func:`reset_encryption_key_cache` after changing the environment.
|
||||
"""
|
||||
raw = os.environ.get(ENCRYPTION_KEY_ENV)
|
||||
if not raw:
|
||||
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} is required to store service secrets")
|
||||
key = raw.strip().encode()
|
||||
try:
|
||||
Fernet(key)
|
||||
except (ValueError, TypeError) as exc: # pragma: no cover - validated by tests
|
||||
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key") from exc
|
||||
return key
|
||||
|
||||
|
||||
def reset_encryption_key_cache() -> None:
|
||||
"""Drop the cached encryption key (used by tests that swap keys)."""
|
||||
get_encryption_key.cache_clear()
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
return Fernet(get_encryption_key())
|
||||
|
||||
|
||||
def encrypt_value(plaintext: str) -> str:
|
||||
"""Encrypt a single secret value and return the ciphertext string."""
|
||||
return _fernet().encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_value(ciphertext: str) -> str:
|
||||
"""Decrypt a single ciphertext value."""
|
||||
try:
|
||||
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise EncryptionKeyError("Service secret could not be decrypted") from exc
|
||||
|
||||
|
||||
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||
"""Encrypt every provided secret value."""
|
||||
fernet = _fernet()
|
||||
return {key: fernet.encrypt(value.encode()).decode() for key, value in values.items()}
|
||||
|
||||
|
||||
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]:
|
||||
"""Decrypt every secret value in a blob."""
|
||||
fernet = _fernet()
|
||||
result: dict[str, str] = {}
|
||||
for key, ciphertext in blob.items():
|
||||
try:
|
||||
result[key] = fernet.decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise EncryptionKeyError(f"Service secret '{key}' could not be decrypted") from exc
|
||||
return result
|
||||
|
||||
|
||||
def generate_development_key() -> str:
|
||||
"""Return a freshly generated Fernet key (helper for operators/docs)."""
|
||||
return Fernet.generate_key().decode()
|
||||
|
||||
|
||||
def validate_encryption_key() -> None:
|
||||
"""Eagerly validate that the encryption key is present and well-formed."""
|
||||
get_encryption_key() # raises EncryptionKeyError on failure
|
||||
@@ -8,6 +8,7 @@ in the same UI.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
import uuid
|
||||
@@ -17,16 +18,16 @@ from typing import Any
|
||||
|
||||
import paramiko
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
DEFAULT_SERVICES = ["monitoring", "files", "jellyfin"]
|
||||
DEFAULT_SERVICES = ["monitoring", "files"]
|
||||
|
||||
|
||||
def _default_local_machine() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return {
|
||||
"id": LOCAL_MACHINE_ID,
|
||||
"name": "This machine",
|
||||
@@ -42,13 +43,6 @@ def _default_local_machine() -> dict[str, Any]:
|
||||
"ssh_private_key": "",
|
||||
"ssh_private_key_passphrase": "",
|
||||
"password": "",
|
||||
"media_root": settings.media_root,
|
||||
"path_prefix": settings.path_prefix,
|
||||
"jellyfin_url": "",
|
||||
"jellyfin_user_id": "",
|
||||
"jellyfin_api_key": "",
|
||||
"jellyseerr_url": "",
|
||||
"jellyseerr_api_key": "",
|
||||
"node_exporter_enabled": False,
|
||||
"node_exporter_port": 9100,
|
||||
"node_exporter_scrape_host": "",
|
||||
@@ -118,7 +112,7 @@ class SettingsStore:
|
||||
task_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
default_machine_id TEXT NOT NULL,
|
||||
default_service_id TEXT NOT NULL,
|
||||
notes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
@@ -126,28 +120,14 @@ class SettingsStore:
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_saved_tasks_name ON saved_tasks(name)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS saved_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
task_name TEXT NOT NULL,
|
||||
machine_id TEXT NOT NULL,
|
||||
machine_name TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
stdout_tail TEXT NOT NULL,
|
||||
stderr_tail TEXT NOT NULL,
|
||||
error TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_saved_task_runs_task_time ON saved_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
# saved_tasks.default_machine_id → default_service_id (saved tasks now
|
||||
# target ssh_tasks service instances). Migrate existing columns.
|
||||
saved_tasks_cols = {row[1] for row in conn.execute("PRAGMA table_info(saved_tasks)").fetchall()}
|
||||
if "default_service_id" not in saved_tasks_cols and "default_machine_id" in saved_tasks_cols:
|
||||
conn.execute("ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id")
|
||||
# Run history for saved tasks now lives in service_task_runs; the
|
||||
# legacy machine-based table is dropped.
|
||||
conn.execute("DROP TABLE IF EXISTS saved_task_runs")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboard_shortcuts (
|
||||
@@ -179,9 +159,12 @@ class SettingsStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)")
|
||||
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||
if "service_id" not in widget_cols:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||
if "widget_kind" not in widget_cols:
|
||||
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -192,6 +175,9 @@ class SettingsStore:
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
|
||||
if "service_id" not in backup_job_cols:
|
||||
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS backup_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -226,6 +212,57 @@ class SettingsStore:
|
||||
""")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS services (
|
||||
id TEXT PRIMARY KEY,
|
||||
service_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
secrets_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS service_task_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
service_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_status INTEGER,
|
||||
duration_ms INTEGER,
|
||||
stdout_tail TEXT NOT NULL DEFAULT '',
|
||||
stderr_tail TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_service "
|
||||
"ON service_task_runs(service_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS named_dashboards (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
@@ -263,13 +300,6 @@ class SettingsStore:
|
||||
"ssh_private_key_set": bool(data.get("ssh_private_key")),
|
||||
"ssh_private_key_passphrase_set": bool(data.get("ssh_private_key_passphrase")),
|
||||
"password_set": bool(data.get("password")),
|
||||
"media_root": data.get("media_root", ""),
|
||||
"path_prefix": data.get("path_prefix", ""),
|
||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
||||
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
|
||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
||||
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
|
||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
@@ -317,19 +347,6 @@ class SettingsStore:
|
||||
if password in (None, ""):
|
||||
password = (current or {}).get("password", "")
|
||||
password = str(password or "")
|
||||
media_root = _current_str("media_root")
|
||||
path_prefix = _current_str("path_prefix")
|
||||
jellyfin_url = _current_str("jellyfin_url")
|
||||
jellyfin_user_id = _current_str("jellyfin_user_id")
|
||||
jellyfin_api_key = payload.get("jellyfin_api_key")
|
||||
if jellyfin_api_key in (None, ""):
|
||||
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
|
||||
jellyfin_api_key = str(jellyfin_api_key or "")
|
||||
jellyseerr_url = _current_str("jellyseerr_url")
|
||||
jellyseerr_api_key = payload.get("jellyseerr_api_key")
|
||||
if jellyseerr_api_key in (None, ""):
|
||||
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
|
||||
jellyseerr_api_key = str(jellyseerr_api_key or "")
|
||||
node_exporter_enabled = bool(
|
||||
payload.get("node_exporter_enabled")
|
||||
if payload.get("node_exporter_enabled") is not None
|
||||
@@ -359,13 +376,6 @@ class SettingsStore:
|
||||
"ssh_private_key": ssh_private_key,
|
||||
"ssh_private_key_passphrase": ssh_private_key_passphrase,
|
||||
"password": password,
|
||||
"media_root": media_root,
|
||||
"path_prefix": path_prefix,
|
||||
"jellyfin_url": jellyfin_url,
|
||||
"jellyfin_user_id": jellyfin_user_id,
|
||||
"jellyfin_api_key": jellyfin_api_key,
|
||||
"jellyseerr_url": jellyseerr_url,
|
||||
"jellyseerr_api_key": jellyseerr_api_key,
|
||||
"node_exporter_enabled": node_exporter_enabled,
|
||||
"node_exporter_port": node_exporter_port,
|
||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||
@@ -387,13 +397,6 @@ class SettingsStore:
|
||||
"ssh_private_key": "",
|
||||
"ssh_private_key_passphrase": "",
|
||||
"password": "",
|
||||
"media_root": machine["media_root"],
|
||||
"path_prefix": machine["path_prefix"],
|
||||
"jellyfin_url": machine["jellyfin_url"],
|
||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
||||
"jellyseerr_url": machine["jellyseerr_url"],
|
||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||
"node_exporter_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
@@ -417,39 +420,13 @@ class SettingsStore:
|
||||
)
|
||||
|
||||
def _seed_dashboard_widgets(self) -> None:
|
||||
"""Seed default dashboard widgets only when the table is empty."""
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
"""Default widget seeding was removed.
|
||||
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
|
||||
if row and int(row[0]) > 0:
|
||||
return
|
||||
defaults = [
|
||||
{
|
||||
"id": "jellyfin-activity-default",
|
||||
"addon_id": "core",
|
||||
"widget_type": "jellyfin",
|
||||
"title": "Jellyfin activity",
|
||||
"config": {"machine_id": ""},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
},
|
||||
{
|
||||
"id": "backups-summary-default",
|
||||
"addon_id": "backups",
|
||||
"widget_type": "backups",
|
||||
"title": "Backups",
|
||||
"config": {},
|
||||
"enabled": True,
|
||||
"sort_order": 1,
|
||||
},
|
||||
]
|
||||
for widget in defaults:
|
||||
info = WIDGET_REGISTRY.get(widget["widget_type"])
|
||||
if not info or info["addon_id"] != widget["addon_id"]:
|
||||
continue
|
||||
self.upsert_widget(widget)
|
||||
Widgets are now service-bound (or built-in). A fresh install starts with
|
||||
no widgets; the user configures services and adds widgets from the UI.
|
||||
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
|
||||
"""
|
||||
return None
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
@@ -457,7 +434,75 @@ class SettingsStore:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
self._seed_dashboard_widgets()
|
||||
self._migrate_jellyseerr_into_jellyfin()
|
||||
|
||||
def _migrate_jellyseerr_into_jellyfin(self) -> None:
|
||||
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
|
||||
|
||||
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
|
||||
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
|
||||
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
|
||||
paired -> drop with a logged warning.
|
||||
"""
|
||||
from media_library_viewer_api.services.secrets import decrypt_value
|
||||
|
||||
self.init_schema()
|
||||
jellyseerr_rows: list[sqlite3.Row] = []
|
||||
with self.connect() as conn:
|
||||
jellyseerr_rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
|
||||
).fetchall()
|
||||
if not jellyseerr_rows:
|
||||
return
|
||||
|
||||
jellyfin_rows = self.list_services("jellyfin")
|
||||
for js_row in jellyseerr_rows:
|
||||
js_config = json.loads(js_row["config_json"] or "{}")
|
||||
js_secrets = json.loads(js_row["secrets_json"] or "{}")
|
||||
js_url = str(js_config.get("base_url", "")).strip()
|
||||
js_api_key = str(js_secrets.get("api_key", "")).strip()
|
||||
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
|
||||
if js_api_key:
|
||||
try:
|
||||
js_api_key = decrypt_value(js_api_key)
|
||||
except Exception:
|
||||
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
|
||||
js_api_key = ""
|
||||
js_name = js_row["name"]
|
||||
|
||||
target = None
|
||||
if len(jellyfin_rows) == 1:
|
||||
target = jellyfin_rows[0]
|
||||
elif len(jellyfin_rows) > 1:
|
||||
for jf in jellyfin_rows:
|
||||
if not str(jf["config"].get("jellyseerr_url", "")).strip():
|
||||
target = jf
|
||||
break
|
||||
|
||||
if target:
|
||||
merged_config = dict(target["config"])
|
||||
merged_config["jellyseerr_url"] = js_url
|
||||
merged_config["jellyseerr_api_key"] = js_api_key
|
||||
self.upsert_service(
|
||||
{
|
||||
"id": target["id"],
|
||||
"service_type": "jellyfin",
|
||||
"name": target["name"],
|
||||
"config": merged_config,
|
||||
"enabled": target["enabled"],
|
||||
},
|
||||
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
|
||||
)
|
||||
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
|
||||
else:
|
||||
logger.warning(
|
||||
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
|
||||
js_name,
|
||||
)
|
||||
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
|
||||
conn.commit()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
@@ -504,13 +549,6 @@ class SettingsStore:
|
||||
"ssh_private_key": data.get("ssh_private_key", ""),
|
||||
"ssh_private_key_passphrase": data.get("ssh_private_key_passphrase", ""),
|
||||
"password": data.get("password", ""),
|
||||
"media_root": data.get("media_root", ""),
|
||||
"path_prefix": data.get("path_prefix", ""),
|
||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
||||
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
|
||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
||||
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
|
||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
@@ -548,13 +586,6 @@ class SettingsStore:
|
||||
"ssh_private_key": machine["ssh_private_key"],
|
||||
"ssh_private_key_passphrase": machine["ssh_private_key_passphrase"],
|
||||
"password": machine["password"],
|
||||
"media_root": machine["media_root"],
|
||||
"path_prefix": machine["path_prefix"],
|
||||
"jellyfin_url": machine["jellyfin_url"],
|
||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
||||
"jellyseerr_url": machine["jellyseerr_url"],
|
||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||
"node_exporter_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
@@ -741,7 +772,7 @@ class SettingsStore:
|
||||
"task_type": row["task_type"],
|
||||
"content": row["content"],
|
||||
"enabled": bool(row["enabled"]),
|
||||
"default_machine_id": row["default_machine_id"],
|
||||
"default_service_id": row["default_service_id"],
|
||||
"notes": row["notes"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
@@ -758,10 +789,10 @@ class SettingsStore:
|
||||
payload.get("content") if payload.get("content") is not None else (current or {}).get("content", "") or ""
|
||||
)
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
default_machine_id = str(
|
||||
payload.get("default_machine_id")
|
||||
if payload.get("default_machine_id") is not None
|
||||
else (current or {}).get("default_machine_id", "") or ""
|
||||
default_service_id = str(
|
||||
payload.get("default_service_id")
|
||||
if payload.get("default_service_id") is not None
|
||||
else (current or {}).get("default_service_id", "") or ""
|
||||
).strip()
|
||||
notes = str(
|
||||
payload.get("notes") if payload.get("notes") is not None else (current or {}).get("notes", "") or ""
|
||||
@@ -772,7 +803,7 @@ class SettingsStore:
|
||||
"task_type": task_type,
|
||||
"content": content,
|
||||
"enabled": enabled,
|
||||
"default_machine_id": default_machine_id,
|
||||
"default_service_id": default_service_id,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
@@ -800,7 +831,7 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_tasks (
|
||||
id, name, task_type, content, enabled, default_machine_id,
|
||||
id, name, task_type, content, enabled, default_service_id,
|
||||
notes, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -809,7 +840,7 @@ class SettingsStore:
|
||||
task_type = excluded.task_type,
|
||||
content = excluded.content,
|
||||
enabled = excluded.enabled,
|
||||
default_machine_id = excluded.default_machine_id,
|
||||
default_service_id = excluded.default_service_id,
|
||||
notes = excluded.notes,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
@@ -819,7 +850,7 @@ class SettingsStore:
|
||||
task["task_type"],
|
||||
task["content"],
|
||||
1 if task["enabled"] else 0,
|
||||
task["default_machine_id"],
|
||||
task["default_service_id"],
|
||||
task["notes"],
|
||||
created_at,
|
||||
now,
|
||||
@@ -832,58 +863,6 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM saved_tasks WHERE id = ?", (task_id,))
|
||||
|
||||
def list_task_runs(self, task_id: str, *, limit: int = 10) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM saved_task_runs WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 50))),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def record_task_run(
|
||||
self,
|
||||
task: dict[str, Any],
|
||||
status: str,
|
||||
*,
|
||||
machine_id: str,
|
||||
machine_name: str,
|
||||
task_type: str,
|
||||
duration_ms: int,
|
||||
request_id: str = "",
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
self.init_schema()
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO saved_task_runs (
|
||||
id, task_id, task_name, machine_id, machine_name, task_type,
|
||||
status, created_at, duration_ms, request_id, stdout_tail,
|
||||
stderr_tail, error
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(task.get("id") or ""),
|
||||
str(task.get("name") or ""),
|
||||
machine_id,
|
||||
machine_name,
|
||||
task_type,
|
||||
status,
|
||||
now,
|
||||
duration_ms,
|
||||
request_id,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
error,
|
||||
),
|
||||
)
|
||||
|
||||
def _row_to_shortcut(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
target = json.loads(row["target_json"] or "{}")
|
||||
return {
|
||||
@@ -1001,6 +980,7 @@ class SettingsStore:
|
||||
"source": row["source"],
|
||||
"target": row["target"],
|
||||
"schedule_interval_seconds": row["schedule_interval_seconds"],
|
||||
"service_id": row["service_id"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
@@ -1019,12 +999,18 @@ class SettingsStore:
|
||||
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
|
||||
if schedule_interval_seconds is not None:
|
||||
schedule_interval_seconds = int(schedule_interval_seconds)
|
||||
service_id = str(
|
||||
payload.get("service_id")
|
||||
if payload.get("service_id") is not None
|
||||
else (current or {}).get("service_id", "") or ""
|
||||
).strip()
|
||||
return {
|
||||
"id": job_id,
|
||||
"name": name,
|
||||
"source": source,
|
||||
"target": target,
|
||||
"schedule_interval_seconds": schedule_interval_seconds,
|
||||
"service_id": service_id,
|
||||
}
|
||||
|
||||
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
|
||||
@@ -1042,17 +1028,19 @@ class SettingsStore:
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
source = excluded.source,
|
||||
target = excluded.target,
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds
|
||||
schedule_interval_seconds = excluded.schedule_interval_seconds,
|
||||
service_id = excluded.service_id
|
||||
""",
|
||||
(
|
||||
job["id"],
|
||||
@@ -1060,6 +1048,7 @@ class SettingsStore:
|
||||
job["source"],
|
||||
job["target"],
|
||||
job["schedule_interval_seconds"],
|
||||
job["service_id"],
|
||||
created_at,
|
||||
),
|
||||
)
|
||||
@@ -1363,16 +1352,18 @@ class SettingsStore:
|
||||
(key, value, now),
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dashboard widgets
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
keys = row.keys()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"addon_id": row["addon_id"],
|
||||
"widget_type": row["widget_type"],
|
||||
"service_id": row["service_id"] if "service_id" in keys else None,
|
||||
"widget_kind": row["widget_kind"] if "widget_kind" in keys else None,
|
||||
"title": row["title"],
|
||||
"config": json.loads(row["config_json"] or "{}"),
|
||||
"enabled": bool(row["enabled"]),
|
||||
@@ -1387,14 +1378,9 @@ class SettingsStore:
|
||||
widget_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_widget(widget_id) if widget_id else None
|
||||
widget_id = (
|
||||
str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip()
|
||||
or uuid.uuid4().hex[:12]
|
||||
)
|
||||
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
|
||||
widget_type = str(
|
||||
payload.get("widget_type") or (current or {}).get("widget_type", "")
|
||||
).strip()
|
||||
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
|
||||
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
|
||||
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
||||
config = payload.get("config", (current or {}).get("config", {}))
|
||||
if not isinstance(config, dict):
|
||||
@@ -1403,21 +1389,49 @@ class SettingsStore:
|
||||
_validate_config_keys(config)
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
||||
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
|
||||
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
|
||||
return {
|
||||
"id": widget_id,
|
||||
"addon_id": addon_id,
|
||||
"addon_id": "",
|
||||
"widget_type": widget_type,
|
||||
"service_id": service_id,
|
||||
"widget_kind": widget_kind,
|
||||
"title": title,
|
||||
"config": config,
|
||||
"enabled": enabled,
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
|
||||
def list_widgets(self) -> list[dict[str, Any]]:
|
||||
def list_widgets(
|
||||
self,
|
||||
service_id: str | None = None,
|
||||
*,
|
||||
scope: str | None = None,
|
||||
all_widgets: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List widget instances, optionally filtered.
|
||||
|
||||
- ``service_id=X``: only widgets for service X.
|
||||
- ``scope="dashboard"``: only widgets with NULL service_id.
|
||||
- ``scope="service"``: only widgets with a non-null service_id.
|
||||
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
|
||||
"""
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if service_id is not None:
|
||||
clauses.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
if scope == "dashboard":
|
||||
clauses.append("service_id IS NULL")
|
||||
elif scope == "service":
|
||||
clauses.append("service_id IS NOT NULL")
|
||||
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
|
||||
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
|
||||
params,
|
||||
).fetchall()
|
||||
return [self._row_to_widget(row) for row in rows]
|
||||
|
||||
@@ -1426,9 +1440,7 @@ class SettingsStore:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
|
||||
).fetchone()
|
||||
row = conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone()
|
||||
return self._row_to_widget(row) if row else None
|
||||
|
||||
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
||||
@@ -1444,13 +1456,15 @@ class SettingsStore:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dashboard_widgets (
|
||||
id, addon_id, widget_type, title, config_json, enabled,
|
||||
sort_order, created_at, updated_at
|
||||
id, addon_id, widget_type, service_id, widget_kind, title,
|
||||
config_json, enabled, sort_order, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
addon_id = excluded.addon_id,
|
||||
widget_type = excluded.widget_type,
|
||||
service_id = excluded.service_id,
|
||||
widget_kind = excluded.widget_kind,
|
||||
title = excluded.title,
|
||||
config_json = excluded.config_json,
|
||||
enabled = excluded.enabled,
|
||||
@@ -1461,6 +1475,8 @@ class SettingsStore:
|
||||
widget["id"],
|
||||
widget["addon_id"],
|
||||
widget["widget_type"],
|
||||
widget["service_id"],
|
||||
widget["widget_kind"],
|
||||
widget["title"],
|
||||
json.dumps(widget["config"]),
|
||||
1 if widget["enabled"] else 0,
|
||||
@@ -1476,6 +1492,311 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Service registry
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _row_to_service(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
secrets_blob = json.loads(row["secrets_json"] or "{}")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"service_type": row["service_type"],
|
||||
"name": row["name"],
|
||||
"config": json.loads(row["config_json"] or "{}"),
|
||||
"secrets": secrets_blob,
|
||||
"enabled": bool(row["enabled"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_services(self, service_type: str | None = None) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
if service_type:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM services WHERE service_type = ? ORDER BY name ASC",
|
||||
(service_type,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall()
|
||||
return [self._row_to_service(row) for row in rows]
|
||||
|
||||
def get_service(self, service_id: str) -> dict[str, Any] | None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone()
|
||||
return self._row_to_service(row) if row else None
|
||||
|
||||
def _normalize_service_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
service_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_service(service_id) if service_id else None
|
||||
service_id = str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
service_type = str(payload.get("service_type") or (current or {}).get("service_type", "")).strip()
|
||||
name = str(payload.get("name") or (current or {}).get("name", "") or "").strip()
|
||||
config = payload.get("config", (current or {}).get("config", {}))
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||
return {
|
||||
"id": service_id,
|
||||
"service_type": service_type,
|
||||
"name": name,
|
||||
"config": config,
|
||||
"enabled": enabled,
|
||||
}
|
||||
|
||||
def upsert_service(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
secret_values: dict[str, str] | None = None,
|
||||
service_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Insert or update a service instance.
|
||||
|
||||
``secret_values`` carries plaintext secrets to encrypt and store. A key
|
||||
absent from ``secret_values`` preserves the existing ciphertext; a key
|
||||
mapped to an empty string clears it.
|
||||
"""
|
||||
self.init_schema()
|
||||
service = self._normalize_service_payload(payload, service_id)
|
||||
now = int(time.time())
|
||||
|
||||
existing = self.get_service(service["id"])
|
||||
secrets_blob: dict[str, str]
|
||||
if existing is not None:
|
||||
secrets_blob = dict(existing["secrets"])
|
||||
else:
|
||||
secrets_blob = {}
|
||||
if secret_values:
|
||||
from media_library_viewer_api.services.secrets import encrypt_value
|
||||
|
||||
for key, value in secret_values.items():
|
||||
if value == "":
|
||||
secrets_blob.pop(key, None)
|
||||
else:
|
||||
secrets_blob[key] = encrypt_value(value)
|
||||
|
||||
with self.connect() as conn:
|
||||
created_at = int(existing["created_at"]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO services (
|
||||
id, service_type, name, config_json, secrets_json,
|
||||
enabled, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
service_type = excluded.service_type,
|
||||
name = excluded.name,
|
||||
config_json = excluded.config_json,
|
||||
secrets_json = excluded.secrets_json,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
service["id"],
|
||||
service["service_type"],
|
||||
service["name"],
|
||||
json.dumps(service["config"]),
|
||||
json.dumps(secrets_blob),
|
||||
1 if service["enabled"] else 0,
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_service(service["id"]) or service
|
||||
|
||||
def delete_service(self, service_id: str) -> None:
|
||||
"""Delete a service and cascade-delete widgets referencing it."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
# The service_id column on dashboard_widgets is added in a later
|
||||
# slice; only cascade when it is present.
|
||||
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||
if "service_id" in widget_cols:
|
||||
conn.execute(
|
||||
"DELETE FROM dashboard_widgets WHERE service_id = ?",
|
||||
(service_id,),
|
||||
)
|
||||
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||
|
||||
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Append a service task run history row."""
|
||||
self.init_schema()
|
||||
run_id = str(payload.get("id") or uuid.uuid4().hex[:12])
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO service_task_runs (
|
||||
id, task_id, service_id, status, exit_status, duration_ms,
|
||||
stdout_tail, stderr_tail, error, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
str(payload.get("task_id") or ""),
|
||||
str(payload.get("service_id") or ""),
|
||||
str(payload.get("status") or "error"),
|
||||
payload.get("exit_status"),
|
||||
payload.get("duration_ms"),
|
||||
str(payload.get("stdout_tail") or "")[:8000],
|
||||
str(payload.get("stderr_tail") or "")[:8000],
|
||||
str(payload.get("error") or "")[:1000],
|
||||
int(payload.get("created_at") or now),
|
||||
),
|
||||
)
|
||||
return {"id": run_id}
|
||||
|
||||
def list_service_task_runs(
|
||||
self,
|
||||
service_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if service_id:
|
||||
clauses.append("service_id = ?")
|
||||
params.append(service_id)
|
||||
if task_id:
|
||||
clauses.append("task_id = ?")
|
||||
params.append(task_id)
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
params.append(int(limit))
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": row["id"],
|
||||
"task_id": row["task_id"],
|
||||
"service_id": row["service_id"],
|
||||
"status": row["status"],
|
||||
"exit_status": row["exit_status"],
|
||||
"duration_ms": row["duration_ms"],
|
||||
"stdout_tail": row["stdout_tail"],
|
||||
"stderr_tail": row["stderr_tail"],
|
||||
"error": row["error"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Named dashboards
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _slugify(label: str) -> str:
|
||||
import re
|
||||
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
|
||||
return slug or "dashboard"
|
||||
|
||||
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
|
||||
self.init_schema()
|
||||
base = slug
|
||||
suffix = 1
|
||||
with self.connect() as conn:
|
||||
while True:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
|
||||
(slug, exclude_id or ""),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return slug
|
||||
suffix += 1
|
||||
slug = f"{base}-{suffix}"
|
||||
|
||||
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"label": row["label"],
|
||||
"slug": row["slug"],
|
||||
"sort_order": row["sort_order"],
|
||||
"payload": json.loads(row["payload_json"] or "{}"),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def list_dashboards(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
|
||||
).fetchall()
|
||||
return [self._row_to_dashboard(row) for row in rows]
|
||||
|
||||
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
|
||||
if not dashboard_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
|
||||
if not slug:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
|
||||
return self._row_to_dashboard(row) if row else None
|
||||
|
||||
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
|
||||
self.init_schema()
|
||||
current = self.get_dashboard(dashboard_id) if dashboard_id else None
|
||||
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
|
||||
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
|
||||
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
|
||||
slug = self._unique_slug(slug, exclude_id=dash_id)
|
||||
sort_order = payload.get("sort_order")
|
||||
if sort_order is None:
|
||||
sort_order = (current or {}).get("sort_order", 0)
|
||||
sort_order = int(sort_order)
|
||||
payload_data = payload.get("payload")
|
||||
if payload_data is None:
|
||||
payload_data = (current or {}).get("payload", {})
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
label = excluded.label,
|
||||
slug = excluded.slug,
|
||||
sort_order = excluded.sort_order,
|
||||
payload_json = excluded.payload_json,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
dash_id,
|
||||
label,
|
||||
slug,
|
||||
sort_order,
|
||||
json.dumps(payload_data),
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
|
||||
|
||||
def delete_dashboard(self, dashboard_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
|
||||
|
||||
|
||||
_store: SettingsStore | None = None
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"""Prometheus file-based service discovery target management.
|
||||
"""Prometheus Node Exporter target discovery.
|
||||
|
||||
The backend owns the list of remote Node Exporter targets so that operators can
|
||||
enable scraping per machine from the Manage UI. Prometheus reads the generated
|
||||
JSON file via `file_sd_configs`; this keeps Prometheus config static and pushes
|
||||
machine-specific changes into a file it can reload.
|
||||
enable scraping per machine from the Manage UI. The list is exposed over HTTP at
|
||||
``GET /api/monitoring/prometheus-targets`` and consumed by an external Prometheus
|
||||
via ``http_sd_configs`` (no shared volume required).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,10 +31,9 @@ def _scrape_address(machine: dict[str, Any]) -> str | None:
|
||||
|
||||
|
||||
def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||
"""Build a file-SD target list for all enabled SSH machines.
|
||||
"""Build an http-SD target list for all enabled SSH machines.
|
||||
|
||||
Local machines are excluded because the Compose-managed node-exporter
|
||||
service already covers the Docker host.
|
||||
Local machines are excluded because the Docker host is scraped directly.
|
||||
"""
|
||||
targets: list[dict[str, Any]] = []
|
||||
for machine in store.list_machines():
|
||||
@@ -60,18 +56,3 @@ def build_node_exporter_targets(store: SettingsStore) -> list[dict[str, Any]]:
|
||||
}
|
||||
)
|
||||
return targets
|
||||
|
||||
|
||||
def write_prometheus_targets(store: SettingsStore, file_sd_dir: Path | None = None) -> Path:
|
||||
"""Render and persist Prometheus file-SD targets.
|
||||
|
||||
Returns the path written so callers can log or expose it.
|
||||
"""
|
||||
settings = get_settings()
|
||||
file_sd_dir = file_sd_dir or Path(settings.prometheus_file_sd_dir)
|
||||
file_sd_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_path = file_sd_dir / "node_exporter_targets.json"
|
||||
targets = build_node_exporter_targets(store)
|
||||
file_path.write_text(json.dumps(targets, indent=2), encoding="utf-8")
|
||||
logger.info("Wrote %s node_exporter targets to %s", len(targets), file_path)
|
||||
return file_path
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Shared runner for saved tasks over SSH task services.
|
||||
|
||||
Both the Actions page (``routers/tasks.py``) and the SSH task widget
|
||||
(``widgets/sources.py``) run saved tasks against ``ssh_tasks`` service instances.
|
||||
This module is the single execution path: build the client from the service
|
||||
record, render the command, run it with the service timeout, append a
|
||||
``service_task_runs`` row, and return the result.
|
||||
|
||||
There is intentionally no local execution mode — tasks are SSH-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from media_library_viewer_api.widgets.sources import ServiceRecord
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRunResult:
|
||||
"""Outcome of a single saved-task run."""
|
||||
|
||||
exit_status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
duration_ms: int
|
||||
status: str # "success" | "failure" | "error"
|
||||
error: str = ""
|
||||
|
||||
|
||||
def build_ssh_client(store: SettingsStore, service: "ServiceRecord") -> RemoteSSHClient:
|
||||
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
|
||||
config = service.config
|
||||
host = str(config.get("host") or "").strip()
|
||||
username = str(config.get("username") or "").strip()
|
||||
if not host or not username:
|
||||
raise ValueError("SSH task service is missing host or username")
|
||||
|
||||
settings = get_settings()
|
||||
private_key = ""
|
||||
key_passphrase = ""
|
||||
ssh_key_id = str(config.get("ssh_key_id") or "").strip()
|
||||
if ssh_key_id:
|
||||
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||
if ssh_key:
|
||||
private_key = str(ssh_key.get("private_key") or "")
|
||||
key_passphrase = str(ssh_key.get("passphrase") or "")
|
||||
# Service-level passphrase secret takes precedence.
|
||||
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
|
||||
|
||||
return RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=int(config.get("port") or 22),
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=key_passphrase or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
timeout=int(config.get("timeout_seconds") or 30),
|
||||
)
|
||||
|
||||
|
||||
def _render_command(task: dict[str, Any]) -> str:
|
||||
"""Render a saved task into a shell command (shell or python3 -c)."""
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
return f"python3 -c {shlex.quote(command)}"
|
||||
if task_type == "shell":
|
||||
return command
|
||||
raise ValueError(f"Unknown task type: {task_type}")
|
||||
|
||||
|
||||
def run_saved_task(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
service: "ServiceRecord",
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
) -> TaskRunResult:
|
||||
"""Run a saved task on an ssh_tasks service instance and log the run.
|
||||
|
||||
The ``timeout`` defaults to the service's ``timeout_seconds`` config. The run
|
||||
is recorded in ``service_task_runs`` regardless of outcome (success, failure,
|
||||
error). Raises ``ValueError`` for an unsupported task type or an incomplete
|
||||
service config (propagated from ``build_ssh_client`` / ``_render_command``).
|
||||
"""
|
||||
timeout = int(timeout if timeout is not None else service.config.get("timeout_seconds") or 30)
|
||||
client = build_ssh_client(store, service)
|
||||
command = _render_command(task)
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
result = client.run(command, timeout=timeout)
|
||||
except Exception as exc:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
_record(store, task, service, "error", duration_ms=duration_ms, error=str(exc)[:1000])
|
||||
logger.exception("saved task run failed task_id=%s", task.get("id"))
|
||||
return TaskRunResult(
|
||||
exit_status=1,
|
||||
stdout="",
|
||||
stderr=str(exc),
|
||||
duration_ms=duration_ms,
|
||||
status="error",
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
status = "success" if result.exit_status == 0 else "failure"
|
||||
_record(
|
||||
store,
|
||||
task,
|
||||
service,
|
||||
status,
|
||||
exit_status=result.exit_status,
|
||||
duration_ms=duration_ms,
|
||||
stdout_tail=stdout,
|
||||
stderr_tail=stderr,
|
||||
error="" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
||||
)
|
||||
return TaskRunResult(
|
||||
exit_status=result.exit_status,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
duration_ms=duration_ms,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def _record(
|
||||
store: SettingsStore,
|
||||
task: dict[str, Any],
|
||||
service: "ServiceRecord",
|
||||
status: str,
|
||||
*,
|
||||
exit_status: int | None = None,
|
||||
duration_ms: int = 0,
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""Append a service_task_runs row for a saved-task run."""
|
||||
store.record_service_task_run(
|
||||
{
|
||||
"task_id": str(task.get("id") or ""),
|
||||
"service_id": service.id,
|
||||
"status": status,
|
||||
"exit_status": exit_status,
|
||||
"duration_ms": duration_ms,
|
||||
"stdout_tail": stdout_tail,
|
||||
"stderr_tail": stderr_tail,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
# backend/src/media_library_viewer_api/widgets (index)
|
||||
dir: backend/src/media_library_viewer_api/widgets
|
||||
|
||||
## role
|
||||
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
|
||||
## 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
|
||||
- builtin.py
|
||||
- sources.py
|
||||
## links
|
||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||
map: backend/src/media_library_viewer_api/widgets/.pi-map.md
|
||||
## workflows
|
||||
- change widgets behavior
|
||||
read: __init__.py, builtin.py, sources.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,29 @@
|
||||
# backend/src/media_library_viewer_api/widgets
|
||||
dir: backend/src/media_library_viewer_api/widgets
|
||||
|
||||
index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md
|
||||
|
||||
## role
|
||||
Provides widget definitions, schemas, and data source adapters for rendering configurable dashboard widgets from both built-in and external service data.
|
||||
## files
|
||||
- __init__.py | Marks the directory as a Python package for the widget subsystem.
|
||||
- builtin.py | Defines built-in widget kinds that don't require external services, providing their configurations, schemas, and validation. | exp: class:StaticConfig, func:get_builtin_widget_kind(kind: str) → WidgetKind | None, call:BUILTIN_WIDGET_KINDS.get, func:is_builtin_kind(kind: str) → bool, func:builtin_widget_kind_models() → dict[str, type], call:Field, func:validate_builtin_config(kind: str, config: dict[str, Any]) → dict[str, Any], call:builtin_widget_kind_models, call:models.get, call:dict, call:model_cls.model_validate(config or {}).model_dump | dep: typing, media_library_viewer_api.integrations.base, pydantic
|
||||
- sources.py | Defines widget source adapters that fetch and transform data from various external services (Grafana, Prometheus, Jellyfin, etc.) into dashboard widget payloads. | exp: class:ServiceRecord, class:WidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], class:BackupsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:build_backup_dashboard_summary, call:summary.model_dump, call:logger.exception, class:StaticWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:config.get, class:GrafanaWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:config.get, call:logger.exception, class:PrometheusWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:logger.exception, class:AlertmanagerWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:service.secrets.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:isinstance, call:summarize_alerts, call:logger.exception, class:JellyfinWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:asyncio.wait_for, call:asyncio.to_thread, call:_map_sessions_to_activity_rows, call:logger.exception, class:SshTaskWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:config.get, call:store.get_task, call:task.get, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:_record_timeout, call:logger.exception, func:build_service_record(store: SettingsStore, service_row: dict[str, Any]) → ServiceRecord, call:ServiceRecord, call:service_row.get, call:decrypt_secrets, call:bool, func:_record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) → None, call:get_settings_store, call:store.record_service_task_run, call:str, call:config.get, call:logger.exception, func:get_service_adapter(service_type: str) → WidgetSource | None, call:SERVICE_ADAPTERS.get, func:get_builtin_adapter(kind: str) → WidgetSource | None, call:BUILTIN_ADAPTERS.get | dep: asyncio, logging, dataclasses, typing, requests, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.domain.dashboard, media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.services.secrets
|
||||
## arch
|
||||
Adapter pattern with modular source integrations that normalize heterogeneous external API responses into a unified widget payload format, complemented by schema-validated built-in widget configurations.
|
||||
## tags
|
||||
widget, source, fetch, call:logger.exception, builtin, call:config.get, record, call:str
|
||||
## symbols
|
||||
- StaticConfig
|
||||
- ServiceRecord
|
||||
- WidgetSource
|
||||
- BackupsWidgetSource
|
||||
- StaticWidgetSource
|
||||
- GrafanaWidgetSource
|
||||
- PrometheusWidgetSource
|
||||
- AlertmanagerWidgetSource
|
||||
## workflows
|
||||
- change widgets behavior
|
||||
read: __init__.py, builtin.py, sources.py
|
||||
## dirty
|
||||
-
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Built-in, service-less widget kinds.
|
||||
|
||||
These widgets do not talk to an external service and therefore have no
|
||||
``service_id``. They are kept out of the service registry (which models
|
||||
configurable external services) and live here as a small closed set.
|
||||
|
||||
Currently: ``backups`` (reads the internal backup tables) and ``static``
|
||||
(plain text/markdown).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.integrations.base import WidgetKind
|
||||
|
||||
BUILTIN_WIDGET_KINDS: dict[str, WidgetKind] = {
|
||||
"backups": WidgetKind(
|
||||
kind="backups",
|
||||
name="Backups",
|
||||
description="Backup job summary and active alerts.",
|
||||
config_schema={"type": "object", "properties": {}, "required": []},
|
||||
default_config={},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
"static": WidgetKind(
|
||||
kind="static",
|
||||
name="Static text",
|
||||
description="Plain text or markdown note.",
|
||||
config_schema={
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string", "description": "Text or markdown content"}},
|
||||
"required": ["text"],
|
||||
},
|
||||
default_config={"text": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_builtin_widget_kind(kind: str) -> WidgetKind | None:
|
||||
return BUILTIN_WIDGET_KINDS.get(kind)
|
||||
|
||||
|
||||
def is_builtin_kind(kind: str) -> bool:
|
||||
return kind in BUILTIN_WIDGET_KINDS
|
||||
|
||||
|
||||
def builtin_widget_kind_models() -> dict[str, type]:
|
||||
"""Pydantic widget-config models for built-in kinds (validated manually).
|
||||
|
||||
Backups has no user fields; static validates ``text``.
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class StaticConfig(BaseModel):
|
||||
text: str = Field(default="")
|
||||
|
||||
return {"static": StaticConfig}
|
||||
|
||||
|
||||
def validate_builtin_config(kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate (lightly) a built-in widget config and return the cleaned dict."""
|
||||
models = builtin_widget_kind_models()
|
||||
model_cls = models.get(kind)
|
||||
if model_cls is None:
|
||||
return dict(config or {})
|
||||
return model_cls.model_validate(config or {}).model_dump(exclude_none=True)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user