"""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()