diff --git a/CHANGELOG.md b/CHANGELOG.md index 714dba515..282ed8a22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,5 @@ # Changelog - ## 3.3.0 (2026-01-03) ## ⚠️ Breaking Changes @@ -70,6 +69,8 @@ - **Performance Monitoring**: Added CPU usage display to the dashboard. - **Stream Table Enhancements**: Added "Copy-To-Clipboard" functions and improved connection monitoring. - **UX Improvements**: Implemented API-user category selection and better session tracking for HLS. +- Added **Stream Buffer** settings (Enabled, Size) to Reverse Proxy configuration UI. +- Added **TMDB** settings (Rate Limit, Cache Duration, Language) and **Metadata Formats** (NFO support) to Library configuration UI. ## 🚀 Performance & Stability - **Connection Reconciliation & GC**: Resolved a critical issue where provider connection counters could leak or become stale during hot reloads. Added automatic garbage collection for unused provider records to prevent logical memory buildup. diff --git a/Cargo.lock b/Cargo.lock index 0dec0dad1..98b8f177e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.52" +version = "3.2.53" dependencies = [ "anyhow", "base64", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.52" +version = "3.2.53" dependencies = [ "base64", "bitflags 2.10.0", @@ -4592,7 +4592,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.52" +version = "3.2.53" dependencies = [ "arc-swap", "async-compression", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 74788f563..02b14ff3c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.52" +version = "3.2.53" edition = "2021" rust-version = "1.89.0" diff --git a/backend/src/api/endpoints/xmltv_api.rs b/backend/src/api/endpoints/xmltv_api.rs index 2c1714786..f20c1e0e9 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -304,7 +304,6 @@ pub async fn serve_short_epg( } else { epg_channel.programmes.iter().map(|p| from_programme(&stream_id, p, &epg_processing_options)).collect() }, - error: None, } } else { ShortEpgResultDto::default() diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index 226322413..7c11d4a83 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -266,7 +266,7 @@ impl MultiProviderLineup { .push(provider); } let mut values: Vec<(i16, Vec)> = providers.into_iter().collect(); - values.sort_by(|(p1, _), (p2, _)| p1.cmp(p2)); + values.sort_by_key(|(p1, _)| *p1); let providers: Vec = values.into_iter().map(|(_, mut group)| { if group.len() > 1 { ProviderPriorityGroup::MultiProviderGroup(AtomicUsize::new(0), group) diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index f38c4b236..ed7352edf 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -176,8 +176,7 @@ fn is_input_expired_at(exp_date: Option, now: u64) -> bool { return false; }; u64::try_from(exp_date) - .map(|exp_ts| exp_ts <= now) - .unwrap_or(true) + .map_or(true, |exp_ts| exp_ts <= now) } fn is_expiring_with_offset_at(exp_date: Option, offset_secs: u64, now: u64) -> bool { @@ -2640,14 +2639,12 @@ async fn sync_panel_api_for_input_on_boot( let root_exp_missing = root_exp_date.is_none(); let root_expired = match root_exp_date { Some(ts) => u64::try_from(ts) - .map(|exp_ts| exp_ts <= now) - .unwrap_or(true), + .map_or(true, |exp_ts| exp_ts <= now), None => false, }; let root_expiring = match root_exp_date { Some(ts) => u64::try_from(ts) - .map(|exp_ts| exp_ts > now && exp_ts <= offset_deadline) - .unwrap_or(true), + .map_or(true, |exp_ts| exp_ts > now && exp_ts <= offset_deadline), None => false, }; let should_refresh_root = root_exp_missing || root_expired || root_expiring; @@ -3099,8 +3096,7 @@ async fn sync_panel_api_for_input_on_boot( } match a.exp_date { Some(ts) => u64::try_from(ts) - .map(|exp_ts| exp_ts > offset_deadline) - .unwrap_or(false), + .is_ok_and(|exp_ts| exp_ts > offset_deadline), None => false, } }) @@ -3125,8 +3121,7 @@ async fn sync_panel_api_for_input_on_boot( match a.exp_date { None => true, Some(ts) => u64::try_from(ts) - .map(|exp_ts| exp_ts <= offset_deadline) - .unwrap_or(true), + .map_or(true, |exp_ts| exp_ts <= offset_deadline), } }) .map(|(idx, _)| idx) diff --git a/backend/src/api/sys_usage.rs b/backend/src/api/sys_usage.rs index 3155801ee..3e88e6a80 100644 --- a/backend/src/api/sys_usage.rs +++ b/backend/src/api/sys_usage.rs @@ -34,7 +34,7 @@ pub fn exec_system_usage(app_state: &Arc) -> tokio::task::JoinHandle<( state.event_manager.send_system_info(info); } - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } }) } \ No newline at end of file diff --git a/backend/src/messaging.rs b/backend/src/messaging.rs index 46fe7c2d3..6aee0126a 100644 --- a/backend/src/messaging.rs +++ b/backend/src/messaging.rs @@ -21,7 +21,7 @@ static HANDLEBARS: LazyLock = LazyLock::new(|| { let mut h = Handlebars::new(); h.register_helper("json_escape", Box::new(|h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| -> HelperResult { let param = h.param(0).and_then(|v| v.value().as_str()).unwrap_or(""); - let escaped = serde_json::to_string(param).unwrap_or_else(|_| "".to_string()); + let escaped = serde_json::to_string(param).unwrap_or_else(|_| String::new()); if escaped.len() >= 2 { out.write(&escaped[1..escaped.len()-1])?; } diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index 603acc49b..00ce3cd12 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -175,7 +175,7 @@ pub struct TVGuide { impl TVGuide { pub fn new(mut epg_sources: Vec) -> Self { - epg_sources.sort_by(|a, b| a.priority.cmp(&b.priority)); + epg_sources.sort_by_key(|a| a.priority); Self { epg_sources, } diff --git a/backend/src/processing/parser/xmltv.rs b/backend/src/processing/parser/xmltv.rs index a32153beb..a1f6c2a85 100644 --- a/backend/src/processing/parser/xmltv.rs +++ b/backend/src/processing/parser/xmltv.rs @@ -326,7 +326,7 @@ impl TVGuide { epg_sources.push(epg); } } - epg_sources.sort_by(|a, b| a.priority.cmp(&b.priority)); + epg_sources.sort_by_key(|a| a.priority); Some(epg_sources) } } @@ -469,7 +469,7 @@ pub fn flatten_tvguide(tv_guides: &[Epg]) -> Option { let epg_attrib_id = EPG_ATTRIB_ID.intern(); let epg_attrib_channel = EPG_ATTRIB_CHANNEL.intern(); // sort by priority - sorted_guides.sort_by(|a, b| a.priority.cmp(&b.priority)); + sorted_guides.sort_by_key(|a| a.priority); // if executed parallel it does not matter how we sort. sorted_guides.par_iter().for_each(|guide| { let mut children = vec![]; diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index 69189b079..683b91efd 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -14,19 +14,21 @@ use shared::utils::{generate_playlist_uuid, trim_last_slash, Internable}; use std::sync::Arc; use tokio::task::spawn_blocking; -async fn map_to_xtream_category(categories: DynReader) -> Result, TuliproxError> { +async fn map_to_xtream_category(categories: DynReader, input_name: &Arc) -> Result, TuliproxError> { + let input_name_clone = Arc::clone(input_name); spawn_blocking(move || { let reader = tokio_util::io::SyncIoBridge::new(categories); match serde_json::from_reader::<_, Vec>(reader) { Ok(xtream_categories) => Ok(xtream_categories), Err(err) => { - notify_err_res!("Failed to process categories {}", &err) + notify_err_res!("Failed to process categories input {input_name_clone}: {err}") } } - }).await.map_err(|e| notify_err!("Mapping xtream categories failed: {e}"))? + }).await.map_err(|err| notify_err!("Mapping xtream categories failed for input {input_name}: {err}"))? } -async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader) -> Result, TuliproxError> { +async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader, input_name: &Arc) -> Result, TuliproxError> { + let input_name_clone = Arc::clone(input_name); spawn_blocking(move || { let reader = tokio_util::io::SyncIoBridge::new(streams); @@ -44,10 +46,10 @@ async fn map_to_xtream_streams(xtream_cluster: XtreamCluster, streams: DynReader Ok(stream_list) } Err(err) => { - notify_err_res!("Failed to map to xtream streams {xtream_cluster}: {err}") + notify_err_res!("Failed to map to xtream streams {xtream_cluster} for input {input_name_clone}: {err}") } } - }).await.map_err(|e| notify_err!("Mapping xtream streams failed: {e}"))? + }).await.map_err(|e| notify_err!("Mapping xtream streams failed for input {input_name}: {e}"))? } fn create_xtream_series_episode_url(url: &str, username: &str, password: &str, episode: &SeriesStreamDetailEpisodeProperties) -> Arc { @@ -134,14 +136,14 @@ pub async fn parse_xtream(input: &ConfigInput, xtream_cluster: XtreamCluster, categories: DynReader, streams: DynReader) -> Result>, TuliproxError> { - match map_to_xtream_category(categories).await { + match map_to_xtream_category(categories, &input.name).await { Ok(xtream_categories) => { let input_name = input.name.clone(); let url = input.url.as_str(); let username = input.username.as_ref().map_or("", |v| v); let password = input.password.as_ref().map_or("", |v| v); - match map_to_xtream_streams(xtream_cluster, streams).await { + match map_to_xtream_streams(xtream_cluster, streams, &input.name).await { Ok(xtream_streams) => { let mut group_map: IndexMap = xtream_categories.into_iter().map(|category| (category.category_id, category)).collect(); @@ -218,7 +220,7 @@ where F: FnMut(XtreamPlaylistItem) -> Result<(), TuliproxError> + Send + 'static, { // 1. Parse Categories - let xtream_categories = map_to_xtream_category(categories).await?; + let xtream_categories = map_to_xtream_category(categories, &input.name).await?; // 2. Prepare for Stream Parsing let input_name = input.name.clone(); @@ -398,7 +400,7 @@ mod tests { async fn test_read_json_stream_into_struct() -> std::io::Result<()> { if fs::exists("/tmp/vod_streams.json").unwrap_or(false) { let reader = Box::pin(async_file_reader(tokio::fs::File::open("/tmp/vod_streams.json").await?)); - match map_to_xtream_streams(XtreamCluster::Video, reader).await { + match map_to_xtream_streams(XtreamCluster::Video, reader, "test").await { Ok(_streams) => { println!("{:?}", _streams.get(1)); println!("{:?}", _streams.get(100)); diff --git a/backend/src/processing/processor/epg.rs b/backend/src/processing/processor/epg.rs index 6c2a33d12..edabcd938 100644 --- a/backend/src/processing/processor/epg.rs +++ b/backend/src/processing/processor/epg.rs @@ -256,7 +256,7 @@ pub async fn process_playlist_epg(fp: &mut FetchedPlaylist<'_>, epg: &mut Vec Result>, Vec> { let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else { - debug!("No Trakt configuration found for target {}", target.name); + trace!("No Trakt configuration found for target {}", target.name); return Ok(None); }; if !trakt_config.enabled { diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index 57b6e202f..ee69111a6 100644 --- a/backend/src/utils/file/config_reader.rs +++ b/backend/src/utils/file/config_reader.rs @@ -628,7 +628,7 @@ async fn persist_single_template(prefix: &str, kind: Option<&MsgKind>, template: if tokio::fs::metadata(template).await.is_ok() { return Url::from_file_path(template) .map(|u| u.to_string()) - .map_err(|_| info_err!("Failed to convert path to file URL: {}", template)); + .map_err(|()| info_err!("Failed to convert path to file URL: {}", template)); } // It's a raw string, persist it @@ -649,7 +649,7 @@ async fn persist_single_template(prefix: &str, kind: Option<&MsgKind>, template: Url::from_file_path(&file_path) .map(|u| u.to_string()) - .map_err(|_| info_err!("Failed to convert persisted path to file URL: {}", file_path.display())) + .map_err(|()| info_err!("Failed to convert persisted path to file URL: {}", file_path.display())) } diff --git a/backend/src/utils/file/file_utils.rs b/backend/src/utils/file/file_utils.rs index 9284e226e..422456ad5 100644 --- a/backend/src/utils/file/file_utils.rs +++ b/backend/src/utils/file/file_utils.rs @@ -361,8 +361,7 @@ pub fn cleanup_unlisted_files_with_suffix( let delete = { let zero_size = entry .metadata() - .map(|m| m.len() == 0) - .unwrap_or(false); + .is_ok_and(|m| m.len() == 0); let suffix_match = path .file_name() diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index deba39d83..568de83e8 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -1095,14 +1095,12 @@ pub fn parse_range(range: &str) -> Option<(u64, Option)> { pub fn is_file_url(url: &str) -> bool { Url::parse(url) - .map(|u| u.scheme().eq_ignore_ascii_case("file")) - .unwrap_or(false) + .is_ok_and(|u| u.scheme().eq_ignore_ascii_case("file")) } pub fn is_uri(url: &str) -> bool { Url::parse(url) - .map(|u| u.scheme().eq_ignore_ascii_case("file") || u.scheme().eq_ignore_ascii_case("http") || u.scheme().eq_ignore_ascii_case("https")) - .unwrap_or(false) + .is_ok_and(|u| u.scheme().eq_ignore_ascii_case("file") || u.scheme().eq_ignore_ascii_case("http") || u.scheme().eq_ignore_ascii_case("https")) } #[cfg(test)] diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 28c83ac0c..3881a1ab2 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.52" +version = "3.2.53" edition = "2021" rust-version = "1.89.0" [dependencies] -shared = { version = "3.2.52", path = "../shared" } +shared = { version = "3.2.53", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/public/assets/i18n/en.json b/frontend/public/assets/i18n/en.json index 6d8663f24..f837ccc51 100644 --- a/frontend/public/assets/i18n/en.json +++ b/frontend/public/assets/i18n/en.json @@ -99,6 +99,7 @@ "START": "Start", "CACHE": "Cache", "STREAM": "Stream", + "STREAM_BUFFER": "Stream Buffer", "LOG": "Log", "LOG_LEVEL": "Log Level", "LOG_ACTIVE_USER": "Log Active User", @@ -248,6 +249,7 @@ "FALLBACK_TO_FILENAME": "Fallback to Filename", "FORMATS": "Formats", "ADD_EXTENSION": "Add Extension", + "ADD_FORMAT": "Add Format", "COUNTER": "Counter", "MAPPING_PATH": "Mapping Path", "USER_CONFIG_DIR": "User Config dir", diff --git a/frontend/scss/app/components/_icon_button.scss b/frontend/scss/app/components/_icon_button.scss index 0828f8cba..78d1c78bc 100644 --- a/frontend/scss/app/components/_icon_button.scss +++ b/frontend/scss/app/components/_icon_button.scss @@ -30,39 +30,39 @@ } } -.tp__icon-button.tp__button-primary { +.tp__icon-button.primary { background-color: var(--text-button-primary-background-color); color: var(--text-button-primary-color); - fill : var(--text-button-primary-color); + fill: var(--text-button-primary-color); &:focus, &:hover { background-color: var(--text-button-primary-hover-background-color); color: var(--text-button-primary-hover-color); - fill : var(--text-button-primary-hover-color); + fill: var(--text-button-primary-hover-color); outline: none; } } -.tp__icon-button.tp__button-secondary { +.tp__icon-button.secondary { background-color: var(--text-button-secondary-background-color); color: var(--text-button-secondary-color); - fill : var(--text-button-secondary-color); + fill: var(--text-button-secondary-color); &:focus, &:hover { background-color: var(--text-button-secondary-hover-background-color); color: var(--text-button-secondary-hover-color); - fill : var(--text-button-secondary-hover-color); + fill: var(--text-button-secondary-hover-color); outline: none; } } -.tp__icon-button.tp__button-active { +.tp__icon-button.active { background-color: var(--text-button-active-background-color); color: var(--text-button-active-color); - fill : var(--text-button-active-color); + fill: var(--text-button-active-color); &:focus, &:hover { background-color: var(--text-button-active-hover-background-color); color: var(--text-button-active-hover-color); - fill : var(--text-button-active-hover-color); + fill: var(--text-button-active-hover-color); outline: none; } } \ No newline at end of file diff --git a/frontend/scss/app/components/_tabset.scss b/frontend/scss/app/components/_tabset.scss index a0ab6fabc..53477e007 100644 --- a/frontend/scss/app/components/_tabset.scss +++ b/frontend/scss/app/components/_tabset.scss @@ -52,22 +52,19 @@ &-desktop { display: block; + @media (max-width: size.$mobile-breakpoint) { + display: none !important; + } } &-mobile { display: none; - } - - @media (max-width: size.$mobile-breakpoint) { - &-desktop { - display: none; - } - - &-mobile { - display: block; + @media (max-width: size.$mobile-breakpoint) { + display: block !important; } } + } &__body { diff --git a/frontend/scss/app/components/_text_button.scss b/frontend/scss/app/components/_text_button.scss index 63f748c21..8b57cf597 100644 --- a/frontend/scss/app/components/_text_button.scss +++ b/frontend/scss/app/components/_text_button.scss @@ -33,42 +33,42 @@ } } -.tp__text-button.tp__button-primary { +.tp__text-button.primary { background-color: var(--text-button-primary-background-color); border-color: var(--text-button-primary-border-color); color: var(--text-button-primary-color); - fill : var(--text-button-primary-color); + fill: var(--text-button-primary-color); &:focus, &:hover { background-color: var(--text-button-primary-hover-background-color); color: var(--text-button-primary-hover-color); - fill : var(--text-button-primary-hover-color); + fill: var(--text-button-primary-hover-color); outline: none; } } -.tp__text-button.tp__button-secondary { +.tp__text-button.secondary { background-color: var(--text-button-secondary-background-color); border-color: var(--text-button-secondary-border-color); color: var(--text-button-secondary-color); - fill : var(--text-button-secondary-color); + fill: var(--text-button-secondary-color); &:focus, &:hover { background-color: var(--text-button-secondary-hover-background-color); color: var(--text-button-secondary-hover-color); - fill : var(--text-button-secondary-hover-color); + fill: var(--text-button-secondary-hover-color); outline: none; } } -.tp__text-button.tp__button-active { +.tp__text-button.active { background-color: var(--text-button-active-background-color); border-color: var(--text-button-active-border-color); color: var(--text-button-active-color); - fill : var(--text-button-active-color); + fill: var(--text-button-active-color); &:focus, &:hover { background-color: var(--text-button-active-hover-background-color); color: var(--text-button-active-hover-color); - fill : var(--text-button-active-hover-color); + fill: var(--text-button-active-hover-color); outline: none; } } \ No newline at end of file diff --git a/frontend/scss/app/components/config/_config_view.scss b/frontend/scss/app/components/config/_config_view.scss index 666f5f1ad..1662d8557 100644 --- a/frontend/scss/app/components/config/_config_view.scss +++ b/frontend/scss/app/components/config/_config_view.scss @@ -53,6 +53,7 @@ &__toolbar { @extend %toolbar-base; + padding-top: var(--padding-default); } &__tags { diff --git a/frontend/src/app/components/config/library_config_view.rs b/frontend/src/app/components/config/library_config_view.rs index 1ad2078b7..5cc59f39c 100644 --- a/frontend/src/app/components/config/library_config_view.rs +++ b/frontend/src/app/components/config/library_config_view.rs @@ -5,10 +5,10 @@ use crate::app::components::select::Select; use crate::app::components::{Card, Chip, DropDownOption, DropDownSelection, IconButton, ToggleSwitch}; use crate::app::context::ConfigContext; use crate::{config_field, config_field_bool, config_field_child, config_field_optional, - edit_field_bool, edit_field_list, edit_field_text, edit_field_text_option, generate_form_reducer}; + edit_field_bool, edit_field_list, edit_field_number, edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; use shared::model::{LibraryConfigDto, LibraryContentType, LibraryMetadataConfigDto, LibraryMetadataReadConfigDto, LibraryPlaylistConfigDto, LibraryScanDirectoryDto, - LibraryTmdbConfigDto}; + LibraryTmdbConfigDto, LibraryMetadataFormat}; use std::rc::Rc; use yew::prelude::*; use yew_i18n::use_translation; @@ -35,6 +35,11 @@ const LABEL_CONTENT_TYPE: &str = "LABEL.CONTENT_TYPE"; const LABEL_AUTO: &str = "LABEL.AUTO"; const LABEL_MOVIE: &str = "LABEL.MOVIE"; const LABEL_SERIES: &str = "LABEL.SERIES"; +const LABEL_RATE_LIMIT_MS: &str = "LABEL.RATE_LIMIT_MS"; +const LABEL_CACHE_DURATION_DAYS: &str = "LABEL.CACHE_DURATION_DAYS"; +const LABEL_LANGUAGE: &str = "LABEL.LANGUAGE"; +const LABEL_FORMATS: &str = "LABEL.FORMATS"; +const LABEL_ADD_FORMAT: &str = "LABEL.ADD_FORMAT"; const TYPE_AUTO: &str = "Auto"; const TYPE_MOVIE: &str = "Movie"; @@ -66,6 +71,7 @@ generate_form_reducer!( fields { Path => path: String, FallbackToFilename => fallback_to_filename: bool, + Formats => formats: Vec, } ); @@ -85,6 +91,9 @@ generate_form_reducer!( fields { Enabled => enabled: bool, ApiKey => api_key: Option, + RateLimitMs => rate_limit_ms: u64, + CacheDurationDays => cache_duration_days: u32, + Language => language: String, } ); @@ -360,6 +369,13 @@ pub fn LibraryConfigView() -> Html {

