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,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,343 @@
|
||||
"""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, cached_library_counts, base_url: str, api_key: str,
|
||||
user_id: str) -> None:
|
||||
"""Render dashboard counts for movies/series/episodes and per-library breakdown."""
|
||||
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
|
||||
|
||||
# Top-level totals
|
||||
total_items = counts.get("movies", 0) + counts.get("series", 0) + counts.get("episodes", 0)
|
||||
top_cols = st.columns(4)
|
||||
top_cols[0].metric("Total items", f"{total_items:,}")
|
||||
top_cols[1].metric("Movies", f"{counts.get('movies', 0):,}")
|
||||
top_cols[2].metric("Series", f"{counts.get('series', 0):,}")
|
||||
top_cols[3].metric("Episodes", f"{counts.get('episodes', 0):,}")
|
||||
|
||||
# Per-library breakdown
|
||||
try:
|
||||
lib_counts = cached_library_counts(base_url, api_key, user_id)
|
||||
except Exception as exc:
|
||||
st.caption(f"Could not load per-library counts: {exc}")
|
||||
return
|
||||
|
||||
if not lib_counts:
|
||||
return
|
||||
|
||||
st.markdown("**Libraries**")
|
||||
|
||||
movie_libs = [e for e in lib_counts if e.get("type") == "movies"]
|
||||
tv_libs = [e for e in lib_counts if e.get("type") == "tvshows"]
|
||||
|
||||
if movie_libs and tv_libs:
|
||||
left_col, right_col = st.columns(2)
|
||||
elif movie_libs:
|
||||
left_col = st.container()
|
||||
right_col = None
|
||||
elif tv_libs:
|
||||
left_col = None
|
||||
right_col = st.container()
|
||||
else:
|
||||
return
|
||||
|
||||
if movie_libs and left_col:
|
||||
with left_col:
|
||||
st.caption("Movie libraries")
|
||||
for entry in movie_libs:
|
||||
with st.container(border=True):
|
||||
st.markdown(f"**{entry['library']}**")
|
||||
m_cols = st.columns(2)
|
||||
m_cols[0].metric("Movies", f"{entry['movies']:,}")
|
||||
|
||||
if tv_libs and right_col:
|
||||
with right_col:
|
||||
st.caption("TV libraries")
|
||||
for entry in tv_libs:
|
||||
with st.container(border=True):
|
||||
st.markdown(f"**{entry['library']}**")
|
||||
m_cols = st.columns(2)
|
||||
m_cols[0].metric("Series", f"{entry['series']:,}")
|
||||
m_cols[1].metric("Episodes", f"{entry['total']:,}")
|
||||
|
||||
|
||||
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 monitoring summary or detailed charts."""
|
||||
st.subheader("Server monitoring 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="monitoring_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="monitoring_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="monitoring_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="monitoring_refresh", use_container_width=True):
|
||||
st.rerun()
|
||||
else:
|
||||
st.caption(f"Collector: `{status}`. Open the Monitoring 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 monitoring 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 monitoring history yet. Open the Monitoring tab to start the collector.")
|
||||
return
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
numeric_columns = [
|
||||
"ts", "cpu_pct", "iowait_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_iowait = df["iowait_pct"].mean() if "iowait_pct" in df.columns else 0
|
||||
max_iowait = df["iowait_pct"].max() if "iowait_pct" in df.columns else 0
|
||||
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(7)
|
||||
metric_cols[0].metric("CPU now", f"{latest['cpu_pct']:.1f}%")
|
||||
metric_cols[0].caption(f"avg {avg_cpu:.1f}% \npeak {max_cpu:.1f}%")
|
||||
metric_cols[1].metric("IO wait", f"{latest.get('iowait_pct', 0):.1f}%")
|
||||
metric_cols[1].caption(f"avg {avg_iowait:.1f}% \npeak {max_iowait:.1f}%")
|
||||
metric_cols[2].metric("RAM now", f"{latest['mem_pct']:.1f}%")
|
||||
metric_cols[2].caption(f"avg {avg_mem:.1f}% \npeak {max_mem:.1f}%")
|
||||
metric_cols[3].metric("Network down", format_rate_bytes(latest["net_rx_bytes_per_sec"]))
|
||||
metric_cols[3].caption(f"avg {format_rate_bytes(avg_net_down)} \npeak {format_rate_bytes(max_net_down)}")
|
||||
metric_cols[4].metric("Network up", format_rate_bytes(latest["net_tx_bytes_per_sec"]))
|
||||
metric_cols[4].caption(f"avg {format_rate_bytes(avg_net_up)} \npeak {format_rate_bytes(max_net_up)}")
|
||||
metric_cols[5].metric("Disk read", format_rate_bytes(latest["disk_read_bps"]))
|
||||
metric_cols[5].caption(f"avg {format_rate_bytes(avg_disk_read)} \npeak {format_rate_bytes(max_disk_read)}")
|
||||
metric_cols[6].metric("Disk write", format_rate_bytes(latest["disk_write_bps"]))
|
||||
metric_cols[6].caption(f"avg {format_rate_bytes(avg_disk_write)} \npeak {format_rate_bytes(max_disk_write)}")
|
||||
|
||||
chart_df = df.set_index("time")
|
||||
if detailed:
|
||||
st.markdown("**CPU, IO wait, and RAM - last hour**")
|
||||
chart_cols = ["cpu_pct", "mem_pct"]
|
||||
if "iowait_pct" in chart_df.columns:
|
||||
chart_cols = ["cpu_pct", "iowait_pct", "mem_pct"]
|
||||
st.line_chart(chart_df[chart_cols], use_container_width=True)
|
||||
else:
|
||||
st.caption(
|
||||
"Detailed CPU/IO wait/RAM charts, network, disk I/O, raw samples, and collector controls are available in the Monitoring 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 monitoring samples"):
|
||||
st.dataframe(df.sort_values("time", ascending=False), use_container_width=True, hide_index=True)
|
||||
@@ -0,0 +1,261 @@
|
||||
"""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, refresh_col = st.columns([6, 1])
|
||||
path_input = path_col.text_input("Remote path", key="file_browser_path_input", label_visibility="collapsed")
|
||||
requested_path = path_input or "/"
|
||||
# Navigate when the user edits the path and presses Enter
|
||||
if requested_path != current_dir:
|
||||
set_file_browser_path(requested_path)
|
||||
st.rerun()
|
||||
if refresh_col.button("Refresh", key="file_browser_refresh", use_container_width=True):
|
||||
cached_dir_listing.clear()
|
||||
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,215 @@
|
||||
"""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, autoSize=True)
|
||||
grid_builder.configure_column("title", header_name="Title", minWidth=150)
|
||||
grid_builder.configure_column("series", header_name="Series", minWidth=120)
|
||||
grid_builder.configure_column("season", header_name="Season", maxWidth=95)
|
||||
grid_builder.configure_column("episode", header_name="Episode", maxWidth=105)
|
||||
grid_builder.configure_column("type", header_name="Type", maxWidth=100)
|
||||
grid_builder.configure_column("year", header_name="Year", maxWidth=90)
|
||||
grid_builder.configure_column("runtime_min", header_name="Runtime (min)", maxWidth=125)
|
||||
grid_builder.configure_column("size", header_name="Size", maxWidth=120)
|
||||
grid_builder.configure_column("bitrate", header_name="Bitrate", maxWidth=125)
|
||||
grid_builder.configure_column("hdr", header_name="HDR", maxWidth=80)
|
||||
grid_builder.configure_column("video", header_name="Video codec", maxWidth=120)
|
||||
grid_builder.configure_column("resolution", header_name="Resolution", maxWidth=120)
|
||||
grid_builder.configure_column("date_added", header_name="Date added", maxWidth=120)
|
||||
grid_builder.configure_column("library", header_name="Library", maxWidth=140)
|
||||
grid_builder.configure_column("path", header_name="Path", minWidth=200)
|
||||
grid_builder.configure_column("id", hide=True)
|
||||
grid_builder.configure_selection(selection_mode="single", use_checkbox=False)
|
||||
grid_options = grid_builder.build()
|
||||
grid_options["autoSizeStrategy"] = {"type": "fitCellContents"}
|
||||
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)
|
||||
Reference in New Issue
Block a user