diff --git a/frontend/scss/_theme.scss b/frontend/scss/_theme.scss index c70fed133..c88208246 100644 --- a/frontend/scss/_theme.scss +++ b/frontend/scss/_theme.scss @@ -120,6 +120,10 @@ --text-button-active-hover-color: #ffffff; --text-button-active-border-color: rgba(74, 222, 128, 0.82); + --text-button-disabled-border-color: #717171; + --text-button-disabled-background-color: #494949; + --text-button-disabled-color: #9b9b9b; + --toggle-switch-on-background-color: rgba(34, 197, 94, 0.72); --toggle-switch-off-background-color: rgba(51, 65, 85, 0.9); --toggle-switch-toggle-color: #ffffff; @@ -474,6 +478,10 @@ body[data-theme="bright"] { --text-button-active-hover-color: #15803d; --text-button-active-border-color: #86efac; + --text-button-disabled-border-color: #434343; + --text-button-disabled-background-color: #717171; + --text-button-disabled-color: #9b9b9b; + /* Form Elements */ --toggle-switch-on-background-color: #22c55e; --toggle-switch-off-background-color: #e2e8f0; diff --git a/frontend/scss/app/components/_text_button.scss b/frontend/scss/app/components/_text_button.scss index 8b57cf597..1de5ada44 100644 --- a/frontend/scss/app/components/_text_button.scss +++ b/frontend/scss/app/components/_text_button.scss @@ -46,6 +46,7 @@ outline: none; } } + .tp__text-button.secondary { background-color: var(--text-button-secondary-background-color); border-color: var(--text-button-secondary-border-color); @@ -59,6 +60,7 @@ outline: none; } } + .tp__text-button.active { background-color: var(--text-button-active-background-color); border-color: var(--text-button-active-border-color); @@ -71,4 +73,13 @@ fill: var(--text-button-active-hover-color); outline: none; } -} \ No newline at end of file +} + +.tp__text-button.primary.disabled, +.tp__text-button.secondary.disabled, +.tp__text-button.disabled { + border-color: var(--text-button-disabled-border-color); + background-color: var(--text-button-disabled-background-color); + color: var(--text-button-disabled-color); + fill: var(--text-button-disabled-color); +} diff --git a/frontend/scss/app/components/playlist/_playlist_explorer.scss b/frontend/scss/app/components/playlist/_playlist_explorer.scss index 1637af9c4..4e35b4973 100644 --- a/frontend/scss/app/components/playlist/_playlist_explorer.scss +++ b/frontend/scss/app/components/playlist/_playlist_explorer.scss @@ -40,7 +40,7 @@ $series-backdrop-mobile-height: 150px; flex-flow: column; gap: var(--gap-default); box-sizing: border-box; - overflow: auto; + overflow: hidden; background-color: var(--card-background-color); color: var(--card-color); border: 1px solid var(--border-color); @@ -94,6 +94,7 @@ $series-backdrop-mobile-height: 150px; flex-flow: column; gap: var(--gap-default); box-sizing: border-box; + overflow: auto; } &__group, diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index 9c8d3bcf0..8dd1438cc 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -9,6 +9,7 @@ use crate::{ i18n::use_translation, }; use shared::{ + concat_string, error::TuliproxError, info_err_res, model::{ @@ -779,31 +780,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { let render_edit_mode = || { html! {
- //
- // { - // for [ - // InputFormPage::Main, - // InputFormPage::Options, - // InputFormPage::Staged, - // InputFormPage::Advanced - // ].iter().map(|page| { - // let active = &*view_visible == page; - // let on_tab_click = { - // let on_tab_click = on_tab_click.clone(); - // let page = *page; - // Callback::from(move |_| on_tab_click.emit(page)) - // }; - // html! { - // - // } - // }) - // } - //
+
{render_input()} @@ -849,14 +826,16 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { } }; + let button_disabled = *show_alias_form_state || *show_epg_form_state; + html! {
- - diff --git a/frontend/src/app/components/source_editor/output_xtream_form.rs b/frontend/src/app/components/source_editor/output_xtream_form.rs index c15e1ed01..b5177c336 100644 --- a/frontend/src/app/components/source_editor/output_xtream_form.rs +++ b/frontend/src/app/components/source_editor/output_xtream_form.rs @@ -7,6 +7,7 @@ use crate::{ i18n::use_translation, }; use shared::{ + concat_string, error::TuliproxError, info_err_res, model::{ @@ -373,14 +374,16 @@ pub fn XtreamTargetOutputView(props: &XtreamTargetOutputViewProps) -> Html { }) }; + let button_disabled = *show_trakt_list_form_state; + html! {
- - diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 3bb4550f8..1e23d12fa 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -9,7 +9,7 @@ use crate::{ default_resolve_background, default_resolve_delay_secs, default_xtream_live_stream_use_prefix, deserialize_timestamp, get_credentials_from_url_str, get_trimmed_string, is_blank_optional_string, is_default_probe_delay_secs, is_default_probe_live_interval, is_default_resolve_delay_secs, is_false, is_true, - is_zero_u16, parse_duration_seconds, parse_provider_scheme_url_parts, sanitize_sensitive_info, + is_zero_i16, is_zero_u16, parse_duration_seconds, parse_provider_scheme_url_parts, sanitize_sensitive_info, serialize_option_vec_flow_map_items, trim_last_slash, Internable, PROVIDER_SCHEME_PREFIX, }, }; @@ -132,6 +132,8 @@ pub enum InputFetchMethod { impl InputFetchMethod { const GET_METHOD: &'static str = "GET"; const POST_METHOD: &'static str = "POST"; + + pub fn is_default(value: &InputFetchMethod) -> bool { matches!(value, Self::GET) } } impl Display for InputFetchMethod { @@ -311,7 +313,7 @@ pub struct ConfigInputAliasDto { pub username: Option, #[serde(default, skip_serializing_if = "is_blank_optional_string")] pub password: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_zero_i16")] pub priority: i16, #[serde(default)] pub max_connections: u16, @@ -370,11 +372,11 @@ pub struct ConfigInputDto { pub cache_duration_seconds: u64, #[serde(default, skip_serializing_if = "Option::is_none", serialize_with = "serialize_option_vec_flow_map_items")] pub aliases: Option>, - #[serde(default)] + #[serde(default, skip_serializing_if = "is_zero_i16")] pub priority: i16, #[serde(default)] pub max_connections: u16, - #[serde(default)] + #[serde(default, skip_serializing_if = "InputFetchMethod::is_default")] pub method: InputFetchMethod, #[serde(default, skip_serializing_if = "Option::is_none")] pub staged: Option, diff --git a/shared/src/utils/default_utils.rs b/shared/src/utils/default_utils.rs index 269d98517..07738b291 100644 --- a/shared/src/utils/default_utils.rs +++ b/shared/src/utils/default_utils.rs @@ -2,6 +2,7 @@ use crate::model::{ConfigTargetOptions, LibraryMetadataFormat, ProcessingOrder}; use std::sync::Arc; pub const fn is_zero_u16(v: &u16) -> bool { *v == 0 } +pub const fn is_zero_i16(v: &i16) -> bool { *v == 0 } pub const fn is_true(v: &bool) -> bool { *v } pub const fn is_false(v: &bool) -> bool { !*v } pub const fn default_as_true() -> bool { true } diff --git a/shared/src/utils/string_interner.rs b/shared/src/utils/string_interner.rs index 17b90897e..6e5308df1 100644 --- a/shared/src/utils/string_interner.rs +++ b/shared/src/utils/string_interner.rs @@ -4,14 +4,33 @@ //! strings like `input_name` and `group` in playlist items. use crate::model::UUIDType; -use serde::{Deserialize, Deserializer, Serializer}; +use serde::{ + de::{IgnoredAny, MapAccess, SeqAccess, Visitor}, + Deserialize, Deserializer, Serializer, +}; use std::{ borrow::Cow, collections::HashSet, + fmt, sync::{Arc, LazyLock, RwLock}, }; -// Global interner store +/// Global interning pool. +/// +/// ## Performance (millions of entries) +/// +/// * **Happy-path (already-interned):** one `RwLock::read()` + hash lookup + +/// `Arc::clone` (single atomic increment). Multiple threads can read +/// concurrently without blocking each other. +/// * **First-time intern:** upgrades to a write lock, double-checks, and +/// inserts. This only happens *once per unique string value*, so the write +/// path is not on the hot parse loop. +/// * **`deserialize_string` vs `deserialize_any`:** the former is *faster* +/// because saphyr skips bool / int / float parsing attempts and hands the +/// raw scalar text directly to the visitor. +/// * **Pruning:** call `interner_gc()` periodically (e.g. after a full +/// playlist reload) to release strings that are only referenced by the +/// pool itself. static INTERNER: LazyLock>>> = LazyLock::new(|| RwLock::new(HashSet::new())); pub trait Internable { @@ -61,21 +80,16 @@ impl Internable for i64 { /// Interns a string slice. fn intern_str(s: &str) -> Arc { - // Try read first if let Ok(guard) = INTERNER.read() { if let Some(existing) = guard.get(s) { return Arc::clone(existing); } drop(guard); } - - // Write lock if let Ok(mut guard) = INTERNER.write() { - // Double check if let Some(existing) = guard.get(s) { return Arc::clone(existing); } - let arc: Arc = Arc::from(s); guard.insert(Arc::clone(&arc)); return arc; @@ -85,21 +99,16 @@ fn intern_str(s: &str) -> Arc { /// Interns an owned string. fn intern_string(s: String) -> Arc { - // Try read first if let Ok(guard) = INTERNER.read() { if let Some(existing) = guard.get(s.as_str()) { return Arc::clone(existing); } drop(guard); } - - // Write lock if let Ok(mut guard) = INTERNER.write() { - // Double check if let Some(existing) = guard.get(s.as_str()) { return Arc::clone(existing); } - let arc: Arc = Arc::from(s); guard.insert(Arc::clone(&arc)); return arc; @@ -111,7 +120,6 @@ fn intern_string(s: String) -> Arc { pub fn interner_gc() -> usize { if let Ok(mut guard) = INTERNER.write() { let before = guard.len(); - // Arc::strong_count == 1 means the cache is the only one holding it. guard.retain(|s| Arc::strong_count(s) > 1); let removed = before - guard.len(); if removed > 0 { @@ -122,6 +130,92 @@ pub fn interner_gc() -> usize { 0 } +/// Convert an `f64` that reached `visit_f64` into a round-trip-safe string +/// using the canonical YAML 1.2 spelling (`.inf`, `-.inf`, `.nan`). +/// +/// `serde_saphyr` recognises these spellings as ambiguous and **quotes** them +/// when re-serializing, so the value survives a YAML round-trip as a string. +/// +/// This is a safety-net: the primary fix is using `deserialize_string` (which +/// skips float parsing entirely), so `visit_f64` is normally not reached for +/// plain string fields. +#[inline] +fn f64_to_str(v: f64) -> String { + if v.is_infinite() { + if v.is_sign_positive() { + ".inf".to_owned() + } else { + "-.inf".to_owned() + } + } else if v.is_nan() { + ".nan".to_owned() + } else { + v.to_string() + } +} + +// +// Two reusable visitor types live here so that multiple public entry-points +// can share them without code duplication: +// +// ArcStrVisitor -> Arc (null/empty -> "") +// OptionArcStrVisitor -> Option> (null/empty -> None) +// +// Both visitors use `deserialize_string` inside `visit_some`, which tells +// saphyr to return the **raw scalar text** without float-parsing. This is +// what makes `name: infinity` survive as the literal string `"infinity"`. + +/// Visitor that produces `Arc`, mapping null / empty -> `""`. +struct ArcStrVisitor; + +impl<'de> Visitor<'de> for ArcStrVisitor { + type Value = Arc; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a string, number, boolean, or null") } + + fn visit_str(self, v: &str) -> Result { Ok(v.intern()) } + fn visit_string(self, v: String) -> Result { Ok(v.intern()) } + fn visit_bool(self, v: bool) -> Result { Ok(v.to_string().intern()) } + fn visit_i64(self, v: i64) -> Result { Ok(v.to_string().intern()) } + fn visit_u64(self, v: u64) -> Result { Ok(v.to_string().intern()) } + fn visit_f64(self, v: f64) -> Result { Ok(f64_to_str(v).intern()) } + fn visit_unit(self) -> Result { Ok("".intern()) } + fn visit_none(self) -> Result { Ok("".intern()) } + fn visit_some>(self, d: D) -> Result { + // `deserialize_string` returns the raw text -> `infinity` stays `infinity`. + d.deserialize_string(self) + } +} + +/// Visitor that produces `Option>`, mapping null / empty -> `None`. +struct OptionArcStrVisitor; + +impl<'de> Visitor<'de> for OptionArcStrVisitor { + type Value = Option>; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a string, number, boolean, null, or empty") + } + + fn visit_str(self, v: &str) -> Result { Ok(Some(v.intern())) } + fn visit_string(self, v: String) -> Result { Ok(Some(v.intern())) } + fn visit_bool(self, v: bool) -> Result { Ok(Some(v.to_string().intern())) } + fn visit_i64(self, v: i64) -> Result { Ok(Some(v.to_string().intern())) } + fn visit_u64(self, v: u64) -> Result { Ok(Some(v.to_string().intern())) } + fn visit_f64(self, v: f64) -> Result { Ok(Some(f64_to_str(v).intern())) } + fn visit_unit(self) -> Result { Ok(None) } + fn visit_none(self) -> Result { Ok(None) } + fn visit_some>(self, d: D) -> Result { d.deserialize_string(self) } + fn visit_seq>(self, mut seq: A) -> Result { + while seq.next_element::()?.is_some() {} + Ok(None) + } + fn visit_map>(self, mut map: A) -> Result { + while map.next_entry::()?.is_some() {} + Ok(None) + } +} + pub mod arc_str_vec_serde { use super::*; use serde::ser::SerializeSeq; @@ -148,7 +242,7 @@ pub mod arc_str_vec_serde { pub mod arc_str_serde { use super::*; - use serde_json::Value; + pub fn serialize(value: &Arc, serializer: S) -> Result where S: Serializer, @@ -156,24 +250,23 @@ pub mod arc_str_serde { serializer.serialize_str(value) } + /// Deserialize a YAML scalar as an interned `Arc`. + /// + /// Uses `deserialize_string` so saphyr hands us the **raw text** without + /// first running it through float/bool/int parsing. This preserves values + /// like `infinity` as the literal string `"infinity"` instead of silently + /// converting them to `".inf"`. pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { - match Value::deserialize(deserializer)? { - Value::Null => Ok("".intern()), - Value::Bool(b) => Ok(b.to_string().intern()), - Value::Number(n) => Ok(n.to_string().intern()), - Value::String(v) => Ok(v.intern()), - Value::Array(_) => Ok("".intern()), - Value::Object(_) => Ok("".intern()), - } + deserializer.deserialize_string(ArcStrVisitor) } } pub mod arc_str_option_serde { use super::*; - use serde_json::Value; + pub fn serialize(value: &Option>, serializer: S) -> Result where S: Serializer, @@ -188,16 +281,7 @@ pub mod arc_str_option_serde { where D: Deserializer<'de>, { - let opt = Option::::deserialize(deserializer)?; - match opt { - Some(value) => match value { - Value::Bool(b) => Ok(Some(b.to_string().intern())), - Value::Number(n) => Ok(Some(n.to_string().intern())), - Value::String(v) => Ok(Some(v.intern())), - Value::Null | Value::Array(_) | Value::Object(_) => Ok(None), - }, - None => Ok(None), - } + deserializer.deserialize_option(OptionArcStrVisitor) } pub fn serialize_null_if_empty(value: &Option>, serializer: S) -> Result @@ -212,24 +296,23 @@ pub mod arc_str_option_serde { } } +// +// Reuses `ArcStrVisitor` via `deserialize_option`: +// - null / ~ / empty -> visit_none / visit_unit -> "" +// - any other scalar -> visit_some -> deserialize_string -> visit_str/visit_string -> interned text + +pub use arc_str_default_on_null as arc_str_none_default_on_null; + pub fn arc_str_default_on_null<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { - let opt = Option::::deserialize(deserializer)?; - Ok(opt.unwrap_or_default().intern()) + deserializer.deserialize_option(ArcStrVisitor) } -pub use arc_str_default_on_null as arc_str_none_default_on_null; - pub fn deserialize_as_option_arc_str<'de, D>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, { - let value: serde_json::Value = Deserialize::deserialize(deserializer)?; - match value { - serde_json::Value::String(s) => Ok(Some(s.intern())), - serde_json::Value::Number(s) => Ok(Some(s.to_string().intern())), - _ => Ok(None), - } + deserializer.deserialize_option(OptionArcStrVisitor) }