Restructure into backend/ and frontend/ subprojects
- backend/ uses proper Python src layout (src/media_library_viewer_api/) with pyproject.toml, hatchling build, and PYTHONPATH=src convention - frontend/ is a Vite + React + TypeScript SPA - archive/ preserves the original Streamlit prototype for reference - Cleaned up root to only contain docs, license, and subproject dirs - Updated README for the new dual-subproject architecture
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user