{translate.t(LABEL_METADATA)}

{ config_field!(metadata, translate.t(LABEL_METADATA_PATH), path) } { config_field_bool!(metadata, translate.t(LABEL_FALLBACK_TO_FILENAME), fallback_to_filename) } + { config_field_child!(translate.t(LABEL_FORMATS), { + html! { +
+ { for metadata.formats.iter().map(|f| html! { }) } +
+ } + }) } @@ -373,6 +389,9 @@ pub fn LibraryConfigView() -> Html {

{translate.t(LABEL_TMDB)}

{ config_field_bool!(tmdb, translate.t(LABEL_ENABLED), enabled) } { config_field_optional!(tmdb, translate.t(LABEL_API_KEY), api_key) } + { config_field!(tmdb, translate.t(LABEL_RATE_LIMIT_MS), rate_limit_ms) } + { config_field!(tmdb, translate.t(LABEL_CACHE_DURATION_DAYS), cache_duration_days) } + { config_field!(tmdb, translate.t(LABEL_LANGUAGE), language) }
@@ -400,6 +419,30 @@ pub fn LibraryConfigView() -> Html {

{translate.t(LABEL_METADATA)}

{ edit_field_text!(metadata_state, translate.t(LABEL_METADATA_PATH), path, LibraryMetadataConfigFormAction::Path) } { edit_field_bool!(metadata_state, translate.t(LABEL_FALLBACK_TO_FILENAME), fallback_to_filename, LibraryMetadataConfigFormAction::FallbackToFilename) } + { config_field_child!(translate.t(LABEL_FORMATS), { + let metadata_state = metadata_state.clone(); + let formats = metadata_state.form.formats.clone(); + html! { +
+ { for formats.iter().map(|f| { + let metadata_state = metadata_state.clone(); + let f = *f; + html! { } + }) } + if !formats.contains(&LibraryMetadataFormat::Nfo) { + + } +
+ } + }) }
@@ -412,7 +455,10 @@ pub fn LibraryConfigView() -> Html {

{translate.t(LABEL_TMDB)}

{ edit_field_bool!(tmdb_state, translate.t(LABEL_ENABLED), enabled, LibraryTmdbConfigFormAction::Enabled) } - { edit_field_text_option!(tmdb_state, translate.t(LABEL_API_KEY), api_key, LibraryTmdbConfigFormAction::ApiKey) } + { edit_field_text_option!(tmdb_state, translate.t(LABEL_API_KEY), api_key, LibraryTmdbConfigFormAction::ApiKey, true) } + { edit_field_number_u64!(tmdb_state, translate.t(LABEL_RATE_LIMIT_MS), rate_limit_ms, LibraryTmdbConfigFormAction::RateLimitMs) } + { edit_field_number!(tmdb_state, translate.t(LABEL_CACHE_DURATION_DAYS), cache_duration_days, LibraryTmdbConfigFormAction::CacheDurationDays) } + { edit_field_text!(tmdb_state, translate.t(LABEL_LANGUAGE), language, LibraryTmdbConfigFormAction::Language) }
diff --git a/frontend/src/app/components/config/macros.rs b/frontend/src/app/components/config/macros.rs index 168da3e1d..50423268e 100644 --- a/frontend/src/app/components/config/macros.rs +++ b/frontend/src/app/components/config/macros.rs @@ -386,6 +386,31 @@ macro_rules! edit_field_number_u64 { }}; } +#[macro_export] +macro_rules! edit_field_number_usize { + ($instance:expr, $label:expr, $field:ident, $action:path) => {{ + let instance = $instance.clone(); + html! { +
+ <$crate::app::components::number_input::NumberInput + label={$label} + name={stringify!($field)} + value={instance.form.$field as i64} + on_change={Callback::from(move |value: Option| { + match value { + Some(value) => { + let val = usize::try_from(value).unwrap_or(0); + instance.dispatch($action(val)) + }, + None => instance.dispatch($action(0)), + } + })} + /> +
+ } + }}; +} + #[macro_export] macro_rules! edit_field_number_option { ($instance:expr, $label:expr, $field:ident, $action:path) => {{ diff --git a/frontend/src/app/components/config/messaging_config_view.rs b/frontend/src/app/components/config/messaging_config_view.rs index c40f1df07..0f7d63027 100644 --- a/frontend/src/app/components/config/messaging_config_view.rs +++ b/frontend/src/app/components/config/messaging_config_view.rs @@ -322,7 +322,7 @@ pub fn MessagingConfigView() -> Html { html! {
{ for notify_on_options.iter().map(|t| { let is_selected = msg_state.form.notify_on.contains(t); - let class = if is_selected { "tp__text-button tp__button-primary" } else { "tp__text-button" }; + let class = if is_selected { "tp__text-button primary" } else { "tp__text-button" }; html! { }}) } diff --git a/frontend/src/app/components/config/reverse_proxy_config_view.rs b/frontend/src/app/components/config/reverse_proxy_config_view.rs index 62b51a426..6f77f8743 100644 --- a/frontend/src/app/components/config/reverse_proxy_config_view.rs +++ b/frontend/src/app/components/config/reverse_proxy_config_view.rs @@ -2,7 +2,7 @@ use yew::prelude::*; use yew_i18n::use_translation; -use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamConfigDto}; +use shared::model::{CacheConfigDto, GeoIpConfigDto, RateLimitConfigDto, ResourceRetryConfigDto, ReverseProxyConfigDto, ReverseProxyDisabledHeaderConfigDto, StreamBufferConfigDto, StreamConfigDto}; use shared::utils::{default_secret, format_float_localized}; use crate::app::context::ConfigContext; use crate::app::components::config::config_view_context::ConfigViewContext; @@ -10,7 +10,7 @@ use crate::app::components::config::config_page::{ConfigForm, LABEL_REVERSE_PROX use crate::app::components::{Card}; use crate::{config_field, config_field_bool, config_field_custom, config_field_hide, config_field_optional, edit_field_bool, edit_field_list, edit_field_number, edit_field_number_f64, - edit_field_number_u64, edit_field_text, edit_field_text_option, generate_form_reducer}; + edit_field_number_u64, edit_field_number_usize, edit_field_text, edit_field_text_option, generate_form_reducer}; const LABEL_CACHE: &str = "LABEL.CACHE"; const LABEL_ENABLED: &str = "LABEL.ENABLED"; @@ -23,6 +23,9 @@ const LABEL_THROTTLE: &str = "LABEL.THROTTLE"; const LABEL_GRACE_PERIOD_MILLIS: &str = "LABEL.GRACE_PERIOD_MILLIS"; const LABEL_GRACE_PERIOD_TIMEOUT_SECS: &str = "LABEL.GRACE_PERIOD_TIMEOUT_SECS"; const LABEL_THROTTLE_KBPS: &str = "LABEL.THROTTLE_KBPS"; +const LABEL_STREAM_BUFFER: &str = "LABEL.STREAM_BUFFER"; +const LABEL_BUFFER_ENABLED: &str = "LABEL.BUFFER_ENABLED"; +const LABEL_BUFFER_SIZE: &str = "LABEL.BUFFER_SIZE"; const LABEL_RATE_LIMIT: &str = "LABEL.RATE_LIMIT"; const LABEL_PERIOD_MILLIS: &str = "LABEL.PERIOD_MILLIS"; @@ -88,6 +91,15 @@ generate_form_reducer!( } ); +generate_form_reducer!( + state: StreamBufferConfigFormState { form: StreamBufferConfigDto }, + action_name: StreamBufferConfigFormAction, + fields { + Enabled => enabled: bool, + Size => size: usize, + } +); + generate_form_reducer!( state: GeoIpConfigFormState { form: GeoIpConfigDto }, action_name: GeoIpConfigFormAction, @@ -146,6 +158,10 @@ pub fn ReverseProxyConfigView() -> Html { GeoIpConfigFormState { form: GeoIpConfigDto::default(), modified: false } }); + let stream_buffer_state: UseReducerHandle = use_reducer(|| { + StreamBufferConfigFormState { form: StreamBufferConfigDto::default(), modified: false } + }); + { let on_form_change = config_view_ctx.on_form_change.clone(); let reverse_proxy_state = reverse_proxy_state.clone(); @@ -155,6 +171,7 @@ pub fn ReverseProxyConfigView() -> Html { let resource_retry_state = resource_retry_state.clone(); let stream_state = stream_state.clone(); let geoip_state = geoip_state.clone(); + let stream_buffer_state = stream_buffer_state.clone(); use_effect_with( ( @@ -165,13 +182,21 @@ pub fn ReverseProxyConfigView() -> Html { resource_retry_state, stream_state, geoip_state, + stream_buffer_state, ), - move |(rp, disabled_header, cache, rl, resource_retry, stream, geoip)| { + move |(rp, disabled_header, cache, rl, resource_retry, stream, geoip, stream_buffer)| { let mut form = rp.form.clone(); + let mut stream_form = stream.form.clone(); + stream_form.buffer = if stream_buffer.form.is_empty() { + None + } else { + Some(stream_buffer.form.clone()) + }; + form.cache = Some(cache.form.clone()); form.rate_limit = Some(rl.form.clone()); form.resource_retry = Some(resource_retry.form.clone()); - form.stream = Some(stream.form.clone()); + form.stream = Some(stream_form); form.geoip = Some(geoip.form.clone()); form.disabled_header = if disabled_header.form.is_empty() { None @@ -185,7 +210,8 @@ pub fn ReverseProxyConfigView() -> Html { || rl.modified || resource_retry.modified || stream.modified - || geoip.modified; + || geoip.modified + || stream_buffer.modified; on_form_change.emit(ConfigForm::ReverseProxy(modified, form)); }, ); @@ -199,6 +225,7 @@ pub fn ReverseProxyConfigView() -> Html { let resource_retry_state = resource_retry_state.clone(); let stream_state = stream_state.clone(); let geoip_state = geoip_state.clone(); + let stream_buffer_state = stream_buffer_state.clone(); let reverse_proxy_cfg = config_ctx.config.as_ref().and_then(|c| c.config.reverse_proxy.clone()); use_effect_with((reverse_proxy_cfg, config_view_ctx.edit_mode.clone()), move |(cfg, _mode)| { @@ -210,6 +237,7 @@ pub fn ReverseProxyConfigView() -> Html { resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll(rp.resource_retry.as_ref().map_or_else(ResourceRetryConfigDto::default, |rr| rr.clone()))); stream_state.dispatch(StreamConfigFormAction::SetAll(rp.stream.as_ref().map_or_else(StreamConfigDto::default, |s| s.clone()))); geoip_state.dispatch(GeoIpConfigFormAction::SetAll(rp.geoip.as_ref().map_or_else(GeoIpConfigDto::default, |s| s.clone()))); + stream_buffer_state.dispatch(StreamBufferConfigFormAction::SetAll(rp.stream.as_ref().and_then(|s| s.buffer.clone()).unwrap_or_default())); } else { reverse_proxy_state.dispatch(ReverseProxyConfigFormAction::SetAll(ReverseProxyConfigDto::default())); disabled_header_state.dispatch(ReverseProxyDisabledHeaderConfigFormAction::SetAll(ReverseProxyDisabledHeaderConfigDto::default())); @@ -218,6 +246,7 @@ pub fn ReverseProxyConfigView() -> Html { resource_retry_state.dispatch(ResourceRetryConfigFormAction::SetAll(ResourceRetryConfigDto::default())); stream_state.dispatch(StreamConfigFormAction::SetAll(StreamConfigDto::default())); geoip_state.dispatch(GeoIpConfigFormAction::SetAll(GeoIpConfigDto::default())); + stream_buffer_state.dispatch(StreamBufferConfigFormAction::SetAll(StreamBufferConfigDto::default())); } || () }); @@ -246,6 +275,15 @@ pub fn ReverseProxyConfigView() -> Html { } }; + let render_stream_buffer = || { + html! { + +

{translate.t(LABEL_STREAM_BUFFER)}

+ { config_field_bool!(stream_buffer_state.form, translate.t(LABEL_BUFFER_ENABLED), enabled) } + { config_field!(stream_buffer_state.form, translate.t(LABEL_BUFFER_SIZE), size) } +
+ } + }; let render_rate_limit = || { html! { @@ -381,6 +419,15 @@ pub fn ReverseProxyConfigView() -> Html { { edit_field_number_u64!(stream_state, translate.t(LABEL_SHARED_BURST_BUFFER_MB), shared_burst_buffer_mb, StreamConfigFormAction::SharedBurstBufferMb) } }; + let render_stream_buffer_edit = || { + html! { + +

{translate.t(LABEL_STREAM_BUFFER)}

+ { edit_field_bool!(stream_buffer_state, translate.t(LABEL_BUFFER_ENABLED), enabled, StreamBufferConfigFormAction::Enabled) } + { edit_field_number_usize!(stream_buffer_state, translate.t(LABEL_BUFFER_SIZE), size, StreamBufferConfigFormAction::Size) } +
+ } + }; let render_view_mode = || { @@ -393,6 +440,7 @@ pub fn ReverseProxyConfigView() -> Html { { render_resource_retry_view() } { render_rate_limit() } { render_stream() } + { render_stream_buffer() }
} }; @@ -406,6 +454,7 @@ pub fn ReverseProxyConfigView() -> Html { { render_resource_retry_edit() } { render_rate_limit_edit() } { render_stream_edit() } + { render_stream_buffer_edit() } }; diff --git a/frontend/src/app/components/config/schedules_config_view.rs b/frontend/src/app/components/config/schedules_config_view.rs index 1a2d54e7e..4cbca5af0 100644 --- a/frontend/src/app/components/config/schedules_config_view.rs +++ b/frontend/src/app/components/config/schedules_config_view.rs @@ -224,7 +224,7 @@ pub fn SchedulesConfigView() -> Html { options={target_options.clone()} /> }})} - + } }; diff --git a/frontend/src/app/components/text_button.rs b/frontend/src/app/components/text_button.rs index b7d29454f..c56df7d46 100644 --- a/frontend/src/app/components/text_button.rs +++ b/frontend/src/app/components/text_button.rs @@ -1,5 +1,5 @@ use web_sys::MouseEvent; -use yew::{function_component, html, Callback, Html, Properties}; +use yew::{classes, function_component, html, Callback, Html, Properties}; use crate::app::components::AppIcon; #[derive(Properties, Clone, PartialEq, Debug)] @@ -32,7 +32,7 @@ pub fn TextButton(props: &TextButtonProps) -> Html {