diff --git a/CHANGELOG.md b/CHANGELOG.md index f59089925..77e33e4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ * timeshift.php api implementation * New Filter `type` added can be uses as `Type = vod` or `Type = live` or `Type = series` * Counter in `mapping.yml`. Each mapper can have counters to add counter to specific fields. +* Added new mapper feature `transform`. `uppercase`, `lowercase` and `capitalize` supported. # 2.0.3(2024-07-11) * added `source` - `input` - `name` attribute to README diff --git a/README.md b/README.md index bff272436..99f683708 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,7 @@ Has the following top level entries: - `suffix` - `prefix` - `assignments` +- `transform` #### 2.3.3.1 `filter` The filter is a string with a statement (@see filter statements). @@ -692,6 +693,36 @@ assignments: ``` This configuration sets `title` property to the value of `name`. +#### 2.3.3.6 `transform` + +`transform` is a list of transformations. + +Each transformation can have the following attributes: +- `field` _mandatory_ the field where the transformation will be applied +- `modifier` _mandatory_, values are: `lowercase`, `uppercase` and `capitalize` +- `pattern` _optional_ is a regular expression (not filter!) with captures. Only needed when you want to transform parts of the property. + +For example: first 3 chars of channel name to lowercase: + +```yaml + mapper: + - pattern: 'Group ~ ".*"' + transform: + - field: name + pattern: "^(...)" + modifier: lowercase +``` + +channel name to uppercase: + +```yaml + mapper: + - pattern: 'Group ~ ".*"' + transform: + - field: name + modifier: uppercase +``` + ### 2.3.4 counter Each mapping can have a list of counter. diff --git a/src/filter.rs b/src/filter.rs index 605762d34..dd49f8fcb 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -222,10 +222,7 @@ fn get_parser_regexp(expr: &Pair, templates: &Vec) -> Res let mut parsed_text = String::from(expr.as_str()); parsed_text.pop(); parsed_text.remove(0); - let mut regstr = String::from(parsed_text.as_str()); - for t in templates { - regstr = regstr.replace(format!("!{}!", &t.name).as_str(), &t.value); - } + let regstr = apply_templates_to_pattern(&parsed_text, templates); let re = regex::Regex::new(regstr.as_str()); if re.is_err() { return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "cant parse regex: {}", regstr); @@ -369,10 +366,7 @@ fn get_parser_binary_op(expr: &Pair) -> Result>) -> Result { let empty_list = Vec::new(); let template_list: &Vec = templates.unwrap_or(&empty_list); - let mut source = String::from(filter_text); - for t in template_list { - source = source.replace(format!("!{}!", &t.name).as_str(), &t.value); - } + let source = apply_templates_to_pattern(filter_text, template_list); match FilterParser::parse(Rule::main, &source) { Ok(pairs) => { @@ -515,3 +509,11 @@ pub(crate) fn prepare_templates(templates: &Vec) -> Result) -> String { + let mut new_pattern = pattern.to_string(); + for template in templates { + new_pattern = new_pattern.replace(format!("!{}!", &template.name).as_str(), &template.value); + } + new_pattern +} diff --git a/src/model/config.rs b/src/model/config.rs index fa15f0dd8..0c4fb6943 100644 --- a/src/model/config.rs +++ b/src/model/config.rs @@ -33,7 +33,6 @@ pub(crate) const MAPPER_ATTRIBUTE_FIELDS: &[&str] = &[ pub(crate) const AFFIX_FIELDS: &[&str] = &["name", "title", "group"]; - pub(crate) const COUNTER_FIELDS: &[&str] = &["name", "title", "chno"]; #[macro_export] diff --git a/src/model/mapping.rs b/src/model/mapping.rs index 6c10d8a4f..41e2b8019 100644 --- a/src/model/mapping.rs +++ b/src/model/mapping.rs @@ -1,21 +1,23 @@ +use std::borrow::Cow; use std::cell::RefCell; use std::collections::HashMap; use std::fmt::Display; use std::rc::Rc; use std::str::FromStr; use std::sync::{Arc, Mutex}; -use enum_iterator::Sequence; +use enum_iterator::Sequence; use log::{debug, error}; use regex::Regex; use crate::{create_m3u_filter_error_result, handle_m3u_filter_error_result, valid_property}; -use crate::filter::{Filter, get_filter, PatternTemplate, prepare_templates, RegexWithCaptures, ValueProcessor}; +use crate::filter::{apply_templates_to_pattern, Filter, get_filter, PatternTemplate, prepare_templates, RegexWithCaptures, ValueProcessor}; use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind}; use crate::model::config::{AFFIX_FIELDS, COUNTER_FIELDS, ItemField, MAPPER_ATTRIBUTE_FIELDS}; use crate::model::playlist::{FieldAccessor, PlaylistItem}; use crate::utils::default_utils::{default_as_empty_map, default_as_empty_str, default_as_false, default_as_zero_u32}; +use crate::utils::string_utils::Capitalize; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct MappingTag { @@ -93,6 +95,80 @@ pub(crate) struct MappingCounter { pub value: Arc>, } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Sequence, PartialEq)] +pub(crate) enum TransformModifier { + #[serde(rename = "lowercase")] + Lowercase, + #[serde(rename = "uppercase")] + Uppercase, + #[serde(rename = "capitalize")] + Capitalize, +} + +impl Default for TransformModifier { + fn default() -> Self { + Self::Lowercase + } +} + +impl Display for TransformModifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", match self { + Self::Lowercase => "lowercase", + Self::Uppercase => "uppercase", + Self::Capitalize => "capitalize" + }) + } +} + +impl FromStr for TransformModifier { + type Err = M3uFilterError; + + fn from_str(s: &str) -> Result { + if s.eq("lowercase") { + Ok(Self::Lowercase) + } else if s.eq("uppercase") { + Ok(Self::Uppercase) + } else if s.eq("capitalize") { + Ok(Self::Capitalize) + } else { + create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "Unknown TransformModifier: {}", s) + } + } +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct MapperTransform { + pub field: String, + pub modifier: TransformModifier, + pub pattern: Option, + #[serde(skip_serializing, skip_deserializing)] + pub t_pattern: Option, +} + +impl MapperTransform { + pub fn prepare(&mut self, templates: Option<&Vec>) -> Result<(), M3uFilterError> { + match &self.pattern { + None => self.t_pattern = None, + Some(pattern) => { + let mut new_pattern = pattern.to_string(); + match templates { + None => {} + Some(template_list) => { + new_pattern = apply_templates_to_pattern(pattern, template_list); + } + } + let re = Regex::new(&*new_pattern); + if re.is_err() { + return create_m3u_filter_error_result!(M3uFilterErrorKind::Info, "cant parse regex: {}", new_pattern); + } + self.t_pattern = Some(re.unwrap()); + } + } + Ok(()) + } +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub(crate) struct Mapper { pub filter: Option, @@ -105,16 +181,17 @@ pub(crate) struct Mapper { prefix: HashMap, #[serde(default = "default_as_empty_map")] assignments: HashMap, + transform: Option>, #[serde(skip_serializing, skip_deserializing)] pub(crate) t_filter: Option, #[serde(skip_serializing, skip_deserializing)] pub(crate) t_pattern: Option, #[serde(skip_serializing, skip_deserializing)] - pub t_tags: Vec, + t_tags: Vec, #[serde(skip_serializing, skip_deserializing)] - pub t_tagre: Option, + t_tagre: Option, #[serde(skip_serializing, skip_deserializing)] - pub t_attre: Option, + t_attre: Option, } impl Mapper { @@ -142,6 +219,22 @@ impl Mapper { return Err(M3uFilterError::new(M3uFilterErrorKind::Info, format!("Invalid mapper assignment field {value}"))); } } + + match &mut self.transform { + None => {} + Some(transforms) => { + for t in transforms { + let field = t.field.as_str(); + if !valid_property!(field, AFFIX_FIELDS) { + return Err(M3uFilterError::new(M3uFilterErrorKind::Info, format!("Invalid mapper transform field {field}"))); + } + if let Err(err) = t.prepare(templates) { + return Err(err); + } + } + } + } + match get_filter(&self.pattern, templates) { Ok(pattern) => { self.t_pattern = Some(pattern); @@ -275,6 +368,35 @@ impl MappingValueProcessor<'_> { } } } + + fn apply_transform_modifier(modifier: &TransformModifier, value: &str) -> String { + match modifier { + TransformModifier::Uppercase => value.to_uppercase(), + TransformModifier::Lowercase => value.to_lowercase(), + TransformModifier::Capitalize => value.capitalize(), + } + } + + fn apply_transform(&mut self) { + let mapper = self.mapper; + match &mapper.transform { + None => {} + Some(transform_list) => { + for transform in transform_list { + if let Some(prop_value) = self.get_property(&transform.field) { + let value = if let Some(regex) = &transform.t_pattern { + regex.replace_all(&prop_value, |caps: ®ex::Captures| { + Self::apply_transform_modifier(&transform.modifier, &caps[0]) + }) + } else { + Cow::from(Self::apply_transform_modifier(&transform.modifier, prop_value.as_str())) + }; + self.set_property(&transform.field, &value); + } + } + } + } + } } impl ValueProcessor for MappingValueProcessor<'_> { @@ -300,6 +422,7 @@ impl ValueProcessor for MappingValueProcessor<'_> { MappingValueProcessor::<'_>::apply_suffix(self, &captured_values); MappingValueProcessor::<'_>::apply_prefix(self, &captured_values); MappingValueProcessor::<'_>::apply_assignments(self); + MappingValueProcessor::<'_>::apply_transform(self); true } } diff --git a/src/utils/config_reader.rs b/src/utils/config_reader.rs index d79f1f7c2..2cea968e3 100644 --- a/src/utils/config_reader.rs +++ b/src/utils/config_reader.rs @@ -71,10 +71,16 @@ pub(crate) fn read_config(config_path: &str, config_file: &str, sources_file: &s pub(crate) fn read_mapping(mapping_file: &str) -> Result, M3uFilterError> { let mapping_file = std::path::PathBuf::from(mapping_file); if let Ok(file) = file_utils::open_file(&mapping_file) { + info!("Mapping file: {}", mapping_file.to_str().unwrap_or("?")); let mapping: Result = serde_yaml::from_reader(file); - if let Ok(mut result) = mapping { - handle_m3u_filter_error_result!(M3uFilterErrorKind::Info, result.prepare()); - return Ok(Some(result)); + match mapping { + Ok(mut result) => { + handle_m3u_filter_error_result!(M3uFilterErrorKind::Info, result.prepare()); + return Ok(Some(result)); + }, + Err(err) => { + return Err(M3uFilterError::new(M3uFilterErrorKind::Info, err.to_string())); + } } } warn!("cant read mapping file: {}", mapping_file.to_str().unwrap_or("?")); diff --git a/src/utils/string_utils.rs b/src/utils/string_utils.rs index 3fc08b113..22bad89e8 100644 --- a/src/utils/string_utils.rs +++ b/src/utils/string_utils.rs @@ -12,3 +12,25 @@ pub (crate) fn get_title_group(text: &str) -> String { } text.to_string() } + +pub(crate) trait Capitalize { + fn capitalize(&self) -> String; +} + +// Implement the Capitalize trait for &str +impl Capitalize for &str { + fn capitalize(&self) -> String { + let mut chars = self.chars(); + match chars.next() { + None => String::new(), // Handle empty string case + Some(first_char) => first_char.to_uppercase().collect::() + chars.as_str(), + } + } +} + +// Implement the trait for String as well +impl Capitalize for String { + fn capitalize(&self) -> String { + self.as_str().capitalize() // Reuse the &str implementation + } +} \ No newline at end of file diff --git a/test/rest-api.http b/test/rest-api.http index c979b4fee..01c45fe29 100644 --- a/test/rest-api.http +++ b/test/rest-api.http @@ -19,8 +19,8 @@ Content-Type: application/json ### xtream live_categories GET http://localhost:8901/m3u?username=me&password=me -### xtream live_categories -GET http://localhost:8901/player_api.php?action=get_live_categories&username=xt&password=xt +### xtream live_streams +GET http://localhost:8901/player_api.php?action=get_live_streams&username=xt&password=xt&category_id=203 ### xtream series_categories GET http://localhost:8901/player_api.php?action=get_series_categories&username=xt&password=xt