Initial commit: modular Streamlit media viewer with Jellyfin, SSH tools, and docs
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
JELLYFIN_URL=https://jellyfin.example.com
|
||||
JELLYFIN_API_KEY=your-api-key
|
||||
# Optional if /Users works with your API key. Otherwise set the id of the Jellyfin user whose library views should be shown.
|
||||
JELLYFIN_USER_ID=
|
||||
|
||||
SSH_HOST=media-server.example.com
|
||||
SSH_USERNAME=username
|
||||
SSH_PORT=22
|
||||
SSH_KEY_FILENAME=/home/username/.ssh/id_rsa
|
||||
# SSH_PASSWORD=optional-password-or-key-passphrase
|
||||
REMOTE_MEDIA_ROOT=/mnt/media
|
||||
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough.
|
||||
# Example: Jellyfin gives /media/... but SSH host requires /srv/media/...
|
||||
REMOTE_PATH_PREFIX=
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Packaging/build
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.streamlit/secrets.toml
|
||||
.envrc
|
||||
|
||||
# Editors/OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
|
||||
# Streamlit/runtime
|
||||
.streamlit/config.toml
|
||||
|
||||
# Local generated indexes/caches
|
||||
.cache/
|
||||
|
||||
# Logs/temp
|
||||
*.log
|
||||
tmp/
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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.
|
||||
|
||||
See `docs/REQUIREMENTS.md` for the living requirements, decisions, and planning history.
|
||||
|
||||
## Phase 1 features
|
||||
|
||||
- Dashboard media counts for movies, series, and episodes, plus now-playing sessions (user, title, playback state, transcoding)
|
||||
- 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
|
||||
- SSH resource overview dashboard plus detailed Resources tab for CPU, RAM, network, disk I/O, and disk space
|
||||
- Jellyfin API-key connection using `GET /Users` plus user-scoped library endpoints
|
||||
- Library selection, search, pagination, poster grid
|
||||
- Item details with Jellyfin metadata and raw JSON
|
||||
- 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
|
||||
|
||||
```text
|
||||
.
|
||||
├── 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
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
For development tools:
|
||||
|
||||
```bash
|
||||
pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
Create `.env` or use Streamlit secrets/env vars:
|
||||
|
||||
```bash
|
||||
JELLYFIN_URL=https://jellyfin.example.com
|
||||
JELLYFIN_API_KEY=your-api-key
|
||||
# Optional fallback if user selection via GET /Users does not work.
|
||||
JELLYFIN_USER_ID=
|
||||
|
||||
SSH_HOST=media-server.example.com
|
||||
SSH_USERNAME=username
|
||||
SSH_PORT=22
|
||||
SSH_KEY_FILENAME=/home/username/.ssh/id_rsa
|
||||
# SSH_PASSWORD=optional-password-or-key-passphrase
|
||||
REMOTE_MEDIA_ROOT=/mnt/media
|
||||
# Optional fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough.
|
||||
# Example fallback: Jellyfin /media/... -> SSH /srv/media/...
|
||||
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
|
||||
|
||||
For file browsing, resource metrics, and media metadata:
|
||||
|
||||
```bash
|
||||
ffprobe -version
|
||||
python3 --version
|
||||
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 uses your local `known_hosts` and rejects unknown host keys. Connect once manually first:
|
||||
|
||||
```bash
|
||||
ssh user@host
|
||||
```
|
||||
|
||||
## Extending jobs
|
||||
|
||||
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
|
||||
PYTHONPATH=src python -m py_compile app.py src/media_library_viewer/*.py src/media_library_viewer/clients/*.py
|
||||
ruff check .
|
||||
pytest
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Prefer Jellyfin API for library metadata.
|
||||
- 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.
|
||||
- Prefer SSH/ffprobe for authoritative disk-level media metadata such as actual bitrate, color transfer, HDR metadata, audio channels, subtitles, and container details.
|
||||
- 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.
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Convenience Streamlit entrypoint.
|
||||
|
||||
The real application lives in :mod:`media_library_viewer.app` under ``src/`` so
|
||||
that the project can be packaged and reused by other frontends later. This file
|
||||
keeps the simple development command working:
|
||||
|
||||
streamlit run app.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parent / "src"
|
||||
if str(SRC) not in sys.path:
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
from media_library_viewer.app import main # noqa: E402
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,316 @@
|
||||
# Media Library Viewer - Requirements and Decision Log
|
||||
|
||||
This is a living document for the project. Update it whenever requirements, UX expectations, architecture decisions, constraints, or implementation plans change.
|
||||
|
||||
## Product Goal
|
||||
|
||||
Build a compact Streamlit application for browsing a remote Jellyfin media library and inspecting the corresponding media files on disk over SSH. The app should support library metadata review, direct file-system navigation, detailed media metadata inspection, and safe remote maintenance/job workflows.
|
||||
|
||||
## Current Phase
|
||||
|
||||
Phase 1: Jellyfin library browser plus SSH-based remote filesystem inspection and safe job templates.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### Jellyfin Library
|
||||
|
||||
- Connect to a remote Jellyfin server using an API key.
|
||||
- Use the Jellyfin server root URL, not the `/web` UI URL.
|
||||
- Handle API-key auth correctly:
|
||||
- use `GET /Users` to list available users;
|
||||
- allow a manual `JELLYFIN_USER_ID` override;
|
||||
- do not rely on `/Users/Me` for API-key auth.
|
||||
- List Jellyfin libraries for the selected user.
|
||||
- Browse library items with search, pagination, media type filtering, and poster cards.
|
||||
- Show item details including overview, genres, ratings where available, file path, media sources/streams, and raw Jellyfin JSON.
|
||||
- Provide a Media tab with a paginated inventory table for large libraries.
|
||||
- Media inventory should show title, series name, season, episode number, type, year, runtime, file size, bitrate, explicit HDR yes/no flag, video codec, resolution, date added, and path where available.
|
||||
- Media inventory should use Jellyfin metadata only for now; full ffprobe enrichment for all media should be deferred to a cached/background scan to avoid expensive per-item SSH probing.
|
||||
- Media inventory should support multi-library selection, type, search, full-index sort/order, HDR filter, page size, and page controls.
|
||||
- Media inventory should use a local SQLite index so sorting/filtering by nested/derived fields such as size, bitrate, HDR, codec, resolution, series, season, and episode can apply to the whole indexed library instead of only one Jellyfin page.
|
||||
- Media inventory table should be read-only; full-index sorting/filtering should be handled by the service/query layer rather than relying on frontend table sorting.
|
||||
- Media inventory should use row-based table selection and automatically sync the File browser tab to the selected row's containing directory.
|
||||
- Media row selection should update both Media selected-row state and File browser location without requiring an extra action button.
|
||||
- File browser interaction should stay explicit and simple: read-only listing plus explicit Open/Select actions rather than another row-selection grid.
|
||||
- Use valid Jellyfin `Fields` query values only, because invalid field names can cause `400 Bad Request` responses.
|
||||
|
||||
### Remote Filesystem over SSH
|
||||
|
||||
- Connect to a remote media server via SSH.
|
||||
- Use strict SSH host key behavior; users should connect manually once to populate `known_hosts`.
|
||||
- Browse remote directories and files rooted at a configurable default media path.
|
||||
- File browser handoff should map Jellyfin paths to `REMOTE_MEDIA_ROOT` when possible (for example `/media/...` -> `/srv/media/...` when root is `/srv/media`).
|
||||
- Support a configurable Jellyfin-to-SSH fallback path prefix for cases where `REMOTE_MEDIA_ROOT` mapping alone is not sufficient.
|
||||
- Support manual path entry and refresh.
|
||||
- Remote file listing must be compact, structured, and navigable.
|
||||
- The file table should be read-only.
|
||||
- The file table should use row selection (single-select) in an AG Grid format consistent with the Media tab.
|
||||
- The file table should not expose a visible checkbox selection column.
|
||||
- The file table should not show a visible `selected` column.
|
||||
- Include a top `[UP] ..` row, when not at `/`, to navigate to the parent directory.
|
||||
- The selected path should be visibly shown outside the table.
|
||||
- Selecting a directory row (including `[UP] ..`) should open it immediately.
|
||||
- Selecting a file row should set the selected file target for metadata/jobs.
|
||||
- Avoid emojis and hard-to-render characters in labels.
|
||||
|
||||
### Remote Listing Controls
|
||||
|
||||
- Show compact listing summary information:
|
||||
- total entries;
|
||||
- directories;
|
||||
- files;
|
||||
- total file size for files in the current directory.
|
||||
- Support filtering by:
|
||||
- all entries;
|
||||
- directories;
|
||||
- files;
|
||||
- file extension.
|
||||
- Support case-insensitive filename search.
|
||||
- Support sorting by:
|
||||
- name;
|
||||
- kind/type;
|
||||
- size;
|
||||
- modified time.
|
||||
- Support ascending/descending sort order.
|
||||
- Support pagination and configurable rows per page.
|
||||
|
||||
### Disk-Level Metadata
|
||||
|
||||
- Run `ffprobe` on selected remote files to inspect authoritative media metadata directly from disk.
|
||||
- When a known video/movie file is selected in the remote file browser, automatically run a blocking `ffprobe` preview and show a spinner while it completes.
|
||||
- Cache preview results briefly to keep repeated Streamlit reruns responsive; allow users to reload the preview manually.
|
||||
- Display `ffprobe` results in separate sections instead of one sparse all-streams table:
|
||||
- container/format summary;
|
||||
- video streams;
|
||||
- audio streams;
|
||||
- subtitle streams.
|
||||
- Video metadata should include codec, profile, resolution, pixel format, bitrate, frame rate, color range/space/transfer/primaries, side data/HDR-related metadata where available, language, title, and default flag.
|
||||
- Audio metadata should include codec, profile, channels/layout, sample rate, bitrate, language, title, default, and forced flags.
|
||||
- Subtitle metadata should include codec, language, title, default, forced, and hearing-impaired flags where available.
|
||||
- Expose raw `ffprobe` JSON for deeper inspection.
|
||||
- Keep a manual `ffprobe` action available for selected paths.
|
||||
- Support `stat` on selected paths.
|
||||
|
||||
### Dashboard / Server Resources
|
||||
|
||||
- Provide a dashboard tab with a compact Jellyfin media library overview and server resource overview.
|
||||
- Show Jellyfin media counts for movies, series, and series episodes on the dashboard.
|
||||
- Show currently playing Jellyfin sessions on the dashboard, including user, media title, playback state, and whether transcoding is active.
|
||||
- Provide a dashboard tab with a compact server resource overview over SSH.
|
||||
- Provide a separate Resources tab for detailed resource charts, collector controls, diagnostics, and raw samples.
|
||||
- Show CPU and RAM usage for the last hour.
|
||||
- Show average and spike/peak values for network throughput and disk I/O.
|
||||
- Show used, available, and total disk space for the configured media root, falling back to `/`.
|
||||
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
|
||||
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
|
||||
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
|
||||
- Last-hour charts require the collector to have been running long enough to collect samples.
|
||||
- Network throughput should be shown split into down/download and up/upload.
|
||||
- Network throughput should use bytes-per-second display units such as KB/s, MB/s, and GB/s to avoid bit/byte ambiguity.
|
||||
- Disk throughput should be shown split into read and write.
|
||||
- Network and disk throughput charts should scale values into readable units such as KB/s, MB/s, and GB/s.
|
||||
|
||||
### Remote Jobs
|
||||
|
||||
- Support running remote jobs over SSH using explicit templates.
|
||||
- Phase 1 jobs should be safe/read-only by default.
|
||||
- Avoid arbitrary free-form command execution in the UI.
|
||||
- Job templates should be centralized in `jobs.py` for future extension.
|
||||
- Command template values must be shell-quoted before execution.
|
||||
- Future destructive jobs should require explicit confirmation.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
- `app.py` - Thin root Streamlit entrypoint for `streamlit run app.py`.
|
||||
- `pyproject.toml` - Authoritative package metadata, dependencies, and tool configuration.
|
||||
- `requirements.txt` - Convenience install file that installs the local package editable.
|
||||
- `src/media_library_viewer/app.py` - Thin Streamlit orchestration layer.
|
||||
- `src/media_library_viewer/ui/` - Streamlit UI modules split by feature area (dashboard, media, file browser, library, preview/tools).
|
||||
- `src/media_library_viewer/clients/jellyfin.py` - Jellyfin API wrapper.
|
||||
- `src/media_library_viewer/clients/ssh.py` - SSH command execution, directory listing, `stat`, and `ffprobe` helpers.
|
||||
- `src/media_library_viewer/domain/` - UI-independent normalization/domain helpers.
|
||||
- `src/media_library_viewer/services/` - UI-independent application services such as the SQLite media index.
|
||||
- `src/media_library_viewer/jobs.py` - Remote job template definitions and runner.
|
||||
- `src/media_library_viewer/utils.py` - Formatting and media metadata summarization helpers.
|
||||
- `src/media_library_viewer/config.py` - Environment variable and `.env` configuration loading.
|
||||
- `docs/REQUIREMENTS.md` - Living requirements and decision log.
|
||||
- `tests/` - Reserved for future test coverage.
|
||||
|
||||
## Key Implementation Decisions
|
||||
|
||||
- Prefer the Jellyfin API for library and server metadata.
|
||||
- Prefer SSH plus `ffprobe` for disk-authoritative stream/container metadata.
|
||||
- Use API-key auth for Jellyfin, but select a user explicitly for user-scoped endpoints.
|
||||
- Use `streamlit-aggrid` as a required dependency for Media table row selection. Avoid optional frontend fallbacks that create multiple interaction models.
|
||||
- Keep remote jobs template-based to reduce accidental destructive actions.
|
||||
- Keep the Phase 1 UI compact and structured rather than using large per-row buttons.
|
||||
- Use a `src/` package layout so the project can grow without accumulating many root-level modules.
|
||||
- Keep root `app.py` as a compatibility/convenience wrapper for Streamlit.
|
||||
- Keep clients, domain normalization, and application services independent from Streamlit so the frontend can later be replaced by React/FastAPI or another UI.
|
||||
- Keep Streamlit rendering split into small UI modules so interaction bugs can be debugged in feature-local code instead of one monolithic app file.
|
||||
|
||||
## Security and Safety Requirements
|
||||
|
||||
- Do not hardcode secrets.
|
||||
- Use `.env`, environment variables, or Streamlit secrets for credentials.
|
||||
- Keep `.env` and Streamlit secrets out of version control.
|
||||
- Reject unknown SSH host keys by default.
|
||||
- Treat SSH jobs as potentially dangerous and keep them explicit/template-based.
|
||||
- Add confirmation steps before implementing cleanup, delete, transcode-replace, or other destructive workflows.
|
||||
|
||||
## Configuration Requirements
|
||||
|
||||
Supported environment variables:
|
||||
|
||||
```bash
|
||||
JELLYFIN_URL=
|
||||
JELLYFIN_API_KEY=
|
||||
JELLYFIN_USER_ID=
|
||||
SSH_HOST=
|
||||
SSH_USERNAME=
|
||||
SSH_PORT=22
|
||||
SSH_KEY_FILENAME=
|
||||
SSH_PASSWORD=
|
||||
REMOTE_MEDIA_ROOT=
|
||||
REMOTE_PATH_PREFIX=
|
||||
```
|
||||
|
||||
## Known External Requirements
|
||||
|
||||
Remote server should have:
|
||||
|
||||
- Linux `/proc` and `/sys/block` for resource metrics
|
||||
- `/bin/sh` for POSIX command execution, even when the user's login shell is fish or another non-POSIX shell
|
||||
- POSIX shell utilities including `awk`, `date`, `tail`, `df`, `kill`, and `nohup`
|
||||
- `python3`
|
||||
- GNU/coreutils-compatible `find` and `stat`
|
||||
- `ffprobe` for media metadata inspection
|
||||
|
||||
Local app dependencies are declared in `pyproject.toml`; `requirements.txt` installs the package editable for convenience. Runtime dependencies include:
|
||||
|
||||
- `streamlit`
|
||||
- `streamlit-aggrid`
|
||||
- `requests`
|
||||
- `paramiko`
|
||||
- `python-dotenv`
|
||||
- `pandas`
|
||||
|
||||
## Backlog / Future Extensions
|
||||
|
||||
- Add transcode job templates.
|
||||
- Add cleanup job templates with dry-run and explicit confirmation.
|
||||
- Add subtitle/audio-track diagnostics.
|
||||
- Add sidecar file inspection for `.nfo`, `.srt`, images, and metadata files.
|
||||
- Compare Jellyfin metadata against disk metadata and sidecars.
|
||||
- Add long-running job tracking/log streaming.
|
||||
- Add saved presets for common media roots and job templates.
|
||||
- Add file previews for text sidecars.
|
||||
- Add richer HDR/Dolby Vision/bit-depth summaries from `ffprobe`.
|
||||
- Add optional integration with existing monitoring stacks such as Prometheus/node_exporter, Netdata, or sysstat/sar.
|
||||
|
||||
## Decision Log
|
||||
|
||||
### 2026-04-30 - Initial app plan
|
||||
|
||||
- Planned a Streamlit app that uses the Jellyfin API as the primary metadata source.
|
||||
- Decided SSH should be used for disk inspection and future maintenance jobs.
|
||||
|
||||
### 2026-04-30 - Phase 1 implementation
|
||||
|
||||
- Created the initial app structure with Jellyfin, SSH, jobs, config, and utility modules.
|
||||
- Added safe/read-only remote job templates.
|
||||
- Added `ffprobe` and `stat` inspection.
|
||||
|
||||
### 2026-04-30 - Jellyfin API fixes
|
||||
|
||||
- Replaced `/Users/Me` usage with `GET /Users` plus user selection.
|
||||
- Added `JELLYFIN_USER_ID` override.
|
||||
- Cleaned Jellyfin `Fields` values to avoid 400 responses.
|
||||
- Added defensive stripping of trailing `/web` from Jellyfin URLs.
|
||||
|
||||
### 2026-04-30 - Remote file browser evolution
|
||||
|
||||
- Added interactive remote listing.
|
||||
- Removed emoji and hard-to-render characters.
|
||||
- Added search, filtering, sorting, pagination, and compact listing summary.
|
||||
- Reworked listing from large button rows into a compact table.
|
||||
- Switched to `streamlit-aggrid` for file-browser-like row click behavior.
|
||||
- Removed visible checkbox/selection column behavior.
|
||||
- Added `[UP] ..` top row for parent directory navigation.
|
||||
|
||||
### 2026-04-30 - Selected-file metadata preview
|
||||
|
||||
- Added a requirement for automatic `ffprobe` preview when known video files are selected.
|
||||
- Initially explored asynchronous/non-blocking preview, then changed to a blocking call with a spinner because it is more streamlined for this app.
|
||||
- Decided to cache preview results briefly and provide a manual reload action.
|
||||
- Decided `ffprobe` output should be separated into container, video, audio, and subtitle sections to avoid sparse mixed-stream tables.
|
||||
|
||||
### 2026-04-30 - Process requirement
|
||||
|
||||
- Added this living requirements and decision log document.
|
||||
- Added a global agent skill to encourage maintaining such a document for future projects.
|
||||
|
||||
### 2026-04-30 - Repository restructuring
|
||||
|
||||
- Restructured the project into a larger-project-ready `src/media_library_viewer/` package layout.
|
||||
- Kept a thin root `app.py` entrypoint so `streamlit run app.py` remains the primary launch command.
|
||||
- Moved service clients into `src/media_library_viewer/clients/`.
|
||||
- Added `pyproject.toml` with runtime dependencies, development extras, Ruff configuration, and pytest configuration.
|
||||
- Simplified `requirements.txt` to install the local project editable.
|
||||
- Expanded `.gitignore` for Python caches, build artifacts, virtual environments, local secrets, editor files, and logs.
|
||||
|
||||
### 2026-04-30 - Resource dashboard
|
||||
|
||||
- Added a dashboard requirement for CPU, RAM, network, disk I/O, and disk space overview.
|
||||
- Decided that true last-hour metrics require collection over time; implemented a lightweight SSH-started remote collector instead of requiring Prometheus, Netdata, or sysstat.
|
||||
- The collector stores JSONL samples in `/tmp` and can be started/stopped from the dashboard.
|
||||
- Charts show the last hour of collected samples; the dashboard becomes more useful once the collector has been running for a while.
|
||||
- Last-hour filtering uses epoch seconds rather than local naive datetimes to avoid timezone-offset issues between the app host and remote sample timestamps.
|
||||
- Fixed SSH command execution to explicitly use `/bin/sh -c` so POSIX resource commands work even when the remote user's login shell is fish.
|
||||
- Added explicit Streamlit keys to dashboard/file/tool buttons to avoid duplicate auto-generated element IDs as the UI grows.
|
||||
- Changed the resource collector script from bash-specific syntax to POSIX `/bin/sh` syntax and added dashboard diagnostics/restart controls for collector troubleshooting.
|
||||
- Added 7-day metrics file pruning plus a 70,000-line safety cap to prevent the JSONL file from growing without bound.
|
||||
- Split network charts and metrics into download and upload, and disk charts and metrics into read and write.
|
||||
- Changed network display units from bits per second to bytes per second to avoid Kbps/KB/s ambiguity; the collector still stores bit-rate compatibility fields for old/debug consumers.
|
||||
- Scaled network and disk throughput charts into readable units such as KB/s, MB/s, and GB/s instead of plotting raw base units.
|
||||
- Updated collector startup to remove old temporary metrics/log files when a new collector process is started after a schema/display change.
|
||||
- Moved detailed resource charts, raw samples, diagnostics, and collector controls into a dedicated Resources tab; the Dashboard now keeps a compact overview.
|
||||
- Removed the CPU/RAM chart from the Dashboard and kept detailed charts in the Resources tab.
|
||||
- Renamed the remote files tab to File browser.
|
||||
- Added Jellyfin media counts for movies, series, and episodes to the Dashboard using lightweight count queries.
|
||||
- Added a Dashboard now-playing section sourced from Jellyfin sessions, showing who is currently playing what and whether each session is transcoding.
|
||||
|
||||
### 2026-04-30 - Media inventory tab
|
||||
|
||||
- Added a paginated Media tab for file-oriented Jellyfin metadata.
|
||||
- Decided not to fetch all media at once because large libraries can make API responses and Streamlit rendering slow.
|
||||
- Decided to derive length, size, bitrate, HDR flag, date added, codec, and resolution from Jellyfin metadata for now.
|
||||
- Added series name, season, and episode number for episode rows.
|
||||
- Added server-side sort/order controls and read-only AG Grid column sorting/filtering for the loaded page.
|
||||
- Reworked the Media tab to use a local SQLite media index for full-library sorting/filtering, including numeric sorting for size and bitrate.
|
||||
- Added last index build duration metadata to the Media tab status line.
|
||||
- Replaced single-library selection with multi-library selection so users can include/exclude multiple libraries in the indexed table.
|
||||
- Changed HDR display from blank/no-value to explicit yes/no.
|
||||
- Added row selection plus an Open folder action in the Media tab that sets the File browser to the containing folder.
|
||||
- Restored row-based Media table selection while keeping File browser state changes limited to the explicit Open folder button.
|
||||
- Updated Media tab behavior so selecting a row automatically syncs the File browser folder to that item's containing directory; removed the extra Open folder button step.
|
||||
- Restored File browser table row selection with AG Grid (single-select), using a table interaction style consistent with the Media tab.
|
||||
- Reintroduced open-on-select behavior in File browser: selecting a directory row (including `[UP] ..`) opens it immediately, while file rows update selected target path.
|
||||
- Refined Media table column presentation with explicit user-friendly headers and null-safe display formatting to keep the grid readable and consistent.
|
||||
- Added broad inline/module documentation across clients, domain, services, and Streamlit adapter modules to make debugging and future frontend extraction easier.
|
||||
- Simplified the File browser by removing its interactive AG Grid and using a read-only listing with explicit Open/Select controls, reducing cross-tab state interactions with the Media grid.
|
||||
- Removed optional/compatibility code paths around the Media table grid and old file-browser state aliases to keep the interaction model easier to reason about during debugging.
|
||||
- Split the Streamlit frontend into dedicated UI modules (dashboard, media, file browser, library, preview/tools) and reduced `app.py` to orchestration glue.
|
||||
- Reviewed remote path handling and kept shell interactions routed through quoted paths (`shlex.quote`) while UI/path-parent operations use POSIX path handling, preserving paths with spaces.
|
||||
- Fixed file browser handoff/navigation to reset stale search/filter/page state when changing folders, preventing old filters from hiding all entries in the newly opened folder.
|
||||
- Reworked file browser state to separate current directory from selected path. Selecting a file no longer changes the directory being listed, while opening a folder updates the current directory and keeps path input synchronized.
|
||||
- Made remote directory listing fail explicitly when the current path is not a directory and recover by listing the parent, preventing file paths from appearing as empty directories.
|
||||
- Made File browser Refresh/Select folder apply a manually typed path if it differs from the current folder, reducing confusion when manually navigating.
|
||||
- Moved media normalization into `domain/media.py` and index/query logic into `services/media_index.py` to make the project less Streamlit-specific and easier to expose through a future API/React frontend.
|
||||
- Deferred full ffprobe enrichment for every item to a future cached/background scan.
|
||||
- Fixed network byte parsing to split `/proc/net/dev` lines at the colon first, so interface indentation differences do not shift fields and accidentally report packet counts instead of byte counts.
|
||||
- Fixed a follow-up `/proc/net/dev` parsing issue where leading whitespace after the colon could produce an empty first split field in some `awk` implementations, resulting in zero network rates. Added `/proc/net/dev` snapshots to collector diagnostics.
|
||||
- Simplified File browser directory error behavior: stopped automatic parent-directory fallback and now show the direct listing error for the current path.
|
||||
- Added configurable `REMOTE_PATH_PREFIX` support so Jellyfin paths can be mapped to SSH-visible paths when opening folders from Media/Library tabs (for example `/media/...` -> `/srv/media/...`).
|
||||
- Updated path handoff logic to prefer mapping through `REMOTE_MEDIA_ROOT` (anchor replacement using the root basename, e.g. `media`) and use `REMOTE_PATH_PREFIX` as fallback.
|
||||
- Added a public-repo readiness note in README describing what local/sensitive files must stay out of version control.
|
||||
@@ -0,0 +1,42 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.24"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "media-library-viewer"
|
||||
version = "0.1.0"
|
||||
description = "Streamlit app for browsing Jellyfin libraries and inspecting remote media files over SSH."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Media Library Viewer contributors" }]
|
||||
dependencies = [
|
||||
"streamlit>=1.35",
|
||||
"streamlit-aggrid>=1.0",
|
||||
"requests>=2.31",
|
||||
"paramiko>=3.4",
|
||||
"python-dotenv>=1.0",
|
||||
"pandas>=2.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff>=0.4",
|
||||
"pytest>=8.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/media_library_viewer"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py311"
|
||||
src = ["src", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["E501"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
@@ -0,0 +1 @@
|
||||
-e .
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Media Library Viewer package."""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Streamlit UI adapter for Media Library Viewer.
|
||||
|
||||
This module is intentionally thin. Reusable logic lives in:
|
||||
|
||||
- ``clients/`` for Jellyfin, SSH, and resource collection integrations.
|
||||
- ``domain/`` for UI-independent normalization of Jellyfin/media metadata.
|
||||
- ``services/`` for application services such as the SQLite media index.
|
||||
- ``ui/`` for Streamlit rendering grouped by feature area.
|
||||
|
||||
Keeping those boundaries makes it easier to replace this Streamlit frontend with
|
||||
another frontend later while preserving the backend/domain code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from media_library_viewer.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer.config import load_config
|
||||
from media_library_viewer.ui.dashboard import render_media_overview, render_now_playing, render_resource_dashboard
|
||||
from media_library_viewer.ui.file_browser import render_file_browser, set_file_browser_path
|
||||
from media_library_viewer.ui.library import show_item_card, show_item_detail
|
||||
from media_library_viewer.ui.media import render_media_tab
|
||||
from media_library_viewer.ui.preview import render_ssh_tools
|
||||
|
||||
st.set_page_config(page_title="Media Library Viewer", layout="wide")
|
||||
|
||||
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def get_jellyfin_client(base_url: str, api_key: str) -> JellyfinClient:
|
||||
"""Return a cached Jellyfin client for the current server/API-key pair."""
|
||||
return JellyfinClient(base_url, api_key)
|
||||
|
||||
|
||||
@st.cache_data(ttl=300, show_spinner=False)
|
||||
def cached_users(base_url: str, api_key: str):
|
||||
"""Cache the Jellyfin user list to avoid repeated auth-scoped requests."""
|
||||
return get_jellyfin_client(base_url, api_key).users()
|
||||
|
||||
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def get_ssh_client(host: str, username: str, port: int, key_filename: str, password: str) -> RemoteSSHClient:
|
||||
"""Return a cached SSH connection wrapper for the active remote server."""
|
||||
client = RemoteSSHClient(
|
||||
host=host,
|
||||
username=username,
|
||||
port=port,
|
||||
key_filename=key_filename or None,
|
||||
password=password or None,
|
||||
)
|
||||
client.connect()
|
||||
return client
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def cached_libraries(base_url: str, api_key: str, user_id: str):
|
||||
"""Cache library views for the selected Jellyfin user."""
|
||||
return get_jellyfin_client(base_url, api_key).libraries(user_id)
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def cached_items(
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
user_id: str,
|
||||
parent_id: str | None,
|
||||
search: str,
|
||||
media_types: str,
|
||||
start: int,
|
||||
limit: int,
|
||||
sort_by: str = "SortName",
|
||||
sort_order: str = "Ascending",
|
||||
):
|
||||
"""Cache paginated Jellyfin item list requests."""
|
||||
return get_jellyfin_client(base_url, api_key).items(
|
||||
user_id=user_id,
|
||||
parent_id=parent_id,
|
||||
search=search,
|
||||
include_item_types=media_types,
|
||||
start_index=start,
|
||||
limit=limit,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
|
||||
@st.cache_data(ttl=60, show_spinner=False)
|
||||
def cached_item(base_url: str, api_key: str, user_id: str, item_id: str):
|
||||
"""Cache a single Jellyfin item detail payload."""
|
||||
return get_jellyfin_client(base_url, api_key).item(user_id, item_id)
|
||||
|
||||
|
||||
@st.cache_data(ttl=300, show_spinner=False)
|
||||
def cached_media_counts(base_url: str, api_key: str, user_id: str):
|
||||
"""Cache dashboard-level media counts for movies/series/episodes."""
|
||||
return get_jellyfin_client(base_url, api_key).media_counts(user_id)
|
||||
|
||||
|
||||
@st.cache_data(ttl=15, show_spinner=False)
|
||||
def cached_active_sessions(base_url: str, api_key: str):
|
||||
"""Cache active Jellyfin sessions briefly for dashboard now-playing status."""
|
||||
return get_jellyfin_client(base_url, api_key).active_sessions()
|
||||
|
||||
|
||||
@st.cache_data(ttl=30, show_spinner=False)
|
||||
def cached_dir_listing(host: str, username: str, port: int, key_filename: str, password: str, path: str):
|
||||
"""Cache remote directory listings briefly for snappier browsing."""
|
||||
ssh = get_ssh_client(host, username, port, key_filename, password)
|
||||
result = ssh.list_dir(path)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
@st.cache_data(ttl=300, show_spinner=False)
|
||||
def cached_ffprobe_preview(host: str, username: str, port: int, key_filename: str, password: str, path: str):
|
||||
"""Cache selected-file ffprobe previews so repeated reruns stay responsive."""
|
||||
ssh = get_ssh_client(host, username, port, key_filename, password)
|
||||
return ssh.ffprobe_json(path)
|
||||
|
||||
|
||||
def apply_remote_path_prefix(path: str, prefix: str) -> str:
|
||||
"""Apply an optional fallback prefix for Jellyfin->SSH path handoff."""
|
||||
if not path:
|
||||
return path
|
||||
normalized_prefix = (prefix or "").strip()
|
||||
if not normalized_prefix:
|
||||
return path
|
||||
normalized_prefix = normalized_prefix.rstrip("/")
|
||||
if path == normalized_prefix or path.startswith(normalized_prefix + "/"):
|
||||
return posixpath.normpath(path)
|
||||
if path.startswith("/"):
|
||||
return posixpath.normpath(normalized_prefix + path)
|
||||
return posixpath.normpath(posixpath.join(normalized_prefix, path))
|
||||
|
||||
|
||||
def map_path_to_media_root(path: str, media_root: str) -> str:
|
||||
"""Map a Jellyfin path to the configured SSH media root when possible.
|
||||
|
||||
Example:
|
||||
- path: ``/media/shows/Show/E01.mkv``
|
||||
- media_root: ``/srv/media``
|
||||
- result: ``/srv/media/shows/Show/E01.mkv``
|
||||
|
||||
If the path is already under ``media_root``, it is returned unchanged.
|
||||
If the final segment of ``media_root`` (e.g. ``media``) appears in the
|
||||
Jellyfin path, the prefix up to that segment is replaced by ``media_root``.
|
||||
"""
|
||||
if not path:
|
||||
return path
|
||||
normalized_root = (media_root or "").strip()
|
||||
if not normalized_root:
|
||||
return path
|
||||
normalized_root = posixpath.normpath(normalized_root)
|
||||
|
||||
raw_parts = [part for part in str(path).split("/") if part]
|
||||
if not raw_parts:
|
||||
return path
|
||||
|
||||
path_absolute = "/" + "/".join(raw_parts)
|
||||
if path_absolute == normalized_root or path_absolute.startswith(normalized_root + "/"):
|
||||
return path_absolute
|
||||
|
||||
root_anchor = posixpath.basename(normalized_root)
|
||||
if not root_anchor:
|
||||
return path
|
||||
|
||||
if root_anchor in raw_parts:
|
||||
anchor_index = raw_parts.index(root_anchor)
|
||||
remainder_parts = raw_parts[anchor_index + 1 :]
|
||||
return posixpath.join(normalized_root, *remainder_parts) if remainder_parts else normalized_root
|
||||
|
||||
return path
|
||||
|
||||
|
||||
def resolve_remote_media_path(path: str, media_root: str, fallback_prefix: str) -> str:
|
||||
"""Resolve Jellyfin paths to SSH-visible paths.
|
||||
|
||||
Strategy:
|
||||
1. Prefer mapping to ``REMOTE_MEDIA_ROOT`` when it can anchor on the root
|
||||
basename (e.g. ``media`` in ``/srv/media``).
|
||||
2. If no mapping happened, apply optional fallback prefix.
|
||||
"""
|
||||
if not path:
|
||||
return path
|
||||
mapped = map_path_to_media_root(path, media_root)
|
||||
if mapped and mapped != path:
|
||||
return mapped
|
||||
return apply_remote_path_prefix(mapped or path, fallback_prefix)
|
||||
|
||||
|
||||
def credentials_panel():
|
||||
"""Render connection settings and return normalized Jellyfin/SSH inputs."""
|
||||
cfg = load_config()
|
||||
with st.sidebar:
|
||||
st.header("Connections")
|
||||
with st.expander("Jellyfin", expanded=True):
|
||||
jellyfin_url = st.text_input("URL", value=cfg.jellyfin.url, placeholder="https://jellyfin.example.com")
|
||||
jellyfin_api_key = st.text_input("API key", value=cfg.jellyfin.api_key, type="password")
|
||||
jellyfin_user_id = st.text_input(
|
||||
"User ID override",
|
||||
value=cfg.jellyfin.user_id,
|
||||
help="Optional. API keys are not user sessions, so the app normally lists users with /Users and lets you choose one.",
|
||||
)
|
||||
with st.expander("SSH", expanded=True):
|
||||
ssh_host = st.text_input("Host", value=cfg.ssh.host)
|
||||
ssh_username = st.text_input("Username", value=cfg.ssh.username)
|
||||
ssh_port = st.number_input("Port", min_value=1, max_value=65535, value=cfg.ssh.port)
|
||||
ssh_key = st.text_input("Private key path", value=cfg.ssh.key_filename)
|
||||
ssh_password = st.text_input("Password / passphrase", value=cfg.ssh.password, type="password")
|
||||
media_root = st.text_input("Default media root", value=cfg.ssh.media_root, placeholder="/mnt/media")
|
||||
remote_path_prefix = st.text_input(
|
||||
"Path prefix for Jellyfin paths",
|
||||
value=cfg.ssh.path_prefix,
|
||||
placeholder="/srv",
|
||||
help="Fallback prefix when REMOTE_MEDIA_ROOT mapping is not enough.",
|
||||
)
|
||||
return jellyfin_url, jellyfin_api_key, jellyfin_user_id, ssh_host, ssh_username, int(ssh_port), ssh_key, ssh_password, media_root, remote_path_prefix
|
||||
|
||||
|
||||
def main():
|
||||
"""Application entrypoint used by the root ``app.py`` wrapper."""
|
||||
st.title("Media Library Viewer")
|
||||
st.caption("Phase 1: Jellyfin browser + SSH filesystem inspection + safe remote job templates")
|
||||
|
||||
jellyfin_url, jellyfin_api_key, jellyfin_user_id, ssh_host, ssh_username, ssh_port, ssh_key, ssh_password, media_root, remote_path_prefix = credentials_panel()
|
||||
if not jellyfin_url or not jellyfin_api_key:
|
||||
st.info("Enter Jellyfin connection details in the sidebar.")
|
||||
return
|
||||
|
||||
try:
|
||||
client = get_jellyfin_client(jellyfin_url, jellyfin_api_key)
|
||||
users = cached_users(jellyfin_url, jellyfin_api_key)
|
||||
except Exception as exc:
|
||||
st.error(f"Jellyfin connection failed: {exc}")
|
||||
st.caption("Note: this app uses an API key with GET /Users. /Users/Me is only reliable for user access tokens, not server API keys.")
|
||||
return
|
||||
|
||||
if jellyfin_user_id:
|
||||
user_id = jellyfin_user_id
|
||||
else:
|
||||
if not users:
|
||||
st.error("No Jellyfin users returned from /Users. Set JELLYFIN_USER_ID manually.")
|
||||
return
|
||||
user_options = {f"{user.get('Name', 'Unnamed')} ({user['Id']})": user["Id"] for user in users}
|
||||
with st.sidebar:
|
||||
selected_user = st.selectbox("Jellyfin user", list(user_options.keys()))
|
||||
user_id = user_options[selected_user]
|
||||
|
||||
tab_dashboard, tab_resources, tab_media, tab_library, tab_files = st.tabs(
|
||||
["Dashboard", "Resources", "Media", "Jellyfin library", "File browser"]
|
||||
)
|
||||
|
||||
with tab_dashboard:
|
||||
render_media_overview(cached_media_counts, jellyfin_url, jellyfin_api_key, user_id)
|
||||
st.divider()
|
||||
render_now_playing(cached_active_sessions, jellyfin_url, jellyfin_api_key)
|
||||
st.divider()
|
||||
if not ssh_host or not ssh_username:
|
||||
st.info("Enter SSH connection details in the sidebar for server resource overview.")
|
||||
else:
|
||||
ssh_args = (ssh_host, ssh_username, ssh_port, ssh_key, ssh_password)
|
||||
render_resource_dashboard(get_ssh_client, ssh_args, media_root or "/", detailed=False)
|
||||
|
||||
with tab_resources:
|
||||
if not ssh_host or not ssh_username:
|
||||
st.info("Enter SSH connection details in the sidebar.")
|
||||
else:
|
||||
ssh_args = (ssh_host, ssh_username, ssh_port, ssh_key, ssh_password)
|
||||
render_resource_dashboard(get_ssh_client, ssh_args, media_root or "/", detailed=True)
|
||||
|
||||
libraries = cached_libraries(jellyfin_url, jellyfin_api_key, user_id)
|
||||
|
||||
def set_prefixed_file_browser_path(path: str, selected_path: str | None = None, reset_filters: bool = True) -> None:
|
||||
set_file_browser_path(
|
||||
resolve_remote_media_path(path, media_root, remote_path_prefix),
|
||||
resolve_remote_media_path(selected_path, media_root, remote_path_prefix) if selected_path else None,
|
||||
reset_filters,
|
||||
)
|
||||
|
||||
with tab_media:
|
||||
render_media_tab(client, user_id, libraries, set_prefixed_file_browser_path)
|
||||
|
||||
with tab_library:
|
||||
if not libraries:
|
||||
st.warning("No libraries found.")
|
||||
return
|
||||
lib_by_name = {lib["Name"]: lib for lib in libraries}
|
||||
with st.sidebar:
|
||||
st.header("Library filters")
|
||||
lib_name = st.selectbox("Library", list(lib_by_name.keys()))
|
||||
media_types = st.multiselect("Media types", ["Movie", "Series", "Episode", "Video", "Audio"], default=[])
|
||||
search = st.text_input("Search")
|
||||
limit = st.slider("Items per page", 10, 200, 50, step=10)
|
||||
page = st.number_input("Page", min_value=1, value=1)
|
||||
|
||||
response = cached_items(
|
||||
jellyfin_url,
|
||||
jellyfin_api_key,
|
||||
user_id,
|
||||
lib_by_name[lib_name]["Id"],
|
||||
search,
|
||||
",".join(media_types),
|
||||
(page - 1) * limit,
|
||||
limit,
|
||||
)
|
||||
items = response.get("Items", [])
|
||||
st.subheader(f"{lib_name} ({response.get('TotalRecordCount', len(items))} items)")
|
||||
cols = st.columns(5)
|
||||
for idx, item in enumerate(items):
|
||||
with cols[idx % 5]:
|
||||
show_item_card(client, item)
|
||||
|
||||
if st.session_state.get("selected_item_id"):
|
||||
st.divider()
|
||||
detail = cached_item(jellyfin_url, jellyfin_api_key, user_id, st.session_state["selected_item_id"])
|
||||
show_item_detail(client, detail)
|
||||
if detail.get("Path") and st.button("Open item path in file browser", key="library_open_item_path_in_file_browser"):
|
||||
resolved_path = resolve_remote_media_path(detail["Path"], media_root, remote_path_prefix)
|
||||
set_file_browser_path(str(PurePosixPath(resolved_path).parent), resolved_path)
|
||||
|
||||
with tab_files:
|
||||
if not ssh_host or not ssh_username:
|
||||
st.info("Enter SSH connection details in the sidebar.")
|
||||
return
|
||||
ssh_args = (ssh_host, ssh_username, ssh_port, ssh_key, ssh_password)
|
||||
selected_path = render_file_browser(cached_dir_listing, ssh_args, media_root or "/")
|
||||
ssh = get_ssh_client(ssh_host, ssh_username, ssh_port, ssh_key, ssh_password)
|
||||
render_ssh_tools(ssh, ssh_args, selected_path, cached_ffprobe_preview)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""External service clients.
|
||||
|
||||
Modules in this package talk to systems outside the app: Jellyfin over HTTP,
|
||||
the media server over SSH, and the lightweight remote resource collector.
|
||||
They should not import Streamlit.
|
||||
"""
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Jellyfin HTTP API client.
|
||||
|
||||
This module is deliberately independent from Streamlit. It wraps only the API
|
||||
calls the app currently needs and returns plain Python dictionaries/lists so a
|
||||
future FastAPI/React frontend can reuse the same client.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
# Jellyfin validates Fields against its ItemFields enum. Keep this list to
|
||||
# documented/commonly supported optional fields; invalid names cause 400s.
|
||||
DEFAULT_FIELDS = ",".join(
|
||||
[
|
||||
"DateCreated",
|
||||
"Genres",
|
||||
"MediaSources",
|
||||
"Overview",
|
||||
"Path",
|
||||
"People",
|
||||
"PremiereDate",
|
||||
"ProviderIds",
|
||||
"Tags",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class JellyfinClient:
|
||||
"""Small wrapper around the Jellyfin/Emby-compatible HTTP API."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: int = 30):
|
||||
if not base_url:
|
||||
raise ValueError("Jellyfin URL is required")
|
||||
if not api_key:
|
||||
raise ValueError("Jellyfin API key is required")
|
||||
|
||||
# Use the server root, not the web UI path. Users often paste
|
||||
# https://host/web; API endpoints live at https://host/...
|
||||
self.base_url = base_url.rstrip("/")
|
||||
if self.base_url.endswith("/web"):
|
||||
self.base_url = self.base_url[:-4]
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"X-Emby-Token": api_key,
|
||||
"Accept": "application/json",
|
||||
"X-Emby-Authorization": 'MediaBrowser Client="MediaLibraryViewer", Device="Streamlit", DeviceId="streamlit", Version="0.1"',
|
||||
}
|
||||
)
|
||||
|
||||
def get(self, path: str, **params: Any) -> dict[str, Any]:
|
||||
"""GET a Jellyfin 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 != ""}
|
||||
response = self.session.get(
|
||||
f"{self.base_url}{path}", params=clean_params, timeout=self.timeout
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
detail = response.text[:500]
|
||||
raise requests.HTTPError(
|
||||
f"{response.status_code} for {response.url}: {detail}",
|
||||
response=response,
|
||||
) from exc
|
||||
return response.json()
|
||||
|
||||
def users(self) -> list[dict[str, Any]]:
|
||||
"""List users visible to this API key.
|
||||
|
||||
Jellyfin API keys are server-level tokens, not user session tokens, so
|
||||
/Users/Me often fails with API-key auth. The user id selected here is
|
||||
then used for user-scoped library endpoints.
|
||||
"""
|
||||
return self.get("/Users")
|
||||
|
||||
def libraries(self, user_id: str) -> list[dict[str, Any]]:
|
||||
"""Return top-level library views visible to the selected Jellyfin user."""
|
||||
return self.get(f"/Users/{user_id}/Views").get("Items", [])
|
||||
|
||||
def items(
|
||||
self,
|
||||
user_id: str,
|
||||
parent_id: str | None = None,
|
||||
start_index: int = 0,
|
||||
limit: int = 50,
|
||||
search: str | None = None,
|
||||
include_item_types: str | None = None,
|
||||
recursive: bool = True,
|
||||
sort_by: str = "SortName",
|
||||
sort_order: str = "Ascending",
|
||||
) -> dict[str, Any]:
|
||||
"""Return a paginated item list for a user/library.
|
||||
|
||||
This is used by both the visual library browser and the media-index
|
||||
builder. Keep arguments close to Jellyfin's own query parameters so the
|
||||
service layer can request server-side pagination and basic sorting.
|
||||
"""
|
||||
return self.get(
|
||||
f"/Users/{user_id}/Items",
|
||||
ParentId=parent_id,
|
||||
StartIndex=start_index,
|
||||
Limit=limit,
|
||||
SearchTerm=search,
|
||||
IncludeItemTypes=include_item_types,
|
||||
Recursive=str(recursive).lower(),
|
||||
Fields=DEFAULT_FIELDS,
|
||||
SortBy=sort_by,
|
||||
SortOrder=sort_order,
|
||||
)
|
||||
|
||||
def item_count(self, user_id: str, include_item_types: str, parent_id: str | None = None) -> int:
|
||||
"""Return a count using Jellyfin's TotalRecordCount without fetching rows."""
|
||||
response = self.get(
|
||||
f"/Users/{user_id}/Items",
|
||||
ParentId=parent_id,
|
||||
Recursive="true",
|
||||
IncludeItemTypes=include_item_types,
|
||||
Limit=0,
|
||||
)
|
||||
return int(response.get("TotalRecordCount", 0))
|
||||
|
||||
def media_counts(self, user_id: str) -> dict[str, int]:
|
||||
"""Return dashboard-level counts for the main media types."""
|
||||
return {
|
||||
"movies": self.item_count(user_id, "Movie"),
|
||||
"series": self.item_count(user_id, "Series"),
|
||||
"episodes": self.item_count(user_id, "Episode"),
|
||||
}
|
||||
|
||||
def item(self, user_id: str, item_id: str) -> dict[str, Any]:
|
||||
return self.get(f"/Users/{user_id}/Items/{item_id}", Fields=DEFAULT_FIELDS)
|
||||
|
||||
def active_sessions(self, active_within_seconds: int = 300) -> list[dict[str, Any]]:
|
||||
"""Return currently active sessions that have a now-playing item."""
|
||||
payload = self.get("/Sessions", ActiveWithinSeconds=active_within_seconds)
|
||||
sessions = payload if isinstance(payload, list) else []
|
||||
return [session for session in sessions if session.get("NowPlayingItem")]
|
||||
|
||||
def image_url(self, item_id: str, image_type: str = "Primary") -> str:
|
||||
"""Build an authenticated image URL suitable for st.image/browser use."""
|
||||
return f"{self.base_url}/Items/{item_id}/Images/{image_type}?api_key={self.api_key}"
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Remote resource collection helpers.
|
||||
|
||||
The app does not require Prometheus, Netdata, or sysstat. Instead it can install
|
||||
and manage a tiny POSIX-sh collector under /tmp on the remote server. The
|
||||
collector samples Linux /proc and /sys counters every 10 seconds and appends JSON
|
||||
Lines. This module starts/stops the collector and reads those JSONL samples.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer.clients.ssh import RemoteSSHClient
|
||||
|
||||
# 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.
|
||||
COLLECTOR_SCRIPT = r'''#!/bin/sh
|
||||
set -u
|
||||
OUT="${1:-/tmp/media_library_viewer_metrics.jsonl}"
|
||||
INTERVAL="${2:-10}"
|
||||
RETENTION_SECONDS="${3:-604800}"
|
||||
MAX_LINES="${4:-70000}"
|
||||
PRUNE_EVERY_SAMPLES="${5:-60}"
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
echo "collector starting at $(date -Is 2>/dev/null || date), interval=${INTERVAL}s, retention=${RETENTION_SECONDS}s, max_lines=${MAX_LINES}, out=${OUT}"
|
||||
|
||||
read_cpu() {
|
||||
awk '/^cpu / {print $2+$3+$4+$5+$6+$7+$8+$9+$10, $5+$6}' /proc/stat
|
||||
}
|
||||
|
||||
read_mem_pct() {
|
||||
awk '
|
||||
/^MemTotal:/ {total=$2}
|
||||
/^MemAvailable:/ {avail=$2}
|
||||
END {if (total > 0) printf "%.2f", (total-avail)*100/total; else printf "0"}
|
||||
' /proc/meminfo
|
||||
}
|
||||
|
||||
read_net_bytes() {
|
||||
awk '
|
||||
NR > 2 {
|
||||
split($0, parts, ":")
|
||||
iface = parts[1]
|
||||
stats = parts[2]
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", iface)
|
||||
gsub(/^[ \t]+|[ \t]+$/, "", stats)
|
||||
if (iface == "lo" || iface == "" || stats == "") next
|
||||
split(stats, values, /[ \t]+/)
|
||||
# /proc/net/dev after the colon:
|
||||
# receive bytes are field 1, transmit bytes are field 9.
|
||||
# Trim the stats block before split; otherwise leading whitespace can make
|
||||
# values[1] empty in some awk implementations, resulting in zero rates.
|
||||
rx += values[1] + 0
|
||||
tx += values[9] + 0
|
||||
}
|
||||
END {printf "%.0f %.0f", rx, tx}
|
||||
' /proc/net/dev
|
||||
}
|
||||
|
||||
read_disk_bytes() {
|
||||
read_sectors=0
|
||||
written_sectors=0
|
||||
for dev in /sys/block/*; do
|
||||
[ -r "$dev/stat" ] || continue
|
||||
name="$(basename "$dev")"
|
||||
case "$name" in
|
||||
loop*|ram*|fd*|sr*) continue ;;
|
||||
esac
|
||||
# Linux /sys/block/<dev>/stat fields: 3=sectors read, 7=sectors written.
|
||||
# Use POSIX sh parsing instead of bash arrays so this works on minimal systems.
|
||||
set -- $(cat "$dev/stat")
|
||||
sectors_read="${3:-0}"
|
||||
sectors_written="${7:-0}"
|
||||
read_sectors=$((read_sectors + sectors_read))
|
||||
written_sectors=$((written_sectors + sectors_written))
|
||||
done
|
||||
printf "%s %s" "$((read_sectors * 512))" "$((written_sectors * 512))"
|
||||
}
|
||||
|
||||
set -- $(read_cpu)
|
||||
prev_total="${1:-0}"
|
||||
prev_idle="${2:-0}"
|
||||
set -- $(read_net_bytes)
|
||||
prev_rx="${1:-0}"
|
||||
prev_tx="${2:-0}"
|
||||
set -- $(read_disk_bytes)
|
||||
prev_disk_read="${1:-0}"
|
||||
prev_disk_write="${2:-0}"
|
||||
prev_ts="$(date +%s)"
|
||||
sample_count=0
|
||||
|
||||
prune_metrics_file() {
|
||||
[ -f "$OUT" ] || return 0
|
||||
cutoff="$1"
|
||||
tmp="${OUT}.$$.tmp"
|
||||
awk -v cutoff="$cutoff" '
|
||||
match($0, /"ts":[0-9]+/) {
|
||||
ts = substr($0, RSTART + 5, RLENGTH - 5)
|
||||
if (ts >= cutoff) print $0
|
||||
}
|
||||
' "$OUT" | tail -n "$MAX_LINES" > "$tmp" && mv "$tmp" "$OUT"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
now_ts="$(date +%s)"
|
||||
dt=$((now_ts - prev_ts))
|
||||
if [ "$dt" -le 0 ]; then dt=1; fi
|
||||
|
||||
set -- $(read_cpu)
|
||||
total="${1:-0}"
|
||||
idle="${2:-0}"
|
||||
set -- $(read_net_bytes)
|
||||
rx="${1:-0}"
|
||||
tx="${2:-0}"
|
||||
set -- $(read_disk_bytes)
|
||||
disk_read="${1:-0}"
|
||||
disk_write="${2:-0}"
|
||||
mem_pct="$(read_mem_pct)"
|
||||
|
||||
total_delta=$((total - prev_total))
|
||||
idle_delta=$((idle - prev_idle))
|
||||
rx_delta=$((rx - prev_rx))
|
||||
tx_delta=$((tx - prev_tx))
|
||||
disk_read_delta=$((disk_read - prev_disk_read))
|
||||
disk_write_delta=$((disk_write - prev_disk_write))
|
||||
|
||||
cpu_pct="$(awk -v total="$total_delta" -v idle="$idle_delta" 'BEGIN {if (total > 0) printf "%.2f", (total-idle)*100/total; else printf "0"}')"
|
||||
rx_bytes_per_sec="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
tx_bytes_per_sec="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
rx_bps="$(awk -v bytes="$rx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||
tx_bps="$(awk -v bytes="$tx_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes*8/dt; else printf "0"}')"
|
||||
disk_read_bps="$(awk -v bytes="$disk_read_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
disk_write_bps="$(awk -v bytes="$disk_write_delta" -v dt="$dt" 'BEGIN {if (bytes >= 0) printf "%.0f", bytes/dt; else printf "0"}')"
|
||||
|
||||
printf '{"ts":%s,"cpu_pct":%s,"mem_pct":%s,"net_rx_bytes_per_sec":%s,"net_tx_bytes_per_sec":%s,"net_rx_bps":%s,"net_tx_bps":%s,"disk_read_bps":%s,"disk_write_bps":%s}\n' \
|
||||
"$now_ts" "$cpu_pct" "$mem_pct" "$rx_bytes_per_sec" "$tx_bytes_per_sec" "$rx_bps" "$tx_bps" "$disk_read_bps" "$disk_write_bps" >> "$OUT"
|
||||
|
||||
sample_count=$((sample_count + 1))
|
||||
if [ $((sample_count % PRUNE_EVERY_SAMPLES)) -eq 0 ]; then
|
||||
prune_metrics_file "$((now_ts - RETENTION_SECONDS))"
|
||||
fi
|
||||
|
||||
prev_total="$total"
|
||||
prev_idle="$idle"
|
||||
prev_rx="$rx"
|
||||
prev_tx="$tx"
|
||||
prev_disk_read="$disk_read"
|
||||
prev_disk_write="$disk_write"
|
||||
prev_ts="$now_ts"
|
||||
done
|
||||
'''
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResourceMonitorPaths:
|
||||
"""Remote file locations used by the lightweight resource collector."""
|
||||
|
||||
metrics_file: str = "/tmp/media_library_viewer_metrics.jsonl"
|
||||
pid_file: str = "/tmp/media_library_viewer_metrics.pid"
|
||||
script_file: str = "/tmp/media_library_viewer_metrics_collector.sh"
|
||||
log_file: str = "/tmp/media_library_viewer_metrics.log"
|
||||
|
||||
|
||||
def start_resource_collector(
|
||||
ssh: RemoteSSHClient,
|
||||
interval_seconds: int = 10,
|
||||
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
"""Install and start the remote metrics collector if it is not running.
|
||||
|
||||
Starting a fresh collector removes old metrics/log files because schema
|
||||
changes during development can otherwise leave mixed JSONL records behind.
|
||||
The collector prunes its own metrics file to 7 days / max_lines.
|
||||
"""
|
||||
command = f"""
|
||||
cat > {shlex.quote(paths.script_file)} <<'MLV_RESOURCE_COLLECTOR'
|
||||
{COLLECTOR_SCRIPT}
|
||||
MLV_RESOURCE_COLLECTOR
|
||||
chmod +x {shlex.quote(paths.script_file)}
|
||||
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||
echo "already running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
else
|
||||
rm -f {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)}
|
||||
nohup {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {int(interval_seconds)} {int(retention_seconds)} {int(max_lines)} >> {shlex.quote(paths.log_file)} 2>&1 &
|
||||
echo $! > {shlex.quote(paths.pid_file)}
|
||||
echo "started pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to start resource collector")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def stop_resource_collector(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Stop the remote collector process if the pid file points to one."""
|
||||
command = f"""
|
||||
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill "$pid"
|
||||
echo "stopped pid=$pid"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
rm -f {shlex.quote(paths.pid_file)}
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to stop resource collector")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def restart_resource_collector(
|
||||
ssh: RemoteSSHClient,
|
||||
interval_seconds: int = 10,
|
||||
retention_seconds: int = 7 * 24 * 60 * 60,
|
||||
max_lines: int = 70_000,
|
||||
paths: ResourceMonitorPaths = ResourceMonitorPaths(),
|
||||
) -> str:
|
||||
stop_message = stop_resource_collector(ssh, paths)
|
||||
start_message = start_resource_collector(ssh, interval_seconds, retention_seconds, max_lines, paths)
|
||||
return f"{stop_message}\n{start_message}"
|
||||
|
||||
|
||||
def resource_collector_status(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Return a short human-readable status string for the dashboard."""
|
||||
command = f"""
|
||||
if [ -f {shlex.quote(paths.pid_file)} ] && kill -0 "$(cat {shlex.quote(paths.pid_file)})" 2>/dev/null; then
|
||||
echo "running pid=$(cat {shlex.quote(paths.pid_file)})"
|
||||
else
|
||||
echo "not running"
|
||||
fi
|
||||
"""
|
||||
result = ssh.run(command, timeout=10)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to check collector status")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def resource_collector_debug_info(ssh: RemoteSSHClient, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> str:
|
||||
"""Collect remote diagnostics for troubleshooting missing metrics."""
|
||||
command = f"""
|
||||
echo "status:"
|
||||
if [ -f {shlex.quote(paths.pid_file)} ]; then
|
||||
pid="$(cat {shlex.quote(paths.pid_file)})"
|
||||
echo "pid_file=$pid"
|
||||
if kill -0 "$pid" 2>/dev/null; then echo "process=running"; else echo "process=not-running"; fi
|
||||
else
|
||||
echo "pid_file=missing"
|
||||
fi
|
||||
|
||||
echo "files:"
|
||||
ls -l {shlex.quote(paths.script_file)} {shlex.quote(paths.metrics_file)} {shlex.quote(paths.log_file)} 2>&1 || true
|
||||
|
||||
echo "sample_count:"
|
||||
if [ -f {shlex.quote(paths.metrics_file)} ]; then wc -l < {shlex.quote(paths.metrics_file)}; else echo 0; fi
|
||||
|
||||
echo "last_samples:"
|
||||
if [ -f {shlex.quote(paths.metrics_file)} ]; then tail -n 5 {shlex.quote(paths.metrics_file)}; fi
|
||||
|
||||
echo "log_tail:"
|
||||
if [ -f {shlex.quote(paths.log_file)} ]; then tail -n 40 {shlex.quote(paths.log_file)}; fi
|
||||
|
||||
echo "netdev_snapshot:"
|
||||
cat /proc/net/dev 2>&1 || true
|
||||
"""
|
||||
result = ssh.run(command, timeout=20)
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def read_resource_metrics(ssh: RemoteSSHClient, max_lines: int = 1000, paths: ResourceMonitorPaths = ResourceMonitorPaths()) -> list[dict[str, Any]]:
|
||||
"""Read recent JSONL metric samples from the remote collector file."""
|
||||
command = f"test -f {shlex.quote(paths.metrics_file)} && tail -n {int(max_lines)} {shlex.quote(paths.metrics_file)} || true"
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read resource metrics")
|
||||
rows = []
|
||||
for line in result.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return rows
|
||||
|
||||
|
||||
def disk_space(ssh: RemoteSSHClient, path: str = "/") -> dict[str, Any]:
|
||||
"""Return df information for the filesystem containing ``path``."""
|
||||
command = (
|
||||
"df -P -B1 -- "
|
||||
+ shlex.quote(path or "/")
|
||||
+ " | awk 'NR==2 {printf \"{\\\"filesystem\\\":\\\"%s\\\",\\\"size\\\":%s,\\\"used\\\":%s,\\\"available\\\":%s,\\\"used_pct\\\":\\\"%s\\\",\\\"mount\\\":\\\"%s\\\"}\", $1,$2,$3,$4,$5,$6}'"
|
||||
)
|
||||
result = ssh.run(command, timeout=20)
|
||||
if result.exit_status != 0 or not result.stdout.strip():
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||
return json.loads(result.stdout)
|
||||
@@ -0,0 +1,146 @@
|
||||
"""SSH client helpers for remote filesystem and media inspection.
|
||||
|
||||
All command execution goes through ``/bin/sh -c`` and all paths inserted into
|
||||
commands are shell-quoted by callers. This is important for two reasons:
|
||||
|
||||
1. The remote login shell may be fish/csh/etc.; internal commands are POSIX sh.
|
||||
2. Media paths frequently contain spaces and punctuation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import posixpath
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import paramiko
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Plain result object returned by remote command execution."""
|
||||
|
||||
command: str
|
||||
exit_status: int
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
class RemoteSSHClient:
|
||||
"""SSH helper for read-only inspection plus explicit job execution."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
username: str,
|
||||
port: int = 22,
|
||||
key_filename: str | None = None,
|
||||
password: str | None = None,
|
||||
timeout: int = 20,
|
||||
):
|
||||
if not host or not username:
|
||||
raise ValueError("SSH host and username are required")
|
||||
self.host = host
|
||||
self.username = username
|
||||
self.port = port
|
||||
self.key_filename = key_filename or None
|
||||
self.password = password or None
|
||||
self.timeout = timeout
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
|
||||
def connect(self) -> paramiko.SSHClient:
|
||||
"""Create or reuse the Paramiko connection.
|
||||
|
||||
Unknown host keys are rejected. Users should connect once manually with
|
||||
ssh so the server is present in known_hosts.
|
||||
"""
|
||||
if self._client:
|
||||
return self._client
|
||||
client = paramiko.SSHClient()
|
||||
client.load_system_host_keys()
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
client.connect(
|
||||
self.host,
|
||||
port=self.port,
|
||||
username=self.username,
|
||||
key_filename=self.key_filename,
|
||||
password=self.password,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def run(self, command: str, timeout: int | None = None) -> CommandResult:
|
||||
"""Run a command through POSIX sh, independent of the user's login shell.
|
||||
|
||||
Paramiko asks the SSH server to execute a command using the account's
|
||||
default shell. If that shell is fish/csh/etc., POSIX snippets containing
|
||||
`if ...; then`, pipes, redirects, or heredocs can fail. All internal app
|
||||
commands and job templates are written for POSIX shell, so explicitly
|
||||
dispatch through `/bin/sh -c`.
|
||||
"""
|
||||
client = self.connect()
|
||||
shell_command = f"/bin/sh -c {shlex.quote(command)}"
|
||||
stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout)
|
||||
exit_status = stdout.channel.recv_exit_status()
|
||||
return CommandResult(
|
||||
command=command,
|
||||
exit_status=exit_status,
|
||||
stdout=stdout.read().decode(errors="replace"),
|
||||
stderr=stderr.read().decode(errors="replace"),
|
||||
)
|
||||
|
||||
def list_dir(self, path: str) -> CommandResult:
|
||||
"""List one remote directory as JSON.
|
||||
|
||||
The command first verifies that ``path`` is a directory. Without that
|
||||
guard, running ``find`` on a file can look like an empty directory, which
|
||||
was a source of file-browser confusion. Output is NUL-delimited before
|
||||
Python serializes it, making spaces in filenames safe.
|
||||
"""
|
||||
# JSON-ish output: type, size, mtime epoch, filename. Handles spaces/newlines reasonably via NUL boundaries.
|
||||
quoted = shlex.quote(path)
|
||||
not_dir_message = shlex.quote(f"Not a directory: {path}")
|
||||
command = (
|
||||
f"test -d {quoted} || "
|
||||
f"{{ echo {not_dir_message} >&2; exit 20; }}; "
|
||||
f"find {quoted} -maxdepth 1 -mindepth 1 -printf "
|
||||
"'%y\\t%s\\t%T@\\t%f\\0' | python3 -c "
|
||||
+ shlex.quote(
|
||||
"import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); "
|
||||
"rows=[]\n"
|
||||
"for row in data:\n"
|
||||
" if not row: continue\n"
|
||||
" t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n"
|
||||
" rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n"
|
||||
"print(json.dumps(rows))"
|
||||
)
|
||||
)
|
||||
return self.run(command)
|
||||
|
||||
def stat_path(self, path: str) -> CommandResult:
|
||||
"""Run stat for a remote file or directory path."""
|
||||
quoted = shlex.quote(path)
|
||||
return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}")
|
||||
|
||||
def ffprobe_json(self, path: str) -> dict[str, Any]:
|
||||
"""Run ffprobe and parse JSON output for a remote media file."""
|
||||
quoted = shlex.quote(path)
|
||||
result = self.run(
|
||||
"ffprobe -v error -show_format -show_streams -print_format json " + quoted,
|
||||
timeout=60,
|
||||
)
|
||||
if result.exit_status != 0:
|
||||
raise RuntimeError(result.stderr or result.stdout or "ffprobe failed")
|
||||
return json.loads(result.stdout)
|
||||
|
||||
@staticmethod
|
||||
def join(parent: str, child: str) -> str:
|
||||
return posixpath.normpath(posixpath.join(parent, child))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Configuration loading for the app.
|
||||
|
||||
Configuration is intentionally environment/.env based so credentials stay out of
|
||||
source control and the same package can be reused by different frontends or
|
||||
process managers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JellyfinConfig:
|
||||
"""Jellyfin connection settings."""
|
||||
|
||||
url: str = os.getenv("JELLYFIN_URL", "")
|
||||
api_key: str = os.getenv("JELLYFIN_API_KEY", "")
|
||||
user_id: str = os.getenv("JELLYFIN_USER_ID", "")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SSHConfig:
|
||||
"""SSH connection settings for remote file/resource access."""
|
||||
|
||||
host: str = os.getenv("SSH_HOST", "")
|
||||
username: str = os.getenv("SSH_USERNAME", "")
|
||||
port: int = int(os.getenv("SSH_PORT", "22"))
|
||||
key_filename: str = os.getenv("SSH_KEY_FILENAME", str(Path.home() / ".ssh" / "id_rsa"))
|
||||
password: str = os.getenv("SSH_PASSWORD", "")
|
||||
media_root: str = os.getenv("REMOTE_MEDIA_ROOT", "")
|
||||
path_prefix: str = os.getenv("REMOTE_PATH_PREFIX", "")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppConfig:
|
||||
jellyfin: JellyfinConfig = JellyfinConfig()
|
||||
ssh: SSHConfig = SSHConfig()
|
||||
|
||||
|
||||
def load_config() -> AppConfig:
|
||||
"""Build an AppConfig snapshot from the current environment/.env file."""
|
||||
return AppConfig()
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Domain-level helpers and normalization code.
|
||||
|
||||
Domain modules convert external data into stable app concepts and should remain
|
||||
independent of Streamlit or any future frontend framework.
|
||||
"""
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Media-domain normalization helpers.
|
||||
|
||||
Jellyfin item JSON is nested and inconsistent across item types. This module
|
||||
flattens Jellyfin items into stable dictionaries suitable for storage in the
|
||||
SQLite media index and display by any frontend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from media_library_viewer.utils import human_size, ticks_to_minutes
|
||||
|
||||
|
||||
def first_media_source(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return the first Jellyfin media source, or an empty dict."""
|
||||
sources = item.get("MediaSources") or []
|
||||
return sources[0] if sources else {}
|
||||
|
||||
|
||||
def media_streams(item: dict[str, Any], stream_type: str | None = None) -> list[dict[str, Any]]:
|
||||
"""Return flattened media streams from all media sources.
|
||||
|
||||
Jellyfin usually nests streams under MediaSources, while some endpoints may
|
||||
expose stream-like fields differently. This function gives callers one place
|
||||
to get streams and optionally filter by type.
|
||||
"""
|
||||
streams = []
|
||||
for source in item.get("MediaSources") or []:
|
||||
streams.extend(source.get("MediaStreams") or [])
|
||||
if stream_type is None:
|
||||
return streams
|
||||
return [stream for stream in streams if str(stream.get("Type") or stream.get("codec_type") or "").lower() == stream_type.lower()]
|
||||
|
||||
|
||||
def stream_value(stream: dict[str, Any], *keys: str) -> Any:
|
||||
for key in keys:
|
||||
if key in stream and stream[key] not in (None, ""):
|
||||
return stream[key]
|
||||
return None
|
||||
|
||||
|
||||
def is_hdr_item(item: dict[str, Any]) -> bool:
|
||||
"""Best-effort HDR detection from Jellyfin video stream metadata."""
|
||||
hdr_markers = {"hdr", "hdr10", "hdr10+", "dolbyvision", "dovi", "hlg", "pq", "smpte2084", "bt2020"}
|
||||
for stream in media_streams(item, "Video"):
|
||||
values = [
|
||||
stream_value(stream, "VideoRange", "video_range"),
|
||||
stream_value(stream, "VideoRangeType", "video_range_type"),
|
||||
stream_value(stream, "ColorTransfer", "color_transfer"),
|
||||
stream_value(stream, "ColorPrimaries", "color_primaries"),
|
||||
stream_value(stream, "ColorSpace", "color_space"),
|
||||
stream_value(stream, "DvVersionMajor", "dv_version_major"),
|
||||
stream_value(stream, "Hdr10PlusPresent", "hdr10_plus_present"),
|
||||
]
|
||||
normalized = " ".join(str(value).lower() for value in values if value not in (None, "", False, 0))
|
||||
if any(marker in normalized for marker in hdr_markers):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def format_date_added(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return pd.to_datetime(value).strftime("%Y-%m-%d")
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
|
||||
def timestamp_date_added(value: str | None) -> int | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(pd.to_datetime(value).timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def format_rate_bits_decimal(bits_per_second: float | int | str | None) -> str:
|
||||
if bits_per_second in (None, ""):
|
||||
return ""
|
||||
try:
|
||||
value = float(bits_per_second)
|
||||
except (TypeError, ValueError):
|
||||
return str(bits_per_second)
|
||||
for unit in ["bps", "Kbps", "Mbps", "Gbps", "Tbps"]:
|
||||
if value < 1000 or unit == "Tbps":
|
||||
return f"{value:.1f} {unit}"
|
||||
value /= 1000
|
||||
return f"{value:.1f} Tbps"
|
||||
|
||||
|
||||
def normalize_media_item(item: dict[str, Any], library_id: str = "", library_name: str = "") -> dict[str, Any]:
|
||||
"""Flatten one Jellyfin item into an indexable row.
|
||||
|
||||
The returned row contains both display strings (``size``, ``bitrate``) and
|
||||
numeric sort fields (``size_bytes``, ``bitrate_bps``, ``date_added_ts``).
|
||||
"""
|
||||
source = first_media_source(item)
|
||||
video_streams = media_streams(item, "Video")
|
||||
video = video_streams[0] if video_streams else {}
|
||||
size = source.get("Size") or source.get("size")
|
||||
bitrate = source.get("Bitrate") or source.get("bitrate") or item.get("Bitrate")
|
||||
width = stream_value(video, "Width", "width")
|
||||
height = stream_value(video, "Height", "height")
|
||||
season_number = item.get("ParentIndexNumber")
|
||||
episode_number = item.get("IndexNumber")
|
||||
|
||||
hdr = is_hdr_item(item)
|
||||
return {
|
||||
"id": item.get("Id", ""),
|
||||
"title": item.get("Name", ""),
|
||||
"series": item.get("SeriesName", ""),
|
||||
"season": f"S{int(season_number):02d}" if season_number is not None else item.get("SeasonName", ""),
|
||||
"season_number": int(season_number) if season_number is not None else None,
|
||||
"episode": int(episode_number) if episode_number is not None else None,
|
||||
"type": item.get("Type", ""),
|
||||
"year": item.get("ProductionYear"),
|
||||
"runtime_ticks": item.get("RunTimeTicks"),
|
||||
"runtime_min": ticks_to_minutes(item.get("RunTimeTicks")),
|
||||
"size_bytes": int(size) if size not in (None, "") else None,
|
||||
"size": human_size(size),
|
||||
"bitrate_bps": int(bitrate) if bitrate not in (None, "") else None,
|
||||
"bitrate": format_rate_bits_decimal(bitrate),
|
||||
"hdr": 1 if hdr else 0,
|
||||
"hdr_label": "yes" if hdr else "",
|
||||
"video": video.get("Codec") or video.get("codec_name") or "",
|
||||
"width": int(width) if width not in (None, "") else None,
|
||||
"height": int(height) if height not in (None, "") else None,
|
||||
"resolution": f"{width}x{height}" if width and height else "",
|
||||
"date_added": format_date_added(item.get("DateCreated")),
|
||||
"date_added_ts": timestamp_date_added(item.get("DateCreated")),
|
||||
"path": item.get("Path") or source.get("Path") or "",
|
||||
"library_id": library_id,
|
||||
"library_name": library_name,
|
||||
}
|
||||
|
||||
|
||||
def display_media_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert a SQLite row back into frontend display fields."""
|
||||
return {
|
||||
"title": row.get("title", ""),
|
||||
"series": row.get("series", ""),
|
||||
"season": row.get("season", ""),
|
||||
"episode": row.get("episode", ""),
|
||||
"type": row.get("type", ""),
|
||||
"year": row.get("year", ""),
|
||||
"runtime_min": row.get("runtime_min", ""),
|
||||
"size": row.get("size") or human_size(row.get("size_bytes")),
|
||||
"bitrate": row.get("bitrate") or format_rate_bits_decimal(row.get("bitrate_bps")),
|
||||
"hdr": "yes" if row.get("hdr") else "no",
|
||||
"video": row.get("video", ""),
|
||||
"resolution": row.get("resolution", ""),
|
||||
"date_added": row.get("date_added", ""),
|
||||
"library": row.get("library_name", ""),
|
||||
"path": row.get("path", ""),
|
||||
"id": row.get("id", ""),
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Template-based remote jobs.
|
||||
|
||||
Remote jobs are intentionally explicit templates instead of free-form shell input.
|
||||
This keeps the UI safer and makes future destructive operations easier to wrap in
|
||||
confirmations/dry-runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
from media_library_viewer.clients.ssh import CommandResult, RemoteSSHClient
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobTemplate:
|
||||
"""Description and command template for one remote job."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
command_template: str
|
||||
destructive: bool = False
|
||||
|
||||
def render(self, values: Mapping[str, str]) -> str:
|
||||
"""Render the command with shell-quoted template values.
|
||||
|
||||
This is what keeps paths with spaces safe when inserted into job commands.
|
||||
"""
|
||||
safe_values = {key: shlex.quote(value) for key, value in values.items()}
|
||||
return self.command_template.format(**safe_values)
|
||||
|
||||
|
||||
# Phase 1 jobs are intentionally conservative. Add your own templates here later.
|
||||
JOB_TEMPLATES: dict[str, JobTemplate] = {
|
||||
"disk_usage": JobTemplate(
|
||||
name="Disk usage for selected path",
|
||||
description="Runs du -sh on the selected remote path.",
|
||||
command_template="du -sh {path}",
|
||||
),
|
||||
"ffprobe": JobTemplate(
|
||||
name="ffprobe JSON",
|
||||
description="Prints raw ffprobe stream/format metadata.",
|
||||
command_template="ffprobe -v error -show_format -show_streams -print_format json {path}",
|
||||
),
|
||||
"dry_run_find_empty_dirs": JobTemplate(
|
||||
name="Find empty directories dry-run",
|
||||
description="Lists empty directories under the selected path. Does not delete anything.",
|
||||
command_template="find {path} -type d -empty -print",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout: int = 600) -> CommandResult:
|
||||
"""Render and execute a configured job template for a selected remote path."""
|
||||
template = JOB_TEMPLATES[job_key]
|
||||
command = template.render({"path": path})
|
||||
return ssh.run(command, timeout=timeout)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Application services.
|
||||
|
||||
Services coordinate clients/domain logic into reusable operations. They are the
|
||||
natural layer to expose through a future HTTP API for a React frontend.
|
||||
"""
|
||||
@@ -0,0 +1,278 @@
|
||||
"""SQLite-backed media inventory service.
|
||||
|
||||
This is the main step toward a frontend-agnostic architecture. The Streamlit UI
|
||||
asks this service to build/query an index, but the same class could be exposed
|
||||
through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from media_library_viewer.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer.domain.media import display_media_row, normalize_media_item
|
||||
|
||||
# Local generated database. It is ignored by git and can be rebuilt from
|
||||
# Jellyfin metadata whenever needed.
|
||||
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
|
||||
MEDIA_TYPES = "Movie,Episode,Video"
|
||||
|
||||
# Only values from this whitelist are interpolated into ORDER BY. User-selected
|
||||
# sort keys map to these known SQL snippets to avoid SQL injection.
|
||||
SORT_COLUMNS = {
|
||||
"title": "title COLLATE NOCASE",
|
||||
"series": "series COLLATE NOCASE",
|
||||
"season": "season_number",
|
||||
"episode": "episode",
|
||||
"type": "type COLLATE NOCASE",
|
||||
"year": "year",
|
||||
"runtime": "runtime_min",
|
||||
"size": "size_bytes",
|
||||
"bitrate": "bitrate_bps",
|
||||
"hdr": "hdr",
|
||||
"video": "video COLLATE NOCASE",
|
||||
"resolution": "height",
|
||||
"date_added": "date_added_ts",
|
||||
"library": "library_name COLLATE NOCASE",
|
||||
"path": "path COLLATE NOCASE",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaIndexStatus:
|
||||
"""Lightweight status object displayed by the Media tab."""
|
||||
|
||||
exists: bool
|
||||
item_count: int = 0
|
||||
updated_at: int | None = None
|
||||
updated_at_label: str = ""
|
||||
build_duration_seconds: float | None = None
|
||||
|
||||
|
||||
class MediaIndex:
|
||||
"""SQLite-backed media inventory.
|
||||
|
||||
This class is UI-framework independent. Streamlit, a future FastAPI backend,
|
||||
or a React-facing API can all use this service.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str = DEFAULT_INDEX_PATH):
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
"""Open a sqlite connection configured to return Row objects."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_schema(self) -> None:
|
||||
"""Create tables/indexes if this is the first use of the index."""
|
||||
with self.connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS media_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
series TEXT,
|
||||
season TEXT,
|
||||
season_number INTEGER,
|
||||
episode INTEGER,
|
||||
type TEXT,
|
||||
year INTEGER,
|
||||
runtime_ticks INTEGER,
|
||||
runtime_min INTEGER,
|
||||
size_bytes INTEGER,
|
||||
bitrate_bps INTEGER,
|
||||
hdr INTEGER,
|
||||
video TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
resolution TEXT,
|
||||
date_added TEXT,
|
||||
date_added_ts INTEGER,
|
||||
path TEXT,
|
||||
library_id TEXT,
|
||||
library_name TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS index_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_type ON media_items(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_library ON media_items(library_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_title ON media_items(title COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_series ON media_items(series COLLATE NOCASE);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_date_added ON media_items(date_added_ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_size ON media_items(size_bytes);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_bitrate ON media_items(bitrate_bps);
|
||||
"""
|
||||
)
|
||||
|
||||
def set_metadata(self, key: str, value: str | int | float) -> None:
|
||||
"""Store a small string metadata value, e.g. build duration."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES (?, ?)",
|
||||
(key, str(value)),
|
||||
)
|
||||
|
||||
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int:
|
||||
"""Atomically replace indexed media rows with a freshly built set."""
|
||||
self.init_schema()
|
||||
row_list = list(rows)
|
||||
columns = [
|
||||
"id",
|
||||
"title",
|
||||
"series",
|
||||
"season",
|
||||
"season_number",
|
||||
"episode",
|
||||
"type",
|
||||
"year",
|
||||
"runtime_ticks",
|
||||
"runtime_min",
|
||||
"size_bytes",
|
||||
"bitrate_bps",
|
||||
"hdr",
|
||||
"video",
|
||||
"width",
|
||||
"height",
|
||||
"resolution",
|
||||
"date_added",
|
||||
"date_added_ts",
|
||||
"path",
|
||||
"library_id",
|
||||
"library_name",
|
||||
]
|
||||
placeholders = ",".join(["?"] * len(columns))
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM media_items")
|
||||
conn.executemany(
|
||||
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
|
||||
[[row.get(column) for column in columns] for row in row_list],
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
|
||||
(str(int(time.time())),),
|
||||
)
|
||||
return len(row_list)
|
||||
|
||||
def status(self) -> MediaIndexStatus:
|
||||
"""Return existence, count, update time, and last build duration."""
|
||||
if not self.db_path.exists():
|
||||
return MediaIndexStatus(exists=False)
|
||||
try:
|
||||
with self.connect() as conn:
|
||||
item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0])
|
||||
updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone()
|
||||
duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone()
|
||||
except sqlite3.Error:
|
||||
return MediaIndexStatus(exists=False)
|
||||
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
|
||||
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
|
||||
build_duration = None
|
||||
if duration_row:
|
||||
try:
|
||||
build_duration = float(duration_row[0])
|
||||
except (TypeError, ValueError):
|
||||
build_duration = None
|
||||
return MediaIndexStatus(
|
||||
exists=True,
|
||||
item_count=item_count,
|
||||
updated_at=updated_at,
|
||||
updated_at_label=label,
|
||||
build_duration_seconds=build_duration,
|
||||
)
|
||||
|
||||
def query(
|
||||
self,
|
||||
library_id: str | None = None,
|
||||
library_ids: list[str] | None = None,
|
||||
media_types: list[str] | None = None,
|
||||
search: str = "",
|
||||
hdr_filter: str = "All",
|
||||
sort_key: str = "title",
|
||||
sort_order: str = "Ascending",
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Query indexed media with full-index filters, sorting, and pagination."""
|
||||
self.init_schema()
|
||||
where = []
|
||||
params: list[Any] = []
|
||||
if library_ids:
|
||||
where.append("library_id IN (" + ",".join(["?"] * len(library_ids)) + ")")
|
||||
params.extend(library_ids)
|
||||
elif library_id:
|
||||
where.append("library_id = ?")
|
||||
params.append(library_id)
|
||||
if media_types:
|
||||
where.append("type IN (" + ",".join(["?"] * len(media_types)) + ")")
|
||||
params.extend(media_types)
|
||||
if search:
|
||||
needle = f"%{search.lower()}%"
|
||||
where.append("(LOWER(title) LIKE ? OR LOWER(series) LIKE ? OR LOWER(path) LIKE ?)")
|
||||
params.extend([needle, needle, needle])
|
||||
if hdr_filter == "HDR only":
|
||||
where.append("hdr = 1")
|
||||
elif hdr_filter == "SDR/unknown only":
|
||||
where.append("(hdr IS NULL OR hdr = 0)")
|
||||
|
||||
where_sql = " WHERE " + " AND ".join(where) if where else ""
|
||||
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
|
||||
direction = "DESC" if sort_order == "Descending" else "ASC"
|
||||
# Always add stable tie-breakers.
|
||||
order_sql = f" ORDER BY {sort_sql} {direction}, series COLLATE NOCASE ASC, season_number ASC, episode ASC, title COLLATE NOCASE ASC"
|
||||
|
||||
with self.connect() as conn:
|
||||
total = int(conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone()[0])
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?",
|
||||
[*params, int(limit), int(offset)],
|
||||
).fetchall()
|
||||
return [display_media_row(dict(row)) for row in rows], total
|
||||
|
||||
|
||||
def build_media_index(
|
||||
client: JellyfinClient,
|
||||
user_id: str,
|
||||
libraries: list[dict[str, Any]],
|
||||
index: MediaIndex | None = None,
|
||||
page_size: int = 500,
|
||||
) -> int:
|
||||
"""Fetch Jellyfin pages for all selected libraries and rebuild the index."""
|
||||
index = index or MediaIndex()
|
||||
started_at = time.perf_counter()
|
||||
normalized_rows: list[dict[str, Any]] = []
|
||||
for library in libraries:
|
||||
library_id = library.get("Id")
|
||||
library_name = library.get("Name", "")
|
||||
if not library_id:
|
||||
continue
|
||||
start = 0
|
||||
while True:
|
||||
response = client.items(
|
||||
user_id=user_id,
|
||||
parent_id=library_id,
|
||||
start_index=start,
|
||||
limit=page_size,
|
||||
include_item_types=MEDIA_TYPES,
|
||||
recursive=True,
|
||||
sort_by="SortName",
|
||||
sort_order="Ascending",
|
||||
)
|
||||
items = response.get("Items", [])
|
||||
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
|
||||
start += len(items)
|
||||
total = int(response.get("TotalRecordCount", start))
|
||||
if not items or start >= total:
|
||||
break
|
||||
count = index.replace_items(normalized_rows)
|
||||
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
|
||||
return count
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Streamlit UI modules.
|
||||
|
||||
Each module renders one major slice of the application so the main app entrypoint
|
||||
stays small and future frontend replacement is easier to reason about.
|
||||
"""
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Dashboard and resource-tab UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from media_library_viewer.clients.resources import (
|
||||
disk_space,
|
||||
read_resource_metrics,
|
||||
resource_collector_debug_info,
|
||||
resource_collector_status,
|
||||
restart_resource_collector,
|
||||
start_resource_collector,
|
||||
stop_resource_collector,
|
||||
)
|
||||
from media_library_viewer.utils import human_size
|
||||
|
||||
|
||||
def format_rate_bytes(bytes_per_second: float | int | None) -> str:
|
||||
if bytes_per_second is None:
|
||||
return ""
|
||||
return f"{human_size(bytes_per_second)}/s"
|
||||
|
||||
|
||||
def rate_scale(max_value: float | int | None) -> tuple[float, str]:
|
||||
value = abs(float(max_value or 0))
|
||||
units = [(1_000_000_000_000, "TB/s"), (1_000_000_000, "GB/s"), (1_000_000, "MB/s"), (1_000, "KB/s"), (1, "B/s")]
|
||||
for divisor, suffix in units:
|
||||
if value >= divisor or divisor == 1:
|
||||
return float(divisor), suffix
|
||||
return 1.0, units[-1][1]
|
||||
|
||||
|
||||
def scaled_rate_chart_df(chart_df: pd.DataFrame, columns: list[str], labels: list[str]) -> tuple[pd.DataFrame, str]:
|
||||
max_value = chart_df[columns].max(numeric_only=True).max()
|
||||
divisor, suffix = rate_scale(max_value)
|
||||
scaled = chart_df[columns].copy() / divisor
|
||||
scaled.columns = [f"{label} ({suffix})" for label in labels]
|
||||
return scaled, suffix
|
||||
|
||||
|
||||
def format_elapsed(seconds: float | int | None) -> str:
|
||||
if seconds is None:
|
||||
return ""
|
||||
seconds = float(seconds)
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
remainder = seconds % 60
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {remainder:.0f}s"
|
||||
hours = minutes // 60
|
||||
minutes = minutes % 60
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
|
||||
def render_media_overview(cached_media_counts, base_url: str, api_key: str, user_id: str) -> None:
|
||||
"""Render dashboard counts for movies/series/episodes."""
|
||||
st.subheader("Media library overview")
|
||||
try:
|
||||
counts = cached_media_counts(base_url, api_key, user_id)
|
||||
except Exception as exc:
|
||||
st.warning(f"Could not load Jellyfin media counts: {exc}")
|
||||
return
|
||||
|
||||
cols = st.columns(3)
|
||||
cols[0].metric("Movies", f"{counts.get('movies', 0):,}")
|
||||
cols[1].metric("Series", f"{counts.get('series', 0):,}")
|
||||
cols[2].metric("Episodes", f"{counts.get('episodes', 0):,}")
|
||||
|
||||
|
||||
def render_now_playing(cached_active_sessions, base_url: str, api_key: str) -> None:
|
||||
"""Render currently playing users/items and transcoding state."""
|
||||
st.subheader("Now playing")
|
||||
try:
|
||||
sessions = cached_active_sessions(base_url, api_key)
|
||||
except Exception as exc:
|
||||
st.warning(f"Could not load active sessions: {exc}")
|
||||
return
|
||||
|
||||
if not sessions:
|
||||
st.caption("No active playback sessions right now.")
|
||||
return
|
||||
|
||||
rows = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
session_id = session.get("Id") or ""
|
||||
user_name = session.get("UserName") or "Unknown"
|
||||
device = session.get("DeviceName") or session.get("Client") or ""
|
||||
play_state = session.get("PlayState") or {}
|
||||
paused = bool(play_state.get("IsPaused"))
|
||||
state_label = "paused" if paused else "playing"
|
||||
|
||||
series = item.get("SeriesName") or ""
|
||||
if series:
|
||||
title = f"{series} - {item.get('Name', '')}"
|
||||
else:
|
||||
title = item.get("Name") or "Unknown"
|
||||
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"user": user_name,
|
||||
"title": title,
|
||||
"type": item.get("Type", ""),
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": device,
|
||||
"session_id": session_id,
|
||||
}
|
||||
)
|
||||
|
||||
st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True)
|
||||
|
||||
|
||||
def render_resource_dashboard(get_ssh_client, ssh_args: tuple, media_root: str, detailed: bool = False) -> None:
|
||||
"""Render server resource summary or detailed resource charts."""
|
||||
st.subheader("Server resource details" if detailed else "Server overview")
|
||||
host, username, port, key_filename, password = ssh_args
|
||||
ssh = get_ssh_client(host, username, port, key_filename, password)
|
||||
|
||||
try:
|
||||
status = resource_collector_status(ssh)
|
||||
except Exception as exc:
|
||||
st.error(f"Could not check resource collector status: {exc}")
|
||||
return
|
||||
|
||||
if detailed:
|
||||
control_col, start_col, restart_col, stop_col, refresh_col = st.columns([2.3, 1, 1, 1, 1])
|
||||
control_col.caption(f"Collector: `{status}` | sample interval: 10s | retention: 7 days / 70k samples")
|
||||
if start_col.button("Start metrics", key="resource_start_metrics", use_container_width=True):
|
||||
try:
|
||||
st.success(start_resource_collector(ssh))
|
||||
except Exception as exc:
|
||||
st.error(str(exc))
|
||||
if restart_col.button("Restart", key="resource_restart_metrics", use_container_width=True):
|
||||
try:
|
||||
st.success(restart_resource_collector(ssh))
|
||||
except Exception as exc:
|
||||
st.error(str(exc))
|
||||
if stop_col.button("Stop metrics", key="resource_stop_metrics", use_container_width=True):
|
||||
try:
|
||||
st.info(stop_resource_collector(ssh))
|
||||
except Exception as exc:
|
||||
st.error(str(exc))
|
||||
if refresh_col.button("Refresh", key="resource_refresh", use_container_width=True):
|
||||
st.rerun()
|
||||
else:
|
||||
st.caption(f"Collector: `{status}`. Open the Resources tab for controls, diagnostics, and detailed charts.")
|
||||
|
||||
try:
|
||||
rows = read_resource_metrics(ssh, max_lines=1000)
|
||||
except Exception as exc:
|
||||
st.error(f"Could not read resource metrics: {exc}")
|
||||
rows = []
|
||||
|
||||
disk_path = media_root or "/"
|
||||
try:
|
||||
space = disk_space(ssh, disk_path)
|
||||
used_pct_value = float(str(space.get("used_pct", "0")).rstrip("%") or 0)
|
||||
disk_cols = st.columns(4)
|
||||
disk_cols[0].metric("Disk used", human_size(space.get("used")))
|
||||
disk_cols[1].metric("Disk available", human_size(space.get("available")))
|
||||
disk_cols[2].metric("Disk total", human_size(space.get("size")))
|
||||
disk_cols[3].metric("Used percent", f"{used_pct_value:.0f}%")
|
||||
st.progress(min(max(used_pct_value / 100, 0), 1), text=f"{space.get('mount', disk_path)} on {space.get('filesystem', '')}")
|
||||
except Exception as exc:
|
||||
st.warning(f"Could not read disk space for {disk_path}: {exc}")
|
||||
|
||||
if not rows:
|
||||
if detailed:
|
||||
st.info("No resource history yet. Click 'Start metrics' and wait at least 10 seconds for the first sample. If this stays empty, use Restart to install the latest collector script.")
|
||||
with st.expander("Collector diagnostics"):
|
||||
try:
|
||||
st.code(resource_collector_debug_info(ssh))
|
||||
except Exception as exc:
|
||||
st.error(f"Could not read collector diagnostics: {exc}")
|
||||
else:
|
||||
st.info("No resource history yet. Open the Resources tab to start the collector.")
|
||||
return
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
numeric_columns = [
|
||||
"ts", "cpu_pct", "mem_pct", "net_rx_bytes_per_sec", "net_tx_bytes_per_sec", "disk_read_bps", "disk_write_bps"
|
||||
]
|
||||
for column in numeric_columns:
|
||||
if column in df.columns:
|
||||
df[column] = pd.to_numeric(df[column], errors="coerce")
|
||||
df = df.dropna(subset=["ts"])
|
||||
df["time"] = pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert(None)
|
||||
|
||||
cutoff_ts = time.time() - 3600
|
||||
all_sample_count = len(df)
|
||||
df = df[df["ts"] >= cutoff_ts]
|
||||
if df.empty:
|
||||
st.info("No samples in the last hour yet.")
|
||||
if detailed:
|
||||
with st.expander("Resource sample diagnostics"):
|
||||
if all_sample_count:
|
||||
raw_df = pd.DataFrame(rows)
|
||||
st.write(f"Parsed samples: {all_sample_count}")
|
||||
st.write(f"Newest remote sample age: {time.time() - float(raw_df['ts'].astype(float).max()):.0f} seconds")
|
||||
st.dataframe(raw_df.tail(10), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.write("No parseable samples found.")
|
||||
return
|
||||
|
||||
df = df.sort_values("ts")
|
||||
latest = df.iloc[-1]
|
||||
avg_cpu = df["cpu_pct"].mean()
|
||||
max_cpu = df["cpu_pct"].max()
|
||||
avg_mem = df["mem_pct"].mean()
|
||||
max_mem = df["mem_pct"].max()
|
||||
avg_net_down = df["net_rx_bytes_per_sec"].mean()
|
||||
max_net_down = df["net_rx_bytes_per_sec"].max()
|
||||
avg_net_up = df["net_tx_bytes_per_sec"].mean()
|
||||
max_net_up = df["net_tx_bytes_per_sec"].max()
|
||||
avg_disk_read = df["disk_read_bps"].mean()
|
||||
max_disk_read = df["disk_read_bps"].max()
|
||||
avg_disk_write = df["disk_write_bps"].mean()
|
||||
max_disk_write = df["disk_write_bps"].max()
|
||||
|
||||
metric_cols = st.columns(6)
|
||||
metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%", f"avg {avg_cpu:.1f}% / peak {max_cpu:.1f}%")
|
||||
metric_cols[1].metric("RAM now", f"{latest['mem_pct']:.1f}%", f"avg {avg_mem:.1f}% / peak {max_mem:.1f}%")
|
||||
metric_cols[2].metric("Network down", format_rate_bytes(latest["net_rx_bytes_per_sec"]), f"avg {format_rate_bytes(avg_net_down)} / peak {format_rate_bytes(max_net_down)}")
|
||||
metric_cols[3].metric("Network up", format_rate_bytes(latest["net_tx_bytes_per_sec"]), f"avg {format_rate_bytes(avg_net_up)} / peak {format_rate_bytes(max_net_up)}")
|
||||
metric_cols[4].metric("Disk read", format_rate_bytes(latest["disk_read_bps"]), f"avg {format_rate_bytes(avg_disk_read)} / peak {format_rate_bytes(max_disk_read)}")
|
||||
metric_cols[5].metric("Disk write", format_rate_bytes(latest["disk_write_bps"]), f"avg {format_rate_bytes(avg_disk_write)} / peak {format_rate_bytes(max_disk_write)}")
|
||||
|
||||
chart_df = df.set_index("time")
|
||||
if detailed:
|
||||
st.markdown("**CPU and RAM - last hour**")
|
||||
st.line_chart(chart_df[["cpu_pct", "mem_pct"]], use_container_width=True)
|
||||
else:
|
||||
st.caption("Detailed CPU/RAM charts, network, disk I/O, raw samples, and collector controls are available in the Resources tab.")
|
||||
return
|
||||
|
||||
net_down_df, net_down_suffix = scaled_rate_chart_df(chart_df, ["net_rx_bytes_per_sec"], ["download"])
|
||||
net_up_df, net_up_suffix = scaled_rate_chart_df(chart_df, ["net_tx_bytes_per_sec"], ["upload"])
|
||||
net_down_col, net_up_col = st.columns(2)
|
||||
with net_down_col:
|
||||
st.markdown(f"**Network down - last hour ({net_down_suffix})**")
|
||||
st.line_chart(net_down_df, use_container_width=True)
|
||||
with net_up_col:
|
||||
st.markdown(f"**Network up - last hour ({net_up_suffix})**")
|
||||
st.line_chart(net_up_df, use_container_width=True)
|
||||
|
||||
disk_read_df, disk_read_suffix = scaled_rate_chart_df(chart_df, ["disk_read_bps"], ["read"])
|
||||
disk_write_df, disk_write_suffix = scaled_rate_chart_df(chart_df, ["disk_write_bps"], ["write"])
|
||||
disk_read_col, disk_write_col = st.columns(2)
|
||||
with disk_read_col:
|
||||
st.markdown(f"**Disk read - last hour ({disk_read_suffix})**")
|
||||
st.line_chart(disk_read_df, use_container_width=True)
|
||||
with disk_write_col:
|
||||
st.markdown(f"**Disk write - last hour ({disk_write_suffix})**")
|
||||
st.line_chart(disk_write_df, use_container_width=True)
|
||||
|
||||
with st.expander("Raw resource samples"):
|
||||
st.dataframe(df.sort_values("time", ascending=False), use_container_width=True, hide_index=True)
|
||||
@@ -0,0 +1,271 @@
|
||||
"""SSH file browser UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Callable
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from st_aggrid import AgGrid, DataReturnMode, GridOptionsBuilder, GridUpdateMode, JsCode
|
||||
|
||||
from media_library_viewer.utils import human_size, timestamp_to_local
|
||||
|
||||
|
||||
FILE_BROWSER_FILTER_KEYS = [
|
||||
"file_browser_kind_filter",
|
||||
"file_browser_search",
|
||||
"file_browser_extension_filter",
|
||||
"file_browser_sort",
|
||||
"file_browser_descending",
|
||||
"file_browser_page",
|
||||
]
|
||||
|
||||
|
||||
def reset_file_browser_filters() -> None:
|
||||
"""Clear directory-local filters before opening another folder."""
|
||||
for key in FILE_BROWSER_FILTER_KEYS:
|
||||
st.session_state.pop(key, None)
|
||||
|
||||
|
||||
def set_file_browser_path(path: str, selected_path: str | None = None, reset_filters: bool = True) -> None:
|
||||
"""Set current directory and selected path for the File browser."""
|
||||
if reset_filters:
|
||||
st.session_state["file_browser_reset_filters_pending"] = True
|
||||
st.session_state["file_browser_current_dir"] = path
|
||||
st.session_state["file_browser_selected_path"] = selected_path or path
|
||||
st.session_state["file_browser_sync_path_input"] = True
|
||||
|
||||
|
||||
def aggrid_selected_rows(response: dict) -> list[dict]:
|
||||
"""Return selected rows from a streamlit-aggrid response."""
|
||||
selected_rows = response.get("selected_rows")
|
||||
if selected_rows is None:
|
||||
return []
|
||||
if isinstance(selected_rows, pd.DataFrame):
|
||||
return selected_rows.to_dict("records")
|
||||
return list(selected_rows)
|
||||
|
||||
|
||||
def render_file_browser(cached_dir_listing: Callable[..., list[dict]], ssh_args: tuple, initial_path: str) -> str:
|
||||
"""Render the File browser and return the selected file/folder path."""
|
||||
host, username, port, key_filename, password = ssh_args
|
||||
st.subheader("Remote filesystem")
|
||||
|
||||
if "file_browser_current_dir" not in st.session_state:
|
||||
st.session_state["file_browser_current_dir"] = initial_path or "/"
|
||||
if "file_browser_selected_path" not in st.session_state:
|
||||
st.session_state["file_browser_selected_path"] = st.session_state["file_browser_current_dir"]
|
||||
if st.session_state.pop("file_browser_reset_filters_pending", False):
|
||||
reset_file_browser_filters()
|
||||
if "file_browser_path_input" not in st.session_state or st.session_state.pop("file_browser_sync_path_input", False):
|
||||
st.session_state["file_browser_path_input"] = st.session_state["file_browser_current_dir"]
|
||||
|
||||
current_dir = st.session_state["file_browser_current_dir"] or "/"
|
||||
selected = st.session_state.get("file_browser_selected_path", current_dir)
|
||||
|
||||
status_col, selected_col = st.columns([1, 2])
|
||||
status_col.caption(f"Current folder: `{current_dir}`")
|
||||
selected_col.caption(f"Selected path: `{selected}`")
|
||||
|
||||
path_col, up_col, go_col, select_col, refresh_col = st.columns([5, 1, 1, 1.5, 1.5])
|
||||
path_input = path_col.text_input("Remote path", key="file_browser_path_input", label_visibility="collapsed")
|
||||
requested_path = path_input or "/"
|
||||
if up_col.button("Up", key="file_browser_up", use_container_width=True):
|
||||
set_file_browser_path(str(PurePosixPath(current_dir).parent))
|
||||
st.rerun()
|
||||
if go_col.button("Go", key="file_browser_go", use_container_width=True):
|
||||
set_file_browser_path(requested_path)
|
||||
st.rerun()
|
||||
if select_col.button("Select folder", key="file_browser_select_current_folder", use_container_width=True):
|
||||
if requested_path != current_dir:
|
||||
set_file_browser_path(requested_path, requested_path)
|
||||
else:
|
||||
st.session_state["file_browser_selected_path"] = current_dir
|
||||
st.rerun()
|
||||
if refresh_col.button("Refresh", key="file_browser_refresh", use_container_width=True):
|
||||
cached_dir_listing.clear()
|
||||
if requested_path != current_dir:
|
||||
set_file_browser_path(requested_path)
|
||||
st.rerun()
|
||||
|
||||
try:
|
||||
rows = cached_dir_listing(host, username, port, key_filename, password, current_dir)
|
||||
except Exception as exc:
|
||||
st.error(f"Could not list directory `{current_dir}`: {exc}")
|
||||
return st.session_state.get("file_browser_selected_path")
|
||||
|
||||
display_rows = []
|
||||
for row in rows:
|
||||
kind = "dir" if row["type"] == "d" else "file"
|
||||
name = row["name"]
|
||||
extension = PurePosixPath(name).suffix.lower() if kind == "file" else ""
|
||||
full_path = str(PurePosixPath(current_dir) / name)
|
||||
display_rows.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"label": "[DIR]" if kind == "dir" else "[FILE]",
|
||||
"name": name,
|
||||
"display_name": f"{'[DIR]' if kind == 'dir' else '[FILE]'} {name}",
|
||||
"extension": extension,
|
||||
"size_bytes": int(row["size"]),
|
||||
"size": "-" if kind == "dir" else human_size(row["size"]),
|
||||
"mtime": float(row["mtime"]),
|
||||
"modified": timestamp_to_local(row["mtime"]),
|
||||
"path": full_path,
|
||||
}
|
||||
)
|
||||
|
||||
total_count = len(display_rows)
|
||||
dir_count = sum(1 for r in display_rows if r["kind"] == "dir")
|
||||
file_count = total_count - dir_count
|
||||
total_file_size = sum(r["size_bytes"] for r in display_rows if r["kind"] == "file")
|
||||
st.caption(
|
||||
f"Entries: {total_count} | Directories: {dir_count} | Files: {file_count} | File size: {human_size(total_file_size)}"
|
||||
)
|
||||
|
||||
with st.container(border=True):
|
||||
filter_col, search_col, ext_col, sort_col, order_col, page_size_col = st.columns([1.1, 2.3, 1.1, 1.2, 1, 1])
|
||||
kind_filter = filter_col.selectbox("Show", ["All", "Directories", "Files"], key="file_browser_kind_filter", label_visibility="collapsed")
|
||||
search_term = search_col.text_input("Search", placeholder="Search names", key="file_browser_search", label_visibility="collapsed")
|
||||
known_exts = sorted({r["extension"] for r in display_rows if r["extension"]})
|
||||
extension_filter = ext_col.selectbox("Ext", ["All"] + known_exts, key="file_browser_extension_filter", label_visibility="collapsed")
|
||||
sort_by = sort_col.selectbox("Sort", ["Name", "Kind", "Size", "Modified"], key="file_browser_sort", label_visibility="collapsed")
|
||||
descending = order_col.toggle("Desc", value=False, key="file_browser_descending")
|
||||
page_size = page_size_col.selectbox("Rows", [10, 25, 50, 100, 200], index=1, key="file_browser_page_size", label_visibility="collapsed")
|
||||
|
||||
filtered_rows = display_rows
|
||||
if kind_filter == "Directories":
|
||||
filtered_rows = [r for r in filtered_rows if r["kind"] == "dir"]
|
||||
elif kind_filter == "Files":
|
||||
filtered_rows = [r for r in filtered_rows if r["kind"] == "file"]
|
||||
if search_term:
|
||||
needle = search_term.lower()
|
||||
filtered_rows = [r for r in filtered_rows if needle in r["name"].lower()]
|
||||
if extension_filter != "All":
|
||||
filtered_rows = [r for r in filtered_rows if r["extension"] == extension_filter]
|
||||
|
||||
sort_key_map = {
|
||||
"Name": lambda r: (r["kind"] != "dir", r["name"].lower()),
|
||||
"Kind": lambda r: (r["kind"], r["name"].lower()),
|
||||
"Size": lambda r: (r["kind"] != "dir", r["size_bytes"]),
|
||||
"Modified": lambda r: r["mtime"],
|
||||
}
|
||||
filtered_rows.sort(key=sort_key_map[sort_by], reverse=descending)
|
||||
|
||||
filtered_count = len(filtered_rows)
|
||||
page_count = max(1, (filtered_count + page_size - 1) // page_size)
|
||||
page_col, summary_col = st.columns([1, 5])
|
||||
if st.session_state.get("file_browser_page", 1) > page_count:
|
||||
st.session_state["file_browser_page"] = page_count
|
||||
page_number = page_col.number_input("Page", min_value=1, max_value=page_count, value=1, step=1, key="file_browser_page")
|
||||
start = (int(page_number) - 1) * page_size
|
||||
end = start + page_size
|
||||
page_rows = filtered_rows[start:end]
|
||||
summary_col.caption(f"Showing {start + 1 if filtered_count else 0}-{min(end, filtered_count)} of {filtered_count} matching entries")
|
||||
|
||||
parent_path = str(PurePosixPath(current_dir).parent)
|
||||
visible_rows = []
|
||||
if current_dir != "/":
|
||||
visible_rows.append(
|
||||
{
|
||||
"kind": "up",
|
||||
"label": "[UP]",
|
||||
"name": "..",
|
||||
"display_name": "[UP] ..",
|
||||
"extension": "",
|
||||
"size_bytes": 0,
|
||||
"size": "-",
|
||||
"mtime": 0.0,
|
||||
"modified": "",
|
||||
"path": parent_path,
|
||||
}
|
||||
)
|
||||
visible_rows.extend(page_rows)
|
||||
|
||||
if not display_rows:
|
||||
st.info("Directory is empty.")
|
||||
elif not page_rows:
|
||||
st.info("No entries match the current filters.")
|
||||
|
||||
if not visible_rows:
|
||||
with st.expander("File browser diagnostics"):
|
||||
st.write(f"Current folder: `{current_dir}`")
|
||||
st.write(f"Path input: `{requested_path}`")
|
||||
st.write(f"Selected path: `{selected}`")
|
||||
st.write(f"Raw entries returned by remote listing: {len(rows)}")
|
||||
return selected
|
||||
|
||||
table_rows = [
|
||||
{
|
||||
"type": row["kind"],
|
||||
"name": row["name"],
|
||||
"ext": row["extension"],
|
||||
"size": row["size"],
|
||||
"modified": row["modified"],
|
||||
"path": row["path"],
|
||||
}
|
||||
for row in visible_rows
|
||||
]
|
||||
table_df = pd.DataFrame(table_rows)
|
||||
|
||||
grid_builder = GridOptionsBuilder.from_dataframe(table_df)
|
||||
grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False)
|
||||
grid_builder.configure_column("type", width=90)
|
||||
grid_builder.configure_column("name", flex=2)
|
||||
grid_builder.configure_column("ext", width=90)
|
||||
grid_builder.configure_column("size", width=120)
|
||||
grid_builder.configure_column("modified", width=180)
|
||||
grid_builder.configure_column("path", hide=True)
|
||||
grid_builder.configure_selection(selection_mode="single", use_checkbox=False)
|
||||
grid_options = grid_builder.build()
|
||||
grid_options["rowSelection"] = {
|
||||
"mode": "singleRow",
|
||||
"checkboxes": False,
|
||||
"headerCheckbox": False,
|
||||
"enableClickSelection": True,
|
||||
}
|
||||
grid_options["suppressRowClickSelection"] = False
|
||||
grid_options["suppressCellFocus"] = True
|
||||
grid_options["onCellClicked"] = JsCode(
|
||||
"""
|
||||
function(event) {
|
||||
if (event && event.node) {
|
||||
event.node.setSelected(true, true);
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
grid_response = AgGrid(
|
||||
table_df,
|
||||
gridOptions=grid_options,
|
||||
height=min(360, 33 * (len(table_rows) + 1)),
|
||||
fit_columns_on_grid_load=True,
|
||||
data_return_mode=DataReturnMode.AS_INPUT,
|
||||
update_mode=GridUpdateMode.SELECTION_CHANGED,
|
||||
key="file_browser_grid",
|
||||
theme="streamlit",
|
||||
allow_unsafe_jscode=True,
|
||||
)
|
||||
|
||||
selected_rows = aggrid_selected_rows(grid_response)
|
||||
if selected_rows:
|
||||
picked_row = selected_rows[0]
|
||||
picked_path = picked_row.get("path")
|
||||
picked_type = picked_row.get("type")
|
||||
action_token = f"{picked_type}:{picked_path}"
|
||||
|
||||
# Open directories immediately on row select, including the [UP] row.
|
||||
if picked_type in {"dir", "up"} and picked_path and picked_path != current_dir:
|
||||
if st.session_state.get("file_browser_last_row_action") != action_token:
|
||||
st.session_state["file_browser_last_row_action"] = action_token
|
||||
set_file_browser_path(picked_path)
|
||||
st.rerun()
|
||||
elif picked_path:
|
||||
st.session_state["file_browser_selected_path"] = picked_path
|
||||
st.session_state["file_browser_last_row_action"] = action_token
|
||||
|
||||
st.caption("Select a directory row to open it (including [UP] ..). Select a file row to target metadata/jobs.")
|
||||
return st.session_state.get("file_browser_selected_path", current_dir)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Jellyfin library browser UI pieces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from media_library_viewer.domain.media import media_streams
|
||||
from media_library_viewer.utils import ticks_to_minutes
|
||||
|
||||
|
||||
def show_item_card(client, item: dict[str, Any]) -> None:
|
||||
"""Render a poster card in the Jellyfin library grid."""
|
||||
try:
|
||||
st.image(client.image_url(item["Id"]), use_container_width=True)
|
||||
except Exception:
|
||||
st.caption("No image")
|
||||
st.markdown(f"**{item.get('Name', 'Untitled')}**")
|
||||
meta = [item.get("Type", "")]
|
||||
if item.get("ProductionYear"):
|
||||
meta.append(str(item["ProductionYear"]))
|
||||
minutes = ticks_to_minutes(item.get("RunTimeTicks"))
|
||||
if minutes:
|
||||
meta.append(f"{minutes} min")
|
||||
st.caption(" - ".join([m for m in meta if m]))
|
||||
if st.button("Open", key=f"open-{item['Id']}"):
|
||||
st.session_state["selected_item_id"] = item["Id"]
|
||||
|
||||
|
||||
def show_item_detail(client, item: dict[str, Any]) -> None:
|
||||
"""Render detailed Jellyfin item metadata for the selected poster card."""
|
||||
st.header(item.get("Name", "Untitled"))
|
||||
left, right = st.columns([1, 2])
|
||||
with left:
|
||||
st.image(client.image_url(item["Id"]), use_container_width=True)
|
||||
with right:
|
||||
st.write(item.get("Overview") or "No overview.")
|
||||
st.write("**Path:**", item.get("Path") or "Not exposed by Jellyfin")
|
||||
st.write("**Genres:**", ", ".join(item.get("Genres", [])) or "-")
|
||||
st.write("**Rating:**", item.get("CommunityRating") or "-")
|
||||
st.write("**Official rating:**", item.get("OfficialRating") or "-")
|
||||
|
||||
streams = media_streams(item)
|
||||
if streams:
|
||||
st.subheader("Jellyfin media streams")
|
||||
st.dataframe(pd.DataFrame(streams), use_container_width=True)
|
||||
|
||||
with st.expander("Raw Jellyfin JSON"):
|
||||
st.json(item)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Media index tab UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Callable
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
from st_aggrid import AgGrid, DataReturnMode, GridOptionsBuilder, GridUpdateMode, JsCode
|
||||
|
||||
from media_library_viewer.services.media_index import MediaIndex, build_media_index
|
||||
|
||||
|
||||
def aggrid_selected_rows(response: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return selected rows from a streamlit-aggrid response."""
|
||||
selected_rows = response.get("selected_rows")
|
||||
if selected_rows is None:
|
||||
return []
|
||||
if isinstance(selected_rows, pd.DataFrame):
|
||||
return selected_rows.to_dict("records")
|
||||
return list(selected_rows)
|
||||
|
||||
|
||||
def format_elapsed(seconds: float | int | None) -> str:
|
||||
if seconds is None:
|
||||
return ""
|
||||
seconds = float(seconds)
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = int(seconds // 60)
|
||||
remainder = seconds % 60
|
||||
if minutes < 60:
|
||||
return f"{minutes}m {remainder:.0f}s"
|
||||
hours = minutes // 60
|
||||
minutes = minutes % 60
|
||||
return f"{hours}h {minutes}m"
|
||||
|
||||
|
||||
def render_media_tab(
|
||||
client,
|
||||
user_id: str,
|
||||
libraries: list[dict[str, Any]],
|
||||
set_file_browser_path: Callable[[str, str | None, bool], None],
|
||||
) -> None:
|
||||
"""Render the SQLite-backed media inventory tab."""
|
||||
st.subheader("Media inventory")
|
||||
st.caption("SQLite-backed index for full-library sorting/filtering. File size/bitrate/HDR are based on Jellyfin media source metadata, not a full ffprobe scan.")
|
||||
|
||||
index = MediaIndex()
|
||||
status = index.status()
|
||||
|
||||
status_col, build_col, refresh_col = st.columns([3.5, 1.2, 1])
|
||||
if status.exists:
|
||||
status_parts = [f"Index: {status.item_count:,} items"]
|
||||
if status.updated_at_label:
|
||||
status_parts.append(f"updated {status.updated_at_label}")
|
||||
if status.build_duration_seconds is not None:
|
||||
status_parts.append(f"last build took {format_elapsed(status.build_duration_seconds)}")
|
||||
status_col.caption(" | ".join(status_parts))
|
||||
else:
|
||||
status_col.warning("No local media index yet. Build it to enable full-library sorting and filtering.")
|
||||
|
||||
if build_col.button("Build index", key="media_index_build", use_container_width=True):
|
||||
with st.spinner("Building media index from Jellyfin. This can take a while for large libraries..."):
|
||||
count = build_media_index(client, user_id, libraries, index)
|
||||
st.success(f"Indexed {count:,} media items.")
|
||||
st.rerun()
|
||||
if refresh_col.button("Refresh", key="media_index_refresh", use_container_width=True):
|
||||
st.rerun()
|
||||
|
||||
if not index.status().exists:
|
||||
st.info("The Media tab uses a local SQLite index so sorting by size, bitrate, HDR, codec, season, and episode works across the whole library rather than just the current Jellyfin page.")
|
||||
return
|
||||
|
||||
library_options = {lib["Name"]: lib["Id"] for lib in libraries}
|
||||
|
||||
filter_col, type_col, search_col, page_size_col, page_col = st.columns([1.9, 1.8, 2.4, 1.1, 1])
|
||||
selected_libraries = filter_col.multiselect(
|
||||
"Libraries",
|
||||
list(library_options.keys()),
|
||||
default=list(library_options.keys()),
|
||||
key="media_inventory_libraries",
|
||||
)
|
||||
media_types = type_col.multiselect(
|
||||
"Types",
|
||||
["Movie", "Episode", "Video"],
|
||||
default=["Movie", "Episode"],
|
||||
key="media_inventory_types",
|
||||
)
|
||||
search = search_col.text_input("Search", key="media_inventory_search")
|
||||
page_size = page_size_col.selectbox("Rows", [50, 100, 250, 500], index=1, key="media_inventory_page_size")
|
||||
page = page_col.number_input("Page", min_value=1, value=1, step=1, key="media_inventory_page")
|
||||
|
||||
sort_options = {
|
||||
"Title": "title",
|
||||
"Series": "series",
|
||||
"Season": "season",
|
||||
"Episode": "episode",
|
||||
"Type": "type",
|
||||
"Year": "year",
|
||||
"Runtime": "runtime",
|
||||
"Size": "size",
|
||||
"Bitrate": "bitrate",
|
||||
"HDR": "hdr",
|
||||
"Video codec": "video",
|
||||
"Resolution": "resolution",
|
||||
"Date added": "date_added",
|
||||
"Library": "library",
|
||||
"Path": "path",
|
||||
}
|
||||
sort_col, order_col, hdr_col = st.columns([1.4, 1.1, 1.2])
|
||||
sort_label = sort_col.selectbox("Sort", list(sort_options.keys()), key="media_inventory_sort")
|
||||
sort_order_label = order_col.selectbox("Order", ["Ascending", "Descending"], key="media_inventory_sort_order")
|
||||
hdr_filter = hdr_col.selectbox("HDR filter", ["All", "HDR only", "SDR/unknown only"], key="media_inventory_hdr_filter")
|
||||
|
||||
if not selected_libraries:
|
||||
st.info("Select at least one library to show indexed media.")
|
||||
return
|
||||
|
||||
rows, total = index.query(
|
||||
library_ids=[library_options[name] for name in selected_libraries],
|
||||
media_types=media_types or ["Movie", "Episode", "Video"],
|
||||
search=search,
|
||||
hdr_filter=hdr_filter,
|
||||
sort_key=sort_options[sort_label],
|
||||
sort_order=sort_order_label,
|
||||
limit=int(page_size),
|
||||
offset=(int(page) - 1) * int(page_size),
|
||||
)
|
||||
|
||||
st.caption(f"Showing {len(rows)} of {total:,} indexed matching items. Sort and filters apply to the full local index.")
|
||||
|
||||
columns = [
|
||||
"title", "series", "season", "episode", "type", "year", "runtime_min",
|
||||
"size", "bitrate", "hdr", "video", "resolution", "date_added", "library", "path", "id",
|
||||
]
|
||||
if not rows:
|
||||
st.info("No media found for the current filters.")
|
||||
return
|
||||
|
||||
table_df = pd.DataFrame(rows)[columns].fillna("")
|
||||
selected_media_path = st.session_state.get("media_inventory_selected_path")
|
||||
|
||||
grid_builder = GridOptionsBuilder.from_dataframe(table_df)
|
||||
grid_builder.configure_default_column(editable=False, resizable=True, sortable=False, filter=False)
|
||||
grid_builder.configure_column("title", header_name="Title", flex=2)
|
||||
grid_builder.configure_column("series", header_name="Series", flex=1.5)
|
||||
grid_builder.configure_column("season", header_name="Season", width=95)
|
||||
grid_builder.configure_column("episode", header_name="Episode", width=105)
|
||||
grid_builder.configure_column("type", header_name="Type", width=100)
|
||||
grid_builder.configure_column("year", header_name="Year", width=90)
|
||||
grid_builder.configure_column("runtime_min", header_name="Runtime (min)", width=125)
|
||||
grid_builder.configure_column("size", header_name="Size", width=120)
|
||||
grid_builder.configure_column("bitrate", header_name="Bitrate", width=125)
|
||||
grid_builder.configure_column("hdr", header_name="HDR", width=80)
|
||||
grid_builder.configure_column("video", header_name="Video codec", width=120)
|
||||
grid_builder.configure_column("resolution", header_name="Resolution", width=120)
|
||||
grid_builder.configure_column("date_added", header_name="Date added", width=120)
|
||||
grid_builder.configure_column("library", header_name="Library", width=140)
|
||||
grid_builder.configure_column("path", header_name="Path", flex=2)
|
||||
grid_builder.configure_column("id", hide=True)
|
||||
grid_builder.configure_selection(selection_mode="single", use_checkbox=False)
|
||||
grid_options = grid_builder.build()
|
||||
grid_options["rowSelection"] = {
|
||||
"mode": "singleRow",
|
||||
"checkboxes": False,
|
||||
"headerCheckbox": False,
|
||||
"enableClickSelection": True,
|
||||
}
|
||||
grid_options["suppressRowClickSelection"] = False
|
||||
grid_options["suppressCellFocus"] = True
|
||||
grid_options["onCellClicked"] = JsCode(
|
||||
"""
|
||||
function(event) {
|
||||
if (event && event.node) {
|
||||
event.node.setSelected(true, true);
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
grid_response = AgGrid(
|
||||
table_df,
|
||||
gridOptions=grid_options,
|
||||
height=min(650, 35 * (len(rows) + 1)),
|
||||
fit_columns_on_grid_load=True,
|
||||
data_return_mode=DataReturnMode.AS_INPUT,
|
||||
update_mode=GridUpdateMode.SELECTION_CHANGED,
|
||||
key="media_inventory_grid",
|
||||
theme="streamlit",
|
||||
allow_unsafe_jscode=True,
|
||||
)
|
||||
selected_rows = aggrid_selected_rows(grid_response)
|
||||
if selected_rows:
|
||||
selected_media_path = selected_rows[0].get("path")
|
||||
if selected_media_path:
|
||||
st.session_state["media_inventory_selected_path"] = selected_media_path
|
||||
# Auto-sync File browser location from Media row selection.
|
||||
# Guarded by last-synced path to avoid reapplying on every rerun.
|
||||
last_synced = st.session_state.get("media_inventory_last_synced_path")
|
||||
if selected_media_path != last_synced:
|
||||
set_file_browser_path(str(PurePosixPath(selected_media_path).parent), selected_media_path)
|
||||
st.session_state["media_inventory_last_synced_path"] = selected_media_path
|
||||
|
||||
if selected_media_path:
|
||||
st.caption(f"Selected media path: `{selected_media_path}` (File browser folder synced automatically)")
|
||||
else:
|
||||
st.caption("Select a table row to automatically sync its containing folder to the File browser tab.")
|
||||
|
||||
with st.expander("Notes"):
|
||||
st.write(
|
||||
"The Media tab now queries a local SQLite index, so sorting/filtering applies across the indexed library. "
|
||||
"Rebuild the index after Jellyfin scans or metadata changes. Full ffprobe enrichment for every item can be added later as a background index extension."
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Selected-file preview and remote path tools UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
from media_library_viewer.jobs import JOB_TEMPLATES, run_job
|
||||
from media_library_viewer.utils import (
|
||||
ffprobe_format_summary,
|
||||
is_known_video_file,
|
||||
summarize_audio_streams,
|
||||
summarize_streams,
|
||||
summarize_subtitle_streams,
|
||||
summarize_video_streams,
|
||||
)
|
||||
|
||||
|
||||
def render_ffprobe_sections(ffprobe_data: dict[str, Any]) -> None:
|
||||
"""Render ffprobe output in separate container/video/audio/subtitle sections."""
|
||||
format_summary = ffprobe_format_summary(ffprobe_data)
|
||||
video_rows = summarize_video_streams(ffprobe_data)
|
||||
audio_rows = summarize_audio_streams(ffprobe_data)
|
||||
subtitle_rows = summarize_subtitle_streams(ffprobe_data)
|
||||
|
||||
st.markdown("**Container**")
|
||||
st.dataframe(pd.DataFrame([format_summary]), use_container_width=True, hide_index=True)
|
||||
|
||||
st.markdown("**Video**")
|
||||
if video_rows:
|
||||
st.dataframe(pd.DataFrame(video_rows), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("No video streams found.")
|
||||
|
||||
st.markdown("**Audio**")
|
||||
if audio_rows:
|
||||
st.dataframe(pd.DataFrame(audio_rows), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("No audio streams found.")
|
||||
|
||||
st.markdown("**Subtitles**")
|
||||
if subtitle_rows:
|
||||
st.dataframe(pd.DataFrame(subtitle_rows), use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("No subtitle streams found.")
|
||||
|
||||
|
||||
def render_selected_file_preview(
|
||||
ssh_args: tuple,
|
||||
selected_path: str | None,
|
||||
cached_ffprobe_preview: Callable[..., dict[str, Any]],
|
||||
) -> None:
|
||||
"""Run and render a blocking ffprobe preview for selected known video files."""
|
||||
with st.container(border=True):
|
||||
st.markdown("**Selected file preview**")
|
||||
if not selected_path:
|
||||
st.caption("Select a file to preview media metadata.")
|
||||
return
|
||||
|
||||
st.caption(f"Path: `{selected_path}`")
|
||||
if not is_known_video_file(selected_path):
|
||||
st.caption("Automatic ffprobe preview runs for known video file extensions only.")
|
||||
return
|
||||
|
||||
refresh_col, status_col = st.columns([1.2, 5])
|
||||
if refresh_col.button("Reload preview", key="preview_reload", use_container_width=True):
|
||||
cached_ffprobe_preview.clear()
|
||||
st.rerun()
|
||||
|
||||
host, username, port, key_filename, password = ssh_args
|
||||
try:
|
||||
with st.spinner("Running ffprobe preview..."):
|
||||
ffprobe_data = cached_ffprobe_preview(host, username, port, key_filename, password, selected_path)
|
||||
except Exception as exc:
|
||||
status_col.error(f"ffprobe failed: {exc}")
|
||||
return
|
||||
|
||||
status_col.success("ffprobe preview loaded.")
|
||||
render_ffprobe_sections(ffprobe_data)
|
||||
with st.expander("Raw ffprobe JSON"):
|
||||
st.json(ffprobe_data)
|
||||
|
||||
|
||||
def render_ssh_tools(
|
||||
ssh,
|
||||
ssh_args: tuple,
|
||||
selected_path: str | None,
|
||||
cached_ffprobe_preview: Callable[..., dict[str, Any]],
|
||||
) -> None:
|
||||
"""Render selected-path diagnostics and safe job templates."""
|
||||
render_selected_file_preview(ssh_args, selected_path, cached_ffprobe_preview)
|
||||
if not selected_path:
|
||||
return
|
||||
|
||||
st.subheader("Disk metadata and jobs")
|
||||
tabs = st.tabs(["ffprobe", "stat", "jobs"])
|
||||
|
||||
with tabs[0]:
|
||||
if st.button("Run ffprobe on selected path", key="tools_run_ffprobe"):
|
||||
try:
|
||||
data = ssh.ffprobe_json(selected_path)
|
||||
render_ffprobe_sections(data)
|
||||
with st.expander("All streams table"):
|
||||
st.dataframe(pd.DataFrame(summarize_streams(data)), use_container_width=True, hide_index=True)
|
||||
with st.expander("Raw ffprobe JSON"):
|
||||
st.json(data)
|
||||
except Exception as exc:
|
||||
st.error(str(exc))
|
||||
|
||||
with tabs[1]:
|
||||
if st.button("Run stat", key="tools_run_stat"):
|
||||
result = ssh.stat_path(selected_path)
|
||||
st.code(result.stdout or result.stderr)
|
||||
|
||||
with tabs[2]:
|
||||
st.warning("Jobs run commands on the remote server. Phase 1 includes safe/read-only templates only.")
|
||||
job_key = st.selectbox("Job", list(JOB_TEMPLATES.keys()), format_func=lambda k: JOB_TEMPLATES[k].name)
|
||||
st.caption(JOB_TEMPLATES[job_key].description)
|
||||
command_preview = JOB_TEMPLATES[job_key].render({"path": selected_path})
|
||||
st.code(command_preview, language="bash")
|
||||
if st.button("Run selected job", key="tools_run_selected_job"):
|
||||
result = run_job(ssh, job_key, selected_path)
|
||||
st.write(f"Exit status: `{result.exit_status}`")
|
||||
if result.stdout:
|
||||
st.code(result.stdout)
|
||||
if result.stderr:
|
||||
st.error(result.stderr)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Formatting and ffprobe summarization helpers.
|
||||
|
||||
These helpers are intentionally UI-framework independent. Streamlit renders the
|
||||
returned dictionaries/dataframes, but another frontend can reuse the same
|
||||
summaries.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
VIDEO_FILE_EXTENSIONS = {
|
||||
".3g2",
|
||||
".3gp",
|
||||
".avi",
|
||||
".divx",
|
||||
".flv",
|
||||
".m2ts",
|
||||
".m4v",
|
||||
".mkv",
|
||||
".mov",
|
||||
".mp4",
|
||||
".mpeg",
|
||||
".mpg",
|
||||
".mts",
|
||||
".ogm",
|
||||
".ogv",
|
||||
".rmvb",
|
||||
".ts",
|
||||
".vob",
|
||||
".webm",
|
||||
".wmv",
|
||||
}
|
||||
|
||||
|
||||
def ticks_to_minutes(ticks: int | None) -> int | None:
|
||||
"""Convert Jellyfin/Emby 100-nanosecond ticks to rounded minutes."""
|
||||
if not ticks:
|
||||
return None
|
||||
return round(ticks / 10_000_000 / 60)
|
||||
|
||||
|
||||
def human_size(num: int | float | None) -> str:
|
||||
"""Format a byte count as B/KB/MB/GB/etc."""
|
||||
if num is None:
|
||||
return ""
|
||||
value = float(num)
|
||||
for unit in ["B", "KB", "MB", "GB", "TB", "PB"]:
|
||||
if value < 1024 or unit == "PB":
|
||||
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} B"
|
||||
value /= 1024
|
||||
return f"{value:.1f} PB"
|
||||
|
||||
|
||||
def timestamp_to_local(ts: float | None) -> str:
|
||||
if ts is None:
|
||||
return ""
|
||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def is_known_video_file(path: str | None) -> bool:
|
||||
"""Return True when a path extension is one we should ffprobe automatically."""
|
||||
if not path:
|
||||
return False
|
||||
return PurePosixPath(path).suffix.lower() in VIDEO_FILE_EXTENSIONS
|
||||
|
||||
|
||||
def format_duration(seconds: str | int | float | None) -> str:
|
||||
if seconds in (None, ""):
|
||||
return ""
|
||||
try:
|
||||
total = float(seconds)
|
||||
except (TypeError, ValueError):
|
||||
return str(seconds)
|
||||
hours = int(total // 3600)
|
||||
minutes = int((total % 3600) // 60)
|
||||
secs = int(total % 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||
|
||||
|
||||
def format_bitrate(bit_rate: str | int | float | None) -> str:
|
||||
if bit_rate in (None, ""):
|
||||
return ""
|
||||
try:
|
||||
value = float(bit_rate)
|
||||
except (TypeError, ValueError):
|
||||
return str(bit_rate)
|
||||
if value >= 1_000_000:
|
||||
return f"{value / 1_000_000:.2f} Mbps"
|
||||
if value >= 1_000:
|
||||
return f"{value / 1_000:.0f} kbps"
|
||||
return f"{value:.0f} bps"
|
||||
|
||||
|
||||
def _tags(stream: dict[str, Any]) -> dict[str, Any]:
|
||||
return stream.get("tags") or {}
|
||||
|
||||
|
||||
def _disposition(stream: dict[str, Any], key: str) -> str:
|
||||
value = (stream.get("disposition") or {}).get(key)
|
||||
return "yes" if value == 1 else ""
|
||||
|
||||
|
||||
def _side_data_types(stream: dict[str, Any]) -> str:
|
||||
values = []
|
||||
for item in stream.get("side_data_list") or []:
|
||||
if item.get("side_data_type"):
|
||||
values.append(item["side_data_type"])
|
||||
return ", ".join(values)
|
||||
|
||||
|
||||
def ffprobe_format_summary(ffprobe: dict[str, Any]) -> dict[str, str]:
|
||||
"""Summarize ffprobe container/format-level metadata."""
|
||||
fmt = ffprobe.get("format") or {}
|
||||
return {
|
||||
"filename": fmt.get("filename", ""),
|
||||
"format": fmt.get("format_name", ""),
|
||||
"format_long": fmt.get("format_long_name", ""),
|
||||
"duration": format_duration(fmt.get("duration")),
|
||||
"size": human_size(float(fmt["size"])) if fmt.get("size") else "",
|
||||
"bit_rate": format_bitrate(fmt.get("bit_rate")),
|
||||
"stream_count": str(fmt.get("nb_streams", "")),
|
||||
}
|
||||
|
||||
|
||||
def summarize_video_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return video-only stream rows with video/HDR-related fields."""
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
if stream.get("codec_type") != "video":
|
||||
continue
|
||||
tags = _tags(stream)
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"profile": stream.get("profile"),
|
||||
"resolution": f"{stream.get('width', '')}x{stream.get('height', '')}",
|
||||
"pix_fmt": stream.get("pix_fmt"),
|
||||
"bit_rate": format_bitrate(stream.get("bit_rate")),
|
||||
"avg_fps": stream.get("avg_frame_rate"),
|
||||
"color_range": stream.get("color_range"),
|
||||
"color_space": stream.get("color_space"),
|
||||
"color_transfer": stream.get("color_transfer"),
|
||||
"color_primaries": stream.get("color_primaries"),
|
||||
"side_data": _side_data_types(stream),
|
||||
"language": tags.get("language"),
|
||||
"title": tags.get("title"),
|
||||
"default": _disposition(stream, "default"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_audio_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return audio-only stream rows with channel/language/default fields."""
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
if stream.get("codec_type") != "audio":
|
||||
continue
|
||||
tags = _tags(stream)
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"profile": stream.get("profile"),
|
||||
"channels": stream.get("channels"),
|
||||
"layout": stream.get("channel_layout"),
|
||||
"sample_rate": stream.get("sample_rate"),
|
||||
"bit_rate": format_bitrate(stream.get("bit_rate")),
|
||||
"language": tags.get("language"),
|
||||
"title": tags.get("title"),
|
||||
"default": _disposition(stream, "default"),
|
||||
"forced": _disposition(stream, "forced"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_subtitle_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return subtitle-only stream rows with language/forced/default fields."""
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
if stream.get("codec_type") != "subtitle":
|
||||
continue
|
||||
tags = _tags(stream)
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"codec_long": stream.get("codec_long_name"),
|
||||
"language": tags.get("language"),
|
||||
"title": tags.get("title"),
|
||||
"default": _disposition(stream, "default"),
|
||||
"forced": _disposition(stream, "forced"),
|
||||
"hearing_impaired": _disposition(stream, "hearing_impaired"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def summarize_streams(ffprobe: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for stream in ffprobe.get("streams", []):
|
||||
rows.append(
|
||||
{
|
||||
"index": stream.get("index"),
|
||||
"type": stream.get("codec_type"),
|
||||
"codec": stream.get("codec_name"),
|
||||
"profile": stream.get("profile"),
|
||||
"width": stream.get("width"),
|
||||
"height": stream.get("height"),
|
||||
"pix_fmt": stream.get("pix_fmt"),
|
||||
"color_transfer": stream.get("color_transfer"),
|
||||
"color_primaries": stream.get("color_primaries"),
|
||||
"color_space": stream.get("color_space"),
|
||||
"bit_rate": format_bitrate(stream.get("bit_rate")),
|
||||
"channels": stream.get("channels"),
|
||||
"sample_rate": stream.get("sample_rate"),
|
||||
"language": stream.get("tags", {}).get("language"),
|
||||
"title": stream.get("tags", {}).get("title"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
Reference in New Issue
Block a user