diff --git a/CHANGELOG.md b/CHANGELOG.md index b50266ef2..889580466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ If you use TimeZone the timeshift will change on Summer/Winter time if its appli - Search in Playlist Explorer now returns groups instead of matching flat channel list. - Added `use_memory_cache` attribute to target definition to hold playlist in memory to reduce disc access. Placing playlist into memory causes more RAM usage but reduces disk access. +- Added optional `filter` attribute to Output (except HDHomerun-Output). +Output filters are applied after all transformations have been performed, therefore, all filter contents must refer to the final state of the playlist. + # 3.1.7 (2025-10-10) - Added Dark/Bright theme switch diff --git a/README.md b/README.md index 277aa88c4..06fc0a70b 100644 --- a/README.md +++ b/README.md @@ -771,13 +771,15 @@ Has three top level attributes - `channels` #### `groups` -has one top level attribute `order` which can be set to `asc`or `desc`. +Used for sorting at the group (category) level. +It has one top-level attribute `order` which can be set to `asc`or `desc`. #### `channels` -is a list of sort configurations for groups. Each configuration has 3 top level entries. -- `field` can be `group`, `title`, `name`, `caption` or `url`. -- `group_pattern` is a regular expression like `'^TR.:\s?(.*)'` which is matched against group title. -- `order` can be `asc` or `desc` -- `sequence` _optional_ is a list of regexp matching field values (based on `field`) which are used to sort based on index. The `order` is ignored for this entries. +Used for sorting the channels within a group/category. +This is a list of sort configurations for groups. Each configuration has the following top-level entries: +- `field` - can be `title`, `name`, `caption` or `url`. +- `group_pattern` - a regular expression like `'^TR.:\s?(.*)'` matched against group title. +- `order` - can be `asc` or `desc` +- `sequence` _optional_ - a list of regexp matching field values (based on `field`). These are used to sort based on index. The `order` is ignored for this entries. The pattern should be selected taking into account the processing sequence. @@ -809,7 +811,8 @@ The numeric suffix indicates the priority: `c1` is evaluated first, followed by ### 2.2.2.2 `output` Is a list of output format: -Each format has different properties +Each format has different properties. +`Attention:` Output filters are applied after all transformations have been performed, therefore, all filter contents must refer to the final state of the playlist. #### 'Target types': `xtream` @@ -822,12 +825,14 @@ Each format has different properties - resolve_vod: true|false, - resolve_vod_delay: true|false, - trakt: Trakt Configuration +- filter: optional filter `m3u` - type: m3u - filename: _optional_ - include_type_in_url: _optional_, true|false, default false - mask_redirect_url: _optional_, true|false, default false +- filter: optional filter `strm` - directory: _mandatory_, @@ -837,6 +842,7 @@ Each format has different properties - style: _mandatory_, kodi|plex|emby|jellyfin - flat: _optional_, true|false, default false - strm_props: _optional_, list of strings, +- filter: optional filter `hdhomerun` - device: _mandatory_, diff --git a/backend/src/api/model/event_manager.rs b/backend/src/api/model/event_manager.rs index cfe0794d6..7efaa7565 100644 --- a/backend/src/api/model/event_manager.rs +++ b/backend/src/api/model/event_manager.rs @@ -1,4 +1,4 @@ -use log::{error, info, trace}; +use log::{info, trace}; use tokio::task; use shared::model::{ConfigType, PlaylistUpdateState}; use crate::api::model::{ActiveUserConnectionChangeReceiver}; @@ -62,7 +62,7 @@ impl EventManager { pub fn send_event(&self, event: EventMessage) { if let Err(err) = self.channel_tx.send(event) { - error!("Failed to send event: {err}"); + trace!("Failed to send event: {err}"); } } diff --git a/backend/src/api/model/streams/buffered_stream.rs b/backend/src/api/model/streams/buffered_stream.rs index 51bfab154..0d9128bfe 100644 --- a/backend/src/api/model/streams/buffered_stream.rs +++ b/backend/src/api/model/streams/buffered_stream.rs @@ -33,16 +33,9 @@ impl BufferedStream { while client_close_signal.is_active() { match stream.next().await { Some(Ok(chunk)) => { - match tx.reserve().await { - Ok(permit) => { - permit.send(Ok(chunk)); - // tokio::task::yield_now().await; - }, - Err(_err) => { - // Receiver dropped, notify and exit - client_close_signal.notify(); - break; - } + if tx.send(Ok(chunk)).await.is_err() { + client_close_signal.notify(); + break; } } Some(Err(err)) => { diff --git a/backend/src/model/config/target.rs b/backend/src/model/config/target.rs index 0e32f7a1a..90cae3773 100644 --- a/backend/src/model/config/target.rs +++ b/backend/src/model/config/target.rs @@ -39,6 +39,7 @@ pub struct XtreamTargetOutput { pub resolve_vod: bool, pub resolve_vod_delay: u16, pub trakt: Option, + pub filter: Option, } macros::from_impl!(XtreamTargetOutput); @@ -53,6 +54,7 @@ impl From<&XtreamTargetOutputDto> for XtreamTargetOutput { resolve_vod: dto.resolve_vod, resolve_vod_delay: dto.resolve_vod_delay, trakt: dto.trakt.as_ref().map(Into::into), + filter: dto.t_filter.clone(), } } } @@ -68,16 +70,18 @@ impl From<&XtreamTargetOutput> for XtreamTargetOutputDto { resolve_vod: instance.resolve_vod, resolve_vod_delay: instance.resolve_vod_delay, trakt: instance.trakt.as_ref().map(TraktConfigDto::from), + filter: instance.filter.as_ref().map(ToString::to_string), + t_filter: instance.filter.clone(), } } } - #[derive(Debug, Clone)] pub struct M3uTargetOutput { pub filename: Option, pub include_type_in_url: bool, pub mask_redirect_url: bool, + pub filter: Option, } macros::from_impl!(M3uTargetOutput); @@ -87,6 +91,7 @@ impl From<&M3uTargetOutputDto> for M3uTargetOutput { filename: dto.filename.clone(), include_type_in_url: dto.include_type_in_url, mask_redirect_url: dto.mask_redirect_url, + filter: dto.t_filter.clone(), } } } @@ -96,6 +101,8 @@ impl From<&M3uTargetOutput> for M3uTargetOutputDto { filename: instance.filename.clone(), include_type_in_url: instance.include_type_in_url, mask_redirect_url: instance.mask_redirect_url, + filter: instance.filter.as_ref().map(ToString::to_string), + t_filter: instance.filter.clone(), } } } @@ -110,6 +117,7 @@ pub struct StrmTargetOutput { pub underscore_whitespace: bool, pub cleanup: bool, pub strm_props: Option>, + pub filter: Option, } macros::from_impl!(StrmTargetOutput); @@ -123,6 +131,7 @@ impl From<&StrmTargetOutputDto> for StrmTargetOutput { underscore_whitespace: dto.underscore_whitespace, cleanup: dto.cleanup, strm_props: dto.strm_props.clone(), + filter: dto.t_filter.clone(), } } } @@ -136,6 +145,8 @@ impl From<&StrmTargetOutput> for StrmTargetOutputDto { underscore_whitespace: instance.underscore_whitespace, cleanup: instance.cleanup, strm_props: instance.strm_props.clone(), + filter: instance.filter.as_ref().map(ToString::to_string), + t_filter: instance.filter.clone(), } } } diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index b3e4c7d35..279c37243 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -29,25 +29,25 @@ use deunicode::deunicode; use log::{debug, error, info, log_enabled, trace, warn, Level}; use reqwest::Client; use shared::error::{get_errors_notify_message, notify_err, TuliproxError}; -use shared::foundation::filter::{get_field_value, set_field_value, ValueAccessor, ValueProvider}; +use shared::foundation::filter::{get_field_value, set_field_value, Filter, ValueAccessor, ValueProvider}; use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistUpdateState, ProcessingOrder, UUIDType, XtreamCluster}; use shared::utils::default_as_default; use std::time::Instant; use crate::api::model::{EventManager, EventMessage, PlaylistStorageState}; -fn is_valid(pli: &PlaylistItem, target: &ConfigTarget) -> bool { +fn is_valid(pli: &PlaylistItem, filter: &Filter) -> bool { let provider = ValueProvider { pli }; - target.filter(&provider) + filter.filter(&provider) } #[allow(clippy::unnecessary_wraps)] -fn filter_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Option> { +pub fn apply_filter_to_playlist(playlist: &mut [PlaylistGroup], filter: &Filter) -> Option> { debug!("Filtering {} groups", playlist.len()); let mut new_playlist = Vec::with_capacity(128); for pg in playlist.iter_mut() { let channels = pg.channels.iter() - .filter(|&pli| is_valid(pli, target)).cloned().collect::>(); + .filter(|&pli| is_valid(pli, filter)).cloned().collect::>(); trace!("Filtered group {} has now {}/{} items", pg.title, channels.len(), pg.channels.len()); if !channels.is_empty() { new_playlist.push(PlaylistGroup { @@ -61,6 +61,9 @@ fn filter_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Opt Some(new_playlist) } +fn filter_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Option> { + apply_filter_to_playlist(playlist, &target.filter) +} fn assign_channel_no_playlist(new_playlist: &mut [PlaylistGroup]) { let assigned_chnos: HashSet = new_playlist.iter().flat_map(|g| &g.channels) diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index ff90a6e2a..c7fc0c6f0 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -17,6 +17,7 @@ use shared::utils::{is_dash_url, is_hls_url}; use shared::create_tuliprox_error; use std::path::Path; use std::sync::Arc; +use crate::processing::processor::playlist::apply_filter_to_playlist; pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGroup], epg: Option<&Epg>, target: &ConfigTarget, playlist_state: Option<&Arc>) -> Result<(), Vec> { @@ -53,11 +54,26 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr } } + + for output in &target.output { + let mut filtered = match output { + TargetOutput::Xtream(out) => out.filter.as_ref().and_then(|flt| apply_filter_to_playlist(playlist, flt)), + TargetOutput::M3u(out) => out.filter.as_ref().and_then(|flt| apply_filter_to_playlist(playlist, flt)), + TargetOutput::Strm(out) => out.filter.as_ref().and_then(|flt| apply_filter_to_playlist(playlist, flt)), + TargetOutput::HdHomeRun(_) => None, + }; + + let pl: &mut [PlaylistGroup] = if let Some(filtered_playlist) = filtered.as_mut() { + filtered_playlist.as_mut_slice() + } else { + playlist + }; + let result = match output { - TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(app_config, target, playlist).await, - TargetOutput::M3u(m3u_output) => m3u_write_playlist(app_config, target, m3u_output, &target_path, playlist).await, - TargetOutput::Strm(strm_output) => write_strm_playlist(app_config, target, strm_output, playlist).await, + TargetOutput::Xtream(_xtream_output) => xtream_write_playlist(app_config, target, pl).await, + TargetOutput::M3u(m3u_output) => m3u_write_playlist(app_config, target, m3u_output, &target_path, pl).await, + TargetOutput::Strm(strm_output) => write_strm_playlist(app_config, target, strm_output, pl).await, TargetOutput::HdHomeRun(_hdhomerun_output) => Ok(()), }; diff --git a/frontend/src/app/components/playlist/target/m3u_output.rs b/frontend/src/app/components/playlist/target/m3u_output.rs index 81032dede..7f1e1ace7 100644 --- a/frontend/src/app/components/playlist/target/m3u_output.rs +++ b/frontend/src/app/components/playlist/target/m3u_output.rs @@ -3,8 +3,9 @@ use yew::prelude::*; use yew_i18n::use_translation; use shared::model::M3uTargetOutputDto; use crate::app::components::chip::{convert_bool_to_chip_style}; -use crate::app::components::Tag; +use crate::app::components::{FilterView, RevealContent, Tag}; use crate::app::components::tag_list::TagList; +use crate::html_if; #[derive(Properties, PartialEq, Clone)] pub struct M3uOutputProps { @@ -28,6 +29,11 @@ pub fn M3uOutput(props: &M3uOutputProps) -> Html { html! {
+ { html_if!(props.output.t_filter.is_some(), { +
+ })}> +
+ }) }
{translator.t("LABEL.FILENAME")} { props.output.filename.as_ref().map(|f| html! {{ f }}) } diff --git a/frontend/src/app/components/playlist/target/strm_output.rs b/frontend/src/app/components/playlist/target/strm_output.rs index 240b03142..cf614bbe2 100644 --- a/frontend/src/app/components/playlist/target/strm_output.rs +++ b/frontend/src/app/components/playlist/target/strm_output.rs @@ -2,7 +2,8 @@ use std::rc::Rc; use yew::prelude::*; use yew_i18n::use_translation; use shared::model::{StrmTargetOutputDto}; -use crate::app::components::{convert_bool_to_chip_style, Tag, TagList}; +use crate::app::components::{convert_bool_to_chip_style, FilterView, RevealContent, Tag, TagList}; +use crate::html_if; #[derive(Properties, PartialEq, Clone)] pub struct StrmOutputProps { @@ -27,6 +28,11 @@ pub fn StrmOutput(props: &StrmOutputProps) -> Html { html! {
+ { html_if!(props.output.t_filter.is_some(), { +
+ })}> +
+ }) }
{translator.t("LABEL.DIRECTORY")} { props.output.directory.clone() } @@ -38,19 +44,14 @@ pub fn StrmOutput(props: &StrmOutputProps) -> Html {
- { - match props.output.strm_props.as_ref() { - None => html! {}, - Some(props) => html! { -
- {translator.t("LABEL.PROPERTIES")} -
    - { props.iter().map(|p| html! {
  • {p}
  • }).collect::() } -
-
- } - } - } + { html_if!(props.output.strm_props.is_some(), { +
+ {translator.t("LABEL.PROPERTIES")} +
    + { props.output.strm_props.as_ref().iter().map(|p| html! {
  • {p}
  • }).collect::() } +
+
+ }) }
} } diff --git a/frontend/src/app/components/playlist/target/xtream_output.rs b/frontend/src/app/components/playlist/target/xtream_output.rs index ee9e6fd2f..59328f691 100644 --- a/frontend/src/app/components/playlist/target/xtream_output.rs +++ b/frontend/src/app/components/playlist/target/xtream_output.rs @@ -3,8 +3,9 @@ use yew::prelude::*; use yew_i18n::use_translation; use shared::model::XtreamTargetOutputDto; use crate::app::components::chip::{convert_bool_to_chip_style}; -use crate::app::components::Tag; +use crate::app::components::{FilterView, RevealContent, Tag}; use crate::app::components::tag_list::{TagList}; +use crate::html_if; #[derive(Properties, PartialEq, Clone)] pub struct XtreamOutputProps { @@ -41,6 +42,11 @@ pub fn XtreamOutput(props: &XtreamOutputProps) -> Html { html! {
+ { html_if!(props.output.t_filter.is_some(), { +
+ })}> +
+ }) }
{translator.t("LABEL.SKIP_DIRECT_SOURCE")} diff --git a/frontend/src/services/config_service.rs b/frontend/src/services/config_service.rs index 6bcf62433..67a986aac 100644 --- a/frontend/src/services/config_service.rs +++ b/frontend/src/services/config_service.rs @@ -1,6 +1,6 @@ use crate::model::WebConfig; use crate::services::{get_base_href, request_get, request_post, EventService}; -use shared::model::{AppConfigDto, ConfigDto, ConfigInputDto, IpCheckDto}; +use shared::model::{AppConfigDto, ConfigDto, ConfigInputDto, IpCheckDto, TargetOutputDto}; use std::cell::RefCell; use std::future::Future; use std::rc::Rc; @@ -70,6 +70,14 @@ impl ConfigService { for source in app_config.sources.sources.iter_mut() { for target in source.targets.iter_mut() { target.t_filter = get_filter(target.filter.as_str(), templates.as_ref()).ok(); + for output in target.output.iter_mut() { + match output { + TargetOutputDto::Xtream(o) => o.t_filter = o.filter.as_ref().and_then(|flt| get_filter(flt, templates.as_ref()).ok()), + TargetOutputDto::M3u(o) => o.t_filter = o.filter.as_ref().and_then(|flt| get_filter(flt, templates.as_ref()).ok()), + TargetOutputDto::Strm(o) => o.t_filter = o.filter.as_ref().and_then(|flt| get_filter(flt, templates.as_ref()).ok()), + TargetOutputDto::HdHomeRun(_) => {} + } + } } } diff --git a/shared/src/model/config/target.rs b/shared/src/model/config/target.rs index 6ee681296..d5f7267f5 100644 --- a/shared/src/model/config/target.rs +++ b/shared/src/model/config/target.rs @@ -37,6 +37,10 @@ pub struct XtreamTargetOutputDto { pub resolve_vod_delay: u16, #[serde(default, skip_serializing_if = "Option::is_none")] pub trakt: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip)] + pub t_filter: Option, } impl Default for XtreamTargetOutputDto { @@ -50,6 +54,8 @@ impl Default for XtreamTargetOutputDto { resolve_vod: false, resolve_vod_delay: default_resolve_delay_secs(), trakt: None, + filter: None, + t_filter: None, } } } @@ -80,6 +86,10 @@ pub struct M3uTargetOutputDto { pub include_type_in_url: bool, #[serde(default)] pub mask_redirect_url: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip)] + pub t_filter: Option, } impl M3uTargetOutputDto { @@ -106,6 +116,10 @@ pub struct StrmTargetOutputDto { pub cleanup: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub strm_props: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filter: Option, + #[serde(skip)] + pub t_filter: Option, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(deny_unknown_fields)]