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! {
- //
+
{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