Feature/mapper built in functions (#945)

* title_case, slug, normalize_spaces, coalesce, and deunicode mapper functions added
This commit is contained in:
euzu
2026-10-28 22:27:34 +01:00
committed by GitHub
parent 44d40da526
commit 6c238cf5a8
11 changed files with 770 additions and 136 deletions
+19
View File
@@ -192,6 +192,21 @@
## 🌟 New Features
- **New mapper built-in functions: `title_case`, `slug`, `normalize_spaces`, `coalesce`, and `deunicode`.** Five new
helper functions for playlist transformations in the mapper DSL:
- `title_case`: Capitalizes the first character of every word and lowercases the rest: `@Title = title_case(@Title)`
turns `us: tnt hd` into `Us: Tnt Hd`. Only whitespace separates words, so `spider-man` becomes `Spider-man`. An
optional array keeps listed acronyms in capitals: `title_case("us: tnt hd", ["TNT", "HD"])` becomes
`Us: TNT HD`. The existing `capitalize` still touches only the first character of the whole string.
- `slug`: Converts text into a lowercase, ASCII-transliterated slug with `-` separators (`slug("US: HBO Max HD!")`
becomes `us-hbo-max-hd`).
- `normalize_spaces`: Collapses multiple consecutive whitespace characters into a single space and trims outer
edges (`normalize_spaces(" US | ESPN HD ")` becomes `US | ESPN HD`).
- `coalesce`: Returns the first non-empty, non-blank value across arguments (`@Logo = coalesce(@Logo, @Logo_Small, "default.png")`).
Arguments are evaluated left to right and evaluation stops at the first usable value.
- `deunicode`: Transliterates Unicode characters to their closest ASCII representation (`deunicode("Univisión: Café y Noticias")`
becomes `Univision: Cafe y Noticias`).
- **Configurable credential generation for new users.** The username, password and token pre-filled when creating
an API user in the Web UI can now be tuned via `credential_generation` in `api-proxy.yml`: length (1-64), allowed
`chars` with ranges, `exclude`, or ordered `segments` (for example 3 letters followed by 3 digits). Defaults match the
@@ -1217,6 +1232,10 @@
## 🐛 Fixes
- **Mapper arity errors name the function the user wrote.** A mapper script with the wrong number of arguments reported
the internal enum variant (`Function accepts only one argument TitleCase`) instead of the function name
(`Function accepts only one argument title_case`).
- **VOD from providers that label ranged media `text/html` plays again in reverse proxy mode.** The
"Provider returned HTML content" check rejected every non-adaptive success response with a `text/html`
content type, so a valid `206` movie (for example Matroska) served with the wrong type fell back to the
+62 -1
View File
@@ -184,7 +184,8 @@ Tuliprox updates both the `Title` AND the `Name` to the same value).*
| `concat(a, b, ...)` | Concatenates multiple strings. | `concat("US \| ", @Title)` |
| `uppercase(a)` | Converts text to UPPERCASE. | `uppercase(@Group)` |
| `lowercase(a)` | Converts text to lowercase. | `lowercase(@Genre)` |
| `capitalize(a)` | Title Case (Capitalizes the first letter of words). | `capitalize(@Title)` |
| `capitalize(a)` | Capitalizes only the first character of the whole string, lowercases the rest. | `capitalize(@Title)` |
| `title_case(a, acronyms?)` | Capitalizes the first character of every word and lowercases the rest. The optional second argument keeps the listed acronyms in capitals. | `title_case(@Title, ["HD"])` |
| `split(a, delim)` | Splits a string and returns a Named list (iterable). | `split(@Genre, ",")` |
| `trim(a)` | Removes whitespace from the edges. | `trim(@Title)` |
| `replace(a, b, c)` | Simple text Search (b) & Replace (c). | `replace(@Title, "FHD", "")` |
@@ -195,6 +196,66 @@ Tuliprox updates both the `Title` AND the `Name` to the same value).*
| `first(list)` | Returns the first element of a Named list/Regex match. | `first(@Caption ~ "(\d+)")` |
| `print(a, b, ...)` | Logs the values to the console (Requires `trace` log level). | `print("Matched:", @Title)` |
| `add_favourite(grp)` | **Background:** Clone function! Takes the currently processed stream, changes its group to `grp`, generates a clean alias UUID, and adds the stream additionally (as a "Favorite") to the playlist. | `add_favourite("Top 10")` |
| `slug(a)` | Converts text to a lowercase URL- and filename-safe slug (ASCII only, `-` separated). | `slug(@Title)` |
| `normalize_spaces(a)` | Collapses consecutive whitespace into a single space and trims edges. | `normalize_spaces(@Title)` |
| `coalesce(a, b, ...)` | Returns the first non-empty, non-blank argument. | `coalesce(@Logo, "fallback")` |
| `deunicode(a)` | Transliterates Unicode characters to ASCII (e.g. `ä` -> `a`, `é` -> `e`). | `deunicode(@Title)` |
Function arguments may contain an array literal: a comma-separated list of quoted strings in square brackets, for
example `["HD", "FHD", "UK"]`. Arrays are currently accepted as the acronym list of `title_case`.
Arguments are positional. `pad`, `replace` and `format` map every argument to the parameter at the position where it
was written, even when an earlier argument evaluates to nothing (for example an unmatched regex capture). The later
arguments keep their own slots instead of moving up, so `format("{} {}", part, "B")` still puts `B` in the second
placeholder and leaves the first as `{}`. `pad` produces no value when text, width or fill is missing, which leaves the
target field unchanged; `replace` returns the text unchanged when the pattern or the replacement is missing.
#### Choosing a case function
All four case functions lowercase the parts they do not uppercase (except acronyms passed to `title_case`), so they
normalise the input rather than only adding capitals. They differ in how much of the string they touch:
```dsl
@Name = capitalize("us: tnt hd") # Us: tnt hd -> first character only
@Title = title_case("us: tnt hd") # Us: Tnt Hd -> first character of every word
@Title = title_case("us: tnt hd", ["TNT", "HD"]) # Us: TNT HD -> acronyms keep their capitals
@Group = uppercase("us: tnt hd") # US: TNT HD
@Genre = lowercase("US: TNT HD") # us: tnt hd
```
The second `title_case` argument is optional. Without it the function title-cases every word; with an array literal it
additionally keeps the listed acronyms in the spelling given in the array.
`title_case` treats only whitespace as a word boundary, so `"spider-man"` becomes `"Spider-man"` and `"o'neill"`
becomes `"O'neill"`. Hyphens and apostrophes therefore stay inside a word. Because the remainder of each word is
lowercased, acronyms such as `"TNT"` become `"Tnt"`. Pass them as the optional array argument to keep their capitals:
```dsl
@Name = title_case("us: tnt hd", ["TNT", "HD"]) # Us: TNT HD
```
Do not run `uppercase` over the result to restore an acronym; it changes the casing of the entire title. To uppercase
only one acronym in an existing title, use a targeted `replace` after `title_case`:
```dsl
@Name = replace(title_case(@Name), " Tnt ", " TNT ")
```
#### Normalization and fallback functions
```dsl
# Create URL- and filename-safe IDs (e.g. for EPG or STRM)
@epg_channel_id = slug("US: HBO Max HD!") # us-hbo-max-hd
# Collapse messy whitespace from playlist titles
@Title = normalize_spaces(" US | ESPN HD ") # US | ESPN HD
# Transliterate Unicode characters to plain ASCII
@Name = deunicode("Univisión: Café y Noticias") # Univision: Cafe y Noticias
# Pick first non-empty, non-blank value with fallback
@Logo = coalesce(@Logo, @Logo_Small, "https://example.com/default.png")
```
### 2. RegEx Captures & Extraction
@@ -72,8 +72,13 @@ fn render_args(args: &[ExprId], script: &MapperScript, format_params: &mut Forma
html! {
<>
{
for args.iter().map(|expr_id| {
render_expression(expr_id, script, format_params)
for args.iter().enumerate().map(|(i, expr_id)| {
let rendered = render_expression(expr_id, script, format_params);
if i < args.len() - 1 {
html! { <>{ rendered }{", "}</> }
} else {
rendered
}
})
}
</>
@@ -115,6 +120,16 @@ fn render_literal(literal: &str) -> Html {
html! { <span class="literal">{"'"}{ literal }{"'"}</span> }
}
fn render_array(values: &[String]) -> Html {
html! {
<>
<span class="bracket">{"["}</span>
{ render_joined_keys(values, |value| render_literal(value)) }
<span class="bracket">{"]"}</span>
</>
}
}
fn render_num_literal(literal: &f64) -> Html {
html! { <span class="num-literal">{ *literal }</span> }
}
@@ -331,6 +346,7 @@ fn render_expression(expr_id: &ExprId, script: &MapperScript, format_params: &mu
|expression| match expression {
Expression::Identifier(ident) => render_identifier(ident),
Expression::StringLiteral(literal) => render_literal(literal),
Expression::ArrayLiteral(values) => render_array(values),
Expression::NumberLiteral(num) => render_num_literal(num),
Expression::FieldAccess(field) => render_field(field),
Expression::VarAccess(name, field) => render_var_access(name, field),
+4 -3
View File
@@ -4,13 +4,14 @@ null = { "null" }
identifier = @{ !null ~ (ASCII_ALPHANUMERIC | "_")+ }
var_access = { identifier ~ ("." ~ identifier)? }
string_literal = @{ "\"" ~ ( "\\\\" | "\\\"" | "\\n" | "\\t" | "\\r" | (!"\"" ~ ANY) )* ~ "\"" }
array_literal = { "[" ~ (string_literal ~ ("," ~ string_literal)*)? ~ "]" }
number = @{ "-"? ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }
number_range_from = { number ~ ".." }
number_range_to = { ".." ~ number }
number_range_full = { number ~ ".." ~ number }
number_range_eq = { number }
number_range = _{ number_range_full | number_range_from | number_range_to | number_range_eq}
read_write_field = _{ ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo" | ^"logo_small" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" | ^"genre" }
read_write_field = _{ ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo_small" | ^"logo" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" | ^"genre" }
read_only_field = _{ ^"input" | ^"type" }
field = { read_write_field | read_only_field }
assignment_field = { read_write_field }
@@ -20,8 +21,8 @@ regex_expr = { regex_source ~ regex_op ~ string_literal }
block_expr = { "{" ~ statements ~ "}" }
condition = { function_call | var_access | field_access }
assignment = { (("@" ~ assignment_field) | identifier) ~ "=" ~ expression }
expression = { assignment | map_block | match_block | for_each_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr }
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "split" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" }
expression = { assignment | map_block | match_block | for_each_block | function_call | regex_expr | array_literal | string_literal | number | var_access | field_access | null | block_expr }
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "title_case" | "split" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" | "slug" | "normalize_spaces" | "coalesce" | "deunicode" }
function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" }
any_match = { "_" }
match_case_key = { any_match | identifier }
+4 -2
View File
@@ -71,6 +71,7 @@ pub enum RegexSource {
pub enum Expression {
Identifier(String),
StringLiteral(String),
ArrayLiteral(Vec<String>),
NumberLiteral(f64),
FieldAccess(String),
VarAccess(String, String),
@@ -87,12 +88,13 @@ pub enum Expression {
impl PartialEq for Expression {
fn eq(&self, other: &Self) -> bool {
use Expression::{
Assignment, Block, FieldAccess, ForEachBlock, FunctionCall, Identifier, MapBlock, MatchBlock, NullValue,
NumberLiteral, RegexExpr, StringLiteral, VarAccess,
ArrayLiteral, Assignment, Block, FieldAccess, ForEachBlock, FunctionCall, Identifier, MapBlock, MatchBlock,
NullValue, NumberLiteral, RegexExpr, StringLiteral, VarAccess,
};
match (self, other) {
(Identifier(a), Identifier(b)) => a == b,
(StringLiteral(a), StringLiteral(b)) => a == b,
(ArrayLiteral(a), ArrayLiteral(b)) => a == b,
(NumberLiteral(a), NumberLiteral(b)) => a == b,
(FieldAccess(a), FieldAccess(b)) => a == b,
(VarAccess(a1, b1), VarAccess(a2, b2)) => a1 == a2 && b1 == b2,
+15
View File
@@ -7,6 +7,7 @@ pub enum BuiltInFunction {
Uppercase,
Lowercase,
Capitalize,
TitleCase,
Split,
Trim,
Print,
@@ -17,6 +18,10 @@ pub enum BuiltInFunction {
Pad,
Format,
AddFavourite,
Slug,
NormalizeSpaces,
Coalesce,
Deunicode,
}
impl FromStr for BuiltInFunction {
@@ -26,6 +31,7 @@ impl FromStr for BuiltInFunction {
match s.to_lowercase().as_str() {
"concat" => Ok(Self::Concat),
"capitalize" => Ok(Self::Capitalize),
"title_case" => Ok(Self::TitleCase),
"lowercase" => Ok(Self::Lowercase),
"uppercase" => Ok(Self::Uppercase),
"split" => Ok(Self::Split),
@@ -38,6 +44,10 @@ impl FromStr for BuiltInFunction {
"pad" => Ok(Self::Pad),
"format" => Ok(Self::Format),
"add_favourite" => Ok(Self::AddFavourite),
"slug" => Ok(Self::Slug),
"normalize_spaces" => Ok(Self::NormalizeSpaces),
"coalesce" => Ok(Self::Coalesce),
"deunicode" => Ok(Self::Deunicode),
_ => Err(TuliproxError::Mapper(format!("Unknown function {s}"))),
}
}
@@ -48,6 +58,7 @@ impl Display for BuiltInFunction {
f.write_str(match self {
Self::Concat => "concat",
Self::Capitalize => "capitalize",
Self::TitleCase => "title_case",
Self::Lowercase => "lowercase",
Self::Uppercase => "uppercase",
Self::Split => "split",
@@ -60,6 +71,10 @@ impl Display for BuiltInFunction {
Self::Pad => "pad",
Self::Format => "format",
Self::AddFavourite => "add_favourite",
Self::Slug => "slug",
Self::NormalizeSpaces => "normalize_spaces",
Self::Coalesce => "coalesce",
Self::Deunicode => "deunicode",
})
}
}
+23 -4
View File
@@ -55,6 +55,7 @@ impl<'a> MapperContext<'a> {
Expression::NullValue
| Expression::FieldAccess(_)
| Expression::StringLiteral(_)
| Expression::ArrayLiteral(_)
| Expression::NumberLiteral(_) => {}
Expression::RegexExpr { field, pattern: _pattern, re_pattern: _re_pattern } => match field {
RegexSource::Identifier(ident) => {
@@ -81,32 +82,50 @@ impl<'a> MapperContext<'a> {
BuiltInFunction::ToNumber
| BuiltInFunction::Template
| BuiltInFunction::First
| BuiltInFunction::Slug
| BuiltInFunction::NormalizeSpaces
| BuiltInFunction::Deunicode
| BuiltInFunction::AddFavourite
if args.len() > 1 =>
{
return Err(TuliproxError::Mapper(format!(
"Function accepts only one argument {:?}, {} given",
"Function accepts only one argument {}, {} given",
name,
args.len()
)));
}
BuiltInFunction::TitleCase => match args.len() {
1 => {}
2 => {
if !matches!(self.expressions.get(args[1].0), Some(Expression::ArrayLiteral(_))) {
return Err(TuliproxError::Mapper(format!(
"Second argument of {name} must be an array of strings"
)));
}
}
len => {
return Err(TuliproxError::Mapper(format!(
"Function accepts one or two arguments {name}, {len} given"
)));
}
},
BuiltInFunction::Split if args.len() != 2 => {
return Err(TuliproxError::Mapper(format!(
"Function accepts two arguments {:?}, {} given",
"Function accepts two arguments {}, {} given",
name,
args.len()
)));
}
BuiltInFunction::Replace if args.len() != 3 => {
return Err(TuliproxError::Mapper(format!(
"Function accepts three arguments {:?}, {} given",
"Function accepts three arguments {}, {} given",
name,
args.len()
)));
}
BuiltInFunction::Pad if !(args.len() == 3 || args.len() == 4) => {
return Err(TuliproxError::Mapper(format!(
"Function accepts three or four arguments {:?}, {} given",
"Function accepts three or four arguments {}, {} given",
name,
args.len()
)));
+229 -118
View File
@@ -1,11 +1,11 @@
use super::{
AssignmentTarget, BuiltInFunction, Expression, ForEachKey, MapCaseKey, MapKey, MapperContext, MapperScript,
AssignmentTarget, BuiltInFunction, ExprId, Expression, ForEachKey, MapCaseKey, MapKey, MapperContext, MapperScript,
MappingDiagnostic, MappingOutcome, MatchCaseKey, RegexSource, ValueAccessor,
};
use crate::{
foundation::mapper::EvalResult::{AnyValue, Failure, Named, Number, Undefined, Value},
model::{PatternTemplate, PlaylistItemType},
utils::{deunicode_string, Capitalize, Internable},
utils::{deunicode_string, normalize_spaces, slugify, Capitalize, Internable},
};
use log::trace;
use regex::Regex;
@@ -129,9 +129,22 @@ impl EvalResult {
}
pub fn is_error(&self) -> bool { matches!(self, Failure(_)) }
fn is_usable(&self) -> bool {
match self {
Value(value) => !value.trim().is_empty(),
Number(_) => true,
Named(values) => !values.is_empty(),
Undefined | AnyValue | Failure(_) => false,
}
}
}
fn concat_args(args: &Vec<EvalResult>) -> Vec<Cow<'_, str>> {
fn function_failure(name: &BuiltInFunction, value: &EvalResult) -> EvalResult {
Failure(format!("Function '{name:?}' failed: {}", if let Failure(msg) = value { msg } else { "Unknown error" }))
}
fn concat_args(args: &[EvalResult]) -> Vec<Cow<'_, str>> {
let mut result = vec![];
for arg in args {
@@ -155,6 +168,139 @@ fn concat_args(args: &Vec<EvalResult>) -> Vec<Cow<'_, str>> {
result
}
fn title_case_args(text: &EvalResult, acronyms: &[&str]) -> EvalResult {
match text {
Value(_) | Number(_) | Named(_) => Value(
concat_args(std::slice::from_ref(text))
.iter()
.map(|s| s.title_case_with_acronyms(acronyms))
.collect::<Vec<_>>()
.join(" "),
),
Undefined | AnyValue | Failure(_) => Undefined,
}
}
fn pad_text_value(arg: &EvalResult) -> Option<String> {
match arg {
Number(value) => Some(value.to_string()),
_ => text_arg(arg).map(String::from),
}
}
/// Text view of an argument for a positional parameter; a `Named` value contributes its first entry.
fn text_arg(arg: &EvalResult) -> Option<&str> {
match arg {
Value(value) => Some(value.as_str()),
Named(values) => values.first().map(|(_key, val)| val.as_str()),
Undefined | Number(_) | AnyValue | Failure(_) => None,
}
}
/// String view of an argument for a format placeholder; numbers keep their formatted representation.
fn format_arg(arg: &EvalResult) -> Option<String> {
match arg {
Number(value) => Some(format_number(*value)),
_ => text_arg(arg).map(String::from),
}
}
fn pad_width_value(arg: &EvalResult) -> Option<usize> {
match arg {
Number(value) => {
Some(if value.is_nan() || value.is_infinite() { 0 } else { value.abs().min(usize::MAX as f64) as usize })
}
Value(value) => Some(value.parse::<usize>().ok().unwrap_or(0)),
Named(values) => Some(values.first().and_then(|(_key, val)| val.parse::<usize>().ok()).unwrap_or(0)),
Undefined | AnyValue | Failure(_) => None,
}
}
/// Builds the padded value from arguments that keep their declared positions.
///
/// `pad(text, width, fill[, align])` is positional, so an argument that evaluated to `Undefined` must leave a gap
/// instead of shifting the following arguments into earlier parameter slots.
fn pad_values(args: &[EvalResult]) -> EvalResult {
let Some(text) = args.first().and_then(pad_text_value) else {
return Undefined;
};
let Some(width) = args.get(1).and_then(pad_width_value) else {
return Undefined;
};
let Some(fill) = args.get(2).and_then(pad_text_value) else {
return Undefined;
};
if width <= text.len() {
return Value(text);
}
let fill_str = fill.chars().next().unwrap_or(' ').to_string();
let count = width - text.len();
match args.get(3).and_then(pad_text_value).as_deref() {
Some("^") => {
let left = count / 2;
let right = count - left;
Value(format!("{}{text}{}", fill_str.repeat(left), fill_str.repeat(right)))
}
Some("<") => Value(format!("{text}{}", fill_str.repeat(count))),
_ => Value(format!("{}{text}", fill_str.repeat(count))),
}
}
/// Replaces `pattern` with `substitution` in `text`, keeping every parameter in its declared position.
fn replace_values(args: &[EvalResult]) -> EvalResult {
let (Some(text), Some(pattern), Some(substitution)) =
(args.first().and_then(text_arg), args.get(1).and_then(text_arg), args.get(2).and_then(text_arg))
else {
// An unknown pattern or substitution leaves the first argument as it was.
return args.first().cloned().unwrap_or(Undefined);
};
Value(text.replace(pattern, substitution))
}
/// Substitutes the positional arguments for the `{}` placeholders of the format string.
fn format_values(args: &[EvalResult]) -> EvalResult {
let Some(format_string) = args.first().and_then(text_arg) else {
return Undefined;
};
let mut formatted = String::with_capacity(format_string.len());
let mut arguments = args.iter().skip(1);
let mut chars = format_string.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' && chars.peek() == Some(&'}') {
chars.next();
// An unknown argument keeps its placeholder, so the later arguments stay in their own positions.
match arguments.next().and_then(format_arg) {
Some(argument) => formatted.push_str(&argument),
None => formatted.push_str("{}"),
}
} else {
formatted.push(ch);
}
}
Value(formatted)
}
/// Evaluates function arguments without compacting them, so each argument keeps its declared position.
fn eval_args_in_place(
name: &BuiltInFunction,
args: &[ExprId],
ctx: &mut MapperContext,
accessor: &mut ValueAccessor,
) -> Result<Vec<EvalResult>, EvalResult> {
let mut values = Vec::with_capacity(args.len());
for &arg in args {
let value = arg.eval(ctx, accessor);
if value.is_error() {
return Err(function_failure(name, &value));
}
values.push(value);
}
Ok(values)
}
pub(super) fn eval_regex(source: &str, re_pattern: &Regex, match_as_ascii: bool) -> EvalResult {
let source = if match_as_ascii { deunicode_string(source) } else { Cow::Borrowed(source) };
let mut values = vec![];
@@ -227,6 +373,9 @@ impl Expression {
},
},
Expression::StringLiteral(s) => Value(s.clone()),
Expression::ArrayLiteral(values) => {
Named(values.iter().enumerate().map(|(i, value)| (i.to_string(), value.clone())).collect())
}
Expression::NumberLiteral(num) => Number(*num),
Expression::RegexExpr { field, pattern: _pattern, re_pattern } => match field {
RegexSource::Identifier(ident) => match ctx.get_var(ident) {
@@ -274,13 +423,62 @@ impl Expression {
}
}
Expression::FunctionCall { name, args } => {
if matches!(name, BuiltInFunction::Coalesce) {
for arg in args {
let value = arg.eval(ctx, accessor);
if value.is_error() {
return function_failure(name, &value);
}
if value.is_usable() {
return value;
}
}
return Undefined;
}
if matches!(name, BuiltInFunction::TitleCase) {
// Evaluate the arguments here instead of using the compacted list below, so the acronym options keep
// their original position and an undefined text argument cannot turn the options into the text.
let Some(text_expr) = args.first() else {
return Undefined;
};
let text = text_expr.eval(ctx, accessor);
if text.is_error() {
return function_failure(name, &text);
}
let Some(options_expr) = args.get(1) else {
return title_case_args(&text, &[]);
};
let options = options_expr.eval(ctx, accessor);
if options.is_error() {
return function_failure(name, &options);
}
return match &options {
Named(values) => {
let acronyms = values.iter().map(|(_, value)| value.as_str()).collect::<Vec<_>>();
title_case_args(&text, &acronyms)
}
_ => title_case_args(&text, &[]),
};
}
// Pad, Replace, and Format address their arguments by position. They evaluate the arguments in place
// instead of the compacted list below, so an argument that evaluated to undefined keeps its slot
// instead of pulling the later arguments forward.
let positional: Option<fn(&[EvalResult]) -> EvalResult> = match name {
BuiltInFunction::Pad => Some(pad_values),
BuiltInFunction::Replace => Some(replace_values),
BuiltInFunction::Format => Some(format_values),
_ => None,
};
if let Some(evaluate) = positional {
return match eval_args_in_place(name, args, ctx, accessor) {
Ok(values) => evaluate(&values),
Err(failure) => failure,
};
}
let mut evaluated_args: Vec<EvalResult> = args.iter().map(|a| a.eval(ctx, accessor)).collect();
for arg in &evaluated_args {
if arg.is_error() {
return Failure(format!(
"Function '{name:?}' failed: {}",
if let Failure(msg) = arg { msg } else { "Unknown error" }
));
return function_failure(name, arg);
}
}
evaluated_args.retain(|er| !matches!(er, Undefined | Failure(_) | AnyValue));
@@ -310,6 +508,27 @@ impl Expression {
.collect::<Vec<_>>()
.join(" "),
),
BuiltInFunction::Slug => {
Value(concat_args(&evaluated_args).iter().map(|s| slugify(s)).collect::<Vec<_>>().join(" "))
}
BuiltInFunction::NormalizeSpaces => Value(
concat_args(&evaluated_args)
.iter()
.map(|s| normalize_spaces(s))
.collect::<Vec<_>>()
.join(" "),
),
BuiltInFunction::Deunicode => Value(
concat_args(&evaluated_args)
.iter()
.map(|s| deunicode::deunicode(s))
.collect::<Vec<_>>()
.join(" "),
),
// TitleCase evaluates its arguments above to keep the acronym options in their original position.
BuiltInFunction::TitleCase => Undefined,
// Coalesce short-circuits argument evaluation above; this arm keeps the match exhaustive.
BuiltInFunction::Coalesce => Undefined,
BuiltInFunction::Split => {
let string = extract_evaluated_arg_value!(evaluated_args, 0);
let pattern = extract_evaluated_arg_value!(evaluated_args, 1);
@@ -360,117 +579,9 @@ impl Expression {
Undefined
}
}
BuiltInFunction::Replace => {
let value = extract_evaluated_arg_value!(evaluated_args, 0);
let pattern = extract_evaluated_arg_value!(evaluated_args, 1);
let substring = extract_evaluated_arg_value!(evaluated_args, 2);
if let (Some(text), Some(pat), Some(subst)) = (value, pattern, substring) {
Value(text.replace(pat, subst))
} else {
evaluated_args[0].clone()
}
}
BuiltInFunction::Pad => {
let value = match &evaluated_args[0] {
Number(value) => Some(value.to_string()),
Value(value) => Some(value.clone()),
Named(values) => values.first().map(|(_key, val)| val.clone()),
_ => None,
};
let width = match &evaluated_args[1] {
Number(value) => {
if value.is_nan() || value.is_infinite() {
0
} else {
value.abs().min(usize::MAX as f64) as usize
}
}
Value(value) => value.parse::<usize>().ok().unwrap_or(0),
Named(values) => {
values.first().and_then(|(_key, val)| val.parse::<usize>().ok()).unwrap_or(0)
}
_ => 0,
};
let fill = match &evaluated_args[2] {
Number(value) => Some(value.to_string()),
Value(value) => Some(value.clone()),
Named(values) => values.first().map(|(_key, val)| val.clone()),
_ => None,
};
let align = extract_evaluated_arg_value!(evaluated_args, 3); // "<", ">", "^"
if let Some(text) = value {
let fill_char = fill.and_then(|s| s.chars().next()).unwrap_or(' ');
let padded = if width <= text.len() {
text.clone()
} else {
let pad = width - text.len();
if let Some(al) = align {
match al.as_str() {
"^" => {
let left = pad / 2;
let right = pad - left;
format!(
"{}{}{}",
fill_char.to_string().repeat(left),
text,
fill_char.to_string().repeat(right)
)
}
"<" => format!("{}{}", text, fill_char.to_string().repeat(pad)),
_ => format!("{}{}", fill_char.to_string().repeat(pad), text),
}
} else {
format!("{}{}", fill_char.to_string().repeat(pad), text)
}
};
Value(padded)
} else {
Undefined
}
}
BuiltInFunction::Format => {
let fmt_pattern = extract_evaluated_arg_value!(evaluated_args, 0);
if let Some(fmt_str) = fmt_pattern {
let args = evaluated_args
.iter()
.skip(1)
.map(|value| match value {
Value(value) => Some(value.clone()),
Number(value) => Some(format_number(*value)),
Named(values) => values.first().map(|(_, value)| value.clone()),
Undefined | AnyValue | Failure(_) => None,
})
.collect::<Vec<_>>();
let mut formatted = String::new();
let mut arg_iter = args.iter();
let mut chars = fmt_str.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '{' && chars.peek() == Some(&'}') {
chars.next();
if let Some(Some(arg)) = arg_iter.next() {
formatted.push_str(arg);
} else {
formatted.push_str("{}");
}
} else {
formatted.push(ch);
}
}
Value(formatted)
} else {
Undefined
}
}
// Pad, Replace, and Format evaluate their arguments in place above so that every parameter
// keeps the position it was written in.
BuiltInFunction::Pad | BuiltInFunction::Replace | BuiltInFunction::Format => Undefined,
BuiltInFunction::AddFavourite => {
let group_name = extract_evaluated_arg_value!(evaluated_args, 0);
if let Some(group) = group_name {
+16 -3
View File
@@ -18,13 +18,14 @@ null = { "null" }
identifier = @{ !null ~ (ASCII_ALPHANUMERIC | "_")+ }
var_access = { identifier ~ ("." ~ identifier)? }
string_literal = @{ "\"" ~ ( "\\\\" | "\\\"" | "\\n" | "\\t" | "\\r" | (!"\"" ~ ANY) )* ~ "\"" }
array_literal = { "[" ~ (string_literal ~ ("," ~ string_literal)*)? ~ "]" }
number = @{ "-"? ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? }
number_range_from = { number ~ ".." }
number_range_to = { ".." ~ number }
number_range_full = { number ~ ".." ~ number }
number_range_eq = { number }
number_range = _{ number_range_full | number_range_from | number_range_to | number_range_eq}
read_write_field = _{ ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo" | ^"logo_small" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" | ^"genre" }
read_write_field = _{ ^"name" | ^"title" | ^"caption" | ^"group" | ^"id" | ^"chno" | ^"logo_small" | ^"logo" | ^"parent_code" | ^"audio_track" | ^"time_shift" | ^"rec" | ^"url" | ^"epg_channel_id" | ^"epg_id" | ^"genre" }
read_only_field = _{ ^"input" | ^"type" }
field = { read_write_field | read_only_field }
assignment_field = { read_write_field }
@@ -34,8 +35,8 @@ regex_expr = { regex_source ~ regex_op ~ string_literal }
block_expr = { "{" ~ statements ~ "}" }
condition = { function_call | var_access | field_access }
assignment = { (("@" ~ assignment_field) | identifier) ~ "=" ~ expression }
expression = { assignment | map_block | match_block | for_each_block | function_call | regex_expr | string_literal | number | var_access | field_access | null | block_expr }
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "split" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" }
expression = { assignment | map_block | match_block | for_each_block | function_call | regex_expr | array_literal | string_literal | number | var_access | field_access | null | block_expr }
function_name = { "concat" | "uppercase" | "lowercase" | "capitalize" | "title_case" | "split" | "trim" | "print" | "number" | "first" | "template" | "replace" | "pad" | "format" | "add_favourite" | "slug" | "normalize_spaces" | "coalesce" | "deunicode" }
function_call = { function_name ~ "(" ~ (expression ~ ("," ~ expression)*)? ~ ")" }
any_match = { "_" }
match_case_key = { any_match | identifier }
@@ -286,6 +287,18 @@ impl MapperScript {
Ok(Some(Expression::StringLiteral(content.to_string())))
}
Rule::array_literal => {
let values = pair
.into_inner()
.filter(|item| item.as_rule() == Rule::string_literal)
.map(|item| {
let raw = item.as_str();
raw[1..raw.len() - 1].to_string()
})
.collect();
Ok(Some(Expression::ArrayLiteral(values)))
}
Rule::number => {
let raw = pair.as_str();
if let Number(val) = to_number(raw) {
+245
View File
@@ -157,6 +157,251 @@ fn test_mapper_format() {
}
}
#[test]
fn test_mapper_pad_with_undefined_arguments() {
// A parameter that evaluated to undefined must keep its slot instead of pulling the later arguments forward.
let dsl = r#"
part = @Caption ~ "(?P<part>zzz)"
@Title = pad(part, 10, "-")
@Name = pad("abc", part, "-")
@Group = pad("abc", 6, part, "^")
@Logo = pad("abc", 6, "*", "^")
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut item = PlaylistItem {
header: PlaylistItemHeader {
title: "keep".into(),
name: "keep".into(),
group: "keep".into(),
..Default::default()
},
};
let mut accessor =
ValueAccessor { pli: &mut item, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
let outcome = mapper.eval(&mut accessor, None);
assert_eq!(outcome.diagnostics.len(), 0);
assert_eq!(accessor.pli.header.title.as_ref(), "keep");
assert_eq!(accessor.pli.header.name.as_ref(), "keep");
assert_eq!(accessor.pli.header.group.as_ref(), "keep");
// A complete argument list still pads, with the align argument in the fourth position.
assert_eq!(accessor.pli.header.logo.as_ref(), "*abc**");
}
#[test]
fn test_mapper_replace_and_format_keep_argument_positions() {
// Replace and Format address their parameters by position, so an undefined argument must not slide the following
// arguments into earlier slots.
let dsl = r#"
part = @Caption ~ "(?P<part>zzz)"
@Title = replace("abc", "b", "X")
@Name = replace(part, "abc", "X")
@Group = format("{} {}", part, "B")
@Logo = format("{}{}", part, "B")
@logo_small = replace("abc", part, "X")
@url = replace("abc", "b", part)
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut item = PlaylistItem {
header: PlaylistItemHeader {
title: "keep".into(),
name: "keep".into(),
group: "keep".into(),
logo: "keep".into(),
..Default::default()
},
};
let mut accessor =
ValueAccessor { pli: &mut item, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
let outcome = mapper.eval(&mut accessor, None);
assert_eq!(outcome.diagnostics.len(), 0);
assert_eq!(accessor.pli.header.title.as_ref(), "aXc");
assert_eq!(accessor.pli.header.name.as_ref(), "keep");
assert_eq!(accessor.pli.header.group.as_ref(), "{} B");
assert_eq!(accessor.pli.header.logo.as_ref(), "{}B");
// A missing pattern or replacement leaves the text unchanged, as documented.
assert_eq!(accessor.pli.header.logo_small.as_ref(), "abc");
assert_eq!(accessor.pli.header.url.as_ref(), "abc");
}
#[test]
fn capitalize_only_touches_the_first_character_while_title_case_covers_every_word() {
let dsl = r#"
@Name = capitalize("us: tnt hd")
@Title = title_case("us: tnt hd")
@Group = title_case(concat("live ", "from ", "new york"))
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
mapper.eval(&mut accessor, None);
assert_eq!(accessor.pli.header.name.as_ref(), "Us: tnt hd");
assert_eq!(accessor.pli.header.title.as_ref(), "Us: Tnt Hd");
assert_eq!(accessor.pli.header.group.as_ref(), "Live From New York");
}
#[test]
fn title_case_keeps_listed_acronyms_in_capitals() {
let dsl = r#"
@Name = title_case("example channel name hd")
@Title = title_case("example channel name hd", ["HD", "FHD", "UK"])
@Group = title_case("uk fhd sports", ["UK", "FHD"])
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
mapper.eval(&mut accessor, None);
assert_eq!(accessor.pli.header.name.as_ref(), "Example Channel Name Hd");
assert_eq!(accessor.pli.header.title.as_ref(), "Example Channel Name HD");
assert_eq!(accessor.pli.header.group.as_ref(), "UK FHD Sports");
}
#[test]
fn title_case_keeps_options_in_their_original_position() {
let dsl = r#"
@Name = title_case(first(@Title ~ "(\d+)"), ["HD"])
@Title = title_case("example hd", ["HD"])
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
mapper.eval(&mut accessor, None);
assert!(accessor.pli.header.name.is_empty(), "options must not become the text argument");
assert_eq!(accessor.pli.header.title.as_ref(), "Example HD");
}
#[test]
fn title_case_rejects_invalid_arguments() {
let error =
MapperScript::parse(r#"@Name = title_case("a", "b")"#, None).expect_err("argument type must be validated");
assert!(error.to_string().contains("array of strings"), "unexpected error: {error}");
let error =
MapperScript::parse(r#"@Name = title_case("a", ["HD"], "b")"#, None).expect_err("arity must be validated");
assert!(error.to_string().contains("title_case"), "unexpected error: {error}");
}
#[test]
fn test_mapper_slug_normalize_spaces_deunicode_coalesce() {
let dsl = r#"
@Name = slug("Österreich: ORF 1 HD!")
@Title = normalize_spaces(" Sky Sport 1 HD ")
@Group = deunicode("München TV - Canal+ Séries")
@URL = coalesce("", " ", "http://stream.m3u8", "Fallback")
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
mapper.eval(&mut accessor, None);
assert_eq!(accessor.pli.header.name.as_ref(), "osterreich-orf-1-hd");
assert_eq!(accessor.pli.header.title.as_ref(), "Sky Sport 1 HD");
assert_eq!(accessor.pli.header.group.as_ref(), "Munchen TV - Canal+ Series");
assert_eq!(accessor.pli.header.url.as_ref(), "http://stream.m3u8");
}
#[test]
fn test_mapper_coalesce_fallback_on_empty_fields() {
let dsl = r#"
@Logo = coalesce(@Logo, @Logo_Small, "https://example.com/default.png")
@Title = coalesce(@Title, "Fallback Title")
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem {
header: PlaylistItemHeader {
logo_small: "https://example.com/small.png".intern(),
..PlaylistItemHeader::default()
},
};
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
mapper.eval(&mut accessor, None);
assert_eq!(accessor.pli.header.logo.as_ref(), "https://example.com/small.png");
assert_eq!(accessor.pli.header.title.as_ref(), "Fallback Title");
}
#[test]
fn coalesce_stops_at_the_first_usable_argument() {
let dsl = r#"
@Name = coalesce("first", number("not a number"))
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
let outcome = mapper.eval(&mut accessor, None);
assert!(outcome.diagnostics.is_empty(), "unexpected diagnostics: {:?}", outcome.diagnostics);
assert_eq!(accessor.pli.header.name.as_ref(), "first");
}
#[test]
fn coalesce_propagates_failure_of_a_reached_argument() {
let dsl = r#"
@Name = coalesce("", number("not a number"))
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
let outcome = mapper.eval(&mut accessor, None);
assert_eq!(outcome.diagnostics.len(), 1, "unexpected diagnostics: {:?}", outcome.diagnostics);
assert!(accessor.pli.header.name.is_empty());
}
#[test]
fn coalesce_skips_empty_named_lists() {
let dsl = r#"
@Name = coalesce([], "fallback")
"#;
let mapper = MapperScript::parse(dsl, None).expect("Parsing failed");
let mut channel = PlaylistItem { header: PlaylistItemHeader::default() };
let mut accessor =
ValueAccessor { pli: &mut channel, virtual_items: vec![], match_as_ascii: false, changed_fields: vec![] };
mapper.eval(&mut accessor, None);
assert_eq!(accessor.pli.header.name.as_ref(), "fallback");
}
#[test]
fn single_arg_functions_reject_more_than_one_argument() {
for func in ["slug", "normalize_spaces", "deunicode"] {
let script = format!(r#"@Name = {func}("a", "b")"#);
let error = MapperScript::parse(&script, None).expect_err("arity must be validated");
assert!(error.to_string().contains(func), "unexpected error for {func}: {error}");
}
}
#[test]
fn regex_identifier_ascii_normalization_preserves_map_selection() {
let dsl = r#"
+135 -3
View File
@@ -1,12 +1,41 @@
use crate::utils::CONSTANTS;
use deunicode::deunicode_with_tofu_cow;
use deunicode::{deunicode, deunicode_with_tofu_cow};
use std::{borrow::Cow, sync::Arc};
/// Cleans a playlist title by removing common IPTV prefixes (e.g., "[US]", "┃DE┃").
pub fn clean_playlist_title(title: &str) -> String { CONSTANTS.re_clean_title.replace(title, "").trim().to_string() }
pub trait Capitalize {
/// Uppercases the first character and lowercases the remainder of the whole string.
fn capitalize(&self) -> String;
/// Uppercases the first character of every whitespace-separated word and lowercases the remainder of each word.
///
/// Only `char::is_whitespace` separates words, so `"spider-man"` and `"o'neill"` each form a single word.
fn title_case(&self) -> String;
/// Like [`Capitalize::title_case`], but words that match an entry in `acronyms` (ASCII case-insensitively) keep
/// the spelling of that entry instead of being title-cased.
fn title_case_with_acronyms(&self, acronyms: &[&str]) -> String;
}
fn push_title_word(result: &mut String, word: &mut String, acronyms: &[&str]) {
if word.is_empty() {
return;
}
if let Some(acronym) = acronyms.iter().find(|acronym| acronym.eq_ignore_ascii_case(word)) {
result.push_str(acronym);
} else {
// Lowercase the whole word before uppercasing its first character, so contextual mappings such as the
// final Greek sigma (`Σ` -> `ς`) apply.
let lowered = word.to_lowercase();
let mut chars = lowered.chars();
if let Some(first) = chars.next() {
result.extend(first.to_uppercase());
result.push_str(chars.as_str());
}
}
word.clear();
}
// Implement the Capitalize trait for &str
@@ -18,6 +47,24 @@ impl<T: AsRef<str>> Capitalize for T {
let rest = chars.as_str().to_lowercase();
first + &rest
}
fn title_case(&self) -> String { self.title_case_with_acronyms(&[]) }
fn title_case_with_acronyms(&self, acronyms: &[&str]) -> String {
let s = self.as_ref();
let mut result = String::with_capacity(s.len());
let mut word = String::new();
for c in s.chars() {
if c.is_whitespace() {
push_title_word(&mut result, &mut word, acronyms);
result.push(c);
} else {
word.push(c);
}
}
push_title_word(&mut result, &mut word, acronyms);
result
}
}
pub fn get_trimmed_string(value: Option<&str>) -> Option<String> {
@@ -217,6 +264,51 @@ pub fn humanize_snake_case(s: &str) -> String {
pub fn deunicode_string(s: &str) -> Cow<'_, str> { deunicode_with_tofu_cow(s, "[?]") }
/// Converts text into a lowercase, URL- and filename-safe slug.
///
/// Transliterates Unicode characters to ASCII (via `deunicode`), converts to lowercase,
/// replaces sequences of non-alphanumeric characters with a single hyphen (`-`), and strips
/// leading and trailing hyphens.
pub fn slugify(s: &str) -> String {
let ascii = deunicode(s);
let mut slug = String::with_capacity(ascii.len());
let mut prev_is_dash = true;
for c in ascii.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
prev_is_dash = false;
} else if !prev_is_dash {
slug.push('-');
prev_is_dash = true;
}
}
if prev_is_dash && !slug.is_empty() {
slug.pop();
}
slug
}
/// Collapses consecutive whitespace characters into a single space and trims edges.
pub fn normalize_spaces(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut prev_is_space = true;
for c in s.chars() {
if c.is_whitespace() {
if !prev_is_space {
result.push(' ');
prev_is_space = true;
}
} else {
result.push(c);
prev_is_space = false;
}
}
if prev_is_space && !result.is_empty() {
result.pop();
}
result
}
/// Percent-encodes every byte outside the RFC 3986 unreserved set
/// (`A-Z a-z 0-9 - . _ ~`), e.g. `:` -> `%3A`, `ü` -> `%C3%BC`. Suitable for
/// cookie values and RFC 5987 `filename*` header parameters.
@@ -380,8 +472,8 @@ macro_rules! concat_string {
#[cfg(test)]
mod test {
use super::{
clean_playlist_title, generate_random_string, natural_cmp, percent_encode_unreserved, quality_rank,
sanitize_filename_chars,
clean_playlist_title, generate_random_string, natural_cmp, normalize_spaces, percent_encode_unreserved,
quality_rank, sanitize_filename_chars, slugify,
};
use crate as shared; // allow path-based macro call in tests
use crate::utils::Capitalize;
@@ -419,6 +511,46 @@ mod test {
#[test]
fn test_capitalize() {
assert_eq!("hELLO".capitalize(), "Hello");
assert_eq!("us: tnt hd".capitalize(), "Us: tnt hd");
assert_eq!("".capitalize(), "");
}
#[test]
fn test_title_case() {
assert_eq!("us: tnt hd".title_case(), "Us: Tnt Hd");
assert_eq!("HELLO WORLD".title_case(), "Hello World");
assert_eq!(" spaced words ".title_case(), " Spaced Words ");
assert_eq!("spider-man".title_case(), "Spider-man");
assert_eq!("".title_case(), "");
assert_eq!("über straße".title_case(), "Über Straße");
assert_eq!("ΟΔΟΣ ΤΕΛΟΣ".title_case(), "Οδος Τελος");
}
#[test]
fn test_title_case_with_acronyms() {
assert_eq!("example channel name hd".title_case_with_acronyms(&["HD", "FHD", "UK"]), "Example Channel Name HD");
assert_eq!("uk fhd sports".title_case_with_acronyms(&["UK", "FHD"]), "UK FHD Sports");
assert_eq!("plain title".title_case_with_acronyms(&[]), "Plain Title");
}
#[test]
fn test_slugify() {
assert_eq!(slugify("Österreich: ORF 1 HD!"), "osterreich-orf-1-hd");
assert_eq!(slugify(" Sky Sport 1 HD "), "sky-sport-1-hd");
assert_eq!(slugify("Live Sports | Sky Premier HD"), "live-sports-sky-premier-hd");
assert_eq!(slugify("---"), "");
assert_eq!(slugify(""), "");
assert_eq!(slugify("a---b"), "a-b");
assert_eq!(slugify("Spider-Man: Far From Home"), "spider-man-far-from-home");
}
#[test]
fn test_normalize_spaces() {
assert_eq!(normalize_spaces(" Sky Sport 1 HD "), "Sky Sport 1 HD");
assert_eq!(normalize_spaces("no_spaces"), "no_spaces");
assert_eq!(normalize_spaces(" "), "");
assert_eq!(normalize_spaces(""), "");
assert_eq!(normalize_spaces("hello \t\n world"), "hello world");
}
#[test]