diff --git a/CHANGELOG.md b/CHANGELOG.md index c56387942..610950cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,24 @@ - **Input Batch Changes**: `name` attribute is now mandatory for input type batch to ensure stable playlist UUIDs. - **Favorites Redesign**: Replaced implicit `create_alias` with explicit `add_favourite(group_name)` script function. - **EpgSmartMatch**: Field `name_prefix` syntax needs to be changed from `name_prefix: !suffix "."` to `name_prefix: { suffix: "." }`. +- **Sort**: Sort can now use filter to sort specific entries. + ```yaml + sort: + match_as_ascii: true + rules: + - target: group + field: group + filter: Input ~ "provider_1" + order: asc + - target: channel + field: caption + filter: Group ~ "!US_TNT_ENTERTAIN!" + order: asc + sequence: + - "!CHAN_SEQ!" + - '(?i)\bHD\b' + - '(?i)\bSD\b' + ``` ## 🌟 New Features - **Discord Notifications**: Support for Discord notifications via webhooks with optional Handlebars templates. diff --git a/Cargo.lock b/Cargo.lock index 29572b2bf..23d38ca62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,7 +1156,7 @@ dependencies = [ [[package]] name = "frontend" -version = "3.2.38" +version = "3.2.39" dependencies = [ "anyhow", "base64", @@ -1168,9 +1168,9 @@ dependencies = [ "futures-signals", "gloo-render 0.2.0", "gloo-storage 0.3.0", - "gloo-timers 0.3.0", + "gloo-timers 0.2.6", "gloo-utils 0.2.0", - "implicit-clone 0.6.0", + "implicit-clone", "js-sys", "log", "prost", @@ -1691,6 +1691,8 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c" dependencies = [ + "futures-channel", + "futures-core", "js-sys", "wasm-bindgen", ] @@ -2250,15 +2252,6 @@ dependencies = [ "indexmap", ] -[[package]] -name = "implicit-clone" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1689b939ee35e3a075b0834b5672efd43aec8a6e81a1c6002b76a5ca2f211ae0" -dependencies = [ - "implicit-clone-derive", -] - [[package]] name = "implicit-clone-derive" version = "0.1.2" @@ -3990,7 +3983,7 @@ dependencies = [ [[package]] name = "shared" -version = "3.2.38" +version = "3.2.39" dependencies = [ "base64", "bitflags 2.10.0", @@ -4564,7 +4557,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tuliprox" -version = "3.2.38" +version = "3.2.39" dependencies = [ "arc-swap", "async-compression", @@ -5349,7 +5342,7 @@ dependencies = [ "console_error_panic_hook", "futures", "gloo 0.10.0", - "implicit-clone 0.4.9", + "implicit-clone", "indexmap", "js-sys", "prokio", diff --git a/README.md b/README.md index d0694072f..09dfb8023 100644 --- a/README.md +++ b/README.md @@ -794,11 +794,15 @@ It can be used inside a sequence The template can now be used for sequence ```yaml sort: - groups: - order: asc - channels: - - field: caption - group_pattern: "!US_TNT_ENTERTAIN!" + match_as_ascii: true + rules: + - target: group + field: group + filter: Input ~ "provider_1" + order: asc + - target: channel + field: caption + filter: Group ~ "!US_TNT_ENTERTAIN!" order: asc sequence: - "!CHAN_SEQ!" @@ -1123,34 +1127,37 @@ Placing playlist into memory causes more RAM usage but reduces disk access. ### 2.2.2.1 `sort` Has three top level attributes - `match_as_ascii` _optional_ default is `false` -- `groups` -- `channels` +- `rules` -#### `groups` -Used for sorting at the group (category) level. -It has one top-level attribute `order` which can be set to `asc`, `desc`, or `none` to preserve the source order for that level. -#### `channels` -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. +#### `rules` + +This is a list of sort configurations. Each configuration has the following top-level entries: +- `target` - can be `group` or `channel`. +- `field`: + - for target `channel`: `title`, `name`, `caption` or `url`. + - for target `group`: `group`. +- `filter` - a filter expression. - `order` - can be `asc`, `desc`, or `none` (which skips sorting for that group_pattern and keeps the playlist order coming from the sources). - `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. ```yaml - groups: - order: asc - sequence: - - '^Freetv' - - '^Shopping' - - '^Entertainment' - - '^Sunrise' - channels: - - field: caption - group_pattern: '^Freetv' +sort: + rules: + - target: group order: asc + filter: Group ~ ".*" + field: group + sequence: + - '^Freetv' + - '^Shopping' + - '^Entertainment' + - '^Sunrise' + - target: channel + order: asc + filter: Group ~ ".*" + field: title sequence: - '(?P.*?)\bUHD\b' - '(?P.*?)\bFHD\b' @@ -2190,10 +2197,6 @@ sources: - type: xtream filter: "!ALL_CHAN!" options: {ignore_logo: false, skip_live_direct_source: true, skip_video_direct_source: true} - sort: - match_as_ascii: true - groups: - order: asc ``` What did we do? First, we defined the input source based on the information we received from our provider. diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 94757ca35..66e32f00e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tuliprox" -version = "3.2.38" +version = "3.2.39" edition = "2021" rust-version = "1.89.0" diff --git a/backend/src/model/config/sort.rs b/backend/src/model/config/sort.rs index 4b7d8b778..d433ea0ad 100644 --- a/backend/src/model/config/sort.rs +++ b/backend/src/model/config/sort.rs @@ -1,61 +1,42 @@ use regex::Regex; -use shared::model::{ConfigSortChannelDto, ConfigSortDto, ConfigSortGroupDto, ItemField, SortOrder}; +use shared::model::{ConfigSortRuleDto, ConfigSortDto, ItemField, SortOrder, SortTarget}; +use shared::foundation::filter::Filter; use crate::model::macros; + + #[derive(Debug, Clone)] -pub struct ConfigSortGroup { +pub struct ConfigSortRule { + pub target: SortTarget, pub order: SortOrder, - pub sequence: Option>, -} - -macros::from_impl!(ConfigSortGroup); -impl From<&ConfigSortGroupDto> for ConfigSortGroup { - fn from(dto: &ConfigSortGroupDto) -> Self { - Self { - order: dto.order, - sequence: dto.t_sequence.clone(), - } - } -} - -impl From<&ConfigSortGroup> for ConfigSortGroupDto { - fn from(instance: &ConfigSortGroup) -> Self { - Self { - order: instance.order, - sequence: instance.sequence.as_ref().map(|l| l.iter().map(ToString::to_string).collect()), - t_sequence: None, - } - } -} - -#[derive(Debug, Clone)] -pub struct ConfigSortChannel { pub field: ItemField, - pub group_pattern: Regex, - pub order: SortOrder, pub sequence: Option>, + pub filter: Filter, } -macros::from_impl!(ConfigSortChannel); -impl From<&ConfigSortChannelDto> for ConfigSortChannel { - fn from(dto: &ConfigSortChannelDto) -> Self { +macros::from_impl!(ConfigSortRule); +impl From<&ConfigSortRuleDto> for ConfigSortRule { + fn from(dto: &ConfigSortRuleDto) -> Self { Self { - field: dto.field, - group_pattern: Regex::new(&dto.group_pattern).unwrap(), + target: dto.target, order: dto.order, + field: dto.field, sequence: dto.t_sequence.clone(), + filter: dto.t_filter.clone().unwrap_or_default(), } } } -impl From<&ConfigSortChannel> for ConfigSortChannelDto { - fn from(instance: &ConfigSortChannel) -> Self { +impl From<&ConfigSortRule> for ConfigSortRuleDto { + fn from(instance: &ConfigSortRule) -> Self { Self { - field: instance.field, - group_pattern: instance.group_pattern.to_string(), + target: instance.target, order: instance.order, - sequence: instance.sequence.as_ref().map(|l| l.iter().map(ToString::to_string).collect()), + field: instance.field, + sequence: instance.sequence.as_ref().map(|l: &Vec| l.iter().map(ToString::to_string).collect()), + filter: instance.filter.to_string(), t_sequence: None, + t_filter: Some(instance.filter.clone()), } } } @@ -63,8 +44,7 @@ impl From<&ConfigSortChannel> for ConfigSortChannelDto { #[derive(Debug, Clone, Default)] pub struct ConfigSort { pub match_as_ascii: bool, - pub groups: Option, - pub channels: Option>, + pub rules: Vec, } macros::from_impl!(ConfigSort); @@ -72,8 +52,7 @@ impl From<&ConfigSortDto> for ConfigSort { fn from(dto: &ConfigSortDto) -> Self { Self { match_as_ascii: dto.match_as_ascii, - groups: dto.groups.as_ref().map(Into::into), - channels: dto.channels.as_ref().map(|v| v.iter().map(Into::into).collect()), + rules: dto.rules.iter().map(Into::into).collect(), } } } @@ -82,8 +61,7 @@ impl From<&ConfigSort> for ConfigSortDto { fn from(instance: &ConfigSort) -> Self { Self { match_as_ascii: instance.match_as_ascii, - groups: instance.groups.as_ref().map(Into::into), - channels: instance.channels.as_ref().map(|v| v.iter().map(Into::into).collect()), + rules: instance.rules.iter().map(Into::into).collect(), } } } diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 734bc1c79..23473873e 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,10 +1,12 @@ +use std::collections::HashMap; use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; use crate::model::{AppConfig, ConfigInput}; use shared::error::TuliproxError; -use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster}; +use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster}; use shared::utils::{generate_playlist_uuid, StringInterner}; use std::path::Path; use std::sync::Arc; +use shared::concat_string; pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &ConfigInput) -> (Vec, Vec) { let config = &*app_config.config.load(); @@ -230,6 +232,21 @@ pub fn metadata_cache_entry_to_xtream_series_info( let release_date = series.year.map(|y| format!("{y}-01-01")); let youtube_trailer = series.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())).unwrap_or_default(); + let mut season_data = HashMap::new(); + series.seasons.as_ref().iter().for_each(|seasons| seasons.iter().for_each(|season_metadata| { + season_data.insert(season_metadata.season_number,SeriesStreamDetailSeasonProperties { + name: season_metadata.name.clone(), + season_number: season_metadata.season_number, + episode_count: 0, + overview: season_metadata.overview.clone(), + air_date: season_metadata.air_date.clone(), + cover: season_metadata.poster_path.clone(), + cover_tmdb: season_metadata.poster_path.clone(), + cover_big: None, + duration: Some(String::from("0")), + }); + })); + let episodes = series.episodes.as_ref().map(|episodes| { episodes.iter().filter(|episode| !episode.file_path.is_empty()).map(|episode| { let container_extension = Path::new(&episode.file_path) @@ -240,6 +257,20 @@ pub fn metadata_cache_entry_to_xtream_series_info( let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default(); let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id); + let _ =season_data.entry(episode.season).or_insert_with(|| { + SeriesStreamDetailSeasonProperties { + name: concat_string!(&series.title, " ", &episode.season.to_string()), + season_number: episode.season, + episode_count: 0, + overview: series.poster.clone(), + air_date: episode.aired.clone(), + cover: series.poster.clone(), + cover_tmdb: None, + cover_big: None, + duration: None, + } + }).episode_count.saturating_add(1); + SeriesStreamDetailEpisodeProperties { id: tmdb_id.unwrap_or_default(), episode_num: episode.episode, @@ -252,6 +283,7 @@ pub fn metadata_cache_entry_to_xtream_series_info( tmdb: tmdb_id, release_date: episode_release_date.clone(), plot: episode.plot.clone(), + crew: series.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ")), duration_secs: episode.runtime.map_or(0, |r| r * 60), duration: episode.runtime .map(|r| format!("{:02}:{:02}:00", r / 60, r % 60)) @@ -265,6 +297,10 @@ pub fn metadata_cache_entry_to_xtream_series_info( }).collect::>() }); + + let mut seasons = season_data.into_values().collect::>(); + seasons.sort_by_key(|s| s.season_number); + let properties = SeriesStreamProperties { name: series.title.clone(), category_id: 0, @@ -293,7 +329,7 @@ pub fn metadata_cache_entry_to_xtream_series_info( tmdb: series.tmdb_id, details: Some(SeriesStreamDetailProperties { year: series.year, - // seasons are not delivered through xtream get_series_info. + seasons: Some(seasons), episodes, }), }; diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index f216be126..5ce5d20f3 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -116,8 +116,8 @@ fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec>, inter let result = pli; for r in renames { let value = get_field_value(result, r.field); - let cap = r.pattern.replace_all(value.as_str(), &r.new_name); - if log_enabled!(log::Level::Debug) && *value != cap { + let cap = r.pattern.replace_all(&*value, &r.new_name); + if log_enabled!(log::Level::Debug) && value != cap { trace_if_enabled!("Renamed {}={value} to {cap}", &r.field); } let value = cap.into_owned(); diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index 1d21f51a8..54dfef91f 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -1,8 +1,15 @@ -use crate::model::{ConfigSortChannel, ConfigSortGroup, ConfigTarget}; -use deunicode::deunicode; +use crate::model::{ConfigSortRule, ConfigTarget}; use std::cmp::Ordering; -use shared::foundation::filter::get_field_value; -use shared::model::{PlaylistGroup, PlaylistItem, SortOrder}; +use shared::foundation::filter::{ValueProvider}; +use shared::model::{PlaylistGroup, SortOrder, SortTarget}; + +fn direction(order: SortOrder, ordering: Ordering) -> Ordering { + match order { + SortOrder::Asc => if ordering == Ordering::Less { Ordering::Less } else { Ordering::Greater }, + SortOrder::Desc => if ordering == Ordering::Less { Ordering::Greater } else { Ordering::Less }, + SortOrder::None => Ordering::Equal, + } +} fn playlist_comparator( sequence: Option<&Vec>, @@ -77,16 +84,8 @@ fn playlist_comparator( SortOrder::None => Ordering::Equal, } } - (Some(_), None) => match order { - SortOrder::Asc => Ordering::Less, - SortOrder::Desc => Ordering::Greater, - SortOrder::None => Ordering::Equal, - }, - (None, Some(_)) => match order { - SortOrder::Asc => Ordering::Greater, - SortOrder::Desc => Ordering::Less, - SortOrder::None => Ordering::Equal, - }, + (Some(_), None) => direction(order, Ordering::Less), + (None, Some(_)) => direction(order, Ordering::Greater), (None, None) => { // NP match → fallback let o = value_a.cmp(value_b); @@ -108,82 +107,165 @@ fn playlist_comparator( } } -fn playlistgroup_comparator(a: &PlaylistGroup, b: &PlaylistGroup, group_sort: &ConfigSortGroup, match_as_ascii: bool) -> Ordering { - let ascii_a; - let ascii_b; - let value_a: &str = if match_as_ascii { - ascii_a = deunicode(&a.title); - &ascii_a - } else { - &a.title - }; - let value_b: &str = if match_as_ascii { - ascii_b = deunicode(&b.title); - &ascii_b - } else { - &b.title +pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, playlist: &mut [PlaylistGroup]) -> bool { + let Some(sort) = &target.sort else { + return false; }; - playlist_comparator(group_sort.sequence.as_ref(), group_sort.order, value_a, value_b) + let rules = &sort.rules; + let match_as_ascii = sort.match_as_ascii; + sort_groups(playlist, rules, match_as_ascii); + sort_channels_in_groups(playlist, rules, match_as_ascii); + + true } -fn playlistitem_comparator( - a: &PlaylistItem, - b: &PlaylistItem, - channel_sort: &ConfigSortChannel, +fn sort_groups( + groups: &mut [PlaylistGroup], + rules: &[ConfigSortRule], match_as_ascii: bool, -) -> Ordering { - let raw_value_a = get_field_value(a, channel_sort.field); - let raw_value_b = get_field_value(b, channel_sort.field); - let value_a = if match_as_ascii { deunicode(&raw_value_a) } else { raw_value_a }; - let value_b = if match_as_ascii { deunicode(&raw_value_b) } else { raw_value_b }; +) { + let group_rules: Vec<_> = rules + .iter() + .filter(|r| matches!(r.target, SortTarget::Group)) + .collect(); - playlist_comparator(channel_sort.sequence.as_ref(), channel_sort.order, &value_a, &value_b) -} + if group_rules.is_empty() { + return; + } -pub(in crate::processing::processor) fn sort_playlist(target: &ConfigTarget, new_playlist: &mut [PlaylistGroup]) -> bool { - if let Some(sort) = &target.sort { - let match_as_ascii = sort.match_as_ascii; - if let Some(group_sort) = &sort.groups { - if !matches!(group_sort.order, SortOrder::None) { - new_playlist.sort_by(|a, b| playlistgroup_comparator(a, b, group_sort, match_as_ascii)); + groups.sort_by(|a_grp, b_grp| { + for rule in &group_rules { + if rule.order == SortOrder::None { + continue; } - } - if let Some(channel_sorts) = &sort.channels { - for channel_sort in channel_sorts { - if matches!(channel_sort.order, SortOrder::None) { - continue; - } - let regexp = &channel_sort.group_pattern; - for group in new_playlist.iter_mut() { - let group_title = if match_as_ascii { deunicode(&group.title) } else { group.title.to_string() }; - if regexp.is_match(group_title.as_str()) { - group.channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, channel_sort, match_as_ascii)); + + let (Some(a_chan), Some(b_chan)) = (a_grp.channels.first(), b_grp.channels.first()) else { + continue; + }; + + let provider_a = ValueProvider { pli: a_chan, match_as_ascii }; + let provider_b = ValueProvider { pli: b_chan, match_as_ascii }; + + match (rule.filter.filter(&provider_a), rule.filter.filter(&provider_b)) { + (false, false) => return Ordering::Equal, + (true, false) => return direction(rule.order, Ordering::Less), + (false, true) => return direction(rule.order, Ordering::Greater), + (true, true) => { /* fallthrough */} + } + + let va = provider_a.get(rule.field.as_str()); + let vb = provider_b.get(rule.field.as_str()); + match (va, vb) { + (None, None) => return Ordering::Equal, + (Some(_), None) => return direction(rule.order, Ordering::Less), + (None, Some(_)) => return direction(rule.order, Ordering::Greater), + (Some(va), Some(vb)) => { + let ord = playlist_comparator( + rule.sequence.as_ref(), + rule.order, + &va, + &vb, + ); + + if ord != Ordering::Equal { + return ord; } } } } - true - } else { - false + + Ordering::Equal + }); +} + +fn sort_channels_in_groups( + groups: &mut [PlaylistGroup], + rules: &[ConfigSortRule], + match_as_ascii: bool, +) { + let channel_rules: Vec<_> = rules + .iter() + .filter(|r| matches!(r.target, SortTarget::Channel)) + .collect(); + + if channel_rules.is_empty() { + return; + } + + for group in groups { + group.channels.sort_by(|a, b| { + for rule in &channel_rules { + if rule.order == SortOrder::None { + continue; + } + + let provider_a = ValueProvider { pli: a, match_as_ascii }; + let provider_b = ValueProvider { pli: b, match_as_ascii }; + + match (rule.filter.filter(&provider_a), rule.filter.filter(&provider_b)) { + (false, false) => continue, + (true, false) => return direction(rule.order, Ordering::Less), + (false, true) => return direction(rule.order, Ordering::Greater), + (true, true) => {} + } + + let va = provider_a.get(rule.field.as_str()); + let vb = provider_b.get(rule.field.as_str()); + match (va, vb) { + (None, None) => return Ordering::Equal, + (Some(_), None) => return direction(rule.order, Ordering::Less), + (None, Some(_)) => return direction(rule.order, Ordering::Greater), + (Some(va), Some(vb)) => { + let ord = playlist_comparator( + rule.sequence.as_ref(), + rule.order, + &va, + &vb, + ); + + if ord != Ordering::Equal { + return ord; + } + } + } + } + + a.header.source_ordinal.cmp(&b.header.source_ordinal) + }); } } + + #[cfg(test)] mod tests { - use crate::processing::processor::sort::playlistitem_comparator; + use std::cmp::Ordering; use regex::Regex; - use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder}; - use crate::model::ConfigSortChannel; + use shared::foundation::filter::Filter; + use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget}; + use crate::model::ConfigSortRule; + use crate::processing::processor::sort::playlist_comparator; #[test] fn test_sort() { let mut channels: Vec = vec![ ("D", "HD"), ("A", "FHD"), ("Z", "HD"), ("K", "HD"), ("B", "HD"), ("A", "HD"), ("K", "UHD"), ("C", "HD"), ("L", "FHD"), ("R", "UHD"), ("T", "SD"), ("A", "FHD"), - ].into_iter().map(|(name, quality)| PlaylistItem { header: PlaylistItemHeader { title: format!("Chanel {name} [{quality}]"), ..Default::default() } }).collect::>(); + ] + .into_iter() + .enumerate() + .map(|(i, (name, quality))| PlaylistItem { + header: PlaylistItemHeader { + title: format!("Chanel {name} [{quality}]"), + source_ordinal: i as u32, + ..Default::default() + }, + }) + .collect(); - let channel_sort = ConfigSortChannel { + let channel_sort = ConfigSortRule { + target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Asc, sequence: Some(vec![ @@ -191,12 +273,47 @@ mod tests { Regex::new(r"(?P.*?)\bFHD\b").unwrap(), Regex::new(r"(?P.*?)\bHD\b").unwrap(), ]), - group_pattern: Regex::new(".*").unwrap(), + filter: Filter::default(), }; - channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, &channel_sort, true)); - let expected = vec!["Chanel K [UHD]", "Chanel R [UHD]", "Chanel A [FHD]", "Chanel A [FHD]", "Chanel L [FHD]", "Chanel A [HD]", "Chanel B [HD]", "Chanel C [HD]", "Chanel D [HD]", "Chanel K [HD]", "Chanel Z [HD]", "Chanel T [SD]"]; - let sorted = channels.into_iter().map(|pli| pli.header.title.clone()).collect::>(); + channels.sort_by(|a, b| { + let va = &a.header.title; + let vb = &b.header.title; + + let ord = playlist_comparator( + channel_sort.sequence.as_ref(), + channel_sort.order, + va, + vb, + ); + + if ord != Ordering::Equal { + ord + } else { + a.header.source_ordinal.cmp(&b.header.source_ordinal) + } + }); + + let expected = vec![ + "Chanel K [UHD]", + "Chanel R [UHD]", + "Chanel A [FHD]", + "Chanel A [FHD]", + "Chanel L [FHD]", + "Chanel A [HD]", + "Chanel B [HD]", + "Chanel C [HD]", + "Chanel D [HD]", + "Chanel K [HD]", + "Chanel Z [HD]", + "Chanel T [SD]", + ]; + + let sorted = channels + .into_iter() + .map(|pli| pli.header.title) + .collect::>(); + assert_eq!(expected, sorted); } @@ -227,9 +344,20 @@ mod tests { "US| East d", "US| West e", "US| West f", - ].into_iter().map(|name| PlaylistItem { header: PlaylistItemHeader { title: name.to_string(), ..Default::default() } }).collect::>(); + ] + .into_iter() + .enumerate() + .map(|(i, name)| PlaylistItem { + header: PlaylistItemHeader { + title: name.to_string(), + source_ordinal: i as u32, + ..Default::default() + }, + }) + .collect(); - let channel_sort = ConfigSortChannel { + let channel_sort = ConfigSortRule { + target: SortTarget::Channel, field: ItemField::Caption, order: SortOrder::Asc, sequence: Some(vec![ @@ -242,12 +370,57 @@ mod tests { Regex::new(r"^US\| WEST.*?\[\bHD\b\](?P.*)").unwrap(), Regex::new(r"^US\| WEST.*?\[\bSD\b\](?P.*)").unwrap(), ]), - group_pattern: Regex::new(".*").unwrap(), + filter: Filter::default(), }; - channels.sort_by(|chan1, chan2| playlistitem_comparator(chan1, chan2, &channel_sort, true)); - let sorted = channels.into_iter().map(|pli| pli.header.title.clone()).collect::>(); - let expected = vec!["US| EAST [UHD] m", "US| EAST [FHD] abc", "US| EAST [FHD] bc", "US| EAST [FHD] de", "US| EAST [FHD] def", "US| EAST [FHD] ghi", "US| EAST [HD] f", "US| EAST [HD] h", "US| EAST [HD] jkl", "US| EAST [HD] mno", "US| EAST [HD] pqrs", "US| EAST [HD] tuv", "US| EAST [HD] wxy", "US| EAST [HD] z", "US| EAST [SD] a", "US| EAST [SD] ijk", "US| EAST [SD] l", "US| WEST [FHD] no", "US| WEST [HD] qrst", "US| WEST [HD] uvw", "US| (West) xv", "US| East d", "US| West e", "US| West f"]; + channels.sort_by(|a, b| { + let ord = playlist_comparator( + channel_sort.sequence.as_ref(), + channel_sort.order, + &a.header.title, + &b.header.title, + ); + + if ord != Ordering::Equal { + ord + } else { + a.header.source_ordinal.cmp(&b.header.source_ordinal) + } + }); + + let expected = vec![ + "US| EAST [UHD] m", + "US| EAST [FHD] abc", + "US| EAST [FHD] bc", + "US| EAST [FHD] de", + "US| EAST [FHD] def", + "US| EAST [FHD] ghi", + "US| EAST [HD] f", + "US| EAST [HD] h", + "US| EAST [HD] jkl", + "US| EAST [HD] mno", + "US| EAST [HD] pqrs", + "US| EAST [HD] tuv", + "US| EAST [HD] wxy", + "US| EAST [HD] z", + "US| EAST [SD] a", + "US| EAST [SD] ijk", + "US| EAST [SD] l", + "US| WEST [FHD] no", + "US| WEST [HD] qrst", + "US| WEST [HD] uvw", + "US| (West) xv", + "US| East d", + "US| West e", + "US| West f", + ]; + + let sorted = channels + .into_iter() + .map(|pli| pli.header.title) + .collect::>(); + assert_eq!(expected, sorted); } + } diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index a2bc42474..9c798b2aa 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -372,7 +372,9 @@ pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str, target pub async fn user_get_bouquet_filter(config: &Config, username: &str, category_id: Option, target: TargetType, cluster: XtreamCluster) -> Option> { if let Some(cid) = category_id { - return Some(HashSet::from([cid.to_string()])); + if cid > 0 { + return Some(HashSet::from([cid.to_string()])); + } } let bouquet = match cluster { diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index 6753a029d..1d5083d17 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -715,11 +715,14 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc, storage_ XtreamCluster::Series => get_collection_path(&root_path, storage_const::COL_CAT_SERIES), }; let data = fetched_categories.get_mut(cluster); - let lock = app_cfg.file_locks.write_lock(&col_path).await; - if let Err(err) = json_write_documents_to_file(&col_path, data).await { - errors.push(format!("Persisting collection failed: {}: {err}", col_path.display())); + // if there is no data save only if no file exists! Prevent data loss from failed download attempt + if !data.is_empty() || tokio::fs::try_exists(&col_path).await.is_ok_and(|v| !v) { + let lock = app_cfg.file_locks.write_lock(&col_path).await; + if let Err(err) = json_write_documents_to_file(&col_path, data).await { + errors.push(format!("Persisting collection failed: {}: {err}", col_path.display())); + } + drop(lock); } - drop(lock); } for cluster in XTREAM_CLUSTER { diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml index 9527633c3..0ddb807c1 100644 --- a/frontend/Cargo.toml +++ b/frontend/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "frontend" -version = "3.2.38" +version = "3.2.39" edition = "2021" rust-version = "1.89.0" [dependencies] -shared = { version = "3.2.38", path = "../shared" } +shared = { version = "3.2.39", path = "../shared" } chrono = "0" yew = "0.21" yew-router = "0.18" diff --git a/frontend/src/app/components/playlist/target/target_sort.rs b/frontend/src/app/components/playlist/target/target_sort.rs index 7a1ca5bde..fe33111d4 100644 --- a/frontend/src/app/components/playlist/target/target_sort.rs +++ b/frontend/src/app/components/playlist/target/target_sort.rs @@ -1,4 +1,4 @@ -use crate::app::components::{convert_bool_to_chip_style, Card, Chip}; +use crate::app::components::{convert_bool_to_chip_style, Card, Chip, FilterView}; use shared::model::{ConfigTargetDto}; use std::rc::Rc; use yew::prelude::*; @@ -17,54 +17,33 @@ pub fn TargetSort(props: &TargetSortProps) -> Html { Some(s) => s, None => return html! {}, }; - - let groups_html = match sort.groups.as_ref() { - Some(groups) => { - match groups.sequence.as_ref() { - Some(seq) => html! { - -

