diff --git a/CHANGELOG.md b/CHANGELOG.md index 282ed8a22..e2494d6db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog + ## 3.3.0 (2026-01-03) ## ⚠️ Breaking Changes diff --git a/Cargo.lock b/Cargo.lock index 98b8f177e..af47fb8f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.53" +version = "3.2.54" dependencies = [ "anyhow", "base64", @@ -4015,7 +4015,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.53" +version = "3.2.54" dependencies = [ "base64", "bitflags 2.10.0", @@ -4592,7 +4592,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.53" +version = "3.2.54" dependencies = [ "arc-swap", "async-compression", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 02b14ff3c..f012f4aea 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.53" +version = "3.2.54" 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 f20c1e0e9..c432eff4e 100644 --- a/backend/src/api/endpoints/xmltv_api.rs +++ b/backend/src/api/endpoints/xmltv_api.rs @@ -197,7 +197,7 @@ async fn serve_epg_with_rewrites( let (user_start, user_stop) = apply_user_offset(programme.start, programme.stop, epg_processing_options.offset_minutes); elem.push_attribute(("start", format_xmltv_time_utc(user_start).as_str())); elem.push_attribute(("stop", format_xmltv_time_utc(user_stop).as_str())); - elem.push_attribute(("channel", &programme.channel[..])); + elem.push_attribute(("channel", channel.id.as_ref())); continue_on_err!(writer.write_event_async(Event::Start(elem)).await); if let Some(title) = &programme.title { @@ -267,18 +267,18 @@ fn apply_user_offset(start: i64, stop: i64, offset_minutes: i32) -> (i64, i64) { (user_start, user_end) } -fn from_programme(stream_id: &Arc, programme: &EpgProgramme, epg_processing_options: &EpgProcessingOptions) -> ShortEpgDto { +fn from_programme(stream_id: &Arc, epg_id: &Arc, programme: &EpgProgramme, epg_processing_options: &EpgProcessingOptions) -> ShortEpgDto { let (user_start, user_end) = apply_user_offset(programme.start, programme.stop, epg_processing_options.offset_minutes); ShortEpgDto { id: Arc::clone(stream_id), - epg_id: Arc::clone(&programme.channel), + epg_id: Arc::clone(epg_id), title: programme.title.as_ref().map_or_else(String::new, ToString::to_string), lang: String::new(), start: format_xmltv_time(user_start), end: format_xmltv_time(user_end), description: programme.desc.as_ref().map_or_else(String::new, ToString::to_string), - channel_id: Arc::clone(&programme.channel), + channel_id: Arc::clone(epg_id), start_timestamp: user_start.to_string(), stop_timestamp: user_end.to_string(), stream_id: Arc::clone(stream_id), @@ -300,9 +300,9 @@ pub async fn serve_short_epg( let epg_processing_options = get_epg_processing_options(app_state, user, target); ShortEpgResultDto { epg_listings: if limit > 0 { - epg_channel.get_programme_with_limit(limit).iter().map(|p| from_programme(&stream_id, p, &epg_processing_options)).collect() + epg_channel.get_programme_with_limit(limit).iter().map(|p| from_programme(&stream_id, channel_id, p, &epg_processing_options)).collect() } else { - epg_channel.programmes.iter().map(|p| from_programme(&stream_id, p, &epg_processing_options)).collect() + epg_channel.programmes.iter().map(|p| from_programme(&stream_id, channel_id, p, &epg_processing_options)).collect() }, } } else { diff --git a/backend/src/api/model/active_provider_manager.rs b/backend/src/api/model/active_provider_manager.rs index 0c63a510f..c28c7a620 100644 --- a/backend/src/api/model/active_provider_manager.rs +++ b/backend/src/api/model/active_provider_manager.rs @@ -67,7 +67,7 @@ impl ActiveProviderManager { } fn get_config_inputs(cfg: &AppConfig) -> Vec> { - cfg.sources.load().inputs.iter().map(Arc::clone).collect() + cfg.sources.load().inputs.iter().filter(|i| i.enabled).map(Arc::clone).collect() } fn get_grace_options(cfg: &AppConfig) -> (u64, u64) { diff --git a/backend/src/api/model/provider_lineup_manager.rs b/backend/src/api/model/provider_lineup_manager.rs index 7c11d4a83..567cfa7d2 100644 --- a/backend/src/api/model/provider_lineup_manager.rs +++ b/backend/src/api/model/provider_lineup_manager.rs @@ -247,7 +247,7 @@ impl MultiProviderLineup { ) -> Self { let input_connection = get_or_create_provider_connection(provider_connections, &cfg_input.name); let mut inputs = vec![ProviderConfigWrapper::new(ProviderConfig::new(cfg_input, input_connection, Arc::clone(connection_change)))]; - if let Some(aliases) = &cfg_input.aliases { + if let Some(aliases) = cfg_input.get_enabled_aliases() { for alias in aliases { let alias_connection = get_or_create_provider_connection(provider_connections, &alias.name); inputs.push(ProviderConfigWrapper::new(ProviderConfig::new_alias( @@ -540,7 +540,7 @@ impl ProviderLineupManager { event_manager.send_provider_event(name, connections); }); - if cfg_input.aliases.as_ref().is_some_and(|a| !a.is_empty()) { + if cfg_input.has_enabled_aliases() { ProviderLineup::Multi(MultiProviderLineup::new(cfg_input, provider_connections, &on_connection_change)) } else { let connection = get_or_create_provider_connection(provider_connections, &cfg_input.name); @@ -575,6 +575,7 @@ impl ProviderLineupManager { }; if a_alias.max_connections != b_alias.max_connections + || a_alias.enabled != b_alias.enabled || a_alias.priority != b_alias.priority || a_alias.username != b_alias.username || a_alias.password != b_alias.password diff --git a/backend/src/api/panel_api.rs b/backend/src/api/panel_api.rs index ed7352edf..f96f53d89 100644 --- a/backend/src/api/panel_api.rs +++ b/backend/src/api/panel_api.rs @@ -1364,6 +1364,7 @@ async fn patch_source_yml_add_alias( priority: 0, max_connections: 1, exp_date, + enabled: true, }; input.upsert_alias(alias)?; @@ -1632,6 +1633,7 @@ fn apply_sources_yml_patches( priority: 0, max_connections: 1, exp_date: *exp_date, + enabled: true, }; alias.prepare(next_index, &input_type)?; aliases.push(alias); diff --git a/backend/src/model/config/input.rs b/backend/src/model/config/input.rs index 3aa852c63..cf89759ec 100644 --- a/backend/src/model/config/input.rs +++ b/backend/src/model/config/input.rs @@ -1,17 +1,17 @@ use crate::model::{macros, EpgConfig, PanelApiConfig}; +use crate::repository::get_csv_file_path; use chrono::Utc; use log::warn; +use shared::check_input_credentials; use shared::error::TuliproxError; use shared::model::{ConfigInputAliasDto, ConfigInputDto, ConfigInputOptionsDto, InputFetchMethod, InputType, StagedInputDto}; use shared::utils::{get_credentials_from_url, Internable}; use shared::{check_input_connections, info_err_res, write_if_some}; -use shared::check_input_credentials; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; use std::sync::Arc; use url::Url; -use crate::repository::get_csv_file_path; #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] @@ -105,6 +105,7 @@ pub struct ConfigInputAlias { pub priority: i16, pub max_connections: u16, pub exp_date: Option, + pub enabled: bool, } macros::from_impl!(ConfigInputAlias); @@ -119,6 +120,7 @@ impl From<&ConfigInputAliasDto> for ConfigInputAlias { priority: dto.priority, max_connections: dto.max_connections, exp_date: dto.exp_date, + enabled: dto.enabled, } } } @@ -165,6 +167,15 @@ impl ConfigInput { self.enabled = false; } + if let Some(aliases) = &mut self.aliases { + for alias in aliases { + if is_input_expired(alias.exp_date) { + warn!("Account {} expired for provider: {}", alias.username.as_ref().map_or("?", |s| s.as_str()), alias.name); + alias.enabled = false; + } + } + } + if let Some(panel_api) = &mut self.panel_api { panel_api.prepare()?; } @@ -210,15 +221,20 @@ impl ConfigInput { } if !aliases.is_empty() { - let mut first = aliases.remove(0); - self.id = first.id; - self.username = first.username.take(); - self.password = first.password.take(); - self.url = first.url.trim().to_string(); - self.max_connections = first.max_connections; - self.priority = first.priority; - if self.name.is_empty() { - self.name.clone_from(&first.name); + if let Some(index) = aliases.iter().position(|alias| alias.enabled) { + let mut first = aliases.remove(index); + self.id = first.id; + self.username = first.username.take(); + self.password = first.password.take(); + self.url = first.url.trim().to_string(); + self.max_connections = first.max_connections; + self.priority = first.priority; + self.enabled = first.enabled; + if self.name.is_empty() { + self.name.clone_from(&first.name); + } + } else { + self.enabled = false; } } } @@ -254,6 +270,23 @@ impl ConfigInput { cache_duration_seconds: self.cache_duration_seconds, } } + + pub fn has_enabled_aliases(&self) -> bool { + self.aliases + .as_ref() + .is_some_and(|aliases| aliases.iter().any(|a| a.enabled)) + } + + pub fn get_enabled_aliases(&self) -> Option> { + self.aliases.as_ref().map_or(None, |aliases| { + let result: Vec<_> = aliases.iter().filter(|alias| alias.enabled).collect(); + if result.is_empty() { + None + } else { + Some(result) + } + }) + } } macros::from_impl!(ConfigInput); diff --git a/backend/src/model/xmltv.rs b/backend/src/model/xmltv.rs index 00ce3cd12..8fa43f606 100644 --- a/backend/src/model/xmltv.rs +++ b/backend/src/model/xmltv.rs @@ -194,7 +194,7 @@ fn filter_channels_and_programmes( ) { let mut prog_map: HashMap, Vec> = HashMap::new(); for prog in programmes.drain(..) { - prog_map.entry(prog.channel.clone()).or_default().push(prog); + prog_map.entry(prog.get_transient_channel_id().clone()).or_default().push(prog); } for channel in channels.iter_mut() { diff --git a/backend/src/repository/alias_repository.rs b/backend/src/repository/alias_repository.rs index 1574729ea..06bac3fbf 100644 --- a/backend/src/repository/alias_repository.rs +++ b/backend/src/repository/alias_repository.rs @@ -23,6 +23,7 @@ const FIELD_NAME: &str = "name"; const FIELD_USERNAME: &str = "username"; const FIELD_PASSWORD: &str = "password"; const FIELD_EXP_DATE: &str = "exp_date"; +const FIELD_ENABLED: &str = "enabled"; const FIELD_UNKNOWN: &str = "?"; const DEFAULT_COLUMNS: &[&str] = &[ FIELD_URL, @@ -32,6 +33,7 @@ const DEFAULT_COLUMNS: &[&str] = &[ FIELD_USERNAME, FIELD_PASSWORD, FIELD_EXP_DATE, + FIELD_ENABLED ]; const CSV_EXTENSION: &str = ".csv"; @@ -108,6 +110,16 @@ fn csv_assign_mandatory_fields(alias: &mut ConfigInputAliasDto, input_type: Inpu } } +fn str_to_bool(val: &str) -> bool { + if val.is_empty() || val == "1" { + return true; + } + if val == "0" || val.eq_ignore_ascii_case("f") || val.eq_ignore_ascii_case("false") { + return false; + } + true +} + fn csv_assign_config_input_column( config_input: &mut ConfigInputAliasDto, header: &str, @@ -143,6 +155,9 @@ fn csv_assign_config_input_column( None }); } + FIELD_ENABLED => { + config_input.enabled = str_to_bool(value); + } _ => {} } } @@ -180,6 +195,7 @@ pub fn csv_read_inputs_from_reader( FIELD_USERNAME => FIELD_USERNAME, FIELD_PASSWORD => FIELD_PASSWORD, FIELD_EXP_DATE => FIELD_EXP_DATE, + FIELD_ENABLED => FIELD_ENABLED, _ => { error!("Field {s} is unsupported for csv input"); FIELD_UNKNOWN @@ -199,6 +215,7 @@ pub fn csv_read_inputs_from_reader( priority: 0, max_connections: 1, exp_date: None, + enabled: true, }; let columns: Vec<&str> = line.split(CSV_SEPARATOR).collect(); @@ -276,6 +293,8 @@ async fn csv_write_input_to_path( content.push(CSV_SEPARATOR); content.push_str(FIELD_URL); content.push(CSV_SEPARATOR); + content.push_str(FIELD_ENABLED); + content.push(CSV_SEPARATOR); content.push_str(FIELD_MAX_CON); content.push(CSV_SEPARATOR); content.push_str(FIELD_PRIO); @@ -292,6 +311,8 @@ async fn csv_write_input_to_path( content.push(CSV_SEPARATOR); content.push_str(&alias.url); content.push(CSV_SEPARATOR); + content.push_str(if alias.enabled { "1" } else {"0"} ); + content.push(CSV_SEPARATOR); content.push_str(&alias.max_connections.to_string()); content.push(CSV_SEPARATOR); content.push_str(&alias.priority.to_string()); @@ -352,6 +373,7 @@ pub async fn csv_patch_batch_append( priority: 0, max_connections: 1, exp_date, + enabled: true, }; aliases.push(alias); diff --git a/backend/src/repository/epg_repository.rs b/backend/src/repository/epg_repository.rs index 82912e83d..497f769a5 100644 --- a/backend/src/repository/epg_repository.rs +++ b/backend/src/repository/epg_repository.rs @@ -107,11 +107,7 @@ pub fn epg_write_file(target: &ConfigTarget, epg: &Epg, path: &Path, playlist: O desc.clone_from(&child.value); } } - channel.programmes.push(EpgProgramme { - start: start_time, - stop: stop_time, - channel: Arc::clone(channel_id), title, desc - }); + channel.programmes.push(EpgProgramme::new_all(start_time, stop_time, Arc::clone(channel_id), title, desc)); } } } diff --git a/backend/src/utils/file/config_reader.rs b/backend/src/utils/file/config_reader.rs index ee69111a6..51ca4249e 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 diff --git a/backend/src/utils/network/request.rs b/backend/src/utils/network/request.rs index 568de83e8..ee3f0a0af 100644 --- a/backend/src/utils/network/request.rs +++ b/backend/src/utils/network/request.rs @@ -1103,6 +1103,12 @@ pub fn is_uri(url: &str) -> bool { .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")) } +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) +} + #[cfg(test)] mod tests { use shared::utils::{get_base_url_from_str, replace_url_extension, sanitize_sensitive_info}; diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 3881a1ab2..e6a0f954e 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.53" +version = "3.2.54" edition = "2021" rust-version = "1.89.0" [dependencies] -shared = { version = "3.2.53", path = "../shared" } +shared = { version = "3.2.54", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/scss/app/components/source_editor/_source_editor_form.scss b/frontend/scss/app/components/source_editor/_source_editor_form.scss index 30c49a544..66fb16098 100644 --- a/frontend/scss/app/components/source_editor/_source_editor_form.scss +++ b/frontend/scss/app/components/source_editor/_source_editor_form.scss @@ -62,6 +62,12 @@ $form-width: 480px; display: flex; flex-flow: row nowrap; } + + &-content{ + .inactive { + text-decoration: line-through; + } + } } .tp__form-list__toolbar { diff --git a/frontend/src/app/components/playlist/input_table.rs b/frontend/src/app/components/playlist/input_table.rs index 883422cf2..ddf935975 100644 --- a/frontend/src/app/components/playlist/input_table.rs +++ b/frontend/src/app/components/playlist/input_table.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use yew::platform::spawn_local; use yew::prelude::*; use yew_i18n::use_translation; +use shared::concat_string; use shared::error::{info_err_res, TuliproxError}; use shared::model::{ConfigInputAliasDto, ConfigInputDto, SortOrder}; use crate::app::components::menu_item::MenuItem; @@ -149,7 +150,7 @@ pub fn InputTable(props: &InputTableProps) -> Html { _ => html! {""}, } }, - InputRow::Alias(alias, dto) => { + InputRow::Alias(alias, _dto) => { match col { 0 => { let popup_onclick = popup_onclick.clone(); @@ -162,8 +163,9 @@ pub fn InputTable(props: &InputTableProps) -> Html { } } 1 => html! { - + }, 2 => html! { alias.name.as_ref() }, 4 => html! { alias.url.as_str() }, diff --git a/frontend/src/app/components/source_editor/alias_item_form.rs b/frontend/src/app/components/source_editor/alias_item_form.rs index 286cf0e73..40a758f73 100644 --- a/frontend/src/app/components/source_editor/alias_item_form.rs +++ b/frontend/src/app/components/source_editor/alias_item_form.rs @@ -1,6 +1,6 @@ use std::sync::Arc; use crate::app::components::{Card, TextButton}; -use crate::{edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; +use crate::{edit_field_bool, edit_field_date, edit_field_number_i16, edit_field_number_u16, edit_field_text, edit_field_text_option, generate_form_reducer}; use shared::model::ConfigInputAliasDto; use yew::{function_component, html, use_reducer, Callback, Html, Properties, UseReducerHandle}; use yew_i18n::use_translation; @@ -13,11 +13,13 @@ const LABEL_PASSWORD: &str = "LABEL.PASSWORD"; const LABEL_PRIORITY: &str = "LABEL.PRIORITY"; const LABEL_MAX_CONNECTIONS: &str = "LABEL.MAX_CONNECTIONS"; const LABEL_EXP_DATE: &str = "LABEL.EXP_DATE"; +const LABEL_ENABLED: &str = "LABEL.ENABLED"; generate_form_reducer!( state: AliasFormState { form: ConfigInputAliasDto }, action_name: AliasFormAction, fields { + Enabled => enabled: bool, Name => name: Arc, Url => url: String, Username => username: Option, @@ -51,6 +53,7 @@ pub fn AliasItemForm(props: &AliasItemFormProps) -> Html { priority: 0, max_connections: 1, exp_date: None, + enabled: true, }), modified: false, } @@ -76,6 +79,7 @@ pub fn AliasItemForm(props: &AliasItemFormProps) -> Html { html! { + { edit_field_bool!(form_state, translate.t(LABEL_ENABLED), enabled, AliasFormAction::Enabled) } { edit_field_text!(form_state, translate.t(LABEL_ALIAS_NAME), name, AliasFormAction::Name) } { edit_field_text!(form_state, translate.t(LABEL_URL), url, AliasFormAction::Url) }
diff --git a/frontend/src/app/components/source_editor/input_form.rs b/frontend/src/app/components/source_editor/input_form.rs index eba0b6c29..01f439eb7 100644 --- a/frontend/src/app/components/source_editor/input_form.rs +++ b/frontend/src/app/components/source_editor/input_form.rs @@ -510,7 +510,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html { onclick={handle_edit_alias_list_item.clone()}/>
- {&alias.name}{" - "}{&alias.url} + {&alias.name}{" - "}{&alias.url}
} diff --git a/shared/Cargo.toml b/shared/Cargo.toml index 7aa6f62b1..edce6dedc 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "3.2.53" +version = "3.2.54" edition = "2021" rust-version = "1.89.0" diff --git a/shared/src/model/config/input.rs b/shared/src/model/config/input.rs index 9eb93a16d..abacabf9c 100644 --- a/shared/src/model/config/input.rs +++ b/shared/src/model/config/input.rs @@ -254,12 +254,10 @@ pub struct ConfigInputAliasDto { pub priority: i16, #[serde(default)] pub max_connections: u16, - #[serde( - default, - deserialize_with = "deserialize_timestamp", - skip_serializing_if = "Option::is_none" - )] + #[serde(default, deserialize_with = "deserialize_timestamp", skip_serializing_if = "Option::is_none")] pub exp_date: Option, + #[serde(default = "default_as_true", skip_serializing_if = "is_true")] + pub enabled: bool, } diff --git a/shared/src/model/epg.rs b/shared/src/model/epg.rs index b3cd4f95b..2b75fa666 100644 --- a/shared/src/model/epg.rs +++ b/shared/src/model/epg.rs @@ -90,7 +90,14 @@ pub struct EpgProgramme { pub title: Option>, pub desc: Option>, #[serde(skip)] - pub channel: Arc, + channel: Arc, +} + +impl EpgProgramme { + // the channel_id is only available when read from xml file, reading from db do not return any epg_id + pub fn get_transient_channel_id(&self) -> &Arc { + &self.channel + } } impl EpgProgramme { @@ -103,4 +110,13 @@ impl EpgProgramme { desc: None, } } + pub fn new_all(start: i64, stop: i64, channel: Arc, title: Option>, desc: Option>) -> Self { + Self { + start, + stop, + channel, + title, + desc, + } + } }