diff --git a/.gitignore b/.gitignore index 21907de2..ea8ac32f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ crates/client-web/dist/ # dev temp files .dev/ + +# git worktrees +.worktrees/ +artifacts diff --git a/Cargo.lock b/Cargo.lock index 5b69f3a8..6887a095 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3346,6 +3346,7 @@ dependencies = [ "sha2 0.11.0", "strsim 0.11.1", "tao", + "tempfile", "tmdb_client", "tokio", "tray-icon", diff --git a/crates/client-web/src/api.ts b/crates/client-web/src/api.ts index 0b566fc2..f41f6bea 100644 --- a/crates/client-web/src/api.ts +++ b/crates/client-web/src/api.ts @@ -53,6 +53,41 @@ export interface ServerCapabilities { }; } +/** Validation result for a single transcoding binary (configured path or candidate). */ +export interface BinaryProbe { + resolved_path?: string; + version?: string; + error?: string; + /** Transcoding-relevant encoders this ffmpeg supports (ffmpeg only). */ + encoders?: string[]; +} + +/** One directory discovered to contain ffmpeg and/or ffprobe. */ +export interface ToolCandidate { + directory: string; + ffmpeg?: BinaryProbe; + ffprobe?: BinaryProbe; +} + +/** Result of the on-demand transcoding-tools discovery call. */ +export interface ToolDiscoveryResponse { + configured_ffmpeg: BinaryProbe; + configured_ffprobe: BinaryProbe; + candidates: ToolCandidate[]; +} + +/** A structured transcode error recoverable via the session-status endpoint. */ +export interface TranscodeErrorBody { + code: string; + message: string; + action?: string; +} + +/** Status of a playback session's transcode. */ +export interface SessionStatusResponse { + error?: TranscodeErrorBody; +} + export interface BootstrapUser { id: number; username: string; @@ -415,8 +450,13 @@ export interface MediaHome { collections: MediaCollectionSummary[]; } +/** Whether the source media has been analyzed by ffprobe. */ +export type PlaybackAnalysisState = 'analyzed' | 'awaiting_analysis'; + export interface PlaybackDecision { item_id: number; + /** Whether the source media was probed; if 'awaiting_analysis', playback is blocked. */ + analysis_state: PlaybackAnalysisState; can_direct_play: boolean; transcode_required: boolean; reason: string; @@ -1275,6 +1315,32 @@ export function deletePlaybackSession(sessionId: string): Promise { return requestJson('DELETE', `/api/v1/sessions/${sessionId}`); } +/** Discover ffmpeg/ffprobe candidates and validate the configured paths (admin). */ +export function discoverTranscodingTools(): Promise { + return requestJson('POST', '/api/v1/system/tools/discover'); +} + +/** Read a playback session's transcode status (cheap map lookup, not a spawn). */ +export function getSessionStatus(sessionId: string): Promise { + return requestJson('GET', `/api/v1/sessions/${encodeURIComponent(sessionId)}/status`); +} + +/** Re-probe status: whether a job is running and how many files await analysis. */ +export interface ReprobeStatusResponse { + in_progress: boolean; + pending_count: number; +} + +/** Get re-probe status (running? how many files pending ffprobe analysis). */ +export function getReprobeStatus(): Promise { + return requestJson('GET', '/api/v1/system/tools/reprobe'); +} + +/** Manually trigger a ffprobe re-probe of all unanalyzed media files (admin). */ +export function triggerReprobe(): Promise { + return requestJson('POST', '/api/v1/system/tools/reprobe'); +} + export function getArtworkUrl(itemId: number, kind: 'poster' | 'backdrop' | 'logo' = 'poster', revision?: number): string { const params = new URLSearchParams({ kind }); if (typeof revision === 'number') { diff --git a/crates/client-web/src/app.ts b/crates/client-web/src/app.ts index 6e80ede2..d047bf7b 100644 --- a/crates/client-web/src/app.ts +++ b/crates/client-web/src/app.ts @@ -468,7 +468,7 @@ function render(preserveScroll = true): void { ${renderRail()}
- ${state.error ? `
${escapeHtml(state.error)}
` : ''} + ${state.error ? `
${escapeHtml(state.error)}${state.playbackError?.action === 'open_settings' ? `` : ''}
` : ''} ${renderCurrentPage()}
diff --git a/crates/client-web/src/app/eventBindings.ts b/crates/client-web/src/app/eventBindings.ts index 08751241..326dc26e 100644 --- a/crates/client-web/src/app/eventBindings.ts +++ b/crates/client-web/src/app/eventBindings.ts @@ -43,7 +43,7 @@ import { selectedItemDefaultMetadataYear, selectedItemExtras, } from './itemPersonView'; -import { buildSettingsFromForm } from './settingsView'; +import { buildSettingsFromForm, renderDiscoverResults, renderPathValidation } from './settingsView'; import { setButtonBusy } from './ui'; import { addLibrary, @@ -52,6 +52,8 @@ import { createUser, deleteLibrary, deleteMissingItems, + discoverTranscodingTools, + getReprobeStatus, getItemMetadata, getLogs, getUsers, @@ -71,6 +73,7 @@ import { type MediaShelf, type ScheduledTaskId, type UpdateUserRequest, + triggerReprobe, } from '../api'; /** Replaces a DOM subtree while preserving any coordinator-level patch behavior. */ @@ -527,6 +530,93 @@ function bindRenderEvents(context: AppEventBindingContext): void { }); }); + document.querySelectorAll('[data-action="open-settings"]').forEach((button) => { + button.addEventListener('click', () => { + navigateTo('/settings'); + }); + }); + + document.querySelector('#detect-ffmpeg')?.addEventListener('click', async (event) => { + const button = event.currentTarget as HTMLButtonElement; + const resultsContainer = document.querySelector('#ffmpeg-discover-results'); + const ffmpegInput = document.querySelector('input[name="ffmpeg_path"]'); + const ffprobeInput = document.querySelector('input[name="ffprobe_path"]'); + const ffmpegValidation = document.querySelector('#ffmpeg-path-validation'); + const ffprobeValidation = document.querySelector('#ffprobe-path-validation'); + if (!resultsContainer || !ffmpegInput || !ffprobeInput) { + return; + } + setButtonBusy(button, true); + try { + const discovery = await discoverTranscodingTools(); + resultsContainer.innerHTML = renderDiscoverResults(discovery); + resultsContainer.hidden = false; + + // Per-field validation detail, driven by the configured-path probes. + if (ffmpegValidation) { + ffmpegValidation.innerHTML = renderPathValidation(discovery.configured_ffmpeg, 'ffmpeg'); + ffmpegValidation.hidden = !ffmpegValidation.innerHTML; + } + if (ffprobeValidation) { + ffprobeValidation.innerHTML = renderPathValidation(discovery.configured_ffprobe, 'ffprobe'); + ffprobeValidation.hidden = !ffprobeValidation.innerHTML; + } + + // [Use] buttons write /ffmpeg + /ffprobe into the path fields. + resultsContainer.querySelectorAll('[data-use-directory]').forEach((useButton) => { + useButton.addEventListener('click', () => { + const dir = useButton.dataset.useDirectory; + if (!dir) { + return; + } + ffmpegInput.value = `${dir}/ffmpeg`; + ffprobeInput.value = `${dir}/ffprobe`; + }); + }); + } catch (error) { + resultsContainer.hidden = false; + resultsContainer.innerHTML = `

Detection failed: ${escapeHtml(error instanceof Error ? error.message : 'unknown error')}

`; + } finally { + setButtonBusy(button, false); + } + }); + + // Re-probe media info: show the button when there are unanalyzed files + // (ffprobe was missing during scan), and trigger the job on click. + const reprobeButton = document.querySelector('#reprobe-media'); + const reprobeStatus = document.querySelector('#reprobe-status'); + if (reprobeButton && reprobeStatus) { + getReprobeStatus() + .then((status) => { + if (status.in_progress) { + reprobeStatus.hidden = false; + reprobeStatus.textContent = 'Re-probing media info is in progress… (see Dashboard → Activities).'; + } else if (status.pending_count > 0) { + reprobeButton.hidden = false; + reprobeStatus.hidden = false; + reprobeStatus.textContent = `${status.pending_count} media file(s) were scanned without ffprobe and need re-analysis. Click to re-probe.`; + } + }) + .catch((error) => { + console.warn('Failed to fetch reprobe status', error); + }); + + reprobeButton.addEventListener('click', async () => { + setButtonBusy(reprobeButton, true); + try { + await triggerReprobe(); + reprobeStatus.hidden = false; + reprobeStatus.textContent = 'Re-probe started. Watch Dashboard → Activities for progress; playback unlocks as files are analyzed.'; + reprobeButton.hidden = true; + } catch (error) { + reprobeStatus.hidden = false; + reprobeStatus.textContent = `Re-probe failed to start: ${escapeHtml(error instanceof Error ? error.message : 'unknown error')}`; + } finally { + setButtonBusy(reprobeButton, false); + } + }); + } + document.querySelectorAll('[data-provider-settings]').forEach((button) => { button.addEventListener('click', () => { const providerId = button.dataset.providerSettings; diff --git a/crates/client-web/src/app/playbackController.ts b/crates/client-web/src/app/playbackController.ts index 30f6427c..a954bfad 100644 --- a/crates/client-web/src/app/playbackController.ts +++ b/crates/client-web/src/app/playbackController.ts @@ -7,6 +7,7 @@ import { deletePlaybackSession, getArtworkUrl, getItem, + getSessionStatus, getSessionStreamUrl, getWebClientProfile, resolveApiUrl, @@ -322,8 +323,8 @@ function renderMediaPlayerOverlay(): string {
- Playback could not start - Try another audio track or start playback again. + ${escapeHtml(state.playbackError ? 'Playback failed' : 'Playback could not start')} + ${escapeHtml(state.playbackError?.message ?? 'Try another audio track or start playback again.')}
@@ -615,6 +616,8 @@ export async function startPlayback(item: MediaItemDetail, startMs: number): Pro state.isPlayerOpen = true; state.activeAudioStreamIndex = undefined; state.isAudioTrackMenuOpen = false; + // Clear any stale playback error from a previous attempt. + state.playbackError = undefined; render(); if (previousSession) { @@ -627,6 +630,28 @@ export async function startPlayback(item: MediaItemDetail, startMs: number): Pro item_id: item.id, client_profile: getWebClientProfile(), }); + + // Server-truth gate: if the source media was never analyzed by ffprobe + // (ffprobe was missing during scan), the decision is untrustworthy and the + // server returns analysis_state = 'awaiting_analysis'. Refuse to open a + // doomed player and surface the error instead. This replaces the old, + // buggy client-side capabilities-cache preflight. + const decision = state.activePlaybackSession.decision; + if (decision.analysis_state === 'awaiting_analysis') { + state.playbackError = { + code: 'media_not_analyzed', + message: decision.reason || 'This media has not been analyzed yet. Set the ffprobe path in Settings and re-probe media info.', + action: 'open_settings', + }; + state.error = state.playbackError.message; + render(); + // The player overlay covers the page banner, so toggle the in-player error + // class directly (the shell is mounted by the render() above). + document.querySelector('.media-player-shell')?.classList.remove('is-media-loading'); + document.querySelector('.media-player-shell')?.classList.add('has-media-error'); + return; + } + render(); } @@ -1330,6 +1355,23 @@ export function bindPlayerProgress(): void { player.addEventListener('error', () => { setPlayerError(); console.error('Media playback failed', player.error); + const sessionId = state.activePlaybackSession?.session_id; + if (!sessionId) { + return; + } + // Best-effort: recover a structured error from the per-session store. This + // is a cheap map lookup on the server, not a transcode spawn. It only helps + // when the browser actually fires `error` (HTTP failures are unreliable). + void getSessionStatus(sessionId) + .then((status) => { + if (status.error) { + state.playbackError = status.error; + render(); + } + }) + .catch((error) => { + console.warn('Failed to fetch session status after playback error', error); + }); }); player.addEventListener('loadedmetadata', () => { applyInitialDirectSeek(); diff --git a/crates/client-web/src/app/settingsView.ts b/crates/client-web/src/app/settingsView.ts index e89276ea..9779bbdf 100644 --- a/crates/client-web/src/app/settingsView.ts +++ b/crates/client-web/src/app/settingsView.ts @@ -1,5 +1,5 @@ /** Renders settings sections and converts settings forms into API payloads. */ -import type { MediaLibrary, MediaLibrarySettings, MetadataProviderSettings, MetadataProviderStatus, ScheduledTaskId, SettingsSnapshot } from '../api'; +import type { BinaryProbe, MediaLibrary, MediaLibrarySettings, MetadataProviderSettings, MetadataProviderStatus, ScheduledTaskId, SettingsSnapshot, ToolDiscoveryResponse } from '../api'; import { escapeHtml } from './format'; import { formDataString, formDataStrings, joinPaths, normalizedMetadataLanguages, parseBoundedInteger, parsePathsInput } from './formUtils'; import { hasActiveLibraryScan, libraryRefreshProgress } from './activities'; @@ -548,11 +548,28 @@ function renderGeneralSettingsPage(settings: SettingsSnapshot): string {
-
-

FFmpeg

+
+
+
+

FFmpeg

+

Transcoding tools detection. Click Detect to scan for installations, then pick a row to fill the paths below.

+
+
+ + +
+ +
+
- - +
+ + +
+
+ + +
@@ -660,6 +677,120 @@ function renderSettingsSectionContent(section: SettingsSection, settings: Settin return ''; } +/** Short display labels for the curated encoder names. Falls back to the raw name. */ +const ENCODER_LABELS: Record = { + libx264: 'H.264', + libx265: 'H.265', + 'libvpx-vp9': 'VP9', + libvpx: 'VP8', + libsvtav1: 'AV1', + libopus: 'Opus', + libmp3lame: 'MP3', + aac: 'AAC', +}; + +/** Render a cluster of encoder tags for a resolved ffmpeg probe. */ +function renderEncoderTags(encoders?: string[]): string { + if (!encoders || encoders.length === 0) { + return ''; + } + return `${encoders.map((name) => `${escapeHtml(ENCODER_LABELS[name] ?? name)}`).join('')}`; +} + +/** Render a version + status cell for one binary probe. */ +function renderBinaryCell(probe?: BinaryProbe): string { + if (!probe || !probe.resolved_path) { + const reason = probe?.error ?? 'missing'; + return `missing
${escapeHtml(reason)}
`; + } + const version = probe.version ? escapeHtml(probe.version) : 'resolved'; + return `${escapeHtml(version)}`; +} + +interface DiscoverRow { + label: string; + directory?: string; + ffmpeg?: BinaryProbe; + ffprobe?: BinaryProbe; +} + +/** + * Render the discovered ffmpeg/ffprobe candidates as a table. Each row pairs + * both binaries per directory; a `[Use]` button writes `/ffmpeg` and + * `/ffprobe` into the path fields. Disabled when one binary is missing. + * Exported so the event binding can call it after the discovery request. + */ +export function renderDiscoverResults(discovery: ToolDiscoveryResponse): string { + const rows: DiscoverRow[] = []; + + // Candidate directories. + for (const candidate of discovery.candidates) { + rows.push({ + label: candidate.directory, + directory: candidate.directory, + ffmpeg: candidate.ffmpeg ?? undefined, + ffprobe: candidate.ffprobe ?? undefined, + }); + } + + if (rows.length === 0) { + return '
No FFmpeg installations were found. Install ffmpeg/ffprobe or enter their paths manually below.
'; + } + + const bodyRows = rows.map((row) => { + const usable = Boolean(row.ffmpeg?.resolved_path) && Boolean(row.ffprobe?.resolved_path); + const directory = row.directory ?? ''; + const action = usable + ? `` + : ''; + return ` + +
${escapeHtml(row.label)}
+ ${renderBinaryCell(row.ffmpeg)} + ${renderBinaryCell(row.ffprobe)} + ${renderEncoderTags(row.ffmpeg?.encoders)} + ${action} + + `; + }).join(''); + + return ` +
+ + + + + + + + + + + ${bodyRows} +
DirectoryffmpegffprobeEncodersAction
+
+ `; +} + +/** + * Render the per-path validation line shown under each manual path field after + * a Detect run. Returns empty string when the probe is healthy and unremarkable + * only when there is nothing to show (probe missing on a not-yet-detected view). + */ +export function renderPathValidation(probe: BinaryProbe | undefined, binaryLabel: string): string { + if (!probe) { + return ''; + } + if (probe.resolved_path) { + const encoders = probe.encoders && probe.encoders.length > 0 + ? ` · encoders: ${probe.encoders.map((n) => ENCODER_LABELS[n] ?? n).join(', ')}` + : ''; + return `found ${escapeHtml(probe.version ?? probe.resolved_path)}${encoders}`; + } + const reason = probe.error ?? `${binaryLabel} not found`; + return `not found ${escapeHtml(reason)}`; +} + export function renderSettingsPage(): string { const settings = state.settingsResponse?.settings; if (!settings) { diff --git a/crates/client-web/src/app/types.ts b/crates/client-web/src/app/types.ts index 60ba8a42..25bf635e 100644 --- a/crates/client-web/src/app/types.ts +++ b/crates/client-web/src/app/types.ts @@ -140,6 +140,8 @@ export interface AppState { activeTrailer?: TrailerOption; expandedTextKeys: Set; error?: string; + /** Structured playback error driving the in-player overlay and global banner. */ + playbackError?: { code: string; message: string; action?: string }; hasDeferredAutoRefreshRender: boolean; metadataDashboardFilters: { libraryId: string; diff --git a/crates/client-web/src/mockApi.ts b/crates/client-web/src/mockApi.ts index 306fff65..7ac86dae 100644 --- a/crates/client-web/src/mockApi.ts +++ b/crates/client-web/src/mockApi.ts @@ -1127,6 +1127,7 @@ export function getMockPlayback(itemId: number): PlaybackDecision { if (!item.playable) { return { item_id: itemId, + analysis_state: 'analyzed', can_direct_play: false, transcode_required: false, video_transcode_required: false, @@ -1140,6 +1141,7 @@ export function getMockPlayback(itemId: number): PlaybackDecision { const canDirectPlay = item.container === 'mp4' || item.container === 'mp3' || item.container === 'flac'; return { item_id: itemId, + analysis_state: 'analyzed', can_direct_play: canDirectPlay, transcode_required: !canDirectPlay, video_transcode_required: !canDirectPlay && item.media_kind === 'video', diff --git a/crates/client-web/src/style.css b/crates/client-web/src/style.css index 5104f099..2b0ac947 100644 --- a/crates/client-web/src/style.css +++ b/crates/client-web/src/style.css @@ -668,6 +668,32 @@ legend { color: #ffd7d7; } +.ffmpeg-tools-panel { + padding: 1.2rem; + margin-bottom: 1rem; +} + +.ffmpeg-tools-table .ffmpeg-tool-detail { + font-size: 0.8rem; + margin-top: 0.25rem; +} + +.ffmpeg-tool-encoders { + display: inline-flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.ffmpeg-path-validation { + margin: 0.35rem 0 0; + font-size: 0.85rem; + line-height: 1.4; +} + +.ffmpeg-tools-table tr.is-disabled { + opacity: 0.5; +} + .workspace-grid { display: grid; grid-template-columns: minmax(0, 1fr) 380px; diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 07c0b8a7..0a7c5a9c 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -55,6 +55,9 @@ tray = [ "dep:tray-icon", "dep:webbrowser", ] +# Opt-in: run a real-ffmpeg smoke test (requires ffmpeg installed locally). +# Normal CI runs without this so the suite is hermetic. +real-ffmpeg-tests = [] [dependencies] # ensure deps are compatible: https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses @@ -102,3 +105,4 @@ objc2-core-foundation = { version = "0.3.0", optional = true } [dev-dependencies] async-std.workspace = true rstest.workspace = true +tempfile = "3.13" diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs index bae2c1d8..8109f091 100644 --- a/crates/server/src/auth.rs +++ b/crates/server/src/auth.rs @@ -1,44 +1,17 @@ //! Authentication utilities for the application. // lib imports -use base64::{ - Engine as _, - engine::general_purpose, -}; -use bcrypt::{ - DEFAULT_COST, - hash, - verify, -}; -use diesel::{ - QueryDsl, - RunQueryDsl, -}; -use jsonwebtoken::{ - DecodingKey, - EncodingKey, - Header, - Validation, - decode, - encode, -}; +use base64::{Engine as _, engine::general_purpose}; +use bcrypt::{DEFAULT_COST, hash, verify}; +use diesel::{QueryDsl, RunQueryDsl}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; use once_cell::sync::Lazy; use rand::Rng; use rocket::http::Status; use rocket::outcome::Outcome; -use rocket::request::{ - self, - FromRequest, - Request, -}; -use rocket_okapi::request::{ - OpenApiFromRequest, - RequestHeaderInput, -}; -use serde::{ - Deserialize, - Serialize, -}; +use rocket::request::{self, FromRequest, Request}; +use rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; +use serde::{Deserialize, Serialize}; // local imports use crate::db::DbConn; @@ -139,11 +112,7 @@ impl<'r, const ROLE: u8> FromRequest<'r> for AuthGuard { /// Helper function to create Bearer token security configuration for OpenAPI fn create_bearer_auth_security(scopes: Vec) -> rocket_okapi::Result { use rocket_okapi::okapi::Map; - use rocket_okapi::okapi::openapi3::{ - SecurityRequirement, - SecurityScheme, - SecuritySchemeData, - }; + use rocket_okapi::okapi::openapi3::{SecurityRequirement, SecurityScheme, SecuritySchemeData}; let security_scheme = SecurityScheme { data: SecuritySchemeData::Http { diff --git a/crates/server/src/certs.rs b/crates/server/src/certs.rs index 50b7db2a..cba2869d 100644 --- a/crates/server/src/certs.rs +++ b/crates/server/src/certs.rs @@ -5,10 +5,7 @@ use std::fs; use std::path::Path; // lib imports -use rcgen::{ - CertifiedKey, - generate_simple_self_signed, -}; +use rcgen::{CertifiedKey, generate_simple_self_signed}; /// Ensure that the certificates exist at the given paths. pub fn ensure_certificates_exist( diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 3b587ad0..76402b3f 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -7,12 +7,7 @@ use std::path::PathBuf; use std::sync::RwLock; // lib imports -use config::{ - Config, - ConfigError, - Environment, - File, -}; +use config::{Config, ConfigError, Environment, File}; use diesel::prelude::*; use dirs::config_local_dir; use once_cell::sync::Lazy; diff --git a/crates/server/src/db/mod.rs b/crates/server/src/db/mod.rs index b98573ba..db4e0a3c 100644 --- a/crates/server/src/db/mod.rs +++ b/crates/server/src/db/mod.rs @@ -4,10 +4,7 @@ pub(crate) mod models; pub(crate) mod schema; // standard imports -use std::collections::{ - HashMap, - HashSet, -}; +use std::collections::{HashMap, HashSet}; use std::error::Error; use std::fmt; use std::fs; @@ -16,30 +13,13 @@ use std::path::Path; // lib imports use diesel::Connection; use diesel::connection::SimpleConnection; -use diesel::migration::{ - Migration, - MigrationSource, - MigrationVersion, - Result as MigrationResult, -}; -use diesel_migrations::{ - EmbeddedMigrations, - MigrationHarness, - embed_migrations, -}; +use diesel::migration::{Migration, MigrationSource, MigrationVersion, Result as MigrationResult}; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use rocket::{ - Build, - Rocket, - fairing::{ - Fairing, - Info, - Kind, - }, -}; -use rocket_sync_db_pools::{ - database, - diesel, + Build, Rocket, + fairing::{Fairing, Info, Kind}, }; +use rocket_sync_db_pools::{database, diesel}; /// Embedded migrations for the SQLite database. const MIGRATIONS: EmbeddedMigrations = embed_migrations!("sql/migrations"); diff --git a/crates/server/src/db/models.rs b/crates/server/src/db/models.rs index 9c253617..1717462a 100644 --- a/crates/server/src/db/models.rs +++ b/crates/server/src/db/models.rs @@ -5,24 +5,10 @@ use diesel::prelude::*; // local imports use crate::db::schema::{ - app_settings, - external_media, - item_metadata_external_ids, - item_metadata_links, - item_metadata_people, - media_file_libraries, - media_files, - media_items, - media_libraries, - metadata_collection_items, - metadata_collections, - metadata_extras, - metadata_people, - metadata_person_credits, - metadata_person_external_ids, - playback_progress, - scan_state, - users, + app_settings, external_media, item_metadata_external_ids, item_metadata_links, + item_metadata_people, media_file_libraries, media_files, media_items, media_libraries, + metadata_collection_items, metadata_collections, metadata_extras, metadata_people, + metadata_person_credits, metadata_person_external_ids, playback_progress, scan_state, users, }; #[derive(Queryable, Selectable, Insertable, AsChangeset, Debug, Clone)] diff --git a/crates/server/src/db/schema.rs b/crates/server/src/db/schema.rs index afd17f86..c12a5c10 100644 --- a/crates/server/src/db/schema.rs +++ b/crates/server/src/db/schema.rs @@ -1,11 +1,7 @@ //! Database schema for the application. // lib imports -use diesel::{ - allow_tables_to_appear_in_same_query, - joinable, - table, -}; +use diesel::{allow_tables_to_appear_in_same_query, joinable, table}; table! { app_settings (key) { diff --git a/crates/server/src/dependencies/mod.rs b/crates/server/src/dependencies/mod.rs index 39073768..17199847 100644 --- a/crates/server/src/dependencies/mod.rs +++ b/crates/server/src/dependencies/mod.rs @@ -1,9 +1,6 @@ //! Module for everything related to dependencies. -use cargo_metadata::{ - MetadataCommand, - Package, -}; +use cargo_metadata::{MetadataCommand, Package}; use std::error::Error; /// Get the dependencies from the Cargo.toml file. diff --git a/crates/server/src/ffmpeg_resolve.rs b/crates/server/src/ffmpeg_resolve.rs new file mode 100644 index 00000000..8549134d --- /dev/null +++ b/crates/server/src/ffmpeg_resolve.rs @@ -0,0 +1,531 @@ +//! Locating the ffmpeg/ffprobe executables used for media processing. +//! +//! GUI-launched processes (macOS `.app` bundles, some Linux desktop launchers) +//! do not inherit the user's shell PATH, so a bare `ffmpeg` lookup can fail +//! even when the binary is installed under e.g. `/opt/homebrew/bin`. This +//! module is the single source of truth for resolution and is used by BOTH +//! capability display and the actual transcode spawn, so "available in UI" +//! is equivalent to "actually launches". + +use std::path::PathBuf; + +use once_cell::sync::Lazy; + +/// Binary base-names the resolver is allowed to resolve. Anything else is +/// rejected as [`ResolvedBinary::Missing`]. This is a security boundary: it +/// prevents a future bug or compromised setting from causing an arbitrary +/// `exec`. +pub const ALLOWED_BINARIES: &[&str] = &["ffmpeg", "ffprobe"]; + +/// How a binary was located. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolveSource { + /// The configured value was an absolute path that exists and is executable. + ConfiguredAbsolute, + /// The configured bare name resolved on PATH. + PathLookup, +} + +/// Result of resolving a single binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedBinary { + /// The binary was found. + Found { + /// The absolute path to use when spawning the binary. + resolved_path: PathBuf, + /// How the binary was located. + via: ResolveSource, + }, + /// The binary could not be found. `checked_paths` lists every location + /// inspected, for UI display. + Missing { + /// The configured value that failed to resolve. + configured: String, + /// Every location the resolver inspected, in order. + checked_paths: Vec, + }, +} + +/// Platform-specific well-known directories, highest priority first. +static WELL_KNOWN_DIRS: Lazy> = Lazy::new(well_known_dirs); + +#[cfg(target_os = "macos")] +fn well_known_dirs() -> Vec { + vec![ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/opt/homebrew/sbin"), + PathBuf::from("/usr/local/sbin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + ] +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn well_known_dirs() -> Vec { + vec![ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/bin"), + PathBuf::from("/usr/sbin"), + PathBuf::from("/opt/ffmpeg/bin"), + PathBuf::from("/snap/bin"), + ] +} + +#[cfg(target_os = "windows")] +fn well_known_dirs() -> Vec { + let mut dirs = vec![ + PathBuf::from(r"C:\Program Files\ffmpeg\bin"), + PathBuf::from(r"C:\Program Files (x86)\ffmpeg\bin"), + PathBuf::from(r"C:\ffmpeg\bin"), + ]; + if let Ok(home) = std::env::var("USERPROFILE") { + dirs.push(PathBuf::from(home).join("bin")); + } + dirs +} + +/// Resolve a single binary. +/// +/// `configured` is what the admin set (a bare name like `ffmpeg`, or an +/// absolute/relative path). `binary_name` is the canonical name being looked +/// up and MUST be in [`ALLOWED_BINARIES`]. +/// +/// Resolution is literal — the resolver runs exactly what is configured and +/// never silently substitutes a binary found elsewhere. Smart discovery +/// (PATH + well-known directories) is a separate concern handled on demand by +/// the Detect UI; the runtime just executes the configured value. +/// +/// Resolution order: +/// 1. If `configured` looks like a path (contains a separator) or is absolute, +/// validate its base name is allowed and it exists + is executable. +/// 2. Else treat `configured` as a bare name: require it to be allowed, then +/// look it up on PATH. +/// 3. Else [`ResolvedBinary::Missing`] with every checked location. +pub fn resolve_binary( + configured: &str, + binary_name: &str, +) -> ResolvedBinary { + resolve_binary_with(configured, binary_name, lookup_on_path) +} + +/// Internal resolver that accepts a PATH-lookup function so tests can run +/// hermetically (the real `PATH` reflects the actual machine, where ffmpeg may +/// be installed). +fn resolve_binary_with( + configured: &str, + binary_name: &str, + path_lookup: impl Fn(&str) -> Option, +) -> ResolvedBinary { + if !ALLOWED_BINARIES.contains(&binary_name) { + return ResolvedBinary::Missing { + configured: configured.to_string(), + checked_paths: Vec::new(), + }; + } + + let mut checked: Vec = Vec::new(); + let configured_path = PathBuf::from(configured); + + // 1. Configured-as-path: only if it actually has a path component, or is absolute. + let is_path_form = configured_path + .parent() + .map(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(false) + || configured_path.is_absolute(); + + if is_path_form { + if base_name_is_allowed(&configured_path) && is_executable(&configured_path) { + return ResolvedBinary::Found { + resolved_path: canonicalize(&configured_path), + via: ResolveSource::ConfiguredAbsolute, + }; + } + checked.push(configured_path.clone()); + } else { + // 2. Bare name: must be the allowed name itself. + if configured == binary_name { + if let Some(found) = path_lookup(binary_name) { + return ResolvedBinary::Found { + resolved_path: found, + via: ResolveSource::PathLookup, + }; + } + checked.push(PathBuf::from(configured)); + } + } + + ResolvedBinary::Missing { + configured: configured.to_string(), + checked_paths: checked, + } +} + +/// Convenience: resolve ffmpeg using the configured value. +pub fn resolve_ffmpeg(configured: &str) -> ResolvedBinary { + resolve_binary(configured, "ffmpeg") +} + +/// Convenience: resolve ffprobe using the configured value. +pub fn resolve_ffprobe(configured: &str) -> ResolvedBinary { + resolve_binary(configured, "ffprobe") +} + +fn base_name_is_allowed(path: &std::path::Path) -> bool { + // The base name of the configured path must itself be an allowed binary. + path.file_name() + .and_then(|name| name.to_str()) + .map(|name| ALLOWED_BINARIES.contains(&name)) + .unwrap_or(false) +} + +fn is_executable(path: &std::path::Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = match std::fs::metadata(path) { + Ok(m) => m, + Err(_) => return false, + }; + if !metadata.is_file() { + return false; + } + metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + std::fs::metadata(path) + .map(|m| m.is_file()) + .unwrap_or(false) + } +} + +fn canonicalize(path: &std::path::Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn lookup_on_path(name: &str) -> Option { + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(name); + if is_executable(&candidate) { + return Some(canonicalize(&candidate)); + } + } + None +} + +/// Run ` -version` and return the first stdout line, or [`None`] on +/// failure. Used for capability display. Uses the synchronous std [`Command`] +/// because this is called rarely and from sync contexts (`detect_binary`). +pub fn probe_version(path: &std::path::Path) -> Option { + let output = std::process::Command::new(path) + .arg("-version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) +} + +/// Encoders relevant to media transcoding that we surface in the discovery UI. +/// Anything else ffmpeg reports is ignored to keep the display focused. +const RELEVANT_ENCODERS: &[&str] = &[ + "libx264", + "libx265", + "libvpx-vp9", + "libvpx", + "libsvtav1", + "libopus", + "libmp3lame", + "aac", +]; + +/// Run `ffmpeg -hide_banner -encoders` and return the subset of +/// [`RELEVANT_ENCODERS`] that this build supports, preserving the allow-list +/// order. Empty if the binary can't be probed. Only called during the on-demand +/// Detect action, so the one ffmpeg invocation is acceptable. +pub fn probe_encoders(path: &std::path::Path) -> Vec { + let output = match std::process::Command::new(path) + .args(["-hide_banner", "-encoders"]) + .output() + { + Ok(output) if output.status.success() => output, + _ => return Vec::new(), + }; + // Encoder data rows look like: ` V....D libx264 ... (codec h264)`. + // Collect the encoder names that appear, then filter to the allow-list in + // a stable order so the UI is deterministic. + let stdout = String::from_utf8_lossy(&output.stdout); + let present: std::collections::HashSet<&str> = + stdout.lines().filter_map(parse_encoder_name).collect(); + RELEVANT_ENCODERS + .iter() + .filter(|name| present.contains(**name)) + .map(|name| (*name).to_string()) + .collect() +} + +/// Extract the encoder name from a `-encoders` data row. Rows begin with a +/// 6-char flags column (` V....D `, ` A..... `, etc.) followed by the name. +/// Legend rows like ` V..... = Video` are rejected because the "name" starts +/// with `=` rather than a letter. +fn parse_encoder_name(line: &str) -> Option<&str> { + let rest = line.strip_prefix(' ')?; + // The flags column is exactly 6 chars (type + 5 flag dots/letters). + let flags = rest.get(0..6)?; + if !matches!(flags.as_bytes()[0], b'V' | b'A' | b'S') { + return None; + } + let after_flags = rest.get(6..)?; + let name = after_flags.trim_start(); + // Real encoder names start with a letter; legend rows yield `=` here. + let first = name.as_bytes().first()?; + if !first.is_ascii_alphabetic() { + return None; + } + let end = name.find(char::is_whitespace).unwrap_or(name.len()); + if end == 0 { + return None; + } + Some(&name[..end]) +} + +/// Public accessor for the platform well-known directory list, for discovery. +pub fn well_known_dirs_public() -> Vec { + WELL_KNOWN_DIRS.clone() +} + +/// Public accessor for the executability check, for discovery. +pub fn is_executable_public(path: &std::path::Path) -> bool { + is_executable(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A binary is "executable enough" for these tests if it exists on disk; + // we create real (empty) files rather than mocking stat calls. + fn write_shim( + dir: &std::path::Path, + name: &str, + ) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, b"#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&path).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&path, perms).unwrap(); + } + path + } + + // The public `resolve_binary` consults the real machine's PATH (where + // ffmpeg may actually be installed), so these tests use the internal + // `resolve_binary_with` with a controlled PATH-lookup function to stay + // hermetic. The resolver no longer searches well-known directories — that + // is the Detect UI's job, not the runtime's. + fn no_path_lookup(_: &str) -> Option { + None + } + + #[cfg(unix)] + #[test] + fn absolute_configured_path_is_used() { + let dir = tempfile::tempdir().unwrap(); + let ffmpeg = write_shim(dir.path(), "ffmpeg"); + let resolved = resolve_binary_with(ffmpeg.to_str().unwrap(), "ffmpeg", no_path_lookup); + assert!(matches!( + resolved, + ResolvedBinary::Found { + via: ResolveSource::ConfiguredAbsolute, + .. + } + )); + if let ResolvedBinary::Found { resolved_path, .. } = resolved { + // macOS canonicalizes /var -> /private/var; compare canonical forms. + assert_eq!(resolved_path, canonicalize(&ffmpeg)); + } + } + + #[cfg(unix)] + #[test] + fn disallowed_base_name_is_rejected_even_as_absolute_path() { + let dir = tempfile::tempdir().unwrap(); + let rogue = write_shim(dir.path(), "not-ffmpeg"); + // The requested binary_name itself must be allowed; a rogue name is never resolved. + assert!(matches!( + resolve_binary_with(rogue.to_str().unwrap(), "rogue", no_path_lookup), + ResolvedBinary::Missing { .. } + )); + // Asking for an allowed name via a path whose base name is rogue is also rejected. + assert!(matches!( + resolve_binary_with(rogue.to_str().unwrap(), "ffmpeg", no_path_lookup), + ResolvedBinary::Missing { .. } + )); + } + + #[test] + fn missing_returns_checked_paths() { + // No PATH hit -> the only checked path is the configured one. + let resolved = resolve_binary_with( + "/definitely/does/not/exist/ffmpeg", + "ffmpeg", + no_path_lookup, + ); + match resolved { + ResolvedBinary::Missing { + configured, + checked_paths, + } => { + assert_eq!(configured, "/definitely/does/not/exist/ffmpeg"); + assert_eq!( + checked_paths, + vec![PathBuf::from( + "/definitely/does/not/exist/ffmpeg" + )] + ); + } + other => panic!("expected Missing, got {other:?}"), + } + } + + #[test] + fn path_lookup_hit_reports_pathlookup_source() { + // A bare name found via PATH lookup reports PathLookup. + let dir = tempfile::tempdir().unwrap(); + let ffmpeg = write_shim(dir.path(), "ffmpeg"); + let via_path = move |name: &str| { + if name == "ffmpeg" { Some(ffmpeg.clone()) } else { None } + }; + let resolved = resolve_binary_with("ffmpeg", "ffmpeg", via_path); + assert!(matches!( + resolved, + ResolvedBinary::Found { + via: ResolveSource::PathLookup, + .. + } + )); + } + + #[test] + fn allowed_binaries_constant_is_what_we_expect() { + assert_eq!(ALLOWED_BINARIES, &["ffmpeg", "ffprobe"]); + } + + #[cfg(unix)] + #[test] + fn probe_version_reads_first_stdout_line() { + let dir = tempfile::tempdir().unwrap(); + // A shim that mimics `ffmpeg -version` first line. + let shim = dir.path().join("ffmpeg"); + std::fs::write( + &shim, + "#!/bin/sh\necho 'ffmpeg version 7.0.2, built locally'\nexit 0\n", + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&shim).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&shim, perms).unwrap(); + + let version = probe_version(&shim); + assert_eq!( + version.as_deref(), + Some("ffmpeg version 7.0.2, built locally") + ); + } + + #[test] + fn probe_version_returns_none_for_missing_binary() { + assert!(probe_version(std::path::Path::new("/definitely/does/not/exist/ffmpeg")).is_none()); + } + + #[test] + fn parse_encoder_name_extracts_from_data_row() { + assert_eq!( + parse_encoder_name(" V....D libx264 libx264 H.264 (codec h264)"), + Some("libx264") + ); + assert_eq!( + parse_encoder_name(" A....D aac AAC (Advanced Audio Coding)"), + Some("aac") + ); + } + + #[test] + fn parse_encoder_name_rejects_legend_and_blank() { + assert_eq!(parse_encoder_name(" V..... = Video"), None); + assert_eq!(parse_encoder_name("Encoders:"), None); + assert_eq!(parse_encoder_name(""), None); + assert_eq!(parse_encoder_name("not a row"), None); + } + + #[cfg(unix)] + #[test] + fn probe_encoders_returns_relevant_subset_in_allow_list_order() { + let dir = tempfile::tempdir().unwrap(); + let shim = dir.path().join("ffmpeg"); + // A shim that emits a few -encoders rows mixing relevant + irrelevant names. + // Recognized: libx264, libx265, aac. Irrelevant/ignored: foo, alias_pix. + // Order in output is intentionally not allow-list order. + let script = "#!/bin/sh\n\ + echo ' V....D libx265 H.265 (codec hevc)'\n\ + echo ' V....D alias_pix Alias PIX (codec alias_pix)'\n\ + echo ' V....D libx264 H.264 (codec h264)'\n\ + echo ' V....D foo not relevant (codec foo)'\n\ + echo ' A....D aac AAC (codec aac)'\n\ + exit 0\n"; + std::fs::write(&shim, script).unwrap(); + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&shim).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&shim, perms).unwrap(); + + let encoders = probe_encoders(&shim); + // Subset of the allow-list, in allow-list order (libx264 before libx265 + // even though libx265 appeared first in the output). + assert_eq!( + encoders, + vec![ + "libx264".to_string(), + "libx265".to_string(), + "aac".to_string() + ] + ); + } + + #[test] + fn probe_encoders_returns_empty_for_missing_binary() { + assert!( + probe_encoders(std::path::Path::new("/definitely/does/not/exist/ffmpeg")).is_empty() + ); + } + + /// Smoke test that only runs when invoked with `--features real-ffmpeg-tests` + /// AND ffmpeg is actually installed. Skipped in normal CI so the suite stays + /// hermetic; locally, a developer with ffmpeg can opt in. + #[cfg(feature = "real-ffmpeg-tests")] + #[test] + fn real_ffmpeg_on_path_is_found() { + match resolve_binary("ffmpeg", "ffmpeg") { + ResolvedBinary::Found { via, .. } => { + println!("resolved ffmpeg via {via:?}"); + } + ResolvedBinary::Missing { checked_paths, .. } => { + panic!( + "real-ffmpeg-tests feature is enabled but ffmpeg was not found; checked: {checked_paths:?}" + ); + } + } + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 31fce3cf..1096994e 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -9,6 +9,7 @@ pub mod certs; pub mod config; pub mod db; pub mod dependencies; +pub mod ffmpeg_resolve; pub mod globals; mod logging; pub mod media; diff --git a/crates/server/src/logging/mod.rs b/crates/server/src/logging/mod.rs index 5b532d60..10d1f105 100644 --- a/crates/server/src/logging/mod.rs +++ b/crates/server/src/logging/mod.rs @@ -5,10 +5,7 @@ use std::io; use std::path::Path; // lib imports -use fern::colors::{ - Color, - ColoredLevelConfig, -}; +use fern::colors::{Color, ColoredLevelConfig}; use log::LevelFilter; use regex::Regex; diff --git a/crates/server/src/media.rs b/crates/server/src/media.rs index bff88d5a..cdf66d91 100644 --- a/crates/server/src/media.rs +++ b/crates/server/src/media.rs @@ -1,89 +1,43 @@ //! Media-library inspection, persistence, and transcoding capability utilities. // standard imports -use std::collections::{ - HashMap, - HashSet, -}; +use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::{ - Path, - PathBuf, -}; +use std::path::{Path, PathBuf}; use std::process::Command; // lib imports use diesel::{ - ExpressionMethods, - OptionalExtension, - QueryDsl, - RunQueryDsl, - SelectableHelper, - SqliteConnection, - sql_types, + ExpressionMethods, OptionalExtension, QueryDsl, RunQueryDsl, SelectableHelper, + SqliteConnection, sql_types, }; use schemars::JsonSchema; -use serde::{ - Deserialize, - Serialize, -}; +use serde::{Deserialize, Serialize}; use serde_json::Value; // local imports use crate::config::{ - FfmpegSettings, - MediaLibraryKind, - MediaLibraryMetadataLanguageMode, - MediaLibraryScanner, - MediaLibrarySettings, - MetadataProviderId, + FfmpegSettings, MediaLibraryKind, MediaLibraryMetadataLanguageMode, MediaLibraryScanner, + MediaLibrarySettings, MetadataProviderId, }; use crate::db::models::{ - ItemMetadataLink, - MediaFile, - MediaItem, - MediaLibrary, - MetadataCollection, - MetadataCollectionItem, - NewMediaFile, - NewMediaFileLibrary, - NewMediaItem, - NewMediaLibrary, - NewPlaybackProgress, - NewScanState, - PlaybackProgress, - ScanState, - User, + ItemMetadataLink, MediaFile, MediaItem, MediaLibrary, MetadataCollection, + MetadataCollectionItem, NewMediaFile, NewMediaFileLibrary, NewMediaItem, NewMediaLibrary, + NewPlaybackProgress, NewScanState, PlaybackProgress, ScanState, User, }; use crate::metadata::{ - ArtworkKind, - DEFAULT_METADATA_LOCALE, - LinkedMetadataExtra, - MetadataCollectionSummary, - MetadataProvider, - MetadataRegistry, + ArtworkKind, DEFAULT_METADATA_LOCALE, LinkedMetadataExtra, MetadataCollectionSummary, + MetadataProvider, MetadataRegistry, list_metadata_collection_summaries_with_preferred_languages, - metadata_extras_from_metadata_links, - normalize_locale_key, - presentation_from_metadata_links, + metadata_extras_from_metadata_links, normalize_locale_key, presentation_from_metadata_links, }; use crate::scanner::shows::parse_show_path; -pub use crate::scanner::shows::{ - infer_episode_number, - infer_season_number, -}; +pub use crate::scanner::shows::{infer_episode_number, infer_season_number}; use crate::scanner::{ - DiscoveredMediaFile, - FileHashCandidate, - ScannerSink, - fallback_title_from_relative_path, - inspect_library, - inspect_library_streaming, -}; -pub use crate::scanner::{ - LibraryScanStatus, - LibraryScanSummary, + DiscoveredMediaFile, FileHashCandidate, ScannerSink, fallback_title_from_relative_path, + inspect_library, inspect_library_streaming, }; +pub use crate::scanner::{LibraryScanStatus, LibraryScanSummary}; use crate::utils::current_timestamp; #[derive(Debug)] @@ -198,8 +152,7 @@ struct LibraryMetadataRefreshCounts { failed_items: i64, } -const CATALOG_MEDIA_FILE_COLUMNS: &str = - "\ +const CATALOG_MEDIA_FILE_COLUMNS: &str = "\ files.id AS id,files.path AS path,memberships.id AS library_file_id,memberships.library_id \ AS library_id,memberships.source_root_path AS source_root_path,memberships.relative_path AS \ relative_path,files.file_size AS file_size,files.modified_at AS modified_at,files.media_kind \ @@ -604,11 +557,27 @@ pub struct ClientProfile { pub prefer_hls: bool, } +/// Whether the source media has been analyzed by ffprobe, which determines +/// whether a playback decision can be trusted. +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub enum PlaybackAnalysisState { + /// The source media was probed by ffprobe; codec/container are known and + /// the direct-play vs transcode decision is reliable. + Analyzed, + /// The source media was never probed (ffprobe was missing during scan), so + /// codec/container are unknown and no trustworthy playback decision can be + /// made. The client should refuse to open a player and surface a setup hint. + AwaitingAnalysis, +} + /// Direct-play versus transcode decision for one media item. #[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] pub struct PlaybackDecision { /// Stable database identifier for the item. pub item_id: i32, + /// Whether the source media has been analyzed; if [`PlaybackAnalysisState::AwaitingAnalysis`], + /// the other fields are not trustworthy and playback should be blocked. + pub analysis_state: PlaybackAnalysisState, /// Whether the item can be played directly in the browser. pub can_direct_play: bool, /// Whether transcoding would be required for ideal playback. @@ -784,8 +753,8 @@ pub fn inspect_libraries(libraries: &[MediaLibrarySettings]) -> Vec TranscodingCapability { TranscodingCapability { - ffmpeg: detect_binary(&settings.ffmpeg_path), - ffprobe: detect_binary(&settings.ffprobe_path), + ffmpeg: detect_binary(&settings.ffmpeg_path, "ffmpeg"), + ffprobe: detect_binary(&settings.ffprobe_path, "ffprobe"), } } @@ -1553,7 +1522,7 @@ fn sync_library_catalog_filtered( let probe_context = ProbeContext { ffprobe_path: &ffmpeg_settings.ffprobe_path, - enabled: detect_binary(&ffmpeg_settings.ffprobe_path).available, + enabled: detect_binary(&ffmpeg_settings.ffprobe_path, "ffprobe").available, }; let existing_library_rows = media_libraries_dsl::media_libraries .order(media_libraries_dsl::id.asc()) @@ -1718,6 +1687,161 @@ fn sync_library_catalog_filtered( Ok(persisted) } +/// A `media_files` row selected for re-probing (ffprobe was missing when it was +/// first scanned, so its codec/container/metadata_json are NULL). Carries just +/// the fields needed to reconstruct a [`DiscoveredMediaFile`] for probing. +#[derive(Debug, Clone, diesel::QueryableByName)] +#[diesel(table_name = crate::db::schema::media_files)] +struct ReprobeCandidate { + #[diesel(sql_type = sql_types::Integer)] + id: i32, + #[diesel(sql_type = sql_types::Text)] + path: String, + #[diesel(sql_type = sql_types::BigInt)] + file_size: i64, + #[diesel(sql_type = sql_types::Nullable)] + modified_at: Option, + #[diesel(sql_type = sql_types::Text)] + media_kind: String, + #[diesel(sql_type = sql_types::Text)] + file_hash: String, +} + +/// Count media files that were cataloged without being probed (ffprobe was +/// missing during their scan). These are candidates for auto re-probe. +pub fn count_media_files_needing_reprobe( + conn: &mut SqliteConnection +) -> Result { + use crate::db::schema::media_files::dsl as media_files_dsl; + media_files_dsl::media_files + .filter(media_files_dsl::metadata_json.is_null()) + .filter(media_files_dsl::media_kind.eq_any([ + "video".to_string(), + "audio".to_string(), + ])) + .count() + .get_result(conn) +} + +/// Load the media files needing re-probe (see [`count_media_files_needing_reprobe`]). +fn load_media_files_needing_reprobe( + conn: &mut SqliteConnection +) -> Result, diesel::result::Error> { + diesel::sql_query( + "SELECT id, path, file_size, modified_at, media_kind, file_hash \ + FROM media_files \ + WHERE metadata_json IS NULL AND media_kind IN ('video', 'audio') \ + ORDER BY id", + ) + .load::(conn) +} + +/// Outcome of a re-probe run: how many files were inspected, how many produced +/// real metadata, and how many failed. +#[derive(Debug, Clone, Default)] +pub struct ReprobeOutcome { + /// Total candidate files inspected. + pub inspected: usize, + /// Files whose probe succeeded and whose codec columns were updated. + pub updated: usize, + /// Files whose probe failed (ffprobe errored or parse failed); left as-is. + pub failed: usize, +} + +/// Re-probe media files that were cataloged without ffprobe (their +/// `metadata_json` is NULL). For each candidate, runs ffprobe and writes only +/// the codec/container/stream columns via a targeted UPDATE (path, hash, and +/// modified_at are untouched — the file content didn't change, only ffprobe +/// availability did). Does not ride the scan path because `sync_discovered_media_file` +/// skips re-probe when `file_hash` matches. +/// +/// `progress` is called with the running count of inspected files after each +/// one, so callers can update an activity record between probes. +pub fn reprobe_media_files( + conn: &mut SqliteConnection, + ffmpeg_settings: &FfmpegSettings, + mut progress: F, +) -> Result +where + F: FnMut(usize), +{ + use crate::db::schema::media_files::dsl as media_files_dsl; + + // Re-check ffprobe availability at call time; detect_binary is stateless. + let probe_context = ProbeContext { + ffprobe_path: &ffmpeg_settings.ffprobe_path, + enabled: detect_binary(&ffmpeg_settings.ffprobe_path, "ffprobe").available, + }; + if !probe_context.enabled { + log::warn!( + "reprobe_media_files called but ffprobe is not available; skipping ({} configured)", + ffmpeg_settings.ffprobe_path + ); + return Ok(ReprobeOutcome::default()); + } + + let candidates = load_media_files_needing_reprobe(conn)?; + let mut outcome = ReprobeOutcome { + inspected: candidates.len(), + ..Default::default() + }; + + for candidate in candidates { + // Reconstruct a minimal DiscoveredMediaFile for extract_metadata. + let discovered = DiscoveredMediaFile { + full_path: PathBuf::from(&candidate.path), + source_root_path: String::new(), + relative_path: String::new(), + file_size: candidate.file_size, + modified_at: candidate.modified_at, + media_kind: candidate.media_kind.clone(), + file_hash: candidate.file_hash.clone(), + default_title: String::new(), + }; + let metadata = extract_metadata(&discovered, probe_context); + + // Only update when the probe actually produced metadata_json; otherwise + // leave the row as-is so a future run retries it. + if metadata.metadata_json.is_some() { + let now = current_timestamp(); + diesel::update( + media_files_dsl::media_files.filter(media_files_dsl::id.eq(candidate.id)), + ) + .set(( + media_files_dsl::container.eq(&metadata.container), + media_files_dsl::duration_ms.eq(metadata.duration_ms), + media_files_dsl::bit_rate.eq(metadata.bit_rate), + media_files_dsl::width.eq(metadata.width), + media_files_dsl::height.eq(metadata.height), + media_files_dsl::video_codec.eq(&metadata.video_codec), + media_files_dsl::audio_codec.eq(&metadata.audio_codec), + media_files_dsl::metadata_json.eq(&metadata.metadata_json), + media_files_dsl::metadata_updated_at.eq(Some(now)), + )) + .execute(conn)?; + outcome.updated += 1; + } else { + outcome.failed += 1; + } + progress(outcome.inspected_done()); + } + + log::info!( + "reprobe_media_files complete: inspected={}, updated={}, failed={}", + outcome.inspected, + outcome.updated, + outcome.failed + ); + Ok(outcome) +} + +impl ReprobeOutcome { + /// Number of candidates processed so far (updated + failed). + fn inspected_done(&self) -> usize { + self.updated + self.failed + } +} + fn sync_logical_media_items_for_library( conn: &mut SqliteConnection, library: &MediaLibrary, @@ -3145,6 +3269,7 @@ pub fn get_playback_decision( if row.missing_since.is_some() { return Ok(Some(PlaybackDecision { item_id, + analysis_state: PlaybackAnalysisState::Analyzed, can_direct_play: false, transcode_required: false, reason: "This item is missing from disk and cannot be played.".into(), @@ -3161,6 +3286,37 @@ pub fn get_playback_decision( })); } + // Decision guard: if the source media was never analyzed by ffprobe + // (metadata_json is NULL — ffprobe was missing during scan), the + // codec/container fields are unknown and no trustworthy direct-play vs + // transcode decision can be made. Refuse to decide and tell the client + // analysis is pending, rather than returning an untrustworthy "transcode + // required" verdict that opens a doomed player. + let media_kind = row.media_kind.as_str(); + let needs_analysis = row.metadata_json.is_none() && matches!(media_kind, "video" | "audio"); + if needs_analysis { + return Ok(Some(PlaybackDecision { + item_id, + analysis_state: PlaybackAnalysisState::AwaitingAnalysis, + can_direct_play: false, + transcode_required: false, + reason: "This media has not been analyzed yet (ffprobe was \ + unavailable during scan). Set the ffprobe path in \ + Settings and re-probe media info." + .into(), + stream_url: None, + mime_type: None, + transcode_container: None, + transcode_video_codec: None, + transcode_audio_codec: None, + video_transcode_required: false, + audio_transcode_required: false, + source_video_codec: row.video_codec, + source_audio_codec: row.audio_codec, + source_container: row.container, + })); + } + let default_profile = ClientProfile { client_type: "web".into(), client_name: "Web".into(), @@ -3212,6 +3368,7 @@ pub fn get_playback_decision( PlaybackDecision { item_id, + analysis_state: PlaybackAnalysisState::Analyzed, can_direct_play, transcode_required: item.playable && !can_direct_play, reason: if can_direct_play { @@ -3233,6 +3390,7 @@ pub fn get_playback_decision( } else { PlaybackDecision { item_id, + analysis_state: PlaybackAnalysisState::Analyzed, can_direct_play: false, transcode_required: false, reason: "This item is a container and cannot be played directly.".into(), @@ -5994,48 +6152,60 @@ pub fn list_media_item_children_with_preferred_languages( Ok(items) } -fn detect_binary(binary: &str) -> BinaryCapability { - let output = Command::new(binary).arg("-version").output(); - - match output { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout) - .lines() - .next() - .map(|line| line.trim().to_string()) - .filter(|line| !line.is_empty()); - +fn detect_binary( + configured: &str, + binary_name: &str, +) -> BinaryCapability { + use crate::ffmpeg_resolve::{self, ResolvedBinary}; + + match ffmpeg_resolve::resolve_binary(configured, binary_name) { + ResolvedBinary::Found { resolved_path, via } => { + // "available" means the binary actually launches and reports a + // version, not merely that it exists on disk. A found binary whose + // `-version` fails (e.g. a broken shim) is reported unavailable + // with the probe error, matching the previous semantics. + let version = ffmpeg_resolve::probe_version(&resolved_path); + let (available, error) = match &version { + Some(_) => (true, None), + None => ( + false, + Some(format!( + "{} was found at {} but did not report a version", + binary_name, + resolved_path.display() + )), + ), + }; + log::info!( + "Resolved {binary_name} via {via:?}: {} (available: {available})", + resolved_path.display() + ); BinaryCapability { - configured_path: binary.to_string(), - available: true, + configured_path: configured.to_string(), + available, version, - error: None, + error, } } - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let error = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("Process exited with status {}", output.status) - }; - + ResolvedBinary::Missing { checked_paths, .. } => { + let checked_display = checked_paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + log::warn!( + "Could not resolve {binary_name}; checked: [{}]", + checked_display + ); BinaryCapability { - configured_path: binary.to_string(), + configured_path: configured.to_string(), available: false, version: None, - error: Some(error), + error: Some(format!( + "Could not find {binary_name}. Checked: [{checked_display}]" + )), } } - Err(error) => BinaryCapability { - configured_path: binary.to_string(), - available: false, - version: None, - error: Some(error.to_string()), - }, } } diff --git a/crates/server/src/metadata/mod.rs b/crates/server/src/metadata/mod.rs index bd211040..bd10c8f7 100644 --- a/crates/server/src/metadata/mod.rs +++ b/crates/server/src/metadata/mod.rs @@ -2,79 +2,38 @@ // standard imports use std::collections::hash_map::DefaultHasher; -use std::collections::{ - HashMap, - HashSet, -}; +use std::collections::{HashMap, HashSet}; use std::fs; -use std::hash::{ - Hash, - Hasher, -}; -use std::path::{ - Path, - PathBuf, -}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; use std::time::Duration; // lib imports use diesel::{ - BoolExpressionMethods, - ExpressionMethods, - JoinOnDsl, - OptionalExtension, - QueryDsl, - RunQueryDsl, - SelectableHelper, - SqliteConnection, + BoolExpressionMethods, ExpressionMethods, JoinOnDsl, OptionalExtension, QueryDsl, RunQueryDsl, + SelectableHelper, SqliteConnection, }; use once_cell::sync::Lazy; use regex::Regex; use schemars::JsonSchema; -use serde::{ - Deserialize, - Serialize, -}; -use sha2::{ - Digest, - Sha256, -}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use strsim::normalized_levenshtein; mod providers; -pub use providers::{ - MetadataProvider, - MetadataRegistry, -}; +pub use providers::{MetadataProvider, MetadataRegistry}; // local imports use crate::config::{ - MediaLibraryKind, - MetadataProviderId, - MetadataProviderSettings, - MetadataSettings, - metadata_provider_api_key_configured, - resolve_metadata_provider_api_key, + MediaLibraryKind, MetadataProviderId, MetadataProviderSettings, MetadataSettings, + metadata_provider_api_key_configured, resolve_metadata_provider_api_key, }; use crate::db::configure_sqlite_connection; use crate::db::models::{ - ExternalMedia, - ItemMetadataLink, - MediaItem, - MetadataCollection, - MetadataCollectionItem, - MetadataExtra, - MetadataPerson, - MetadataPersonCredit, - NewExternalMedia, - NewItemMetadataExternalId, - NewItemMetadataLink, - NewItemMetadataPerson, - NewMetadataCollection, - NewMetadataCollectionItem, - NewMetadataExtra, - NewMetadataPerson, - NewMetadataPersonCredit, + ExternalMedia, ItemMetadataLink, MediaItem, MetadataCollection, MetadataCollectionItem, + MetadataExtra, MetadataPerson, MetadataPersonCredit, NewExternalMedia, + NewItemMetadataExternalId, NewItemMetadataLink, NewItemMetadataPerson, NewMetadataCollection, + NewMetadataCollectionItem, NewMetadataExtra, NewMetadataPerson, NewMetadataPersonCredit, NewMetadataPersonExternalId, }; use crate::utils::current_timestamp; diff --git a/crates/server/src/metadata/providers/mod.rs b/crates/server/src/metadata/providers/mod.rs index bf1c1110..6d0f0364 100644 --- a/crates/server/src/metadata/providers/mod.rs +++ b/crates/server/src/metadata/providers/mod.rs @@ -6,22 +6,11 @@ pub(crate) mod tvdb; use std::future::Future; use std::pin::Pin; -use crate::config::{ - MediaLibraryKind, - MetadataProviderId, - MetadataSettings, -}; +use crate::config::{MediaLibraryKind, MetadataProviderId, MetadataSettings}; use crate::metadata::{ - MetadataItemKind, - MetadataProviderDescriptor, - MetadataProviderRole, - MetadataSearchResult, - ProviderDescendantTarget, - ProviderMetadataCollection, - ProviderMetadataDetails, - ProviderMetadataPerson, - StoredMetadataSnapshot, - normalize_locale_key, + MetadataItemKind, MetadataProviderDescriptor, MetadataProviderRole, MetadataSearchResult, + ProviderDescendantTarget, ProviderMetadataCollection, ProviderMetadataDetails, + ProviderMetadataPerson, StoredMetadataSnapshot, normalize_locale_key, }; /// Boxed async result returned by metadata provider operations. diff --git a/crates/server/src/metadata/providers/themerr.rs b/crates/server/src/metadata/providers/themerr.rs index 43524809..94136dba 100644 --- a/crates/server/src/metadata/providers/themerr.rs +++ b/crates/server/src/metadata/providers/themerr.rs @@ -2,13 +2,8 @@ use serde_json::Value; use crate::config::MetadataProviderId; use crate::metadata::{ - METADATA_EXTRA_TYPE_THEME_SONG, - MediaLibraryKind, - MetadataProviderDescriptor, - MetadataProviderRole, - ProviderMetadataDetails, - ProviderMetadataExtra, - youtube_watch_url, + METADATA_EXTRA_TYPE_THEME_SONG, MediaLibraryKind, MetadataProviderDescriptor, + MetadataProviderRole, ProviderMetadataDetails, ProviderMetadataExtra, youtube_watch_url, }; const THEMERR_API_BASE: &str = "https://app.lizardbyte.dev/ThemerrDB"; @@ -209,9 +204,7 @@ fn text_field( #[cfg(test)] mod tests { use super::{ - database_path_for_item_type, - item_lookup_reference_priority, - normalize_database_id, + database_path_for_item_type, item_lookup_reference_priority, normalize_database_id, parse_youtube_theme_url, }; use crate::config::MetadataProviderId; diff --git a/crates/server/src/metadata/providers/tmdb.rs b/crates/server/src/metadata/providers/tmdb.rs index 3c2ac407..619afeeb 100644 --- a/crates/server/src/metadata/providers/tmdb.rs +++ b/crates/server/src/metadata/providers/tmdb.rs @@ -1,52 +1,21 @@ -use std::collections::{ - HashMap, - HashSet, -}; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; use once_cell::sync::Lazy; use serde_json::Value; use strsim::normalized_levenshtein; use tmdb_client::apis::client::APIClient as TmdbApiClient; -use tmdb_client::models::{ - EpisodeDetails, - MovieDetails, - MovieObject, - SeasonDetails, - TvDetails, -}; +use tmdb_client::models::{EpisodeDetails, MovieDetails, MovieObject, SeasonDetails, TvDetails}; -use crate::config::{ - MetadataProviderId, - MetadataProviderSettings, - MetadataSettings, -}; +use crate::config::{MetadataProviderId, MetadataProviderSettings, MetadataSettings}; use crate::metadata::{ - MediaLibraryKind, - MetadataItemKind, - MetadataProviderDescriptor, - MetadataProviderRole, - MetadataSearchResult, - ProviderDescendantTarget, - ProviderExternalId, - ProviderMetadataCollection, - ProviderMetadataDetails, - ProviderMetadataPerson, - StoredMetadataSnapshot, - cleanup_movie_title, - extract_release_year, - managed_metadata_asset_dir, - metadata_asset_db_path, - metadata_response_cache_key, - movie_match_score, - normalize_external_id_source, - parse_movie_name, - preferred_image_url_by_format, - provider_settings, - read_metadata_response_cache_text, - show_search_query, - try_cache_item_artwork, - write_metadata_response_cache_text, + MediaLibraryKind, MetadataItemKind, MetadataProviderDescriptor, MetadataProviderRole, + MetadataSearchResult, ProviderDescendantTarget, ProviderExternalId, ProviderMetadataCollection, + ProviderMetadataDetails, ProviderMetadataPerson, StoredMetadataSnapshot, cleanup_movie_title, + extract_release_year, managed_metadata_asset_dir, metadata_asset_db_path, + metadata_response_cache_key, movie_match_score, normalize_external_id_source, parse_movie_name, + preferred_image_url_by_format, provider_settings, read_metadata_response_cache_text, + show_search_query, try_cache_item_artwork, write_metadata_response_cache_text, }; const TMDB_IMAGE_BASE: &str = "https://image.tmdb.org/t/p"; @@ -1764,16 +1733,9 @@ fn sort_and_dedupe_people(people: Vec) -> Vec Option { #[cfg(test)] mod tests { use super::{ - artwork_url, - backdrop_url, - best_overview, - metadata_details, - metadata_item_kind, - movie_snapshot_from_value, - search_result_from_value, - tvdb_logo_url, + artwork_url, backdrop_url, best_overview, metadata_details, metadata_item_kind, + movie_snapshot_from_value, search_result_from_value, tvdb_logo_url, tvdb_people_with_language, }; use crate::metadata::MetadataItemKind; diff --git a/crates/server/src/scanner/books.rs b/crates/server/src/scanner/books.rs index 6e763aed..691595cd 100644 --- a/crates/server/src/scanner/books.rs +++ b/crates/server/src/scanner/books.rs @@ -1,18 +1,8 @@ //! Book scanner rules. -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; -use crate::scanner::directory::{ - self, - ScannerRules, -}; -use crate::scanner::{ - LibraryInspection, - ScannerSink, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; +use crate::scanner::directory::{self, ScannerRules}; +use crate::scanner::{LibraryInspection, ScannerSink}; pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { directory::scan_with_rules( diff --git a/crates/server/src/scanner/directory.rs b/crates/server/src/scanner/directory.rs index a73e3007..bf8bb116 100644 --- a/crates/server/src/scanner/directory.rs +++ b/crates/server/src/scanner/directory.rs @@ -4,26 +4,15 @@ use std::collections::HashSet; use std::convert::Infallible; use std::fs; use std::io; -use std::path::{ - Path, - PathBuf, -}; +use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use imohash::Hasher as ImoHasher; -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; use crate::scanner::{ - DiscoveredMediaFile, - FileHashCandidate, - LibraryInspection, - LibraryScanStatus, - LibraryScanSummary, - ScannerSink, + DiscoveredMediaFile, FileHashCandidate, LibraryInspection, LibraryScanStatus, + LibraryScanSummary, ScannerSink, }; #[derive(Debug, Default)] diff --git a/crates/server/src/scanner/mod.rs b/crates/server/src/scanner/mod.rs index 02fb934a..e373caed 100644 --- a/crates/server/src/scanner/mod.rs +++ b/crates/server/src/scanner/mod.rs @@ -8,19 +8,12 @@ pub mod photos; pub mod shows; use std::collections::HashSet; -use std::path::{ - Path, - PathBuf, -}; +use std::path::{Path, PathBuf}; use schemars::JsonSchema; use serde::Serialize; -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; pub(crate) use directory::fallback_title_from_relative_path; diff --git a/crates/server/src/scanner/movies.rs b/crates/server/src/scanner/movies.rs index c6dcbe79..8b771c44 100644 --- a/crates/server/src/scanner/movies.rs +++ b/crates/server/src/scanner/movies.rs @@ -3,19 +3,9 @@ use once_cell::sync::Lazy; use regex::Regex; -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; -use crate::scanner::directory::{ - self, - ScannerRules, -}; -use crate::scanner::{ - LibraryInspection, - ScannerSink, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; +use crate::scanner::directory::{self, ScannerRules}; +use crate::scanner::{LibraryInspection, ScannerSink}; pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { directory::scan_with_rules( diff --git a/crates/server/src/scanner/music.rs b/crates/server/src/scanner/music.rs index 8906280b..3fd83db1 100644 --- a/crates/server/src/scanner/music.rs +++ b/crates/server/src/scanner/music.rs @@ -1,18 +1,8 @@ //! Music scanner rules. -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; -use crate::scanner::directory::{ - self, - ScannerRules, -}; -use crate::scanner::{ - LibraryInspection, - ScannerSink, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; +use crate::scanner::directory::{self, ScannerRules}; +use crate::scanner::{LibraryInspection, ScannerSink}; pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { directory::scan_with_rules( diff --git a/crates/server/src/scanner/photos.rs b/crates/server/src/scanner/photos.rs index 16a74df8..1b13d103 100644 --- a/crates/server/src/scanner/photos.rs +++ b/crates/server/src/scanner/photos.rs @@ -1,18 +1,8 @@ //! Photo scanner rules. -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; -use crate::scanner::directory::{ - self, - ScannerRules, -}; -use crate::scanner::{ - LibraryInspection, - ScannerSink, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; +use crate::scanner::directory::{self, ScannerRules}; +use crate::scanner::{LibraryInspection, ScannerSink}; pub(crate) fn scan(library: &MediaLibrarySettings) -> LibraryInspection { directory::scan_with_rules( diff --git a/crates/server/src/scanner/shows.rs b/crates/server/src/scanner/shows.rs index a3ae9267..405cccd8 100644 --- a/crates/server/src/scanner/shows.rs +++ b/crates/server/src/scanner/shows.rs @@ -3,19 +3,9 @@ use once_cell::sync::Lazy; use regex::Regex; -use crate::config::{ - MediaLibraryKind, - MediaLibraryScanner, - MediaLibrarySettings, -}; -use crate::scanner::directory::{ - self, - ScannerRules, -}; -use crate::scanner::{ - LibraryInspection, - ScannerSink, -}; +use crate::config::{MediaLibraryKind, MediaLibraryScanner, MediaLibrarySettings}; +use crate::scanner::directory::{self, ScannerRules}; +use crate::scanner::{LibraryInspection, ScannerSink}; /// Show, season, and episode fields derived from a library-relative episode path. #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/server/src/scheduled_tasks/database_maintenance.rs b/crates/server/src/scheduled_tasks/database_maintenance.rs index d529bd42..e8a178a0 100644 --- a/crates/server/src/scheduled_tasks/database_maintenance.rs +++ b/crates/server/src/scheduled_tasks/database_maintenance.rs @@ -8,11 +8,7 @@ use crate::config::ScheduledTasksSettings; use crate::db::DbConn; use crate::utils::current_timestamp; -use super::{ - ScheduledTask, - ScheduledTaskFuture, - save_scheduled_task_last_run, -}; +use super::{ScheduledTask, ScheduledTaskFuture, save_scheduled_task_last_run}; const LAST_RUN_KEY: &str = "scheduled_tasks.database_maintenance.last_run_at"; diff --git a/crates/server/src/scheduled_tasks/metadata_refresh.rs b/crates/server/src/scheduled_tasks/metadata_refresh.rs index a5270605..fd6c60cc 100644 --- a/crates/server/src/scheduled_tasks/metadata_refresh.rs +++ b/crates/server/src/scheduled_tasks/metadata_refresh.rs @@ -1,16 +1,10 @@ //! Scheduled metadata refresh task. // local imports -use crate::config::{ - ScheduledTasksSettings, - current_settings, -}; +use crate::config::{ScheduledTasksSettings, current_settings}; use crate::db::DbConn; -use super::{ - ScheduledTask, - ScheduledTaskFuture, -}; +use super::{ScheduledTask, ScheduledTaskFuture}; static TASK: MetadataRefreshTask = MetadataRefreshTask; diff --git a/crates/server/src/scheduled_tasks/mod.rs b/crates/server/src/scheduled_tasks/mod.rs index ee0be1ed..99320b98 100644 --- a/crates/server/src/scheduled_tasks/mod.rs +++ b/crates/server/src/scheduled_tasks/mod.rs @@ -6,11 +6,7 @@ mod metadata_refresh; mod trash_cleanup; // lib imports -use chrono::{ - Datelike, - Local, - Timelike, -}; +use chrono::{Datelike, Local, Timelike}; use diesel::ExpressionMethods; use diesel::OptionalExtension; use diesel::QueryDsl; @@ -19,17 +15,10 @@ use diesel::SelectableHelper; use once_cell::sync::Lazy; use std::future::Future; use std::pin::Pin; -use std::sync::atomic::{ - AtomicBool, - Ordering, -}; +use std::sync::atomic::{AtomicBool, Ordering}; // local imports -use crate::config::{ - ScheduledTaskWeekday, - ScheduledTasksSettings, - current_settings, -}; +use crate::config::{ScheduledTaskWeekday, ScheduledTasksSettings, current_settings}; use crate::db::DbConn; use crate::db::models::AppSetting; diff --git a/crates/server/src/scheduled_tasks/trash_cleanup.rs b/crates/server/src/scheduled_tasks/trash_cleanup.rs index 1d75326c..f5d10e62 100644 --- a/crates/server/src/scheduled_tasks/trash_cleanup.rs +++ b/crates/server/src/scheduled_tasks/trash_cleanup.rs @@ -6,11 +6,7 @@ use crate::db::DbConn; use crate::media::delete_missing_media_items; use crate::utils::current_timestamp; -use super::{ - ScheduledTask, - ScheduledTaskFuture, - save_scheduled_task_last_run, -}; +use super::{ScheduledTask, ScheduledTaskFuture, save_scheduled_task_last_run}; const LAST_RUN_KEY: &str = "scheduled_tasks.trash_cleanup.last_run_at"; diff --git a/crates/server/src/secrets.rs b/crates/server/src/secrets.rs index a9029261..46ce667e 100644 --- a/crates/server/src/secrets.rs +++ b/crates/server/src/secrets.rs @@ -5,11 +5,7 @@ use std::collections::HashMap; use std::sync::Mutex; // lib imports -use keyring_core::{ - Entry, - Error, - set_default_store, -}; +use keyring_core::{Entry, Error, set_default_store}; use once_cell::sync::Lazy; // local imports diff --git a/crates/server/src/signal_handler.rs b/crates/server/src/signal_handler.rs index 89dc5f95..62419e47 100644 --- a/crates/server/src/signal_handler.rs +++ b/crates/server/src/signal_handler.rs @@ -1,10 +1,7 @@ //! Signal handling utilities for graceful shutdown. use std::sync::Arc; -use std::sync::atomic::{ - AtomicBool, - Ordering, -}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::JoinHandle; use std::time::Duration; diff --git a/crates/server/src/transcode.rs b/crates/server/src/transcode.rs index 8f6f711f..f56bd50f 100644 --- a/crates/server/src/transcode.rs +++ b/crates/server/src/transcode.rs @@ -3,10 +3,7 @@ // standard imports use std::path::PathBuf; use std::process::Stdio; -use std::sync::atomic::{ - AtomicU64, - Ordering, -}; +use std::sync::atomic::{AtomicU64, Ordering}; // lib imports use tokio::fs; @@ -16,6 +13,120 @@ use tokio::process::Command; // local imports use crate::config::FfmpegSettings; +use rocket::http::Status; + +/// A structured error from attempting to spawn a transcode. +#[derive(Debug)] +pub enum SpawnTranscodeError { + /// ffmpeg could not be resolved/executed. `checked_paths` lists where we looked. + ExecutableMissing { + /// Every location the resolver inspected, for diagnostics. + checked_paths: Vec, + }, + /// ffmpeg started but reported its input was unusable. Produced by the + /// deferred lifecycle phase; declared here so the shared mapper is complete. + BadInput { + /// The captured stderr from ffmpeg explaining the input failure. + ffmpeg_stderr: String, + }, + /// Any other spawn-time I/O error. + Io(std::io::Error), +} + +impl From for SpawnTranscodeError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl std::fmt::Display for SpawnTranscodeError { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + match self { + SpawnTranscodeError::ExecutableMissing { checked_paths } => { + let checked = checked_paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + write!(f, "FFmpeg could not be found. Checked: [{checked}]") + } + SpawnTranscodeError::BadInput { ffmpeg_stderr } => { + write!( + f, + "FFmpeg could not read the source media. {}", + ffmpeg_stderr.trim() + ) + } + SpawnTranscodeError::Io(error) => { + write!(f, "Transcode failed to start: {error}") + } + } + } +} + +impl std::error::Error for SpawnTranscodeError {} + +/// The JSON body returned to clients when a transcode fails. The `action` +/// field lets the UI decide whether to show an actionable control (e.g. +/// "Open settings") for this kind of failure. +#[derive(Debug, Clone, serde::Serialize)] +pub struct TranscodeErrorBody { + /// Stable machine code: `transcode_executable_missing` | `transcode_input_error` | `transcode_failed`. + pub code: &'static str, + /// Human-readable explanation. + pub message: String, + /// Optional UI action hint, e.g. `Some("open_settings")`. + pub action: Option<&'static str>, +} + +/// Map a [`SpawnTranscodeError`] to an HTTP status + body. This is the single +/// error-shaping function reused by the route handler today and the lifecycle +/// watcher in a later phase. +pub fn map_transcode_error(error: SpawnTranscodeError) -> (Status, TranscodeErrorBody) { + match error { + SpawnTranscodeError::ExecutableMissing { checked_paths } => { + let checked = checked_paths + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", "); + ( + Status::ServiceUnavailable, + TranscodeErrorBody { + code: "transcode_executable_missing", + message: format!( + "FFmpeg could not be found. Install it or set its path in Settings. \ + Checked: [{checked}]" + ), + action: Some("open_settings"), + }, + ) + } + SpawnTranscodeError::BadInput { ffmpeg_stderr } => ( + Status::UnprocessableEntity, + TranscodeErrorBody { + code: "transcode_input_error", + message: format!( + "FFmpeg could not read the source media. {}", + ffmpeg_stderr.trim() + ), + action: None, + }, + ), + SpawnTranscodeError::Io(error) => ( + Status::InternalServerError, + TranscodeErrorBody { + code: "transcode_failed", + message: format!("Transcode failed to start: {error}"), + action: None, + }, + ), + } +} + static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); /// Create a new unique session ID. @@ -180,25 +291,40 @@ impl TranscodeSpec { } } +/// Resolve the configured ffmpeg path to an absolute, verified executable, +/// logging the resolution. Returns [`SpawnTranscodeError::ExecutableMissing`] +/// when it cannot be found — this is the actual fix for issue 1 (a bare +/// `ffmpeg` lookup fails in GUI-launched processes that don't inherit the +/// user's shell PATH). +fn resolve_ffmpeg_for_spawn( + settings: &FfmpegSettings, + args_label: &str, +) -> Result { + match crate::ffmpeg_resolve::resolve_ffmpeg(&settings.ffmpeg_path) { + crate::ffmpeg_resolve::ResolvedBinary::Found { resolved_path, .. } => { + log::info!("Starting FFmpeg {args_label}: {}", resolved_path.display()); + Ok(resolved_path) + } + crate::ffmpeg_resolve::ResolvedBinary::Missing { checked_paths, .. } => { + Err(SpawnTranscodeError::ExecutableMissing { checked_paths }) + } + } +} + /// Spawns a transcode process and returns it. pub async fn spawn_transcode( _session_id: &str, spec: &TranscodeSpec, settings: &FfmpegSettings, -) -> Result { +) -> Result { if let Some(parent) = spec.output_path.parent() { fs::create_dir_all(parent).await?; } let args = spec.to_ffmpeg_args(); + let ffmpeg_path = resolve_ffmpeg_for_spawn(settings, &args.join(" "))?; - log::info!( - "Starting FFmpeg: {} {}", - settings.ffmpeg_path, - args.join(" ") - ); - - let mut command = Command::new(&settings.ffmpeg_path); + let mut command = Command::new(&ffmpeg_path); command .args(&args) .stdin(Stdio::null()) @@ -215,16 +341,13 @@ pub async fn spawn_transcode_stdout( _session_id: &str, spec: &TranscodeSpec, settings: &FfmpegSettings, -) -> Result { +) -> Result { let args = spec.to_ffmpeg_stdout_args(); + let ffmpeg_path = resolve_ffmpeg_for_spawn(settings, "stdout stream")?; - log::info!( - "Starting FFmpeg stdout stream: {} {}", - settings.ffmpeg_path, - args.join(" ") - ); + log::info!("FFmpeg stdout args: {}", args.join(" ")); - let mut command = Command::new(&settings.ffmpeg_path); + let mut command = Command::new(&ffmpeg_path); command .args(&args) .stdin(Stdio::null()) @@ -235,3 +358,121 @@ pub async fn spawn_transcode_stdout( Ok(child) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn executable_missing_maps_to_open_settings_action() { + let err = SpawnTranscodeError::ExecutableMissing { + checked_paths: vec![PathBuf::from( + "/usr/bin/ffmpeg", + )], + }; + let (status, body) = map_transcode_error(err); + assert_eq!(status, Status::ServiceUnavailable); + assert_eq!(body.code, "transcode_executable_missing"); + assert_eq!(body.action, Some("open_settings")); + assert!(body.message.contains("ffmpeg"), "{}", body.message); + } + + #[test] + fn io_error_maps_to_failed_with_no_action() { + let err = SpawnTranscodeError::Io(std::io::Error::other("boom")); + let (status, body) = map_transcode_error(err); + assert_eq!(status, Status::InternalServerError); + assert_eq!(body.code, "transcode_failed"); + assert_eq!(body.action, None); + } + + #[test] + fn bad_input_maps_to_input_error() { + let err = SpawnTranscodeError::BadInput { + ffmpeg_stderr: "No such file".into(), + }; + let (status, body) = map_transcode_error(err); + assert_eq!(status, Status::UnprocessableEntity); + assert_eq!(body.code, "transcode_input_error"); + assert_eq!(body.action, None); + assert!(body.message.contains("No such file")); + } + + #[test] + fn io_error_conversion_wraps_in_variant() { + let io_err = std::io::Error::other("disk full"); + let wrapped: SpawnTranscodeError = io_err.into(); + assert!(matches!(wrapped, SpawnTranscodeError::Io(_))); + } + + fn spec_with_source(source: &str) -> TranscodeSpec { + TranscodeSpec { + source_path: PathBuf::from(source), + output_path: PathBuf::from("/tmp/out.mp4"), + container: "mp4".into(), + video_codec: None, + audio_codec: None, + max_width: None, + max_height: None, + max_bitrate_kbps: None, + start_time_ms: None, + audio_stream_index: None, + } + } + + #[test] + fn source_path_with_spaces_and_brackets_is_one_argv_entry() { + // The "spaces in path" framing is a red herring: Command::args bypasses + // the shell, so a source path with spaces/brackets is passed to ffmpeg + // as a single argv entry. This test pins that invariant. + let spec = + spec_with_source("/Users/hazer/Downloads/Torrents/[Group] Title With Spaces E10.mkv"); + let args = spec.to_ffmpeg_args(); + let i_pos = args.iter().position(|a| a == "-i").expect("-i present"); + let path_arg = &args[i_pos + 1]; + assert_eq!( + path_arg, + "/Users/hazer/Downloads/Torrents/[Group] Title With Spaces E10.mkv" + ); + // And it must be exactly one element (no shell splitting happened). + assert_eq!(args.iter().filter(|a| **a == *path_arg).count(), 1); + } + + #[test] + fn copy_codecs_when_none_specified() { + let spec = spec_with_source("/tmp/in.mkv"); + let args = spec.to_ffmpeg_args(); + let v_pos = args.iter().position(|a| a == "-c:v").expect("-c:v present"); + assert_eq!(args[v_pos + 1], "copy"); + let a_pos = args.iter().position(|a| a == "-c:a").expect("-c:a present"); + assert_eq!(args[a_pos + 1], "copy"); + } + + #[test] + fn mp4_container_emits_fragmented_movflags() { + let spec = spec_with_source("/tmp/in.mkv"); + let args = spec.to_ffmpeg_args(); + let mf_pos = args + .iter() + .position(|a| a == "-movflags") + .expect("-movflags present"); + assert!( + args[mf_pos + 1].contains("frag_keyframe"), + "expected frag_keyframe in movflags, got {}", + args[mf_pos + 1] + ); + assert!( + args[mf_pos + 1].contains("empty_moov"), + "expected empty_moov in movflags, got {}", + args[mf_pos + 1] + ); + } + + #[test] + fn stdout_args_target_pipe1() { + let spec = spec_with_source("/tmp/in.mkv"); + let args = spec.to_ffmpeg_stdout_args(); + assert_eq!(args.last().expect("args non-empty"), "pipe:1"); + } +} diff --git a/crates/server/src/tray.rs b/crates/server/src/tray.rs index 095373ba..8dc72488 100644 --- a/crates/server/src/tray.rs +++ b/crates/server/src/tray.rs @@ -1,33 +1,19 @@ //! Tray icon utilities for the application. // standard imports -use std::path::{ - Path, - PathBuf, -}; +use std::path::{Path, PathBuf}; // lib imports #[cfg(target_os = "windows")] use tao::platform::windows::EventLoopBuilderExtWindows; use tao::{ event::Event, - event_loop::{ - ControlFlow, - EventLoopBuilder, - }, + event_loop::{ControlFlow, EventLoopBuilder}, platform::run_return::EventLoopExtRunReturn, }; use tray_icon::{ - TrayIconBuilder, - TrayIconEvent, - menu::{ - AboutMetadata, - Menu, - MenuEvent, - MenuItem, - PredefinedMenuItem, - Submenu, - }, + TrayIconBuilder, TrayIconEvent, + menu::{AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, }; // local imports diff --git a/crates/server/src/web/mod.rs b/crates/server/src/web/mod.rs index d8f2ef10..490adeb6 100644 --- a/crates/server/src/web/mod.rs +++ b/crates/server/src/web/mod.rs @@ -10,25 +10,14 @@ use rocket::config::TlsConfig; use rocket::fairing::AdHoc; use rocket::figment::Figment; use rocket_okapi::settings::UrlObject; -use rocket_okapi::{ - rapidoc::*, - swagger_ui::*, -}; +use rocket_okapi::{rapidoc::*, swagger_ui::*}; // local imports use crate::certs; use crate::config::{ - current_settings, - load_database_settings, - replace_current_settings, - seed_database_settings, -}; -use crate::db::{ - DbConn, - Migrate, - ReleaseDatabase, - initialize_sqlite_database, + current_settings, load_database_settings, replace_current_settings, seed_database_settings, }; +use crate::db::{DbConn, Migrate, ReleaseDatabase, initialize_sqlite_database}; use crate::globals; use crate::signal_handler::ShutdownSignal; @@ -143,6 +132,27 @@ pub fn rocket_with_db_path(custom_db_path: Option) -> rocket::Rocket { + rocket::tokio::spawn(async move { + let settings = crate::config::current_settings(); + crate::web::routes::media::try_spawn_reprobe( + reprobe_db, + settings.ffmpeg.clone(), + ) + .await; + }); + } + None => { + log::error!( + "Failed to acquire database connection for ffprobe re-probe startup" + ); + } + } }) })) .mount("/", routes::api_routes()) diff --git a/crates/server/src/web/routes/auth.rs b/crates/server/src/web/routes/auth.rs index f66c0338..01fcaf5e 100644 --- a/crates/server/src/web/routes/auth.rs +++ b/crates/server/src/web/routes/auth.rs @@ -3,31 +3,15 @@ // lib imports use diesel::QueryDsl; use diesel::RunQueryDsl; -use diesel::{ - ExpressionMethods, - SelectableHelper, -}; +use diesel::{ExpressionMethods, SelectableHelper}; use rocket::http::Status; -use rocket::serde::{ - Deserialize, - Serialize, - json::Json, -}; -use rocket::{ - get, - post, -}; -use rocket_okapi::{ - JsonSchema, - openapi, -}; +use rocket::serde::{Deserialize, Serialize, json::Json}; +use rocket::{get, post}; +use rocket_okapi::{JsonSchema, openapi}; use serde_json::json; // local imports -use crate::auth::{ - AdminGuard, - UserGuard, -}; +use crate::auth::{AdminGuard, UserGuard}; use crate::db::DbConn; use crate::db::models::User; diff --git a/crates/server/src/web/routes/common.rs b/crates/server/src/web/routes/common.rs index ed9a9293..a0e1cd5a 100644 --- a/crates/server/src/web/routes/common.rs +++ b/crates/server/src/web/routes/common.rs @@ -2,18 +2,12 @@ // standard imports use std::env; -use std::path::{ - Path, - PathBuf, -}; +use std::path::{Path, PathBuf}; // lib imports use rocket::fs::NamedFile; use rocket::get; -use rocket::http::uri::{ - Segments, - fmt::Path as UriPath, -}; +use rocket::http::uri::{Segments, fmt::Path as UriPath}; use rocket::response::content::RawHtml; // local imports diff --git a/crates/server/src/web/routes/media.rs b/crates/server/src/web/routes/media.rs index 7ad23cd9..ff52500f 100644 --- a/crates/server/src/web/routes/media.rs +++ b/crates/server/src/web/routes/media.rs @@ -1,46 +1,25 @@ //! Media and system discovery routes. // lib imports -use std::collections::{ - HashMap, - HashSet, -}; +use std::collections::{HashMap, HashSet}; use std::io::SeekFrom; use std::path::PathBuf; -use std::sync::atomic::{ - AtomicBool, - AtomicU64, - Ordering, -}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use once_cell::sync::Lazy; use rocket::delete; use rocket::fs::NamedFile; use rocket::get; -use rocket::http::{ - ContentType, - Status, -}; +use rocket::http::{ContentType, Status}; use rocket::outcome::Outcome; use rocket::post; -use rocket::request::{ - FromRequest, - Request, -}; +use rocket::request::{FromRequest, Request}; use rocket::response::stream::ReaderStream; -use rocket::response::{ - self, - Responder, - Response, -}; +use rocket::response::{self, Responder, Response}; use rocket::serde::Deserialize; use rocket::serde::json::Json; use rocket::tokio::fs::File; -use rocket::tokio::io::{ - AsyncReadExt, - AsyncSeekExt, - Take, -}; +use rocket::tokio::io::{AsyncReadExt, AsyncSeekExt, Take}; use rocket::tokio::process::ChildStdout; use rocket_okapi::openapi; use schemars::JsonSchema; @@ -48,114 +27,54 @@ use serde::Serialize; use strsim::normalized_levenshtein; // local imports -use crate::auth::UserGuard; -use crate::config::{ - MetadataProviderId, - Settings, - current_settings, -}; +use crate::auth::{AdminGuard, UserGuard}; +use crate::config::{MetadataProviderId, Settings, current_settings}; use crate::db::DbConn; use crate::db::models::ItemMetadataLink; use crate::globals; use crate::media::{ - MediaHome, - MediaItemDetail, - MediaItemSummary, - PersistedLibrarySummary, - PersistedMediaFileSummary, - PlaybackDecision, - ShowMetadataDescendantPlan, - ShowMetadataEpisodePlan, - ShowMetadataSeasonPlan, - TranscodingCapability, - apply_user_playback_context_to_detail, - delete_missing_media_items, - get_item_secondary_provider_references, - get_item_youtube_theme_collection_references, - get_library_files, - get_library_metadata_languages, - get_library_metadata_providers, - get_media_home_with_preferred_languages, - get_media_item, - get_media_item_summary, - get_media_item_with_preferred_languages, - get_persisted_library_summaries, - get_playback_decision, - get_preferred_item_artwork_metadata_link_for_languages, - get_preferred_item_metadata_link, - inspect_transcoding_capability, - library_exists, - list_automatic_metadata_candidates, - list_automatic_metadata_refresh_candidates, - list_library_settings, - list_media_item_children, - list_media_items, - list_media_items_for_user_with_preferred_languages, - mark_metadata_match_attempted, - preferred_audio_stream_index, - resolve_item_subtitle_path, - resolve_item_theme_song_path, - resolve_local_item_artwork_path, - resolve_media_item_source_path, + MediaHome, MediaItemDetail, MediaItemSummary, PersistedLibrarySummary, + PersistedMediaFileSummary, PlaybackDecision, ShowMetadataDescendantPlan, + ShowMetadataEpisodePlan, ShowMetadataSeasonPlan, TranscodingCapability, + apply_user_playback_context_to_detail, delete_missing_media_items, + get_item_secondary_provider_references, get_item_youtube_theme_collection_references, + get_library_files, get_library_metadata_languages, get_library_metadata_providers, + get_media_home_with_preferred_languages, get_media_item, get_media_item_summary, + get_media_item_with_preferred_languages, get_persisted_library_summaries, + get_playback_decision, get_preferred_item_artwork_metadata_link_for_languages, + get_preferred_item_metadata_link, inspect_transcoding_capability, library_exists, + list_automatic_metadata_candidates, list_automatic_metadata_refresh_candidates, + list_library_settings, list_media_item_children, list_media_items, + list_media_items_for_user_with_preferred_languages, mark_metadata_match_attempted, + preferred_audio_stream_index, resolve_item_subtitle_path, resolve_item_theme_song_path, + resolve_local_item_artwork_path, resolve_media_item_source_path, search_media_items_for_user_with_preferred_languages, - sync_persisted_library_catalog_for_library, - upsert_playback_progress, - upsert_show_metadata_descendant_items, - user_can_access_library, + sync_persisted_library_catalog_for_library, upsert_playback_progress, + upsert_show_metadata_descendant_items, user_can_access_library, }; use crate::metadata::{ - ArtworkKind, - DEFAULT_METADATA_LOCALE, - ItemMetadataSummary, - MetadataCollectionSummary, - MetadataPersonCreditSummary, - MetadataPersonEnrichmentTarget, - MetadataPersonSummary, - MetadataProviderRole, - MetadataProviderStatus, - MetadataSearchResult, - MetadataSnapshotFetchOptions, - ProviderDescendantTarget, - ProviderEpisodeMetadataSnapshotFetch, - ProviderMetadataPerson, - StoredMetadataSnapshot, - expected_artwork_cache_path, + ArtworkKind, DEFAULT_METADATA_LOCALE, ItemMetadataSummary, MetadataCollectionSummary, + MetadataPersonCreditSummary, MetadataPersonEnrichmentTarget, MetadataPersonSummary, + MetadataProviderRole, MetadataProviderStatus, MetadataSearchResult, + MetadataSnapshotFetchOptions, ProviderDescendantTarget, ProviderEpisodeMetadataSnapshotFetch, + ProviderMetadataPerson, StoredMetadataSnapshot, expected_artwork_cache_path, fetch_provider_episode_metadata_snapshot_for_locale_with_options, fetch_provider_metadata_snapshot_for_locale_with_options, fetch_provider_person_metadata_for_locale, fetch_provider_season_metadata_snapshot_for_locale_with_options, - fetch_provider_secondary_collection_metadata, - fetch_provider_secondary_metadata, - get_item_metadata_summaries, - get_metadata_person_for_languages, - get_metadata_person_locale_peer_ids, - get_primary_item_metadata_link, - guess_provider_movie_match, - guess_provider_show_match, - list_due_item_metadata_links, - list_metadata_collection_summaries_with_preferred_languages, - list_metadata_people_for_library, - list_metadata_person_credit_summaries_for_person_ids, - list_pending_item_metadata_links, - list_provider_statuses, - load_provider_show_descendant_targets, - managed_metadata_asset_dir, - metadata_asset_db_path, - normalize_locale_key, - persist_item_metadata_assets, - persist_metadata_people_assets, - provider_locale_key, - provider_uses_localized_metadata, - resolve_metadata_asset_db_path, - search_metadata_people_with_preferred_languages, - search_provider, - set_item_metadata_refresh_state, - sort_item_metadata_summaries_for_languages, - try_cache_item_artwork, - update_cached_artwork_path, - update_metadata_person_details, - upsert_item_metadata_link, - upsert_item_metadata_snapshot_with_refresh_interval, + fetch_provider_secondary_collection_metadata, fetch_provider_secondary_metadata, + get_item_metadata_summaries, get_metadata_person_for_languages, + get_metadata_person_locale_peer_ids, get_primary_item_metadata_link, + guess_provider_movie_match, guess_provider_show_match, list_due_item_metadata_links, + list_metadata_collection_summaries_with_preferred_languages, list_metadata_people_for_library, + list_metadata_person_credit_summaries_for_person_ids, list_pending_item_metadata_links, + list_provider_statuses, load_provider_show_descendant_targets, managed_metadata_asset_dir, + metadata_asset_db_path, normalize_locale_key, persist_item_metadata_assets, + persist_metadata_people_assets, provider_locale_key, provider_uses_localized_metadata, + resolve_metadata_asset_db_path, search_metadata_people_with_preferred_languages, + search_provider, set_item_metadata_refresh_state, sort_item_metadata_summaries_for_languages, + try_cache_item_artwork, update_cached_artwork_path, update_metadata_person_details, + upsert_item_metadata_link, upsert_item_metadata_snapshot_with_refresh_interval, upsert_secondary_collection_theme_song_url, }; use crate::utils::current_timestamp; @@ -186,6 +105,73 @@ impl<'r> Responder<'r, 'static> for SessionStream { } } +/// The structured transcode error as exposed to clients (owned strings, so it +/// serializes cleanly and matches the client `TranscodeErrorBody` type). +#[derive(Debug, Clone, Serialize, JsonSchema, PartialEq, Eq)] +pub struct SessionTranscodeError { + /// Stable machine code. + pub code: String, + /// Human-readable explanation. + pub message: String, + /// Optional UI action hint, e.g. `Some("open_settings")`. + pub action: Option, +} + +impl From for SessionTranscodeError { + fn from(body: crate::transcode::TranscodeErrorBody) -> Self { + Self { + code: body.code.to_string(), + message: body.message, + action: body.action.map(str::to_string), + } + } +} + +/// Status of a playback session's transcode, returned to the client so it can +/// recover a structured error when the `