{translator.t("LABEL.GROUPS")}

-
- {translator.t("LABEL.ORDER")} - { groups.order.to_string() } -
-
- {translator.t("LABEL.SEQUENCE")} - -
    - { for seq.iter().map(|p| html! {
  • {p}
  • }) } -
-
-
-
- }, - None => html! {}, - } - }, - None => html! {}, - }; - - let channels_html = match sort.channels.as_ref() { - Some(channels) => html! { + let rules_html = if sort.rules.is_empty() { + html! {} + } else { + html! {

{ translator.t("LABEL.CHANNELS") }

{ - for channels.iter().map(|channel| html! { + for sort.rules.iter().map(|rule| html! { <>
- { translator.t("LABEL.FIELD") } - { channel.field.to_string() } + { translator.t("LABEL.TARGET") } + { rule.target.as_str() }
- { translator.t("LABEL.GROUP_PATTERN") } - { channel.group_pattern.to_string() } + { translator.t("LABEL.FIELD") } + { rule.field.as_str() }
{ translator.t("LABEL.ORDER") } - { channel.order.to_string() } + { rule.order.to_string() } +
+
+ { translator.t("LABEL.FILTER") } +
{ - match channel.sequence.as_ref() { + match rule.sequence.as_ref() { Some(seq) => html! {
{ translator.t("LABEL.SEQUENCE") } @@ -81,12 +60,10 @@ pub fn TargetSort(props: &TargetSortProps) -> Html { }) } - - }, - None => html! {}, + + } }; - html! {

{translator.t("LABEL.SORT_SETTINGS")}

@@ -94,8 +71,7 @@ pub fn TargetSort(props: &TargetSortProps) -> Html {
- { groups_html } - { channels_html } + { rules_html }
} } diff --git a/frontend/src/services/config_service.rs b/frontend/src/services/config_service.rs index 78ac89985..91024c060 100644 --- a/frontend/src/services/config_service.rs +++ b/frontend/src/services/config_service.rs @@ -77,6 +77,11 @@ 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(); + if let Some(sort) = target.sort.as_mut() { + for channel in sort.rules.iter_mut() { + channel.t_filter = get_filter(&channel.filter, templates.as_ref()).map_err(|e| error!("Failed to parse groups sort filter: {}", e)).ok(); + } + } for output in target.output.iter_mut() { match output { TargetOutputDto::Xtream(o) => diff --git a/shared/Cargo.toml b/shared/Cargo.toml index a213267f6..31bc6f658 100644 --- a/shared/Cargo.toml +++ b/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shared" -version = "3.2.38" +version = "3.2.39" edition = "2021" rust-version = "1.89.0" diff --git a/shared/src/foundation/filter.rs b/shared/src/foundation/filter.rs index 5c6cedf4e..993990357 100644 --- a/shared/src/foundation/filter.rs +++ b/shared/src/foundation/filter.rs @@ -14,18 +14,17 @@ use crate::info_err; pub use crate::model::{ItemField, PatternTemplate, PlaylistItem, PlaylistItemType, FieldGetAccessor, FieldSetAccessor, TemplateValue}; use crate::utils::{DirectedGraph, StringInterner, CONSTANTS}; -pub fn get_field_value(pli: &PlaylistItem, field: ItemField) -> String { +pub fn get_field_value(pli: &PlaylistItem, field: ItemField) -> Cow<'_, str> { let header = &pli.header; - let value = match field { - ItemField::Group => header.group.to_string(), - ItemField::Name => header.name.to_string(), - ItemField::Title => header.title.to_string(), - ItemField::Url => header.url.to_string(), - ItemField::Input => header.input_name.to_string(), - ItemField::Type => header.item_type.to_string(), - ItemField::Caption => if header.title.is_empty() { header.name.to_string() } else { header.title.to_string() }, - }; - value.to_string() + match field { + ItemField::Group => Cow::Borrowed(&*header.group), + ItemField::Name => Cow::Borrowed(&*header.name), + ItemField::Title => Cow::Borrowed(&*header.title), + ItemField::Url => Cow::Borrowed(&*header.url), + ItemField::Input => Cow::Borrowed(&*header.input_name), + ItemField::Type => header.item_type.as_str(), + ItemField::Caption => if header.title.is_empty() { Cow::Borrowed(&*header.name) } else { Cow::Borrowed(&*header.title) }, + } } pub fn set_field_value(pli: &mut PlaylistItem, field: ItemField, value: String, interner: &mut StringInterner) -> bool { @@ -156,6 +155,12 @@ pub enum Filter { BinaryExpression(Box, BinaryOperator, Box), } +impl Default for Filter { + fn default() -> Self { + Self::Group(Box::new(Filter::FieldComparison(ItemField::Group, CompiledRegex { restr: ".*".to_string(), re: regex::Regex::new(".*").unwrap() }))) + } +} + fn get_caption<'a>(provider: &'a ValueProvider<'a>, rewc: &'a CompiledRegex) -> (bool, Cow<'a, str>) { if let Some(value) = provider.get("title") { if rewc.re.is_match(&value) { diff --git a/shared/src/model/config/sort.rs b/shared/src/model/config/sort.rs index 131e87539..9df82ec9a 100644 --- a/shared/src/model/config/sort.rs +++ b/shared/src/model/config/sort.rs @@ -1,9 +1,11 @@ -use std::fmt::Display; use crate::error::{TuliproxError, TuliproxErrorKind}; -use crate::foundation::filter::{apply_templates_to_pattern, apply_templates_to_pattern_single}; +use crate::foundation::filter::{apply_templates_to_pattern, get_filter, Filter}; use crate::model::{ItemField, PatternTemplate, TemplateValue}; -use crate::{info_err, handle_tuliprox_error_result_list}; +use crate::{handle_tuliprox_error_result_list, info_err, info_err_res}; use regex::Regex; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; fn compile_regex_vec(patterns: Option<&Vec>) -> Result>, TuliproxError> { patterns.as_ref() @@ -38,76 +40,98 @@ impl Display for SortOrder { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ConfigSortGroupDto { - pub order: SortOrder, - #[serde(default)] - pub sequence: Option>, - #[serde(skip)] - pub t_sequence: Option>, + +#[derive(Serialize, Debug, Copy, Clone, Eq, PartialEq)] +pub enum SortTarget { + #[serde(rename = "group")] + Group, + #[serde(rename = "channel")] + Channel, } -impl PartialEq for ConfigSortGroupDto { - fn eq(&self, other: &Self) -> bool { - self.order == other.order - && self.sequence == other.sequence +impl SortTarget { + const GROUP: &'static str = "group"; + const CHANNEL: &'static str = "channel"; + + pub fn as_str(&self) -> &'static str { + match *self { + Self::Group => Self::GROUP, + Self::Channel => Self::CHANNEL, + } } } -impl ConfigSortGroupDto { - pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - let processed_sequence = match (&self.sequence, templates) { - (Some(seqs), Some(_templs)) => { - let mut result = Vec::new(); - for s in seqs { - match apply_templates_to_pattern(s, templates, true)? { - TemplateValue::Single(val) => result.push(val), - TemplateValue::Multi(vals) => result.extend(vals), - } - } - Some(result) - } - (Some(seqs), None) => Some(seqs.clone()), - (None, _) => None, - }; +impl FromStr for SortTarget { + type Err = TuliproxError; - self.t_sequence = compile_regex_vec(processed_sequence.as_ref())?; - Ok(()) + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case(Self::GROUP) { + Ok(Self::Group) + } else if s.eq_ignore_ascii_case(Self::CHANNEL) { + Ok(Self::Channel) + } else { + info_err_res!("Unknown SortTarget: {}", s) + } + } +} + +impl<'de> Deserialize<'de> for SortTarget { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + SortTarget::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl Display for SortTarget { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", + match self { + Self::Group => Self::GROUP, + Self::Channel => Self::CHANNEL, + } + ) } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] -pub struct ConfigSortChannelDto { - // channel field +pub struct ConfigSortRuleDto { + pub target: SortTarget, + // channel/Group field pub field: ItemField, - // match against group title - pub group_pattern: String, pub order: SortOrder, #[serde(default)] pub sequence: Option>, + pub filter: String, #[serde(skip)] pub t_sequence: Option>, + #[serde(skip)] + pub t_filter: Option, } -impl PartialEq for ConfigSortChannelDto { +impl PartialEq for ConfigSortRuleDto { fn eq(&self, other: &Self) -> bool { self.field == other.field - && self.group_pattern == other.group_pattern && self.order == other.order && self.sequence == other.sequence + && self.filter == other.filter } } -impl ConfigSortChannelDto { +impl ConfigSortRuleDto { pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - self.group_pattern = apply_templates_to_pattern_single(&self.group_pattern, templates)?; - // Compile group_pattern - Regex::new(&self.group_pattern).map_err(|err| { - info_err!("can't parse regex: {} {err}", &self.group_pattern) - })?; + if self.target == SortTarget::Group { + if !matches!(self.field, ItemField::Group | ItemField::Title | ItemField::Name | ItemField::Caption) { + return info_err_res!("Group sorting can only be done on the Group field"); + } + self.field = ItemField::Group; // hard coded because we only can't match a group until we can use PlaylistGroup with filter + } + + self.t_filter = Some(get_filter(&self.filter, templates)?); // Transform sequence with templates if provided, otherwise use raw sequence let processed_sequence = match (&self.sequence, templates) { @@ -137,20 +161,12 @@ pub struct ConfigSortDto { #[serde(default)] pub match_as_ascii: bool, #[serde(default)] - pub groups: Option, - #[serde(default)] - pub channels: Option>, + pub rules: Vec, } - impl ConfigSortDto { pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - if let Some(group) = self.groups.as_mut() { - group.prepare(templates)?; - } - if let Some(channels) = self.channels.as_mut() { - handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, channels.iter_mut().map(|csc| csc.prepare(templates))); - } + handle_tuliprox_error_result_list!(TuliproxErrorKind::Info, self.rules.iter_mut().map(|rule| rule.prepare(templates))); Ok(()) } } diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index a2f6a4d0d..7affa2b24 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -222,6 +222,16 @@ impl PlaylistItemType { pub fn as_u8(self) -> u8 { self as u8 } + + pub fn as_str(&self) -> Cow<'static, str> { + match self { + Self::Live | Self::LiveHls | Self::LiveDash | Self::LiveUnknown => Cow::Borrowed(Self::LIVE), + Self::Video | Self::LocalVideo => Cow::Borrowed(Self::VIDEO), + Self::Series | Self::LocalSeries => Cow::Borrowed(Self::SERIES), + Self::SeriesInfo | Self::LocalSeriesInfo => Cow::Borrowed(Self::SERIES_INFO), + Self::Catchup => Cow::Borrowed(Self::CATCHUP), + } + } } impl Display for PlaylistItemType { @@ -399,61 +409,80 @@ macro_rules! generate_field_accessor_impl_for_playlist_item_header { ($($prop:ident),*;) => { impl crate::model::FieldGetAccessor for crate::model::PlaylistItemHeader { fn get_field(&self, field: &str) -> Option> { - let field = field.to_lowercase(); - match field.as_str() { - $( - stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), - )* - "group" => Some(Cow::Borrowed(&*self.group)), - "chno" => Some(Cow::Owned(self.chno.to_string())), - "input" => Some(Cow::Borrowed(&*self.input_name)), - "type" => Some(Cow::Owned(self.item_type.to_string())), - "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), - "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), - _ => None, + let bytes = field.as_bytes(); + + $( + { + let target = stringify!($prop).as_bytes(); + if bytes.len() == target.len() && bytes.iter().zip(target).all(|(a, b)| a.to_ascii_lowercase() == *b) { + return Some(Cow::Borrowed(&self.$prop)); + } + } + )* + + if bytes.eq_ignore_ascii_case(b"group") { + Some(Cow::Borrowed(&*self.group)) + } else if bytes.eq_ignore_ascii_case(b"caption") { + Some(if self.title.is_empty() { + Cow::Borrowed(&self.name) + } else { + Cow::Borrowed(&self.title) + }) + } else if bytes.eq_ignore_ascii_case(b"input") { + Some(Cow::Borrowed(&*self.input_name)) + } else if bytes.eq_ignore_ascii_case(b"type") { + Some(Cow::Owned(self.item_type.to_string())) + } else if bytes.eq_ignore_ascii_case(b"epg_channel_id") || bytes.eq_ignore_ascii_case(b"epg_id") { + self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())) + } else if bytes.eq_ignore_ascii_case(b"chno") { + Some(Cow::Owned(self.chno.to_string())) + } else { + None } } } + impl crate::model::FieldSetAccessor for crate::model::PlaylistItemHeader { fn set_field(&mut self, field: &str, value: &str) -> bool { - let field = field.to_lowercase(); - let val = String::from(value); - match field.as_str() { - $( - stringify!($prop) => { - self.$prop = val; - true + let bytes = field.as_bytes(); + $( + { + let target = stringify!($prop).as_bytes(); + if bytes.len() == target.len() && + bytes.iter().zip(target).all(|(a, b)| a.to_ascii_lowercase() == *b) + { + self.$prop = String::from(value); + return true; } - )* - "group" => { - self.group = crate::utils::intern(value); - true } - "chno" => { - if let Ok(val) = value.parse::() { - self.chno = val; - true - } else { - false - } - }, - "caption" => { - self.title = val.clone(); - self.name = val; + )* + + if bytes.eq_ignore_ascii_case(b"group") { + self.group = crate::utils::intern(value); + true + } else if bytes.eq_ignore_ascii_case(b"caption") { + self.title = String::from(value); + self.name = String::from(value); + true + } else if bytes.eq_ignore_ascii_case(b"epg_channel_id") || bytes.eq_ignore_ascii_case(b"epg_id") { + self.epg_channel_id = Some(String::from(value)); + true + } else if bytes.eq_ignore_ascii_case(b"chno") { + if let Ok(parsed) = value.parse::() { + self.chno = parsed; true + } else { + false } - "epg_channel_id" | "epg_id" => { - self.epg_channel_id = Some(value.to_owned()); - true - } - _ => false, + } else { + false } } } } } -generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ name, logo, logo_small, title, parent_code, audio_track, time_shift, rec, url;); +generate_field_accessor_impl_for_playlist_item_header!(id, /*virtual_id,*/ title, name, logo, logo_small, parent_code, audio_track, time_shift, rec, url;); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct M3uPlaylistItem { @@ -600,22 +629,38 @@ macro_rules! generate_field_accessor_impl_for_m3u_playlist_item { ($($prop:ident),*;) => { impl crate::model::FieldGetAccessor for M3uPlaylistItem { fn get_field(&self, field: &str) -> Option> { - let field = field.to_lowercase(); - match field.as_str() { - $( - stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), - )* - "chno" => Some(Cow::Owned(self.chno.to_string())), - "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), - "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), - _ => None, + let bytes = field.as_bytes(); + $( + { + let target = stringify!($prop).as_bytes(); + if bytes.len() == target.len() && + bytes.iter().zip(target).all(|(a, b)| a.to_ascii_lowercase() == *b) + { + return Some(Cow::Borrowed(&self.$prop)); + } + } + )* + if bytes.eq_ignore_ascii_case(b"group") { + Some(Cow::Borrowed(&*self.group)) + } else if bytes.eq_ignore_ascii_case(b"caption") { + Some(if self.title.is_empty() { + Cow::Borrowed(&self.name) + } else { + Cow::Borrowed(&self.title) + }) + } else if bytes.eq_ignore_ascii_case(b"epg_channel_id") || bytes.eq_ignore_ascii_case(b"epg_id") { + self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())) + } else if bytes.eq_ignore_ascii_case(b"chno") { + Some(Cow::Owned(self.chno.to_string())) + } else { + None } } } } } -generate_field_accessor_impl_for_m3u_playlist_item!(provider_id, name, logo, logo_small, group, title, parent_code, audio_track, time_shift, rec, url;); +generate_field_accessor_impl_for_m3u_playlist_item!(title, name, provider_id, logo, logo_small, parent_code, audio_track, time_shift, rec, url;); impl From for CommonPlaylistItem { fn from(item: M3uPlaylistItem) -> Self { @@ -794,61 +839,77 @@ macro_rules! generate_field_accessor_impl_for_xtream_playlist_item { ($($prop:ident),*;) => { impl crate::model::FieldGetAccessor for crate::model::XtreamPlaylistItem { fn get_field(&self, field: &str) -> Option> { - let field = field.to_lowercase(); - match field.as_str() { - $( - stringify!($prop) => Some(Cow::Borrowed(&self.$prop)), - )* - "caption" => Some(if self.title.is_empty() { Cow::Borrowed(&self.name) } else { Cow::Borrowed(&self.title) }), - "epg_channel_id" | "epg_id" => self.epg_channel_id.as_ref().map(|s| Cow::Borrowed(s.as_str())), - _ => { - if field.starts_with(xtream_const::XC_PROP_BACKDROP_PATH) - || field == xtream_const::XC_PROP_COVER + let bytes = field.as_bytes(); + + $( + { + let target = stringify!($prop).as_bytes(); + if bytes.len() == target.len() && + bytes.iter().zip(target).all(|(a, b)| a.to_ascii_lowercase() == *b) { - match self.additional_properties.as_ref() { - Some(additional_properties) => match additional_properties { - StreamProperties::Live(_) => None, - StreamProperties::Video(video) => { - if field == xtream_const::XC_PROP_COVER { - video.details.as_ref().and_then(|details| { - details.cover_big.as_ref() - .or(details.movie_image.as_ref()) - .or_else(|| details.backdrop_path.as_ref().and_then(|p| p.first())) - .map(|s| Cow::::Borrowed(s)) - }) - } else { - video.details.as_ref().and_then(|details| { - details.backdrop_path.as_ref().and_then(|p| p.first()) - .or(details.movie_image.as_ref()) - .or(details.cover_big.as_ref()) - .map(|s| Cow::::Borrowed(s)) - }) - } - } - StreamProperties::Series(series) => { - if field == xtream_const::XC_PROP_COVER { - if series.cover.is_empty() { - series.backdrop_path.as_ref().and_then(|p| p.first()).map(|s| Cow::::Borrowed(s)) - } else { - Some(Cow::Borrowed(&series.cover)) - } - } else { - match series.backdrop_path.as_ref() { - None => if series.cover.is_empty() { None } else { Some(Cow::Borrowed(&series.cover)) } - Some(p) => p.first().map(|s| Cow::::Borrowed(s)) - } - } - } - StreamProperties::Episode(episode) => { - Some(Cow::::Borrowed(&episode.movie_image)) - } - }, - None => None, - } - } else { - None + return Some(Cow::Borrowed(&self.$prop)); } } + )* + + // Caption + if bytes.eq_ignore_ascii_case(b"caption") { + Some(if self.title.is_empty() { + Cow::Borrowed(self.name.as_str()) + } else { + Cow::Borrowed(self.title.as_str()) + }) + } + // epg_channel_id / epg_id + else if bytes.eq_ignore_ascii_case(b"epg_channel_id") || bytes.eq_ignore_ascii_case(b"epg_id") { + self.epg_channel_id.as_ref().map(|s| Cow::::Borrowed(s.as_str())) + } + // Additional Properties + else if field.starts_with(xtream_const::XC_PROP_BACKDROP_PATH) + || bytes.eq_ignore_ascii_case(xtream_const::XC_PROP_COVER.as_bytes()) + { + match self.additional_properties.as_ref() { + Some(additional_properties) => match additional_properties { + StreamProperties::Live(_) => None, + StreamProperties::Video(video) => { + if bytes.eq_ignore_ascii_case(xtream_const::XC_PROP_COVER.as_bytes()) { + video.details.as_ref().and_then(|details| { + details.cover_big.as_ref() + .or(details.movie_image.as_ref()) + .or_else(|| details.backdrop_path.as_ref().and_then(|p| p.first())) + .map(|s| Cow::::Borrowed(s.as_str())) + }) + } else { + video.details.as_ref().and_then(|details| { + details.backdrop_path.as_ref().and_then(|p| p.first()) + .or(details.movie_image.as_ref()) + .or(details.cover_big.as_ref()) + .map(|s| Cow::::Borrowed(s.as_str())) + }) + } + } + StreamProperties::Series(series) => { + if bytes.eq_ignore_ascii_case(xtream_const::XC_PROP_COVER.as_bytes()) { + if series.cover.is_empty() { + series.backdrop_path.as_ref().and_then(|p| p.first()).map(|s| Cow::::Borrowed(s.as_str())) + } else { + Some(Cow::::Borrowed(series.cover.as_str())) + } + } else { + match series.backdrop_path.as_ref() { + None => if series.cover.is_empty() { None } else { Some(Cow::Borrowed(&series.cover)) }, + Some(p) => p.first().map(|s| Cow::::Borrowed(s.as_str())), + } + } + } + StreamProperties::Episode(episode) => Some(Cow::::Borrowed(episode.movie_image.as_str())), + }, + None => None, + } + } + // Default fallback + else { + None } } } @@ -867,7 +928,7 @@ pub struct PlaylistItem { pub header: PlaylistItemHeader, } -generate_field_accessor_impl_for_xtream_playlist_item!(name, logo, logo_small, group, title, parent_code, rec, url;); +generate_field_accessor_impl_for_xtream_playlist_item!(group, title, name, logo, logo_small, parent_code, rec, url;); impl PlaylistItem { diff --git a/shared/src/model/playlist_info_document.rs b/shared/src/model/playlist_info_document.rs index 14105f740..ad2b800c2 100644 --- a/shared/src/model/playlist_info_document.rs +++ b/shared/src/model/playlist_info_document.rs @@ -3,12 +3,18 @@ use crate::utils::deserialize_as_option_string; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use serde_json::Value; -use crate::model::{PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties, VirtualId, XtreamCluster, XtreamMappingOptions}; +use crate::concat_string; +use crate::model::{PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, VideoStreamProperties, VirtualId, XtreamCluster, XtreamMappingOptions}; use crate::model::info_doc_utils::InfoDocUtils; #[inline] fn build_season_episode_field(season: u32, episode: u32, field: &str) -> String { - format!("nfo_ep_{season}_{episode}_{field}") + concat_string!("nfo_ep_", &season.to_string(), "_", &episode.to_string(), "_", field) +} + +#[inline] +fn build_season_field(season: u32, field: &str) -> String { + concat_string!("nfo_s_", &season.to_string(), "_", field) } #[allow(clippy::large_enum_variant)] @@ -84,11 +90,36 @@ pub struct XtreamVideoMovieData { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct XtreamSeriesInfoDoc { #[serde(default)] - pub seasons: Vec, + pub seasons: Vec, pub info: XtreamSeriesInfoData, pub episodes: HashMap>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct XtreamSeriesSeasonDoc { + #[serde(default)] + pub name: String, + #[serde(default)] + pub season_number: u32, + #[serde(default)] + pub episode_count: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overview: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub air_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover_tmdb: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cover_big: Option, + #[serde(default, rename = "releaseDate", skip_serializing_if = "Option::is_none")] + pub release_date: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct XtreamSeriesInfoData { pub name: String, @@ -130,6 +161,8 @@ pub struct XtreamSeriesEpisodeInfoData { #[serde(rename = "id")] pub tmdb_id: u32, pub air_date: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub crew: Option, pub rating: f64, pub movie_image: String, pub duration: String, @@ -164,7 +197,11 @@ impl StreamProperties { item_type: PlaylistItemType, virtual_id: VirtualId, category_id: u32) -> XtreamSeriesInfoDoc { let resource_url = options.get_resource_url(XtreamCluster::Series, item_type, virtual_id); XtreamSeriesInfoDoc { - seasons: Vec::new(), + seasons: if let Some(seasons) = series.details.as_ref().and_then(|d| d.seasons.as_ref()) { + self.series_seasons_to_info_document(resource_url.as_deref(), seasons) + } else { + Vec::new() + }, info: XtreamSeriesInfoData { name: series.name.clone(), cover: InfoDocUtils::make_resource_url(resource_url.as_deref(), series.cover.as_ref(), "cover"), @@ -280,6 +317,29 @@ impl StreamProperties { } } + fn series_seasons_to_info_document(&self, + resource_url: Option<&str>, + seasons: &[SeriesStreamDetailSeasonProperties]) -> Vec { + seasons.iter().map(|season| + XtreamSeriesSeasonDoc { + name: season.name.clone(), + season_number: season.season_number, + episode_count: season.episode_count.to_string(), + overview: season.cover_big.as_ref().map(|v| if v.starts_with("http") { + InfoDocUtils::make_resource_url(resource_url, v, &build_season_field(season.season_number, "overview")) + } else { + v.clone() + }), + air_date: season.air_date.clone(), + cover: season.cover.as_ref().map(|v| InfoDocUtils::make_resource_url(resource_url, v, &build_season_field(season.season_number, "cover"))), + cover_tmdb: season.cover_tmdb.as_ref().map(|v| InfoDocUtils::make_resource_url(resource_url, v, &build_season_field(season.season_number, "cover_tmdb"))), + cover_big: season.cover_big.as_ref().map(|v| InfoDocUtils::make_resource_url(resource_url, v, &build_season_field(season.season_number, "cover_big"))), + release_date: season.air_date.clone(), + duration: season.duration.clone(), + } + ).collect() + } + fn series_episodes_to_info_document(&self, options: &XtreamMappingOptions, resource_url: Option<&str>, episodes: &[SeriesStreamDetailEpisodeProperties]) -> HashMap> { @@ -293,6 +353,7 @@ impl StreamProperties { info: XtreamSeriesEpisodeInfoData { tmdb_id: ep.tmdb.unwrap_or_default(), air_date: ep.release_date.clone(), + crew: ep.crew.clone(), rating: ep.rating.unwrap_or_default(), movie_image: InfoDocUtils::make_resource_url(resource_url, &ep.movie_image, &build_season_episode_field(ep.season, ep.episode_num, "movie_image")), duration: ep.duration.clone(), diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 562e5be8f..21cb9bd18 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -104,6 +104,28 @@ pub struct VideoStreamProperties { pub details: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct SeriesStreamDetailSeasonProperties { + #[serde(default, deserialize_with = "string_default_on_null")] + pub name: String, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub season_number: u32, + #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] + pub episode_count: u32, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub overview: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub air_date: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub cover: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub cover_tmdb: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub cover_big: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub duration: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct SeriesStreamDetailEpisodeProperties { #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] @@ -128,6 +150,8 @@ pub struct SeriesStreamDetailEpisodeProperties { pub release_date: String, #[serde(default, deserialize_with = "deserialize_as_option_string")] pub plot: Option, + #[serde(default, deserialize_with = "deserialize_as_option_string")] + pub crew: Option, #[serde(default, deserialize_with = "deserialize_number_from_string_or_zero")] pub duration_secs: u32, #[serde(default, deserialize_with = "deserialize_as_string")] @@ -148,6 +172,7 @@ pub struct SeriesStreamDetailEpisodeProperties { pub struct SeriesStreamDetailProperties { #[serde(default, deserialize_with = "deserialize_number_from_string")] pub year: Option, + pub seasons: Option>, pub episodes: Option>, } @@ -438,6 +463,34 @@ impl StreamProperties { } return None; } + + if field.starts_with("nfo_s_") { + if let Some((season_num, field)) = parse_season_field(field) { + if let StreamProperties::Series(series) = self { + if let Some(details) = series.details.as_ref() { + if let Some(seasons) = details.seasons.as_ref() { + for season in seasons { + if season.season_number == season_num { + if field == "cover" { + return season.cover.as_ref().map(|c| Cow::Borrowed(c.as_str())); + } + if field == "cover_tmdb" { + return season.cover_tmdb.as_ref().map(|c| Cow::Borrowed(c.as_str())); + } + if field == "cover_big" { + return season.cover_big.as_ref().map(|c| Cow::Borrowed(c.as_str())); + } + if field == "overview" { + return season.overview.as_ref().map(|c| Cow::Borrowed(c.as_str())); + } + } + } + } + } + } + } + } + if field.starts_with("nfo_ep_") { if let Some((season, episode_num, field)) = parse_season_episode_field(field) { if let StreamProperties::Series(series) = self { @@ -456,8 +509,24 @@ impl StreamProperties { None } +} +fn parse_season_field(s: &str) -> Option<(u32, String)> { + let mut parts = s.split('_'); + let (prefix, suffix) = (parts.next()?, parts.next()?); + if prefix != "nfo" || suffix != "s" { + return None; + } + + let season: u32 = parts.next()?.parse().ok()?; + + let kind = parts.collect::>().join("_"); + if kind.is_empty() { + return None; + } + + Some((season, kind)) } fn parse_season_episode_field(s: &str) -> Option<(u32, u32, String)> { @@ -572,6 +641,21 @@ impl SeriesStreamProperties { tmdb: info.info.tmdb, details: Some(SeriesStreamDetailProperties { year: InfoDocUtils::extract_year_from_release_date(&info.info.release_date), + seasons: info.seasons.as_ref().map(|list| { + list.iter().map(|s| { + SeriesStreamDetailSeasonProperties { + name: s.name.clone(), + season_number: s.season_number, + episode_count: s.episode_count, + overview: Some(s.overview.clone()), + air_date: Some(s.air_date.clone()), + cover: Some(s.cover.clone()), + cover_tmdb: Some(s.cover_tmdb.clone()), + cover_big: Some(s.cover_big.clone()), + duration: Some(s.duration.clone()), + } + }).collect() + }), episodes: info.episodes.as_ref().map(|list| list.iter().map(|e| { SeriesStreamDetailEpisodeProperties { @@ -586,6 +670,7 @@ impl SeriesStreamProperties { tmdb: info.info.tmdb, release_date: e.info.as_ref().map(|i| i.air_date.clone()).unwrap_or_default(), plot: None, + crew: e.info.as_ref().map(|i| i.crew.clone()), duration_secs: e.info.as_ref().map(|i| i.duration_secs).unwrap_or_default(), duration: e.info.as_ref().map(|i| i.duration.clone()).unwrap_or_default(), movie_image: e.info.as_ref().map(|i| i.movie_image.clone()).unwrap_or_default(), diff --git a/shared/src/model/xtream.rs b/shared/src/model/xtream.rs index 42389f676..faac29efc 100644 --- a/shared/src/model/xtream.rs +++ b/shared/src/model/xtream.rs @@ -99,6 +99,10 @@ pub struct XtreamSeriesInfoSeason { pub cover: String, #[serde(default, deserialize_with = "string_default_on_null")] pub cover_big: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub cover_tmdb: String, + #[serde(default, deserialize_with = "string_default_on_null")] + pub duration: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)]