diff --git a/Makefile.toml b/Makefile.toml index 1c7ff06a..d312b4d7 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -2,7 +2,7 @@ default_to_workspace = false [env] -RUST_LOG = "warn,remux=info,remux_server=info,remux_server::request=debug,axum=info,tower_http=info,hyper=warn,sqlx=warn" +RUST_LOG = "warn,remux=info,remux_server=info,remux_server::request=info,axum=info,tower_http=info,hyper=warn,sqlx=warn" RUST_BACKTRACE = 0 CONFIG = "./config" AXUM_ANYHOW_EXPOSE_ERRORS=1 diff --git a/crates/remux-dashboard/src/pages/settings.rs b/crates/remux-dashboard/src/pages/settings.rs index a1333a40..119fc086 100644 --- a/crates/remux-dashboard/src/pages/settings.rs +++ b/crates/remux-dashboard/src/pages/settings.rs @@ -4,10 +4,11 @@ use crate::{ }; use dioxus::prelude::*; use remux_sdks::remux::{ - CountryInfo, EncodingOptions, GetCountries, GetEncodingConfiguration, - GetIntroConfiguration, GetSystemConfiguration, HardwareAccelerationType, - IntroOptions, IntroOrder, IntroTriggers, ServerConfiguration, StartTask, - UpdateEncodingConfiguration, UpdateIntroConfiguration, UpdateSystemConfiguration, + CountryInfo, EmbeddedSubtitleHandling, EncodingOptions, GetCountries, + GetEncodingConfiguration, GetIntroConfiguration, GetSystemConfiguration, + HardwareAccelerationType, IntroOptions, IntroOrder, IntroTriggers, + ServerConfiguration, StartTask, UpdateEncodingConfiguration, + UpdateIntroConfiguration, UpdateSystemConfiguration, }; #[component] @@ -315,6 +316,7 @@ pub fn PlaybackSettingsCard(app_state: AppState) -> Element { let mut h264_crf = use_signal(|| 23_u32); let mut h265_crf = use_signal(|| 28_u32); let mut enable_video_transcoding = use_signal(|| true); + let mut subtitle_mode = use_signal(|| "Burn".to_string()); let mut loading = use_signal(|| true); let mut saving = use_signal(|| false); let mut error = use_signal(|| Option::::None); @@ -394,6 +396,11 @@ pub fn PlaybackSettingsCard(app_state: AppState) -> Element { opts.enable_video_transcoding .unwrap_or(true), ); + subtitle_mode.set( + opts.subtitle_mode + .unwrap_or(EmbeddedSubtitleHandling::Burn) + .to_string(), + ); } Err(e) => error.set(Some(format!("Failed to load settings: {e}"))), } @@ -442,6 +449,10 @@ pub fn PlaybackSettingsCard(app_state: AppState) -> Element { h264_crf: Some(*h264_crf.peek()), h265_crf: Some(*h265_crf.peek()), enable_video_transcoding: Some(*enable_video_transcoding.peek()), + subtitle_mode: subtitle_mode + .peek() + .parse::() + .ok(), }; saving.set(true); error.set(None); @@ -479,6 +490,21 @@ pub fn PlaybackSettingsCard(app_state: AppState) -> Element { } } + div { class: "field", + label { class: "field-label", "Unsupported Subtitle Handling" } + div { class: "field-hint", + "What to do with embedded subtitle streams the client device doesn't support. Burn encodes them into the video. Extract delivers them separately via the subtitle stream endpoint (may be slow for remote sources). Strip removes them from the media source so the client never sees them — no subtitle-triggered transcoding." + } + select { + class: "select-input", + value: subtitle_mode.read().clone(), + onchange: move |e| subtitle_mode.set(e.value()), + option { value: "Burn", "Burn into video (default)" } + option { value: "Extract", "Extract and deliver separately" } + option { value: "Strip", "Strip (remove, no transcoding)" } + } + } + div { class: "field", label { class: "field-label", "Hardware Acceleration" } div { class: "field-hint", diff --git a/crates/remux-sdks/src/remux/mod.rs b/crates/remux-sdks/src/remux/mod.rs index 9e926903..5ef140d5 100644 --- a/crates/remux-sdks/src/remux/mod.rs +++ b/crates/remux-sdks/src/remux/mod.rs @@ -561,6 +561,39 @@ pub struct EncodingOptions { /// unaffected by this setting. #[default(Some(true))] pub enable_video_transcoding: Option, + /// Controls how embedded subtitle streams unsupported by the client are handled. + /// Burn: encode into video (default). Extract: serve via Stream.js/VTT endpoint. + /// Strip: remove from media source so the client never sees them. + #[default(Some(EmbeddedSubtitleHandling::Burn))] + pub subtitle_mode: Option, +} + +// --- Embedded subtitle handling --- + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Default, + Serialize, + Deserialize, + strum_macros::Display, + strum_macros::EnumString, +)] +#[serde(rename_all = "PascalCase")] +#[strum(serialize_all = "PascalCase")] +pub enum EmbeddedSubtitleHandling { + /// Burn unsupported embedded subtitles into the video during transcoding. + #[default] + Burn, + /// Extract and deliver unsupported embedded subtitles via the subtitle stream + /// endpoint (Stream.js / Stream.vtt). May be slow for remote sources. + Extract, + /// Remove unsupported embedded subtitle streams from the media source entirely. + /// No transcoding is triggered for subtitles; they simply won't be available. + Strip, } // --- Preroll configuration --- @@ -1457,6 +1490,52 @@ mod tests { "nextUpDateCutoff must be RFC3339, YYYY-MM-DD, or YYYY-MM-DD HH:MM:SS" ); } + + #[test] + fn embedded_subtitle_handling_default_is_burn() { + assert_eq!( + EmbeddedSubtitleHandling::default(), + EmbeddedSubtitleHandling::Burn + ); + } + + #[test] + fn embedded_subtitle_handling_display_round_trips() { + for (variant, expected) in [ + (EmbeddedSubtitleHandling::Burn, "Burn"), + (EmbeddedSubtitleHandling::Extract, "Extract"), + (EmbeddedSubtitleHandling::Strip, "Strip"), + ] { + assert_eq!(variant.to_string(), expected); + let parsed: EmbeddedSubtitleHandling = expected + .parse() + .unwrap(); + assert_eq!(parsed, variant); + } + } + + #[test] + fn embedded_subtitle_handling_serde_round_trips() { + for variant in [ + EmbeddedSubtitleHandling::Burn, + EmbeddedSubtitleHandling::Extract, + EmbeddedSubtitleHandling::Strip, + ] { + let json = serde_json::to_string(&variant).unwrap(); + let back: EmbeddedSubtitleHandling = serde_json::from_str(&json).unwrap(); + assert_eq!(back, variant); + } + } + + #[test] + fn encoding_options_subtitle_mode_defaults_to_burn() { + let opts = EncodingOptions::default(); + assert_eq!( + opts.subtitle_mode + .unwrap_or_default(), + EmbeddedSubtitleHandling::Burn + ); + } } #[derive(Default, Debug, Deserialize)] diff --git a/crates/remux-server/src/api/hls.rs b/crates/remux-server/src/api/hls.rs index a92d36b4..c69860a6 100644 --- a/crates/remux-server/src/api/hls.rs +++ b/crates/remux-server/src/api/hls.rs @@ -297,6 +297,8 @@ pub async fn master_hls_video( s.codec .clone() }); + let burn_subtitle = + q.subtitle_method == Some(api::SubtitleDeliveryMethod::Encode); let session = TranscodeSession::new( play_session_id.clone(), id, @@ -309,7 +311,7 @@ pub async fn master_hls_video( .map(|v| v as i32), q.subtitle_stream_index .map(|v| v as i32), - q.subtitle_method == Some(api::SubtitleDeliveryMethod::Encode), + burn_subtitle, segment_length, // Parse reasons from query param (set by playbackinfo on the transcoding URL) q.transcode_reasons @@ -376,8 +378,7 @@ pub async fn master_hls_video( subtitle_stream_index: q .subtitle_stream_index .map(|v| v as i32), - burn_subtitle: q.subtitle_method - == Some(api::SubtitleDeliveryMethod::Encode), + burn_subtitle, subtitle_width: None, subtitle_height: None, encoding_preset: encoding_opts.encoding_preset, diff --git a/crates/remux-server/src/api/playback.rs b/crates/remux-server/src/api/playback.rs index 397837a6..2d7d73f9 100644 --- a/crates/remux-server/src/api/playback.rs +++ b/crates/remux-server/src/api/playback.rs @@ -15,6 +15,7 @@ use futures_util::{StreamExt, TryStreamExt}; use headers; use http::{Response, StatusCode}; use remux_macros::{delete, get, post, query}; +use remux_utils::Store; use serde::Deserialize; use serde_json::json; use serde_with::{DurationSeconds, serde_as}; @@ -35,9 +36,9 @@ use crate::{ use crate::{ IntoApiError, OptionExt, ResultExt, - device_profile::{DeviceProfileExt, subtitle_codec_matches_profile}, + device_profile::{DeviceProfileExt, SubtitleCodec, subtitle_codec_matches_profile}, sdks, - services::MediaResolveService, + services::{MediaResolveService, StreamResolver}, torrent, transcode::session::{TranscodeSession, TranscodeState}, }; @@ -92,47 +93,26 @@ async fn items_playbackinfo_inner( .await .unwrap_or_default(); - let mut media = + let initial = MediaResolveService::resolve_item(media_source_id.unwrap_or(id), &state.ctx) .await? .context_not_found("not found")?; - // When a StreamGroup UUID is requested, resolve it to the group's best candidate - // and keep all candidates (including subsequent groups) for probe fallback scope. - let group_source_override: Option<(Uuid, String, Vec)> = if media.kind - == db::MediaKind::StreamGroup - { - let gid = media.id; - let gtitle = media - .title - .clone(); - let mut candidates = db::StreamGroup::streams_for( - &state - .ctx - .db, - &gid, - &id, - ) - .await?; - if candidates.is_empty() { - return Err(anyhow::anyhow!("no streams available for this group").into()); - } - // Append streams from lower-priority groups so probe can cascade across groups. - let cascade = db::StreamGroup::streams_for_groups_after( - &state - .ctx - .db, - &gid, - &id, - ) - .await - .unwrap_or_default(); - candidates.extend(cascade); - media = candidates[0].clone(); - Some((gid, gtitle, candidates)) - } else { - None - }; + let resolver = StreamResolver::resolve_group( + &state + .ctx + .db, + &state + .ctx + .store, + id, + media_source_id, + initial, + ) + .await?; + let mut media = resolver + .stream + .clone(); // Load the top-level Movie/Episode for subtitle lookup. // `id` is always the movie/episode UUID; `media_source_id` may point to a @@ -233,16 +213,35 @@ async fn items_playbackinfo_inner( // // Android TV always sends media_source_id = item_id (not None) for auto-play. // Treat that the same as "return first source only" — no need to send all versions. - let specific_source_requested = media_source_id - .map(|sid| { - sid != id - && all_source_medias - .iter() - .any(|s| s.id == sid) - }) - .unwrap_or(false); - let (source_medias, probe_only_first) = if specific_source_requested { - // Specific stream requested: return only that stream. + // A group request counts as a specific source request: the client sent a stable group UUID + // and must receive that same UUID back. Non-group requests check if the UUID maps to an + // actual child stream in all_source_medias. + let specific_source_requested = resolver + .group + .is_some() + || media_source_id + .map(|sid| { + sid != id + && all_source_medias + .iter() + .any(|s| s.id == sid) + }) + .unwrap_or(false); + let (source_medias, probe_only_first) = if resolver + .group + .is_some() + { + // Group request: the resolver already picked the best candidate stream. + ( + vec![ + resolver + .stream + .clone(), + ], + false, + ) + } else if specific_source_requested { + // Specific non-group stream: return only that stream. let sid = media_source_id.unwrap(); let filtered: Vec = all_source_medias .into_iter() @@ -297,12 +296,19 @@ async fn items_playbackinfo_inner( // For group selections, use all group candidates (including cascade) as the probe fallback pool. // Resolution filtering is disabled for group requests: the group priority order already // encodes the user's quality preference, so cross-resolution fallback is intentional. - let (all_sources, restrict_resolution) = - if let Some((_, _, ref candidates)) = group_source_override { - (candidates.clone(), false) - } else { - (source_medias.clone(), true) - }; + let (all_sources, restrict_resolution) = if resolver + .group + .is_some() + { + ( + resolver + .candidates() + .to_vec(), + false, + ) + } else { + (source_medias.clone(), true) + }; let mut media_sources = Vec::with_capacity(source_medias.len()); for (idx, sm) in source_medias .into_iter() @@ -340,11 +346,24 @@ async fn items_playbackinfo_inner( .db, ) .await?; - source.id = sm.id; - source.e_tag = sm.id; + // Use the client-facing ID from the resolver: group UUID for group requests, + // stream UUID otherwise. Set once here — no later overrides. + let cid = resolver + .group + .as_ref() + .map(|(gid, _, _)| *gid) + .unwrap_or(sm.id); + source.id = cid; + source.e_tag = cid; source.name = Some( - sm.title - .clone(), + resolver + .group + .as_ref() + .map(|(_, t, _)| t.clone()) + .unwrap_or_else(|| { + sm.title + .clone() + }), ); source.has_segments = true; source.path = Some(format!("/remux/{}", sm.id)); @@ -397,23 +416,19 @@ async fn items_playbackinfo_inner( reasons }; - // Image-based subtitles (PGS/DVD) can't be rendered by web clients — detect - // from the explicitly-selected or default subtitle stream and add a transcode reason. - let effective_sub_idx = q - .subtitle_stream_index - .or(source.default_subtitle_stream_index); - // Signal burn-in for image-based subtitle codecs the client does not - // declare support for in its device profile. Text codecs are handled via - // VTT conversion in the DeliveryUrl loop below. - if let Some(idx) = effective_sub_idx { - let needs_burn = source + let subtitle_mode = encoding_cfg + .subtitle_mode + .unwrap_or_default(); + + // Strip mode: remove embedded subtitle streams not supported by the client so + // they don't trigger a transcode. External/addon subs are never touched. + if subtitle_mode == remux_sdks::remux::EmbeddedSubtitleHandling::Strip { + source .media_streams - .iter() - .any(|s| { - s.index == idx - && matches!(s.type_, Some(api::MediaStreamType::Subtitle)) - && !s.is_text_subtitle_stream - && !device_profile + .retain(|s| { + !matches!(s.type_, Some(api::MediaStreamType::Subtitle)) + || s.is_external + || device_profile .as_ref() .map(|dp| { dp.subtitle_profiles @@ -430,8 +445,78 @@ async fn items_playbackinfo_inner( }) }) }) - .unwrap_or(false) + .unwrap_or(true) }); + } + + // Pre-extract all embedded text subtitle streams in the background, in one + // FFmpeg pass. By the time the client requests a subtitle URL, the cache file + // is already written (same approach Jellyfin uses). + if let Some(ref input_url) = url_opt { + let text_sub_indices: Vec = source + .media_streams + .iter() + .filter(|s| { + matches!(s.type_, Some(api::MediaStreamType::Subtitle)) + && !s.is_external + && s.is_text_subtitle_stream + }) + .map(|s| s.index) + .collect(); + if !text_sub_indices.is_empty() { + let data_dir = state + .ctx + .config + .data_dir + .clone(); + let url = input_url.clone(); + tokio::spawn( + crate::api::subtitles::pre_extract_all_subtitles_to_cache( + data_dir, + url, + id, + text_sub_indices, + ), + ); + } + } + + // Detect embedded subtitle codecs unsupported by the client device profile. + // In Burn mode this triggers transcoding so the subtitle can be burned in. + // In Extract/Strip modes, no transcode reason is added for subtitles. + let effective_sub_idx = q + .subtitle_stream_index + .or(source.default_subtitle_stream_index); + if let Some(idx) = effective_sub_idx { + let needs_burn = subtitle_mode + == remux_sdks::remux::EmbeddedSubtitleHandling::Burn + && source + .media_streams + .iter() + .any(|s| { + s.index == idx + && matches!(s.type_, Some(api::MediaStreamType::Subtitle)) + && !s.is_external + && !s.is_text_subtitle_stream + && !device_profile + .as_ref() + .map(|dp| { + dp.subtitle_profiles + .iter() + .filter_map(|p| { + p.format + .as_deref() + }) + .any(|f| { + s.codec + .as_deref() + .map_or(false, |c| { + subtitle_codec_matches_profile(c, f) + }) + }) + }) + .unwrap_or(false) + }); if needs_burn { let codec = source .media_streams @@ -578,8 +663,11 @@ async fn items_playbackinfo_inner( let audio_codec = if needs_audio_transcode { "aac" } else { "copy" }.to_string(); - // Detect image-based subtitle streams (PGS, DVD) that cannot be - // embedded in HLS — burn them into the video via FFmpeg overlay. + // Determine subtitle delivery method for the selected stream. + // In Burn mode, any embedded codec not in the device profile gets + // SubtitleDeliveryMethod::Encode so FFmpeg burns it into the video. + // In Extract/Strip modes we defer to the client (no burn). + // External subtitles are never burned — they have their own URL. let selected_sub_idx = effective_sub_idx; let subtitle_method = selected_sub_idx .and_then(|idx| { @@ -595,23 +683,34 @@ async fn items_playbackinfo_inner( }) }) .and_then(|stream| { + if stream.is_external + || stream.is_text_subtitle_stream + || subtitle_mode + != remux_sdks::remux::EmbeddedSubtitleHandling::Burn + { + return None; + } let codec = stream .codec .as_deref() .unwrap_or(""); - let is_image_sub = matches!( - codec, - "pgssub" | "hdmv_pgs_subtitle" | "dvd_subtitle" - ); - if !is_image_sub { - return None; + let not_in_profile = !device_profile + .as_ref() + .map(|dp| { + dp.subtitle_profiles + .iter() + .filter_map(|p| { + p.format + .as_deref() + }) + .any(|f| subtitle_codec_matches_profile(codec, f)) + }) + .unwrap_or(false); + if not_in_profile { + Some(api::SubtitleDeliveryMethod::Encode) + } else { + None } - Some( - device_profile - .as_ref() - .and_then(|p| p.subtitle_delivery_method(codec)) - .unwrap_or(api::SubtitleDeliveryMethod::Encode), - ) }); if subtitle_method == Some(api::SubtitleDeliveryMethod::Encode) { @@ -710,7 +809,7 @@ async fn items_playbackinfo_inner( .codec .as_deref() .unwrap_or_default(); - let profile_supports = |fmt: &str| -> bool { + let profile_supports = |codec: SubtitleCodec| -> bool { device_profile .as_ref() .map(|dp| { @@ -720,11 +819,16 @@ async fn items_playbackinfo_inner( p.format .as_deref() }) - .any(|f| subtitle_codec_matches_profile(fmt, f)) + .any(|f| { + f.parse::() + .ok() + .as_ref() + == Some(&codec) + }) }) .unwrap_or(false) }; - let profile_embeds = |fmt: &str| -> bool { + let profile_embeds = |codec: SubtitleCodec| -> bool { device_profile .as_ref() .map(|dp| { @@ -734,27 +838,56 @@ async fn items_playbackinfo_inner( p.method == Some(api::SubtitleDeliveryMethod::Embed) && p.format .as_deref() - .map_or(false, |f| f.eq_ignore_ascii_case(fmt)) + .and_then(|f| { + f.parse::() + .ok() + }) + .as_ref() + == Some(&codec) }) }) .unwrap_or(false) }; + let parsed_codec = codec + .parse::() + .ok(); + let is_image_sub = parsed_codec + .as_ref() + .map(SubtitleCodec::is_image) + .unwrap_or(false); let format = if stream.is_text_subtitle_stream { - if (codec.eq_ignore_ascii_case("ass") - || codec.eq_ignore_ascii_case("ssa")) - && profile_supports("ass") + if parsed_codec == Some(SubtitleCodec::Ass) + && profile_supports(SubtitleCodec::Ass) { "ass" } else { "vtt" } - } else if profile_supports("sup") || profile_supports("pgssub") { + } else if profile_supports(SubtitleCodec::Pgs) { "sup" } else { "vtt" }; - if !stream.is_external && profile_embeds(format) { + let client_can_handle_image = is_image_sub + && parsed_codec + .as_ref() + .map(|c| profile_supports(c.clone()) || profile_embeds(c.clone())) + .unwrap_or(false); + if !stream.is_external + && parsed_codec + .as_ref() + .map(|c| profile_embeds(c.clone())) + .unwrap_or(false) + { stream.delivery_method = Some(api::SubtitleDeliveryMethod::Embed); + } else if !stream.is_external + && is_image_sub + && !client_can_handle_image + && subtitle_mode == remux_sdks::remux::EmbeddedSubtitleHandling::Burn + { + // Burn mode: embedded image sub (PGS/VOBSUB) the client can't render + // externally → force transcode with burn-in. + stream.delivery_method = Some(api::SubtitleDeliveryMethod::Encode); } else { stream.delivery_url = Some(format!( "/Videos/{id}/{source_id}/Subtitles/{idx}/0/Stream.{format}?ApiKey={api_key}", @@ -768,17 +901,6 @@ async fn items_playbackinfo_inner( source.transcoding_reasons = transcode_reasons; - // For group selections, expose the stable group UUID to the client. - // Subtitle delivery URLs above use the real source_id (captured before - // this point) so the extraction endpoint finds the media by its actual - // database UUID, not the group alias. - // TranscodingUrl already embeds the real source UUID (set before this point). - if let Some((gid, ref gtitle, _)) = group_source_override { - source.id = gid; - source.e_tag = gid; - source.name = Some(gtitle.clone()); - } - media_sources.push(source); } @@ -814,10 +936,25 @@ async fn items_playbackinfo_inner( ) .await; + // Cache the group-resolved stream UUID so the stream endpoint can find it + // without re-running filter_sources (which could pick a different candidate). + if resolver + .group + .is_some() + { + resolver.save_preference( + &state + .ctx + .store, + &session + .device + .id, + ); + } + // When no specific source was requested (initial load, or media_source_id == item_id), // override source[0].Id to equal the item ID — clients expect this for auto-play. - // When a real specific source was requested, keep its UUID so the client - // sends it back and we resolve the right stream. + // Group and specific-stream requests keep their own UUIDs (specific_source_requested = true). if !specific_source_requested && !media_sources.is_empty() { media_sources[0].id = id; media_sources[0].e_tag = id; @@ -1185,56 +1322,20 @@ async fn videos_stream_inner( id: Uuid, q: api::VideoStreamQuery, ) -> Result { - let mut media = db::Media::get_by_id( + let media = StreamResolver::resolve( &state .ctx .db, - &q.media_source_id - .unwrap_or(id), + &state + .ctx + .store, + id, + q.media_source_id, + q.device_id + .as_deref(), ) .await? - .context_not_found("not found")?; - - if media.kind == db::MediaKind::StreamGroup { - let group_id = media.id; - let candidates = db::StreamGroup::streams_for( - &state - .ctx - .db, - &group_id, - &id, - ) - .await?; - media = candidates - .into_iter() - .next() - .context_not_found("no streams available for this group")?; - } else if media.kind == db::MediaKind::Movie - || media.kind == db::MediaKind::Episode - || media.kind == db::MediaKind::Track - { - let sources = media - .streams( - &state - .ctx - .db, - ) - .await?; - media = if let Some(wanted) = q.media_source_id { - sources - .iter() - .find(|s| s.id == wanted) - .cloned() - } else { - None - } - .or_else(|| { - sources - .into_iter() - .next() - }) - .context_not_found("no playable source found")?; - } + .stream; let si = media .stream_info @@ -1352,6 +1453,11 @@ async fn videos_stream_inner( s.codec .clone() }); + let burn_subtitle_prog = q + .subtitle_method + .as_deref() + == Some("Encode"); + let params = crate::transcode::engine::ProgressiveTranscodeParams { input_url: url, container: container.clone(), @@ -1383,10 +1489,7 @@ async fn videos_stream_inner( subtitle_stream_index: q .subtitle_stream_index .map(|v| v as i32), - burn_subtitle: q - .subtitle_method - .as_deref() - == Some("Encode"), + burn_subtitle: burn_subtitle_prog, subtitle_width: None, subtitle_height: None, encoding_preset: encoding_opts.encoding_preset, @@ -3102,6 +3205,36 @@ async fn apply_user_playback_prefs( .unwrap_or(false); for source in media_sources.iter_mut() { + // --- client explicit selection wins --- + if client_wants_audio { + if let Some(idx) = client_audio_idx { + let exists = source + .media_streams + .iter() + .any(|s| { + s.index == idx + && matches!(s.type_, Some(api::MediaStreamType::Audio)) + }); + if exists { + source.default_audio_stream_index = Some(idx); + } + } + } + if client_wants_subtitle { + if let Some(idx) = client_subtitle_idx { + let exists = source + .media_streams + .iter() + .any(|s| { + s.index == idx + && matches!(s.type_, Some(api::MediaStreamType::Subtitle)) + }); + if exists { + source.default_subtitle_stream_index = Some(idx); + } + } + } + // --- remember_audio_selections --- if !client_wants_audio && cfg.remember_audio_selections { if let Some(idx) = saved_audio { diff --git a/crates/remux-server/src/api/subtitles.rs b/crates/remux-server/src/api/subtitles.rs index 9542e1b6..b27784ab 100644 --- a/crates/remux-server/src/api/subtitles.rs +++ b/crates/remux-server/src/api/subtitles.rs @@ -7,7 +7,12 @@ use axum::{ use axum_anyhow::ApiResult as Result; use http::{Response, StatusCode}; use remux_macros::get; -use tracing::error; +use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, +}; +use tokio::sync::watch; +use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::{AppState, IntoApiError, OptionExt, ResultExt, api, db, db::auth}; @@ -16,6 +21,214 @@ fn ffmpeg_bin() -> String { std::env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".into()) } +/// Tracks in-progress batch subtitle extractions. Subtitle endpoint waits on these +/// instead of launching a competing on-demand FFmpeg process. +static BATCH_EXTRACTING: OnceLock>>> = + OnceLock::new(); + +fn batch_extraction_map() -> &'static Mutex>> { + BATCH_EXTRACTING.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Extract an embedded subtitle stream to the SRT cache and return the cache path. +/// The cache key is `{data_dir}/subtitle-cache/{item_id}_{stream_index}.srt`. +/// Returns immediately if the cache already exists and is non-empty. +pub(crate) async fn extract_subtitle_to_cache( + data_dir: &std::path::Path, + input_url: &str, + map_spec: &str, + item_id: uuid::Uuid, + stream_index: i64, +) -> anyhow::Result { + let cache_dir = data_dir.join("subtitle-cache"); + tokio::fs::create_dir_all(&cache_dir) + .await + .map_err(|e| anyhow!("failed to create subtitle cache dir: {e}"))?; + let cache_path = cache_dir.join(format!("{item_id}_{stream_index}.srt")); + + // Return cached copy if it exists and is non-empty. + if cache_path.exists() { + let bytes = tokio::fs::read(&cache_path) + .await + .unwrap_or_default(); + let content = String::from_utf8_lossy(&bytes); + if !content + .trim() + .is_empty() + { + return Ok(cache_path); + } + } + + let mut cmd = tokio::process::Command::new(ffmpeg_bin()); + cmd.kill_on_drop(true); + cmd.args([ + "-y", + "-nostdin", + "-copyts", + "-i", + input_url, + "-map", + map_spec, + "-an", + "-vn", + "-c:s", + "srt", + "-f", + "srt", + cache_path + .to_str() + .ok_or_else(|| anyhow!("invalid cache path"))?, + ]); + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + + let output = + tokio::time::timeout(std::time::Duration::from_secs(120), cmd.output()) + .await + .map_err(|_| { + let p = cache_path.clone(); + tokio::spawn(async move { + let _ = tokio::fs::remove_file(p).await; + }); + anyhow!("subtitle extraction timed out") + })? + .map_err(|e| anyhow!("failed to run ffmpeg: {e}"))?; + + if !output + .status + .success() + { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("ffmpeg subtitle extraction failed: {stderr}"); + } + + let bytes = tokio::fs::read(&cache_path) + .await + .map_err(|e| anyhow!("failed to read cached subtitle: {e}"))?; + if bytes + .iter() + .all(|b| b.is_ascii_whitespace()) + { + let _ = tokio::fs::remove_file(&cache_path).await; + anyhow::bail!("subtitle extraction produced empty output"); + } + + Ok(cache_path) +} + +/// Pre-extract all embedded text subtitle streams for a media source in one FFmpeg pass. +/// Mirrors Jellyfin's approach: one command, multiple outputs, fire-and-forget at PlaybackInfo time. +/// The `subtitles_stream` endpoint falls back to on-demand extraction for any cache misses. +pub(crate) async fn pre_extract_all_subtitles_to_cache( + data_dir: std::path::PathBuf, + input_url: String, + item_id: uuid::Uuid, + stream_indices: Vec, +) { + let cache_dir = data_dir.join("subtitle-cache"); + let _ = tokio::fs::create_dir_all(&cache_dir).await; + + let mut to_extract: Vec<(i64, std::path::PathBuf)> = Vec::new(); + for idx in &stream_indices { + let path = cache_dir.join(format!("{item_id}_{idx}.srt")); + if path.exists() { + if let Ok(b) = tokio::fs::read(&path).await { + if !String::from_utf8_lossy(&b) + .trim() + .is_empty() + { + debug!(%item_id, stream_index = idx, "subtitle cache hit, skipping"); + continue; + } + } + } + to_extract.push((*idx, path)); + } + + if to_extract.is_empty() { + debug!(%item_id, "all {} subtitle track(s) already cached", stream_indices.len()); + return; + } + + let indices: Vec = to_extract + .iter() + .map(|(i, _)| *i) + .collect(); + info!( + %item_id, + ?indices, + "pre-extracting {} subtitle track(s) in background", + to_extract.len() + ); + + // Register in-progress signal so the subtitle endpoint can wait on us + // instead of launching a competing FFmpeg process. + let (done_tx, done_rx) = watch::channel(false); + batch_extraction_map() + .lock() + .unwrap() + .insert(item_id, done_rx); + + let mut cmd = tokio::process::Command::new(ffmpeg_bin()); + cmd.kill_on_drop(true); + // -y: overwrite without prompting (hangs forever waiting for stdin otherwise) + // -nostdin: don't read from stdin at all + // -c:s srt: convert to SRT so the cache is always valid SRT (not raw ASS/VTT bytes) + cmd.args(["-y", "-nostdin", "-i", &input_url]); + for (idx, path) in &to_extract { + if let Some(p) = path.to_str() { + cmd.args([ + "-map", + &format!("0:{idx}"), + "-an", + "-vn", + "-c:s", + "srt", + "-flush_packets", + "1", + p, + ]); + } + } + cmd.stdin(std::process::Stdio::null()); + cmd.stdout(std::process::Stdio::null()); + cmd.stderr(std::process::Stdio::piped()); + + let start = std::time::Instant::now(); + match tokio::time::timeout(std::time::Duration::from_secs(120), cmd.output()).await + { + Ok(Ok(output)) => { + let elapsed = start + .elapsed() + .as_secs_f32(); + if output + .status + .success() + { + info!(%item_id, ?indices, elapsed_secs = elapsed, "batch subtitle extraction completed"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!(%item_id, ?indices, elapsed_secs = elapsed, %stderr, "batch subtitle extraction non-zero exit"); + } + } + Ok(Err(e)) => { + warn!(%item_id, ?indices, "failed to spawn ffmpeg for batch subtitle extraction: {e}"); + } + Err(_) => { + warn!(%item_id, ?indices, "batch subtitle extraction timed out after 120s"); + } + } + + // Signal done and clean up (drop tx signals all receivers). + let _ = done_tx.send(true); + batch_extraction_map() + .lock() + .unwrap() + .remove(&item_id); +} + /// Subtitle extraction endpoint - extracts a subtitle stream from a media source /// and optionally converts it to the requested format (vtt, srt, ass). // Jellyfin clients include a start-position-ticks segment in the path. @@ -45,41 +258,20 @@ pub async fn subtitles_stream( .ok() .flatten() { - let source_media = { - let sm = db::Media::get_by_id( - &state - .ctx - .db, - &media_source_id, - ) - .await - .ok() - .flatten(); - if let Some(mut m) = sm { - if matches!( - m.kind, - db::MediaKind::Movie - | db::MediaKind::Episode - | db::MediaKind::Track - ) { - m.streams( - &state - .ctx - .db, - ) - .await - .ok() - .and_then(|v| { - v.into_iter() - .next() - }) - } else { - Some(m) - } - } else { - None - } - }; + let source_media = crate::services::StreamResolver::resolve( + &state + .ctx + .db, + &state + .ctx + .store, + item_id, + Some(media_source_id), + None, + ) + .await + .ok() + .map(|r| r.stream); if let Some(ref source) = source_media { let embedded_indices: std::collections::HashSet = source .probe_data @@ -188,30 +380,19 @@ pub async fn subtitles_stream( } } - let mut media = db::Media::get_by_id( + let media = crate::services::StreamResolver::resolve( &state .ctx .db, - &media_source_id, + &state + .ctx + .store, + item_id, + Some(media_source_id), + None, ) .await? - .context_not_found("media source not found")?; - - if matches!( - media.kind, - db::MediaKind::Movie | db::MediaKind::Episode | db::MediaKind::Track - ) { - media = media - .streams( - &state - .ctx - .db, - ) - .await? - .get(0) - .context_not_found("no sources found")? - .clone(); - } + .stream; let url = media .stream_info @@ -264,6 +445,7 @@ pub async fn subtitles_stream( // Binary formats (PGS/SUP): extract on-the-fly as raw bytes. if is_binary { let mut cmd = tokio::process::Command::new(ffmpeg_bin()); + cmd.kill_on_drop(true); cmd.args([ "-copyts", "-i", @@ -280,10 +462,11 @@ pub async fn subtitles_stream( ]); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - let output = cmd - .output() - .await - .map_err(|e| anyhow!("failed to run ffmpeg: {e}"))?; + let output = + tokio::time::timeout(std::time::Duration::from_secs(120), cmd.output()) + .await + .map_err(|_| anyhow!("subtitle extraction timed out"))? + .map_err(|e| anyhow!("failed to run ffmpeg: {e}"))?; if !output .status .success() @@ -300,89 +483,81 @@ pub async fn subtitles_stream( .unwrap()); } - // Text formats: cache SRT, convert on-the-fly. - let (ext_codec, ext_fmt, ext) = if is_passthrough { - ("copy", "ass", "ass") - } else { - ("srt", "srt", "srt") - }; - - let cache_dir = state + // Text formats: serve from SRT cache (populated by pre_extract_all_subtitles_to_cache + // at PlaybackInfo time). Falls back to on-demand extraction on cache miss. + let cache_file = state .ctx .config .data_dir - .join("subtitle-cache"); - tokio::fs::create_dir_all(&cache_dir) - .await - .map_err(|e| anyhow!("failed to create subtitle cache dir: {e}"))?; - let cache_path = cache_dir.join(format!("{media_source_id}_{stream_index}.{ext}")); + .join("subtitle-cache") + .join(format!("{item_id}_{stream_index}.srt")); + let is_cached = |path: &std::path::Path| -> bool { + path.exists() + && std::fs::read(path) + .ok() + .map(|b| { + !String::from_utf8_lossy(&b) + .trim() + .is_empty() + }) + .unwrap_or(false) + }; - let mut cached = if cache_path.exists() { - tokio::fs::read(&cache_path) - .await - .ok() - .map(|b| String::from_utf8_lossy(&b).into_owned()) - .filter(|s| { - !s.trim() - .is_empty() - }) - .unwrap_or_default() + if is_cached(&cache_file) { + debug!(%item_id, stream_index, "subtitle cache hit"); } else { - String::new() - }; + // Check if a batch extraction is in progress for this item. + // If so, wait for it to finish rather than launching a competing FFmpeg process. + let in_progress_rx = batch_extraction_map() + .lock() + .unwrap() + .get(&item_id) + .cloned(); + if let Some(mut rx) = in_progress_rx { + if !*rx.borrow() { + info!(%item_id, stream_index, "batch extraction in progress — waiting for it to finish"); + let _ = tokio::time::timeout( + std::time::Duration::from_secs(120), + rx.changed(), + ) + .await; + } + } - if cached.is_empty() { - let mut cmd = tokio::process::Command::new(ffmpeg_bin()); - cmd.args([ - "-copyts", - "-i", - &url, - "-map", - &map_spec, - "-an", - "-vn", - "-c:s", - ext_codec, - "-f", - ext_fmt, - cache_path - .to_str() - .ok_or_else(|| anyhow!("invalid cache path"))?, - ]); - cmd.stdout(std::process::Stdio::null()); - cmd.stderr(std::process::Stdio::piped()); - let output = cmd - .output() - .await - .map_err(|e| anyhow!("failed to run ffmpeg: {e}"))?; - if !output - .status - .success() - { - let stderr = String::from_utf8_lossy(&output.stderr); - error!(%media_source_id, stream_index, %map_spec, "ffmpeg subtitle extraction failed: {stderr}"); - return Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::from("subtitle extraction failed")) - .unwrap()); + if is_cached(&cache_file) { + info!(%item_id, stream_index, "subtitle ready after waiting for batch extraction"); + } else { + info!(%item_id, stream_index, %map_spec, "subtitle cache miss — extracting on-demand"); } - cached = String::from_utf8_lossy( - &tokio::fs::read(&cache_path) - .await - .map_err(|e| anyhow!("failed to read cached subtitle: {e}"))?, - ) - .into_owned(); - if cached - .trim() - .is_empty() - { - let _ = tokio::fs::remove_file(&cache_path).await; + } + let cache_path = match extract_subtitle_to_cache( + &state + .ctx + .config + .data_dir, + &url, + &map_spec, + item_id, + stream_index, + ) + .await + { + Ok(p) => p, + Err(e) => { + error!(%item_id, stream_index, %map_spec, "subtitle extraction failed: {e}"); return Ok(Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(Body::from("subtitle extraction failed")) .unwrap()); } - } + }; + + let cached = String::from_utf8_lossy( + &tokio::fs::read(&cache_path) + .await + .map_err(|e| anyhow!("failed to read cached subtitle: {e}"))?, + ) + .into_owned(); let body = if is_passthrough { cached diff --git a/crates/remux-server/src/conversions.rs b/crates/remux-server/src/conversions.rs index 3a64231a..622efc5b 100644 --- a/crates/remux-server/src/conversions.rs +++ b/crates/remux-server/src/conversions.rs @@ -187,6 +187,17 @@ impl From for api::MediaSourceInfo { .runtime .and_then(|r| r.to_ticks(common::TickUnit::Seconds)); let run_time_ticks = probe_ticks.or(meta_ticks); + let (media_streams, default_audio_stream_index, default_subtitle_stream_index) = + source + .probe_data + .map(|p| { + ( + p.media_streams, + p.default_audio_stream_index, + p.default_subtitle_stream_index, + ) + }) + .unwrap_or_default(); api::MediaSourceInfo { id: client_id, e_tag: client_id, @@ -204,10 +215,9 @@ impl From for api::MediaSourceInfo { formats: Some(vec![]), required_http_headers: Some(HashMap::new()), run_time_ticks, - media_streams: source - .probe_data - .map(|p| p.media_streams) - .unwrap_or_default(), + media_streams, + default_audio_stream_index, + default_subtitle_stream_index, ..Default::default() } } diff --git a/crates/remux-server/src/device_profile.rs b/crates/remux-server/src/device_profile.rs index 115ec60e..bbb8fbe3 100644 --- a/crates/remux-server/src/device_profile.rs +++ b/crates/remux-server/src/device_profile.rs @@ -17,27 +17,162 @@ pub trait DeviceProfileExt { )] #[strum(ascii_case_insensitive)] pub(crate) enum SubtitleCodec { + // Canonical: "pgssub". Aliases: pgs, hdmv_pgs_subtitle, sup. #[strum( - serialize = "pgs", + to_string = "pgssub", serialize = "pgssub", + serialize = "pgs", serialize = "hdmv_pgs_subtitle", serialize = "sup" )] Pgs, - #[strum(serialize = "subrip", serialize = "srt")] + // Canonical: "srt". Aliases: subrip. + #[strum(to_string = "srt", serialize = "srt", serialize = "subrip")] Srt, - #[strum(serialize = "dvd_subtitle", serialize = "dvdsub")] + // Canonical: "dvdsub". Aliases: dvd_subtitle. + #[strum(to_string = "dvdsub", serialize = "dvdsub", serialize = "dvd_subtitle")] DvdSub, - #[strum(serialize = "dvb_subtitle", serialize = "dvbsub")] + // Canonical: "dvbsub". Aliases: dvb_subtitle. + #[strum(to_string = "dvbsub", serialize = "dvbsub", serialize = "dvb_subtitle")] DvbSub, - #[strum(serialize = "ass", serialize = "ssa")] + // Canonical: "ass". Aliases: ssa. + #[strum(to_string = "ass", serialize = "ass", serialize = "ssa")] Ass, - #[strum(serialize = "webvtt", serialize = "vtt")] + // Canonical: "vtt". Aliases: webvtt. + #[strum(to_string = "vtt", serialize = "vtt", serialize = "webvtt")] WebVtt, - #[strum(serialize = "mov_text", serialize = "tx3g")] + // Canonical: "tx3g". Aliases: mov_text. + #[strum(to_string = "tx3g", serialize = "tx3g", serialize = "mov_text")] MovText, } +impl SubtitleCodec { + pub(crate) fn is_image(&self) -> bool { + matches!(self, Self::Pgs | Self::DvdSub | Self::DvbSub) + } + + pub(crate) fn is_text(&self) -> bool { + !self.is_image() + } +} + +#[derive( + Debug, Clone, PartialEq, Eq, strum_macros::EnumString, strum_macros::Display, +)] +#[strum(ascii_case_insensitive)] +pub(crate) enum VideoCodec { + #[strum( + to_string = "h264", + serialize = "h264", + serialize = "avc", + serialize = "avc1" + )] + H264, + #[strum( + to_string = "hevc", + serialize = "hevc", + serialize = "h265", + serialize = "hvc1", + serialize = "hev1" + )] + Hevc, + #[strum( + to_string = "av1", + serialize = "av1", + serialize = "libaom-av1", + serialize = "libsvtav1" + )] + Av1, + #[strum(to_string = "vp9", serialize = "vp9", serialize = "libvpx-vp9")] + Vp9, + #[strum(to_string = "vp8", serialize = "vp8", serialize = "libvpx")] + Vp8, + #[strum(to_string = "mpeg4", serialize = "mpeg4")] + Mpeg4, + #[strum( + to_string = "mpeg2video", + serialize = "mpeg2video", + serialize = "mpeg2" + )] + Mpeg2, + #[strum(default)] + Unknown(String), +} + +impl VideoCodec { + pub(crate) fn is_hevc(&self) -> bool { + matches!(self, Self::Hevc) + } +} + +#[derive( + Debug, Clone, PartialEq, Eq, strum_macros::EnumString, strum_macros::Display, +)] +#[strum(ascii_case_insensitive)] +pub(crate) enum AudioCodec { + #[strum( + to_string = "aac", + serialize = "aac", + serialize = "aac_fixed", + serialize = "aac_latm" + )] + Aac, + #[strum(to_string = "ac3", serialize = "ac3", serialize = "a52")] + Ac3, + #[strum(to_string = "eac3", serialize = "eac3", serialize = "ec3")] + Eac3, + #[strum(to_string = "truehd", serialize = "truehd")] + TrueHd, + #[strum(to_string = "dts", serialize = "dts", serialize = "dca")] + Dts, + #[strum(to_string = "flac", serialize = "flac")] + Flac, + #[strum(to_string = "mp3", serialize = "mp3", serialize = "mp3float")] + Mp3, + #[strum(to_string = "opus", serialize = "opus", serialize = "libopus")] + Opus, + #[strum(to_string = "vorbis", serialize = "vorbis")] + Vorbis, + #[strum(to_string = "alac", serialize = "alac")] + Alac, + #[strum( + to_string = "pcm", + serialize = "pcm", + serialize = "pcm_s16le", + serialize = "pcm_s24le", + serialize = "pcm_s32le", + serialize = "pcm_f32le", + serialize = "pcm_s16be", + serialize = "pcm_u8" + )] + Pcm, + #[strum(default)] + Unknown(String), +} + +impl AudioCodec { + pub(crate) fn friendly_name(&self) -> &str { + match self { + Self::Aac => "AAC", + Self::Ac3 => "Dolby Digital", + Self::Eac3 => "Dolby Digital Plus", + Self::TrueHd => "TrueHD", + Self::Dts => "DTS", + Self::Flac => "FLAC", + Self::Mp3 => "MP3", + Self::Opus => "Opus", + Self::Vorbis => "Vorbis", + Self::Alac => "ALAC", + Self::Pcm => "PCM", + Self::Unknown(s) => s.as_str(), + } + } + + pub(crate) fn needs_adts_reframe(&self) -> bool { + matches!(self, Self::Aac) + } +} + pub(crate) fn subtitle_codec_matches_profile( codec: &str, profile_format: &str, diff --git a/crates/remux-server/src/services/mod.rs b/crates/remux-server/src/services/mod.rs index f49a9e50..282ebd61 100644 --- a/crates/remux-server/src/services/mod.rs +++ b/crates/remux-server/src/services/mod.rs @@ -1,6 +1,8 @@ pub mod image; pub(crate) mod resolve; +pub(crate) mod stream_resolve; pub mod stremio; pub use resolve::MediaResolveService; pub(crate) use resolve::ResolvedItem; +pub(crate) use stream_resolve::StreamResolver; diff --git a/crates/remux-server/src/services/stream_resolve.rs b/crates/remux-server/src/services/stream_resolve.rs new file mode 100644 index 00000000..d1e2a2e3 --- /dev/null +++ b/crates/remux-server/src/services/stream_resolve.rs @@ -0,0 +1,178 @@ +use crate::db; +use remux_utils::Store; +use uuid::Uuid; + +/// Resolved stream for a client-supplied source UUID. +/// +/// Encapsulates the mapping from a client-facing UUID (which may be a StreamGroup, a direct +/// Stream, or a Movie/Episode/Track) to the concrete `db::Media` that should actually be +/// probed and played, along with the group context needed to echo the right ID back. +pub(crate) struct StreamResolver { + /// The item UUID from the URL route (Movie/Episode/Track). + pub item_id: Uuid, + /// When the requested source was a StreamGroup: (group_uuid, display_title, all_candidates). + pub group: Option<(Uuid, String, Vec)>, + /// The concrete playable stream. + pub stream: db::Media, +} + +impl StreamResolver { + /// Full resolution: StreamGroup → best candidate; Movie/Episode/Track → preferred/first source. + /// + /// Use this in video-stream and subtitle handlers where exactly one concrete stream is needed. + pub async fn resolve( + db: &sqlx::SqlitePool, + store: &Store, + item_id: Uuid, + requested_id: Option, + device_key: Option<&str>, + ) -> anyhow::Result { + let lookup_id = requested_id.unwrap_or(item_id); + let media = db::Media::get_by_id(db, &lookup_id) + .await? + .ok_or_else(|| anyhow::anyhow!("stream not found: {}", lookup_id))?; + Self::dispatch(db, store, item_id, requested_id, device_key, media).await + } + + /// Group-only resolution: StreamGroup → best candidate; Movie/Episode/Track → returned as-is. + /// + /// Use this in the playbackinfo handler where `all_source_medias` building happens externally. + /// `initial` is typically the result of `MediaResolveService::resolve_item`. + pub async fn resolve_group( + db: &sqlx::SqlitePool, + store: &Store, + item_id: Uuid, + requested_id: Option, + initial: db::Media, + ) -> anyhow::Result { + if initial.kind == db::MediaKind::StreamGroup { + Self::resolve_stream_group(db, item_id, initial).await + } else { + Ok(Self { + item_id, + group: None, + stream: initial, + }) + } + } + + async fn dispatch( + db: &sqlx::SqlitePool, + store: &Store, + item_id: Uuid, + requested_id: Option, + device_key: Option<&str>, + media: db::Media, + ) -> anyhow::Result { + match media.kind { + db::MediaKind::StreamGroup => { + Self::resolve_stream_group(db, item_id, media).await + } + db::MediaKind::Movie | db::MediaKind::Episode | db::MediaKind::Track => { + let mut media = media; + let sources = media + .streams(db) + .await?; + let stream = if let Some(sid) = + requested_id.filter(|&sid| sid != item_id) + { + sources + .into_iter() + .find(|s| s.id == sid) + .ok_or_else(|| anyhow::anyhow!("stream not found: {}", sid))? + } else if let Some(key) = device_key { + let saved = + store.get::(&format!("pstream:{}:{}", item_id, key)); + let by_pref = saved.and_then(|sid| { + sources + .iter() + .find(|s| s.id == sid) + .cloned() + }); + by_pref + .or_else(|| { + sources + .into_iter() + .next() + }) + .ok_or_else(|| { + anyhow::anyhow!("no playable sources for {}", item_id) + })? + } else { + sources + .into_iter() + .next() + .ok_or_else(|| { + anyhow::anyhow!("no playable sources for {}", item_id) + })? + }; + Ok(Self { + item_id, + group: None, + stream, + }) + } + _ => Ok(Self { + item_id, + group: None, + stream: media, + }), + } + } + + async fn resolve_stream_group( + db: &sqlx::SqlitePool, + item_id: Uuid, + media: db::Media, + ) -> anyhow::Result { + let gid = media.id; + let gtitle = media + .title + .clone(); + let mut candidates = db::StreamGroup::streams_for(db, &gid, &item_id).await?; + if candidates.is_empty() { + return Err(anyhow::anyhow!("no streams available for group {}", gid)); + } + let cascade = db::StreamGroup::streams_for_groups_after(db, &gid, &item_id) + .await + .unwrap_or_default(); + candidates.extend(cascade); + let stream = candidates[0].clone(); + Ok(Self { + item_id, + group: Some((gid, gtitle, candidates)), + stream, + }) + } + + /// UUID the client should see in `MediaSources[0].Id` and `TranscodingUrl MediaSourceId`. + /// + /// Returns the group UUID when a StreamGroup was requested, otherwise the stream's own UUID. + pub fn client_facing_id(&self) -> Uuid { + self.group + .as_ref() + .map(|(gid, _, _)| *gid) + .unwrap_or( + self.stream + .id, + ) + } + + /// Probe fallback pool — includes cascade candidates for group requests, empty otherwise. + pub fn candidates(&self) -> &[db::Media] { + self.group + .as_ref() + .map(|(_, _, c)| c.as_slice()) + .unwrap_or(&[]) + } + + /// Persist the resolved stream UUID in the device-preference store (24 h TTL). + pub fn save_preference(&self, store: &Store, device_key: &str) { + store.save( + format!("pstream:{}:{}", self.item_id, device_key), + self.stream + .id, + std::time::Duration::from_secs(24 * 3600), + ); + } +} diff --git a/crates/remux-server/src/transcode/engine.rs b/crates/remux-server/src/transcode/engine.rs index faccf238..e037006c 100644 --- a/crates/remux-server/src/transcode/engine.rs +++ b/crates/remux-server/src/transcode/engine.rs @@ -12,7 +12,10 @@ use std::{ use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; -use crate::common::{TickUnit, ToRunTimeTicks}; +use crate::{ + common::{TickUnit, ToRunTimeTicks}, + device_profile::{AudioCodec, VideoCodec}, +}; use remux_sdks::remux::{EncodingPreset, HardwareAccelerationType, VideoRangeType}; use super::session::{TranscodeSession, TranscodeState}; @@ -587,12 +590,16 @@ pub(crate) fn build_hls_args(params: &TranscodeParams) -> Vec { // fMP4 (fragmented MP4) is required for HEVC on iOS Safari per Apple's HLS // authoring specification. MPEG-TS cannot carry HEVC correctly in HLS. let is_hevc_copy = ffmpeg_video_codec == "copy" - && matches!( - params - .source_video_codec - .as_deref(), - Some("hevc") | Some("h265") | Some("hvc1") | Some("hev1") - ); + && params + .source_video_codec + .as_deref() + .and_then(|s| { + s.parse::() + .ok() + }) + .as_ref() + .map(VideoCodec::is_hevc) + .unwrap_or(false); let mut args: Vec = vec![ "-v".into(), @@ -671,6 +678,7 @@ pub(crate) fn build_hls_args(params: &TranscodeParams) -> Vec { // Stream mapping if params.burn_subtitle { if let Some(sub_idx) = params.subtitle_stream_index { + // Image subtitle (PGS/DVD): bitmap overlay via filter_complex. // Scale subtitle bitmap to output dimensions (matching Jellyfin's approach). // When output size is known, scale to that; otherwise pass through as-is. let (out_w, out_h) = output_dimensions(params); @@ -888,12 +896,17 @@ pub(crate) fn build_hls_args(params: &TranscodeParams) -> Vec { if ffmpeg_audio_codec == "copy" { // AAC streams from IPTV sources often use ADTS framing, which is not // valid inside MP4/fMP4 containers. Apply the reframing filter when copying. - if matches!( - params - .source_audio_codec - .as_deref(), - Some("aac") | Some("aac_fixed") | Some("aac_latm") - ) { + if params + .source_audio_codec + .as_deref() + .and_then(|s| { + s.parse::() + .ok() + }) + .as_ref() + .map(AudioCodec::needs_adts_reframe) + .unwrap_or(false) + { args.extend(["-bsf:a".into(), "aac_adtstoasc".into()]); } } else { @@ -1444,6 +1457,7 @@ pub(crate) fn build_progressive_args( if params.burn_subtitle { if let Some(sub_idx) = params.subtitle_stream_index { + // Image subtitle (PGS/DVD): bitmap overlay via filter_complex. let (out_w, out_h) = (params.max_width, params.max_height); let sub_scale = match (out_w, out_h) { (Some(w), Some(h)) => format!("scale={w}:{h}:fast_bilinear"), @@ -1559,12 +1573,17 @@ pub(crate) fn build_progressive_args( args.extend(["-c:v".into(), ffmpeg_video_codec.clone()]); if ffmpeg_video_codec == "copy" { // Apply hvc1 codec tag for HEVC Apple compatibility - if matches!( - params - .source_video_codec - .as_deref(), - Some("hevc") | Some("h265") | Some("hvc1") | Some("hev1") - ) { + if params + .source_video_codec + .as_deref() + .and_then(|s| { + s.parse::() + .ok() + }) + .as_ref() + .map(VideoCodec::is_hevc) + .unwrap_or(false) + { args.extend(["-tag:v".into(), "hvc1".into()]); } } else if is_hw { @@ -1626,12 +1645,17 @@ pub(crate) fn build_progressive_args( // Audio args.extend(["-c:a".into(), ffmpeg_audio_codec.into()]); if ffmpeg_audio_codec == "copy" { - if matches!( - params - .source_audio_codec - .as_deref(), - Some("aac") | Some("aac_fixed") | Some("aac_latm") - ) { + if params + .source_audio_codec + .as_deref() + .and_then(|s| { + s.parse::() + .ok() + }) + .as_ref() + .map(AudioCodec::needs_adts_reframe) + .unwrap_or(false) + { args.extend(["-bsf:a".into(), "aac_adtstoasc".into()]); } } else { @@ -1863,20 +1887,28 @@ pub fn generate_master_playlist(session: &TranscodeSession) -> String { .video_codec .as_str() { - "copy" => match session - .source_video_codec - .as_deref() - { - Some("hevc") | Some("h265") | Some("hvc1") | Some("hev1") => { + "copy" => { + if session + .source_video_codec + .as_deref() + .and_then(|s| { + s.parse::() + .ok() + }) + .as_ref() + .map(VideoCodec::is_hevc) + .unwrap_or(false) + { hevc_hls_codec_string( session .source_video_profile .as_deref(), session.source_video_level, ) + } else { + "avc1.640028".to_string() } - _ => "avc1.640028".to_string(), - }, + } "h264" | "libx264" => "avc1.640028".to_string(), "hevc" | "libx265" => hevc_hls_codec_string( session @@ -1886,12 +1918,23 @@ pub fn generate_master_playlist(session: &TranscodeSession) -> String { ), _ => "avc1.640028".to_string(), }; - let audio_codec_str = match session - .audio_codec - .as_str() - { - "copy" | "aac" => "mp4a.40.2", - _ => "mp4a.40.2", + let audio_codec_str = if session.audio_codec == "copy" { + // Use the actual source codec when copying so the CODECS attribute + // matches the bitstream. Browsers that see "mp4a.40.2" but receive + // eac3 will fail to initialize the audio decoder. + match session + .source_audio_codec + .as_deref() + .and_then(|s| { + s.parse::() + .ok() + }) { + Some(AudioCodec::Eac3) => "ec-3", + Some(AudioCodec::Ac3) => "ac-3", + _ => "mp4a.40.2", + } + } else { + "mp4a.40.2" }; let codecs = format!("{},{}", video_codec_str, audio_codec_str); diff --git a/crates/remux-server/src/transcode/probing.rs b/crates/remux-server/src/transcode/probing.rs index 1238971f..98022ad1 100644 --- a/crates/remux-server/src/transcode/probing.rs +++ b/crates/remux-server/src/transcode/probing.rs @@ -1,6 +1,7 @@ use crate::{ api, common::{TickUnit, ToRunTimeTicks}, + device_profile::{AudioCodec, SubtitleCodec, VideoCodec}, }; use anyhow::{Result, anyhow}; use isolang::Language; @@ -55,31 +56,6 @@ fn first_to_upper(s: &str) -> String { } } -fn subtitle_codec_display(codec: &str) -> &str { - match codec { - "hdmv_pgs_subtitle" => "pgssub", - "dvd_subtitle" => "dvdsub", - other => other, - } -} - -fn audio_codec_friendly(codec: &str) -> &str { - match codec { - "aac" => "AAC", - "ac3" | "a52" => "Dolby Digital", - "eac3" => "Dolby Digital Plus", - "truehd" => "TrueHD", - "dca" | "dts" => "DTS", - "flac" => "FLAC", - "mp3" => "MP3", - "opus" => "Opus", - "vorbis" => "Vorbis", - "pcm_s16le" | "pcm_s24le" | "pcm_s32le" | "pcm_f32le" => "PCM", - "alac" => "ALAC", - other => other, - } -} - fn video_resolution_text(width: Option, height: Option) -> Option { match (width, height) { (Some(w), _) if w >= 3840 => Some("4K".into()), @@ -158,7 +134,13 @@ fn display_title_audio(m: &StreamMeta) -> Option { ); } } else if let Some(codec) = m.codec { - attrs.push(audio_codec_friendly(codec).to_string()); + attrs.push( + codec + .parse::() + .unwrap() + .friendly_name() + .to_string(), + ); } if let Some(layout) = m.channel_layout { @@ -232,7 +214,11 @@ fn display_title_subtitle(m: &StreamMeta) -> Option { attrs.push("Forced".into()); } if let Some(codec) = m.codec { - attrs.push(subtitle_codec_display(codec).to_ascii_uppercase()); + let display = codec + .parse::() + .map(|c| c.to_string()) + .unwrap_or_else(|_| codec.to_string()); + attrs.push(display.to_ascii_uppercase()); } if m.is_external { attrs.push("External".into()); @@ -269,6 +255,8 @@ struct FfprobeDisposition { forced: i64, #[serde(default)] hearing_impaired: i64, + #[serde(default)] + attached_pic: i64, } #[derive(Deserialize)] @@ -497,6 +485,15 @@ pub fn probe_media(url: &str) -> Result<(api::MediaSourceInfo, MediaSegments)> { match codec_type { "video" => { + // Skip attached pictures (embedded cover art). They are not playable + // video streams, and having two Type:Video entries confuses clients + // that look for the primary video stream. + if s.disposition + .attached_pic + != 0 + { + continue; + } let bitrate = s .bit_rate .as_deref() @@ -515,10 +512,14 @@ pub fn probe_media(url: &str) -> Result<(api::MediaSourceInfo, MediaSegments)> { .as_deref() .and_then(parse_frame_rate) }); - let codec = s + let raw_codec = s .codec_name .clone() .unwrap_or_default(); + let codec = raw_codec + .parse::() + .unwrap() + .to_string(); let is_default = video_idx == 0; let is_forced = s .disposition @@ -615,10 +616,14 @@ pub fn probe_media(url: &str) -> Result<(api::MediaSourceInfo, MediaSegments)> { .ok() }) .and_then(nonzero); - let codec = s + let raw_codec = s .codec_name .clone() .unwrap_or_default(); + let codec = raw_codec + .parse::() + .unwrap() + .to_string(); let is_default = audio_idx == 0; let is_forced = s .disposition @@ -671,18 +676,25 @@ pub fn probe_media(url: &str) -> Result<(api::MediaSourceInfo, MediaSegments)> { audio_idx += 1; } "subtitle" => { - let codec = s + let raw_codec = s .codec_name .clone() .unwrap_or_default(); - let is_text = matches!( - codec.as_str(), - "ass" | "ssa" | "subrip" | "webvtt" | "mov_text" | "text" - ); - let is_image = matches!( - codec.as_str(), - "pgssub" | "hdmv_pgs_subtitle" | "dvd_subtitle" | "dvdsub" - ); + let parsed_codec = raw_codec + .parse::() + .ok(); + let codec = parsed_codec + .as_ref() + .map(|c| c.to_string()) + .unwrap_or(raw_codec); + let is_text = parsed_codec + .as_ref() + .map(SubtitleCodec::is_text) + .unwrap_or(false); + let is_image = parsed_codec + .as_ref() + .map(SubtitleCodec::is_image) + .unwrap_or(false); let delivery_method = if is_image { Some(api::SubtitleDeliveryMethod::Embed) } else {