51b10438a9
- 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
278 lines
12 KiB
Python
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()
|