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.
This commit is contained in:
euzu
2026-01-26 17:37:01 +01:00
committed by GitHub
parent 62f7611125
commit 9a6b7e574b
21 changed files with 132 additions and 45 deletions
+1
View File
@@ -1,5 +1,6 @@
# Changelog
## 3.3.0 (2026-01-03)
## ⚠️ Breaking Changes
Generated
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tuliprox"
version = "3.2.53"
version = "3.2.54"
edition = "2021"
rust-version = "1.89.0"
+6 -6
View File
@@ -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<str>, programme: &EpgProgramme, epg_processing_options: &EpgProcessingOptions) -> ShortEpgDto {
fn from_programme(stream_id: &Arc<str>, epg_id: &Arc<str>, 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 {
@@ -67,7 +67,7 @@ impl ActiveProviderManager {
}
fn get_config_inputs(cfg: &AppConfig) -> Vec<Arc<ConfigInput>> {
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) {
@@ -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
+2
View File
@@ -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);
+44 -11
View File
@@ -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<i64>,
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<Vec<&ConfigInputAlias>> {
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);
+1 -1
View File
@@ -194,7 +194,7 @@ fn filter_channels_and_programmes(
) {
let mut prog_map: HashMap<Arc<str>, Vec<EpgProgramme>> = 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() {
@@ -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);
+1 -5
View File
@@ -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));
}
}
}
+1 -1
View File
@@ -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
+6
View File
@@ -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};
+2 -2
View File
@@ -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"
@@ -62,6 +62,12 @@ $form-width: 480px;
display: flex;
flex-flow: row nowrap;
}
&-content{
.inactive {
text-decoration: line-through;
}
}
}
.tp__form-list__toolbar {
@@ -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! {
<Chip class={ format!("{} tp__input-table__alias", convert_bool_to_chip_style(dto.enabled).map_or_else(String::new, |s| if s == "active" { "alias".to_string() } else {s} )) }
label={translator.t("LABEL.ALIAS")} />
<Chip class={format!("{} tp__input-table__alias", convert_bool_to_chip_style(alias.enabled).map_or("alias", |s| if s == "active" { "alias" } else {"inactive"} )) }
label={if alias.enabled {translator.t("LABEL.ALIAS")} else { translator.t("LABEL.DISABLED")} }
/>
},
2 => html! { alias.name.as_ref() },
4 => html! { alias.url.as_str() },
@@ -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<str>,
Url => url: String,
Username => username: Option<String>,
@@ -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! {
<Card class="tp__config-view__card tp__item-form">
{ 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) }
<div class="tp__config-view__cols-2">
@@ -510,7 +510,7 @@ pub fn ConfigInputView(props: &ConfigInputViewProps) -> Html {
onclick={handle_edit_alias_list_item.clone()}/>
</div>
<div class="tp__form-list__item-content">
<span><strong>{&alias.name}</strong>{" - "}{&alias.url}</span>
<span class={if alias.enabled {""} else {"inactive"}}><strong>{&alias.name}</strong>{" - "}{&alias.url}</span>
</div>
</div>
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shared"
version = "3.2.53"
version = "3.2.54"
edition = "2021"
rust-version = "1.89.0"
+3 -5
View File
@@ -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<i64>,
#[serde(default = "default_as_true", skip_serializing_if = "is_true")]
pub enabled: bool,
}
+17 -1
View File
@@ -90,7 +90,14 @@ pub struct EpgProgramme {
pub title: Option<Arc<str>>,
pub desc: Option<Arc<str>>,
#[serde(skip)]
pub channel: Arc<str>,
channel: Arc<str>,
}
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<str> {
&self.channel
}
}
impl EpgProgramme {
@@ -103,4 +110,13 @@ impl EpgProgramme {
desc: None,
}
}
pub fn new_all(start: i64, stop: i64, channel: Arc<str>, title: Option<Arc<str>>, desc: Option<Arc<str>>) -> Self {
Self {
start,
stop,
channel,
title,
desc,
}
}
}