Restructure into backend/ and frontend/ subprojects

- backend/ uses proper Python src layout (src/media_library_viewer_api/)
  with pyproject.toml, hatchling build, and PYTHONPATH=src convention
- frontend/ is a Vite + React + TypeScript SPA
- archive/ preserves the original Streamlit prototype for reference
- Cleaned up root to only contain docs, license, and subproject dirs
- Updated README for the new dual-subproject architecture
This commit is contained in:
2026-04-30 21:48:46 +02:00
parent 3c432473e5
commit 51b10438a9
47 changed files with 127 additions and 130 deletions
+49 -97
View File
@@ -1,148 +1,100 @@
# Media Library Viewer # Media Library Viewer
Small Streamlit app for browsing a remote Jellyfin library, inspecting files on disk over SSH, previewing media metadata with `ffprobe`, and running safe remote job templates. Media library management tool with Jellyfin integration, SSH file inspection, server monitoring, and safe remote job templates.
See `docs/REQUIREMENTS.md` for the living requirements, decisions, and planning history. See `docs/REQUIREMENTS.md` for the living requirements, decisions, and planning history.
See `docs/MIGRATION_PLAN.md` for the FastAPI + React architecture plan.
Project policy/docs: Project policy/docs:
- License: `LICENSE` (MIT) - License: `LICENSE` (MIT)
- Contributing guide: `CONTRIBUTING.md` - Contributing guide: `CONTRIBUTING.md`
## Phase 1 features ## Architecture
- Dashboard media counts for movies, series, and episodes, plus now-playing sessions (user, title, playback state, transcoding) The project consists of two subprojects:
- SQLite-indexed Media tab with full-library sort/filter for runtime, size, bitrate, explicit HDR yes/no flag, date added, codec, resolution, series, season, episode, path, and row-based selection that automatically syncs File browser to the selected item's folder
- Server monitoring dashboard plus detailed Monitoring tab for CPU, IO wait, RAM, network, disk I/O, and disk space
- Jellyfin API-key connection using `GET /Users` plus user-scoped library endpoints
- Compact SSH remote directory browser with clickable rows, search, filters, sorting, pagination, and `[UP] ..` navigation
- Blocking selected-file `ffprobe` preview for known video files
- Separate container, video, audio, and subtitle metadata sections
- `stat` inspection
- Safe/read-only job templates, designed to extend later
## Project structure - **`backend/`** — FastAPI Python API (see `backend/README.md`)
- **`frontend/`** — React + TypeScript SPA (see `frontend/README.md`)
- **`archive/`** — Original Streamlit prototype (preserved for reference)
```text ## Features
.
├── app.py # Thin Streamlit entrypoint
├── pyproject.toml # Package metadata, dependencies, tool config
├── requirements.txt # Convenience install file (-e .)
├── docs/
│ └── REQUIREMENTS.md # Living requirements and decision log
├── src/
│ └── media_library_viewer/
│ ├── app.py # Thin Streamlit orchestration layer
│ ├── config.py # Environment/.env config
│ ├── jobs.py # Remote job templates
│ ├── utils.py # Formatting and ffprobe summaries
│ ├── domain/ # UI-independent normalization/domain helpers
│ ├── services/ # UI-independent application services/indexes
│ ├── ui/ # Streamlit UI modules split by feature area
│ └── clients/
│ ├── jellyfin.py # Jellyfin API client
│ └── ssh.py # SSH/ffprobe client
└── tests/
```
## Setup - 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
- SQLite-indexed media table with full-library sort/filter
- Remote file browser with ffprobe preview and job execution
- Jellyfin API integration for library metadata
- SSH-based file inspection and remote job templates
## Quick Start
### Backend
```bash ```bash
cd backend
python -m venv .venv python -m venv .venv
source .venv/bin/activate source .venv/bin/activate
pip install -r requirements.txt pip install -e '.[dev]'
uvicorn media_library_viewer_api.main:app --reload --port 8000
``` ```
For development tools: ### Frontend
```bash ```bash
pip install -e '.[dev]' cd frontend
npm install
npm run dev
``` ```
Create `.env` or use Streamlit secrets/env vars: Frontend runs on http://localhost:5173 and proxies API requests to http://localhost:8000.
## Configuration
Create a `.env` file in the project root:
```bash ```bash
JELLYFIN_URL=https://jellyfin.example.com JELLYFIN_URL=https://jellyfin.example.com
JELLYFIN_API_KEY=your-api-key JELLYFIN_API_KEY=your-api-key
# Optional fallback if user selection via GET /Users does not work.
JELLYFIN_USER_ID= JELLYFIN_USER_ID=
SSH_HOST=media-server.example.com SSH_HOST=media-server.example.com
SSH_USERNAME=username SSH_USERNAME=username
SSH_PORT=22 SSH_PORT=22
SSH_KEY_FILENAME=/home/username/.ssh/id_rsa SSH_KEY_FILENAME=/home/username/.ssh/id_rsa
# SSH_PASSWORD=optional-password-or-key-passphrase SSH_PASSWORD=
REMOTE_MEDIA_ROOT=/mnt/media
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough. REMOTE_MEDIA_ROOT=/srv/media
# Example fallback: Jellyfin /media/... -> SSH /srv/media/... REMOTE_PATH_PREFIX=
REMOTE_PATH_PREFIX=/srv
``` ```
Run:
```bash
streamlit run app.py
```
## Before publishing / committing
Keep these out of git:
- `.env` and any real secrets/tokens/passwords
- `.streamlit/secrets.toml`
- `.venv/`, local IDE files (`.idea/`, `.vscode/`)
- local cache/index files under `.cache/`
- logs and temporary files
The included `.gitignore` already excludes these.
## Remote server requirements ## Remote server requirements
For file browsing, resource metrics, and media metadata: The remote server needs:
```bash - Linux `/proc` and `/sys/block` for monitoring
ffprobe -version - `/bin/sh` (POSIX shell)
python3 --version - `python3`, `ffprobe`, `find`, `stat`, `df`, `awk`
find --version
stat --version
df --version
awk --version || true
```
The resource dashboard uses Linux `/proc`, `/sys/block`, `/bin/sh`, and a lightweight collector started over SSH. No full monitoring stack is required, but last-hour charts require the collector to have been running long enough to gather samples. Network is shown as download/upload in bytes per second, and disk I/O is shown as read/write in bytes per second. The metrics JSONL file is pruned to 7 days with a 70,000-line safety cap. SSH commands are explicitly run through `/bin/sh -c`, so the remote user's login shell may be fish or another shell. If no samples appear after 10-20 seconds, use the dashboard's `Restart` button and inspect `Collector diagnostics`. The SSH client rejects unknown host keys. Connect manually once first:
The SSH client uses your local `known_hosts` and rejects unknown host keys. Connect once manually first:
```bash ```bash
ssh user@host ssh user@host
``` ```
## Extending jobs ## Development
Add templates in `src/media_library_viewer/jobs.py`:
```python
JOB_TEMPLATES["my_job"] = JobTemplate(
name="My job",
description="What it does.",
command_template="my-command --input {path}",
destructive=False,
)
```
Template variables are shell-quoted before insertion. For destructive jobs, add confirmation UI before executing.
## Development checks
```bash ```bash
PYTHONPATH=src python -m py_compile app.py src/media_library_viewer/*.py src/media_library_viewer/clients/*.py # Backend
ruff check . cd backend && PYTHONPATH=src python -m py_compile src/media_library_viewer_api/main.py
pytest
# Frontend
cd frontend && npx tsc --noEmit && npm run build
``` ```
## Notes ## Notes
- Prefer Jellyfin API for library metadata. - Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
- Enter the Jellyfin server root URL, for example `https://jellyfin.example.com`, not `https://jellyfin.example.com/web`. The client strips a trailing `/web` defensively. - SSH commands run through `/bin/sh -c` regardless of remote login shell.
- Prefer SSH/ffprobe for authoritative disk-level media metadata such as actual bitrate, color transfer, HDR metadata, audio channels, subtitles, and container details. - Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
- Keep cleanup/transcode jobs explicit and template-based; avoid free-form command execution in the UI unless this app is only used locally by trusted users. - Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
View File
+41 -8
View File
@@ -2,6 +2,38 @@
FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, and server monitoring. FastAPI backend serving the REST API for Jellyfin media browsing, SSH file inspection, and server monitoring.
## Project structure
```
backend/
├── pyproject.toml
├── README.md
├── src/
│ └── media_library_viewer_api/
│ ├── __init__.py
│ ├── main.py # FastAPI app entrypoint
│ ├── config.py # pydantic-settings config
│ ├── dependencies.py # Dependency injection
│ ├── path_utils.py # Jellyfin→SSH path resolution
│ ├── jobs.py # Job templates
│ ├── utils.py # Formatting helpers
│ ├── routers/
│ │ ├── dashboard.py
│ │ ├── monitoring.py
│ │ ├── media.py
│ │ ├── files.py
│ │ └── jobs.py
│ ├── clients/
│ │ ├── jellyfin.py
│ │ ├── resources.py
│ │ └── ssh.py
│ ├── domain/
│ │ └── media.py
│ └── services/
│ └── media_index.py
└── tests/
```
## Setup ## Setup
```bash ```bash
@@ -11,12 +43,6 @@ source .venv/bin/activate
pip install -e '.[dev]' pip install -e '.[dev]'
``` ```
Or install dependencies directly:
```bash
pip install fastapi uvicorn[standard] pydantic-settings paramiko requests python-dotenv pandas
```
## Configuration ## Configuration
Create a `.env` file in the project root (or set environment variables): Create a `.env` file in the project root (or set environment variables):
@@ -40,7 +66,13 @@ REMOTE_PATH_PREFIX=
```bash ```bash
cd backend cd backend
uvicorn main:app --reload --port 8000 uvicorn media_library_viewer_api.main:app --reload --port 8000
```
Or with PYTHONPATH if not installed:
```bash
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
@@ -51,9 +83,10 @@ API docs available at: http://localhost:8000/docs
- `GET /api/dashboard/libraries` — Per-library breakdown - `GET /api/dashboard/libraries` — Per-library breakdown
- `GET /api/dashboard/now-playing` — Active playback sessions - `GET /api/dashboard/now-playing` — Active playback sessions
- `GET /api/monitoring/status` — Collector status - `GET /api/monitoring/status` — Collector status
- `GET /api/monitoring/metrics` — Resource samples - `GET /api/monitoring/metrics` — Resource samples (last hour)
- `GET /api/monitoring/disk` — Disk space - `GET /api/monitoring/disk` — Disk space
- `POST /api/monitoring/start|stop|restart` — Collector controls - `POST /api/monitoring/start|stop|restart` — Collector controls
- `GET /api/monitoring/diagnostics` — Collector debug info
- `GET /api/media/status` — Index status - `GET /api/media/status` — Index status
- `POST /api/media/build` — Rebuild index - `POST /api/media/build` — Rebuild index
- `GET /api/media/query` — Query with filters/sort/pagination - `GET /api/media/query` — Query with filters/sort/pagination
+12
View File
@@ -16,9 +16,21 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = ["httpx", "pytest", "pytest-asyncio", "ruff"] dev = ["httpx", "pytest", "pytest-asyncio", "ruff"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/media_library_viewer_api"]
[tool.ruff] [tool.ruff]
line-length = 120 line-length = 120
target-version = "py311" target-version = "py311"
src = ["src"]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "W"] select = ["E", "F", "I", "W"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
@@ -13,7 +13,7 @@ import shlex
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
# POSIX shell script copied to the remote server by start_resource_collector(). # POSIX shell script copied to the remote server by start_resource_collector().
# Keep this script bash-free because many NAS/media servers have minimal shells. # Keep this script bash-free because many NAS/media servers have minimal shells.
@@ -8,9 +8,9 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
from config import get_settings from media_library_viewer_api.config import get_settings
@lru_cache @lru_cache
@@ -11,7 +11,7 @@ from typing import Any
import pandas as pd import pandas as pd
from utils import human_size, ticks_to_minutes from media_library_viewer_api.utils import human_size, ticks_to_minutes
def first_media_source(item: dict[str, Any]) -> dict[str, Any]: def first_media_source(item: dict[str, Any]) -> dict[str, Any]:
@@ -11,7 +11,7 @@ import shlex
from dataclasses import dataclass from dataclasses import dataclass
from typing import Mapping from typing import Mapping
from clients.ssh import CommandResult, RemoteSSHClient from media_library_viewer_api.clients.ssh import CommandResult, RemoteSSHClient
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from routers import dashboard, monitoring, media, files, jobs from media_library_viewer_api.routers import dashboard, monitoring, media, files, jobs
@asynccontextmanager @asynccontextmanager
@@ -6,8 +6,8 @@ from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from dependencies import get_jellyfin_client, get_user_id from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -7,10 +7,10 @@ from typing import Any
from fastapi import APIRouter, Depends, Query, HTTPException from fastapi import APIRouter, Depends, Query, HTTPException
from dependencies import get_ssh_client from media_library_viewer_api.dependencies import get_ssh_client
from clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
from config import get_settings from media_library_viewer_api.config import get_settings
from path_utils import resolve_remote_media_path from media_library_viewer_api.path_utils import resolve_remote_media_path
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
@@ -7,9 +7,9 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from dependencies import get_ssh_client from media_library_viewer_api.dependencies import get_ssh_client
from clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
from jobs import JOB_TEMPLATES, run_job from media_library_viewer_api.jobs import JOB_TEMPLATES, run_job
router = APIRouter(prefix="/api/jobs", tags=["jobs"]) router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@@ -6,9 +6,9 @@ from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from dependencies import get_jellyfin_client, get_user_id from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from services.media_index import MediaIndex, build_media_index from media_library_viewer_api.services.media_index import MediaIndex, build_media_index
router = APIRouter(prefix="/api/media", tags=["media"]) router = APIRouter(prefix="/api/media", tags=["media"])
@@ -7,9 +7,9 @@ from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from dependencies import get_ssh_client from media_library_viewer_api.dependencies import get_ssh_client
from clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
from clients.resources import ( from media_library_viewer_api.clients.resources import (
disk_space, disk_space,
read_resource_metrics, read_resource_metrics,
resource_collector_debug_info, resource_collector_debug_info,
@@ -18,7 +18,7 @@ from clients.resources import (
start_resource_collector, start_resource_collector,
stop_resource_collector, stop_resource_collector,
) )
from config import get_settings from media_library_viewer_api.config import get_settings
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"]) router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
@@ -13,8 +13,8 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Iterable
from clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from domain.media import display_media_row, normalize_media_item from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
# Local generated database. It is ignored by git and can be rebuilt from # Local generated database. It is ignored by git and can be rebuilt from
# Jellyfin metadata whenever needed. # Jellyfin metadata whenever needed.
View File