Files
manage/archive/src/media_library_viewer/app.py
T
alex 51b10438a9 Restructure into backend/ and frontend/ subprojects
- backend/ uses proper Python src layout (src/media_library_viewer_api/)
  with pyproject.toml, hatchling build, and PYTHONPATH=src convention
- frontend/ is a Vite + React + TypeScript SPA
- archive/ preserves the original Streamlit prototype for reference
- Cleaned up root to only contain docs, license, and subproject dirs
- Updated README for the new dual-subproject architecture
2026-04-30 21:48:46 +02:00

278 lines
12 KiB
Python

"""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 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.media import render_media_tab
from media_library_viewer.ui.preview import render_ssh_tools
@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=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=300, show_spinner=False)
def cached_library_counts(base_url: str, api_key: str, user_id: str):
"""Cache per-library item counts for dashboard breakdown."""
client = get_jellyfin_client(base_url, api_key)
libraries = client.libraries(user_id)
return client.library_item_counts(user_id, libraries)
@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."""
# Set page config per Streamlit session. Doing this at module import time is
# unreliable here because the root wrapper imports ``main`` from this module,
# and Python may reuse the already-imported module on browser reloads.
st.set_page_config(page_title="Media Library Viewer", layout="wide")
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_monitoring, tab_media, tab_files = st.tabs(
["Dashboard", "Monitoring", "Media", "File browser"]
)
with tab_dashboard:
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 monitoring 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)
st.divider()
render_media_overview(cached_media_counts, cached_library_counts, jellyfin_url, jellyfin_api_key, user_id)
with tab_monitoring:
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_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()