From 9a6b7e574bc4110a22e9d89d2e705ca6fbded296 Mon Sep 17 00:00:00 2001 From: euzu <33094714+euzu@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:37:01 +0100 Subject: [PATCH] epg fix (#537) New Features Aliases can now be individually enabled or disabled to manage active sources. Bug Fixes Improved channel identification accuracy in XMLTV outputs. Style Disabled aliases display with strikethrough text for better visibility. --- CHANGELOG.md | 1 + Cargo.lock | 6 +- backend/Cargo.toml | 2 +- backend/src/api/endpoints/xmltv_api.rs | 12 ++-- .../src/api/model/active_provider_manager.rs | 2 +- .../src/api/model/provider_lineup_manager.rs | 5 +- backend/src/api/panel_api.rs | 2 + backend/src/model/config/input.rs | 55 +++++++++++++++---- backend/src/model/xmltv.rs | 2 +- backend/src/repository/alias_repository.rs | 22 ++++++++ backend/src/repository/epg_repository.rs | 6 +- backend/src/utils/file/config_reader.rs | 2 +- backend/src/utils/network/request.rs | 6 ++ frontend/Cargo.toml | 4 +- .../source_editor/_source_editor_form.scss | 6 ++ .../app/components/playlist/input_table.rs | 8 ++- .../source_editor/alias_item_form.rs | 6 +- .../components/source_editor/input_form.rs | 2 +- shared/Cargo.toml | 2 +- shared/src/model/config/input.rs | 8 +-- shared/src/model/epg.rs | 18 +++++- 21 files changed, 132 insertions(+), 45 deletions(-) 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, + } + } }