diff --git a/README.md b/README.md index 09dfb8023..4a4591516 100644 --- a/README.md +++ b/README.md @@ -1140,7 +1140,7 @@ This is a list of sort configurations. Each configuration has the following top- - `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. +The pattern should be selected considering the processing sequence. ```yaml sort: @@ -1165,7 +1165,7 @@ sort: - '(?P.*?)\bSD\b' ``` In the example above, groups are sorted based on the specified sequence. -Channels within the `Freetv` group are first sorted by `quality` (as matched by the regex sequence), and then by the `captured prefix`. +Channels within the `Freetv` group are first sorted by `quality` (as matched by the regexp sequence), and then by the `captured prefix`. To sort by specific parts of the content, use named capture groups such as `c1`, `c2`, `c3`, etc. The numeric suffix indicates the priority: `c1` is evaluated first, followed by `c2`, and so on. @@ -1556,8 +1556,8 @@ It is whitespace-tolerant and uses familiar programming concepts with a custom s - FieldNames: `Playlist Field Names` starting with `@` following compose of ASCII alphanumeric characters and underscores. - Strings / Text: Enclosed in double quotes. "example string" - Null value `null` -- Regex Matching: `@FieldName ~ "Regex"` like in filter statements. You can match a `FieldName` or a existing `variable`. -- Access a field in a regex match result: with `result.capture`. For example, if you have multiple captures you can access them by their name, or their index beginning at `1` like `result.1`, `result.2`. +- Regexp Matching: `@FieldName ~ "Regexp"` like in filter statements. You can match a `FieldName` or a existing `variable`. +- Access a field in a regexp match result: with `result.capture`. For example, if you have multiple captures you can access them by their name, or their index beginning at `1` like `result.1`, `result.2`. - Builtin functions: - concat(a, b, ...) - uppercase(a) @@ -2541,7 +2541,7 @@ The result should look like @Group = concat("FR | MOVIES ", year_group) ``` Filter: Matches channels where the Group starts with "FR" and the Caption ends in a 4-digit year (optionally inside parentheses). -Regex extraction: Pulls the 4-digit year from the caption. +Regexp extraction: Pulls the 4-digit year from the caption. Mapping: If the year is ≤ 2019, it maps to " < 2020". Otherwise, the group is named by the actual year (e.g., "2021"). diff --git a/backend/src/api/endpoints/xtream_api.rs b/backend/src/api/endpoints/xtream_api.rs index f36e8510c..d8b17f7f7 100644 --- a/backend/src/api/endpoints/xtream_api.rs +++ b/backend/src/api/endpoints/xtream_api.rs @@ -34,13 +34,12 @@ use log::{debug, error, warn}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use shared::error::{info_err_res, info_err, TuliproxError}; -use shared::model::{create_stream_channel_with_type, PlaylistEntry, PlaylistItemType, ProxyType, - TargetType, UserConnectionPermission, XtreamCluster}; -use shared::utils::{ deserialize_as_string, extract_extension_from_url, generate_playlist_uuid, - sanitize_sensitive_info, trim_slash, HLS_EXT}; +use shared::model::{create_stream_channel_with_type, PlaylistEntry, PlaylistItemType, ProxyType, TargetType, UserConnectionPermission, XtreamCluster, XtreamPlaylistItem}; +use shared::utils::{deserialize_as_string, extract_extension_from_url, generate_playlist_uuid, sanitize_sensitive_info, trim_slash, HLS_EXT}; use std::fmt::{Display, Formatter}; use std::str::FromStr; use std::sync::Arc; +use shared::concat_string; #[derive(Serialize, Deserialize, Debug, Copy, Clone, Eq, PartialEq)] pub enum ApiStreamContext { @@ -188,6 +187,7 @@ pub(in crate::api) fn get_xtream_player_api_stream_url( async fn get_user_info(user: &ProxyUserCredentials, app_state: &AppState) -> XtreamAuthorizationResponse { let server_info = app_state.app_config.get_user_server_info(user); let active_connections = app_state.get_active_connections_for_user(&user.username).await; + XtreamAuthorizationResponse::new( &server_info, user, @@ -353,16 +353,7 @@ async fn xtream_player_api_stream( return response.into_response(); } - let extension = stream_ext.unwrap_or_else(|| { - extract_extension_from_url(&pli.url) - .map_or_else(String::new, std::string::ToString::to_string) - }); - - let query_path = if stream_req.action_path.is_empty() { - format!("{}{extension}", pli.provider_id) - } else { - format!("{}/{}{extension}", stream_req.action_path, pli.provider_id) - }; + let (query_path, extension) = get_query_path(stream_req.action_path, stream_ext.as_ref(), &pli); let stream_url = try_option_bad_request!( get_xtream_player_api_stream_url(&input, stream_req.context, &query_path, session_url), @@ -411,6 +402,27 @@ async fn xtream_player_api_stream( .into_response() } +fn get_query_path(action_path: &str, stream_ext: Option<&String>, pli: &XtreamPlaylistItem) -> (String, String) { + let provider_id = pli.provider_id.to_string(); + + let extracted_ext; + let extension: &str = if pli.item_type.is_live() { + "" + } else if let Some(ext) = stream_ext { + ext + } else { + extracted_ext = extract_extension_from_url(&pli.url); + extracted_ext.unwrap_or("") + }; + + let query_path = if action_path.is_empty() { + concat_string!(&provider_id, extension) + } else { + concat_string!(action_path, "/", &provider_id, extension) + }; + (query_path, extension.to_string()) +} + #[allow(clippy::too_many_lines)] // Used by webui async fn xtream_player_api_stream_with_token( diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 23473873e..479c158f5 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -220,6 +220,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info( Some(StreamProperties::Video(Box::new(properties))) } +#[allow(clippy::too_many_lines)] pub fn metadata_cache_entry_to_xtream_series_info( entry: &MetadataCacheEntry, ) -> Option { @@ -257,7 +258,7 @@ 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(|| { + let season_entry =season_data.entry(episode.season).or_insert_with(|| { SeriesStreamDetailSeasonProperties { name: concat_string!(&series.title, " ", &episode.season.to_string()), season_number: episode.season, @@ -269,7 +270,8 @@ pub fn metadata_cache_entry_to_xtream_series_info( cover_big: None, duration: None, } - }).episode_count.saturating_add(1); + }); + season_entry.episode_count = season_entry.episode_count.saturating_add(1); SeriesStreamDetailEpisodeProperties { id: tmdb_id.unwrap_or_default(), @@ -283,7 +285,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(", ")), + crew: Some(actor_names.clone()), duration_secs: episode.runtime.map_or(0, |r| r * 60), duration: episode.runtime .map(|r| format!("{:02}:{:02}:00", r / 60, r % 60)) diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index 5ce5d20f3..1bf3abfae 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -116,7 +116,7 @@ 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, &r.new_name); + 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); } diff --git a/backend/src/processing/processor/sort.rs b/backend/src/processing/processor/sort.rs index 54dfef91f..c837ff678 100644 --- a/backend/src/processing/processor/sort.rs +++ b/backend/src/processing/processor/sort.rs @@ -1,13 +1,14 @@ use crate::model::{ConfigSortRule, ConfigTarget}; -use std::cmp::Ordering; -use shared::foundation::filter::{ValueProvider}; +use shared::foundation::filter::ValueProvider; use shared::model::{PlaylistGroup, SortOrder, SortTarget}; +use std::cmp::Ordering; 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, + match (order, ordering) { + (SortOrder::None, _) + | (_, Ordering::Equal) => Ordering::Equal, + (SortOrder::Asc, o) => o, + (SortOrder::Desc, o) => o.reverse(), } } @@ -134,14 +135,16 @@ fn sort_groups( return; } - groups.sort_by(|a_grp, b_grp| { - for rule in &group_rules { - if rule.order == SortOrder::None { - continue; - } - - let (Some(a_chan), Some(b_chan)) = (a_grp.channels.first(), b_grp.channels.first()) else { - continue; + for rule in &group_rules { + if rule.order == SortOrder::None { + continue; + } + groups.sort_by(|a_grp, b_grp| { + let (a_chan, b_chan) = match (a_grp.channels.first(), b_grp.channels.first()) { + (None, None) => return Ordering::Equal, + (Some(_), None) => return direction(rule.order, Ordering::Less), + (None, Some(_)) => return direction(rule.order, Ordering::Greater), + (Some(a), Some(b)) => (a, b), }; let provider_a = ValueProvider { pli: a_chan, match_as_ascii }; @@ -149,9 +152,9 @@ fn sort_groups( match (rule.filter.filter(&provider_a), rule.filter.filter(&provider_b)) { (false, false) => return Ordering::Equal, - (true, false) => return direction(rule.order, Ordering::Less), + (true, false) => return direction(rule.order, Ordering::Less), (false, true) => return direction(rule.order, Ordering::Greater), - (true, true) => { /* fallthrough */} + (true, true) => { /* fallthrough */ } } let va = provider_a.get(rule.field.as_str()); @@ -173,10 +176,9 @@ fn sort_groups( } } } - } - - Ordering::Equal - }); + Ordering::Equal + }); + } } fn sort_channels_in_groups( @@ -193,59 +195,53 @@ fn sort_channels_in_groups( return; } - for group in groups { - group.channels.sort_by(|a, b| { - for rule in &channel_rules { - if rule.order == SortOrder::None { - continue; - } + for rule in channel_rules { + for group in &mut *groups { + if rule.order == SortOrder::None { + group.channels.sort_by(|a, b| + a.header.source_ordinal.cmp(&b.header.source_ordinal)); + } else { + group.channels.sort_by(|a, b| { + let provider_a = ValueProvider { pli: a, match_as_ascii }; + let provider_b = ValueProvider { pli: b, match_as_ascii }; - 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) => return Ordering::Equal, + (true, false) => return direction(rule.order, Ordering::Less), + (false, true) => return direction(rule.order, Ordering::Greater), + (true, true) => { /* fallthrough */ } + } - 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; + let va = provider_a.get(rule.field.as_str()); + let vb = provider_b.get(rule.field.as_str()); + match (va, vb) { + (None, None) => Ordering::Equal, + (Some(_), None) => direction(rule.order, Ordering::Less), + (None, Some(_)) => direction(rule.order, Ordering::Greater), + (Some(va), Some(vb)) => { + playlist_comparator( + rule.sequence.as_ref(), + rule.order, + &va, + &vb, + ) } } - } + }); } - - a.header.source_ordinal.cmp(&b.header.source_ordinal) - }); + } } } - #[cfg(test)] mod tests { - use std::cmp::Ordering; + use crate::model::ConfigSortRule; + use crate::processing::processor::sort::playlist_comparator; use regex::Regex; use shared::foundation::filter::Filter; use shared::model::{ItemField, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget}; - use crate::model::ConfigSortRule; - use crate::processing::processor::sort::playlist_comparator; + use std::cmp::Ordering; #[test] fn test_sort() { @@ -422,5 +418,4 @@ mod tests { assert_eq!(expected, sorted); } - } diff --git a/backend/src/repository/user_repository.rs b/backend/src/repository/user_repository.rs index 9c798b2aa..8c746a0aa 100644 --- a/backend/src/repository/user_repository.rs +++ b/backend/src/repository/user_repository.rs @@ -370,6 +370,17 @@ pub(crate) async fn user_get_series_bouquet(cfg: &Config, username: &str, target user_get_cluster_bouquet(cfg, username, target, XtreamCluster::Series).await } +/// Returns a filter set for bouquet categories based on user preferences. +/// +/// # Arguments +/// * `category_id` - Optional category filter: +/// - `Some(id)` where id > 0: Returns only the specified category +/// - `Some(0)`: Treated as uncategorized, uses bouquet filtering +/// - `None`: Uses user's saved bouquet configuration +/// +/// # Returns +/// * `Some(HashSet)` - Set of category IDs or names to include +/// * `None` - No filtering applied 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 { if cid > 0 { diff --git a/frontend/src/services/config_service.rs b/frontend/src/services/config_service.rs index 91024c060..283bcb83a 100644 --- a/frontend/src/services/config_service.rs +++ b/frontend/src/services/config_service.rs @@ -78,8 +78,8 @@ impl ConfigService { 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 rule in sort.rules.iter_mut() { + rule.t_filter = get_filter(&rule.filter, templates.as_ref()).map_err(|e| error!("Failed to parse groups sort filter: {}", e)).ok(); } } for output in target.output.iter_mut() { diff --git a/shared/src/model/config/sort.rs b/shared/src/model/config/sort.rs index 9df82ec9a..70acb9385 100644 --- a/shared/src/model/config/sort.rs +++ b/shared/src/model/config/sort.rs @@ -29,14 +29,19 @@ pub enum SortOrder { None, } +impl SortOrder { + pub fn as_str(&self) -> &'static str { + match *self { + Self::Asc => "asc", + Self::Desc => "desc", + Self::None => "none", + } + } +} + impl Display for SortOrder { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let str = match self { - SortOrder::Asc => "asc".to_string(), - SortOrder::Desc => "desc".to_string(), - SortOrder::None => "none".to_string(), - }; - write!(f, "{str}") + f.write_str(self.as_str()) } } @@ -115,6 +120,7 @@ pub struct ConfigSortRuleDto { impl PartialEq for ConfigSortRuleDto { fn eq(&self, other: &Self) -> bool { self.field == other.field + && self.target == other.target && self.order == other.order && self.sequence == other.sequence && self.filter == other.filter @@ -124,11 +130,11 @@ impl PartialEq for ConfigSortRuleDto { impl ConfigSortRuleDto { pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), TuliproxError> { - if self.target == SortTarget::Group { + 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.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)?); diff --git a/shared/src/model/playlist.rs b/shared/src/model/playlist.rs index 7affa2b24..cecebecc3 100644 --- a/shared/src/model/playlist.rs +++ b/shared/src/model/playlist.rs @@ -771,6 +771,11 @@ impl XtreamPlaylistItem { } pub fn resolve_resource_url<'a>(&'a self, field: &str) -> Option> { + if field == "logo" { + return Some(Cow::Borrowed(&self.logo)); + } else if field == "logo_small" { + return Some(Cow::Borrowed(&self.logo_small)); + } self.additional_properties.as_ref().and_then(|a| a.resolve_resource_url(field)) } } diff --git a/shared/src/model/playlist_info_document.rs b/shared/src/model/playlist_info_document.rs index ad2b800c2..3e9182423 100644 --- a/shared/src/model/playlist_info_document.rs +++ b/shared/src/model/playlist_info_document.rs @@ -325,7 +325,7 @@ impl StreamProperties { 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") { + overview: season.overview.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() diff --git a/shared/src/model/stream_properties.rs b/shared/src/model/stream_properties.rs index 21cb9bd18..a3361f6da 100644 --- a/shared/src/model/stream_properties.rs +++ b/shared/src/model/stream_properties.rs @@ -172,7 +172,9 @@ pub struct SeriesStreamDetailEpisodeProperties { pub struct SeriesStreamDetailProperties { #[serde(default, deserialize_with = "deserialize_number_from_string")] pub year: Option, + #[serde(default)] pub seasons: Option>, + #[serde(default)] pub episodes: Option>, }