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:
2026-04-30 21:48:46 +02:00
parent 3c432473e5
commit 51b10438a9
47 changed files with 127 additions and 130 deletions
@@ -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)