Added support for regular expression-based sequence sorting.

This commit is contained in:
euzu
2025-04-30 17:16:53 +02:00
parent a9df86eabb
commit 5ebfd83028
3 changed files with 247 additions and 68 deletions
+26
View File
@@ -106,6 +106,32 @@ proxy:
username: uname # <- optional basic auth
password: secret # <- optional basic auth
```
- Added support for regular expression-based sequence sorting.
You can now sort both groups and channels using custom regex sequences.
```yaml
sort:
groups:
order: asc
sequence:
- '^Freetv'
- '^Shopping'
- '^Entertainment'
- '^Sunrise'
channels:
- field: caption
group_pattern: '^Freetv'
order: asc
sequence:
- '(?P<c1>.*?)\bUHD\b'
- '(?P<c1>.*?)\bFHD\b'
- '(?P<c1>.*?)\bHD\b'
- '(?P<c1>.*?)\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`.
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.
# 2.2.5 (2025-03-27)
- fixed web ui playlist regexp search
+30 -15
View File
@@ -3,13 +3,13 @@ use bitflags::bitflags;
use enum_iterator::Sequence;
use std::borrow::BorrowMut;
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fmt::Display;
use std::fs::File;
use std::io::BufRead;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::fmt;
use tokio::sync::RwLock;
use crate::auth::user::UserCredential;
@@ -240,6 +240,18 @@ impl ProcessTargets {
}
}
fn compile_regex_vec(patterns: Option<&Vec<String>>) -> Result<Option<Vec<Regex>>, M3uFilterError> {
patterns.as_ref()
.map(|seq| {
seq.iter()
.map(|s| Regex::new(s).map_err(|err| {
create_m3u_filter_error!(M3uFilterErrorKind::Info, "cant parse regex: {} {err}", s)
}))
.collect::<Result<Vec<_>, _>>()
})
.transpose() // convert Option<Result<...>> to Result<Option<...>>
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConfigSortGroup {
@@ -247,7 +259,16 @@ pub struct ConfigSortGroup {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sequence: Option<Vec<String>>,
#[serde(default, skip)]
pub t_sequence: Option<Vec<String>>,
pub t_sequence: Option<Vec<Regex>>,
}
impl ConfigSortGroup {
pub fn prepare(&mut self) -> Result<(), M3uFilterError> {
// Compile sequence patterns, if any
self.t_sequence = compile_regex_vec(self.sequence.as_ref())?;
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -276,16 +297,7 @@ impl ConfigSortChannel {
);
// Compile sequence patterns, if any
self.t_sequence = self.sequence.as_ref()
.map(|seq| {
seq.iter()
.map(|s| Regex::new(s).map_err(|err| {
create_m3u_filter_error!(M3uFilterErrorKind::Info, "cant parse regex: {} {err}", s)
}))
.collect::<Result<Vec<_>, _>>()
})
.transpose()?; // convert Option<Result<...>> to Result<Option<...>>
self.t_sequence = compile_regex_vec(self.sequence.as_ref())?;
Ok(())
}
}
@@ -303,6 +315,9 @@ pub struct ConfigSort {
impl ConfigSort {
pub fn prepare(&mut self) -> Result<(), M3uFilterError> {
if let Some(group) = self.groups.as_mut() {
group.prepare()?;
}
if let Some(channels) = self.channels.as_mut() {
handle_m3u_filter_error_result_list!(M3uFilterErrorKind::Info, channels.iter_mut().map(ConfigSortChannel::prepare));
}
@@ -1780,18 +1795,18 @@ impl ConfigProxy {
let uname = username.trim();
let pwd = password.trim();
if uname.is_empty() || pwd.is_empty() {
return Err(M3uFilterError::new(M3uFilterErrorKind::Info,"Proxy credentials missing".to_string()));
return Err(M3uFilterError::new(M3uFilterErrorKind::Info, "Proxy credentials missing".to_string()));
}
self.username = Some(uname.to_string());
self.password = Some(pwd.to_string());
} else {
return Err(M3uFilterError::new(M3uFilterErrorKind::Info,"Proxy credentials missing".to_string()));
return Err(M3uFilterError::new(M3uFilterErrorKind::Info, "Proxy credentials missing".to_string()));
}
}
self.url = self.url.trim().to_string();
if self.url.is_empty() {
return Err(M3uFilterError::new(M3uFilterErrorKind::Info,"Proxy url missing".to_string()));
return Err(M3uFilterError::new(M3uFilterErrorKind::Info, "Proxy url missing".to_string()));
}
Ok(())
}
+191 -53
View File
@@ -1,5 +1,5 @@
use crate::{Config};
use crate::model::config::{ConfigInput, ConfigRename};
use crate::model::config::{ConfigInput, ConfigRename, SortOrder};
use crate::utils::network::epg;
use crate::utils::network::m3u;
use crate::utils::network::xtream;
@@ -17,7 +17,7 @@ use crate::foundation::filter::{get_field_value, set_field_value, MockValueProce
use crate::m3u_filter_error::{M3uFilterError, M3uFilterErrorKind, get_errors_notify_message, notify_err};
use crate::messaging::{send_message, MsgKind};
use crate::model::config::{ConfigSortChannel, ConfigSortGroup, ConfigTarget, InputType,
ItemField, ProcessTargets, ProcessingOrder, SortOrder::{Asc, Desc}};
ItemField, ProcessTargets, ProcessingOrder};
use crate::model::mapping::{CounterModifier, Mapping, MappingValueProcessor};
use crate::model::playlist::{FetchedPlaylist, FieldGetAccessor, FieldSetAccessor, PlaylistEntry, PlaylistGroup, PlaylistItem, UUIDType, XtreamCluster};
use crate::model::stats::{InputStats, PlaylistStats, SourceStats, TargetStats};
@@ -59,55 +59,162 @@ fn filter_playlist(playlist: &mut [PlaylistGroup], target: &ConfigTarget) -> Opt
Some(new_playlist)
}
fn playlistgroup_comparator(a: &PlaylistGroup, b: &PlaylistGroup, group_sort: &ConfigSortGroup, match_as_ascii: bool) -> Ordering {
let value_a = if match_as_ascii { deunicode(&a.title) } else { a.title.to_string() };
let value_b = if match_as_ascii { deunicode(&b.title) } else { b.title.to_string() };
let ordering = value_a.partial_cmp(&value_b).unwrap();
match group_sort.order {
Asc => ordering,
Desc => ordering.reverse()
// fn playlist_comparator(sequence: Option<&Vec<regex::Regex>>, order: &SortOrder, value_a: &str, value_b: &str) -> Ordering {
// if let Some(regex_list) = sequence {
// for (i, regex) in regex_list.iter().enumerate() {
// let cap_a = regex.captures(value_a);
// let cap_b = regex.captures(value_b);
//
// match (cap_a, cap_b) {
// (Some(captured_a), Some(captured_b)) => {
// // both match regex → compare capture order: c1, c2, ...
// let mut result = Ordering::Equal;
//
// // sort captures nach c1, c2, c3...
// let mut named: Vec<_> = regex
// .capture_names()
// .flatten()
// .filter(|name| name.starts_with('c'))
// .collect();
//
// // Sort by number (c2 < c10)
// named.sort_by_key(|name| name[1..].parse::<u32>().unwrap_or(0));
//
// for name in named {
// let va = captured_a.name(name).map(|m| m.as_str());
// let vb = captured_b.name(name).map(|m| m.as_str());
// if let (Some(va), Some(vb)) = (va, vb) {
// let o = va.cmp(vb);
// if o != Ordering::Equal {
// result = o;
// break;
// }
// }
// }
//
// return match order {
// Asc => result,
// Desc => result.reverse(),
// };
// }
// (Some(_), None) => return Ordering::Less,
// (None, Some(_)) => return Ordering::Greater,
// (None, None) => {},
// }
// }
// }
//
// // No Regex-Match → fallback lexicographically match
// let ordering = value_a.partial_cmp(value_b).unwrap_or(Ordering::Equal);
// match order {
// Asc => ordering,
// Desc => ordering.reverse(),
// }
// }
fn playlist_comparator(
sequence: Option<&Vec<regex::Regex>>,
order: &SortOrder,
value_a: &str,
value_b: &str,
) -> Ordering {
if let Some(regex_list) = sequence {
let mut match_a = None;
let mut match_b = None;
for (i, regex) in regex_list.iter().enumerate() {
if match_a.is_none() {
if let Some(caps) = regex.captures(value_a) {
match_a = Some((i, caps));
}
}
if match_b.is_none() {
if let Some(caps) = regex.captures(value_b) {
match_b = Some((i, caps));
}
}
// Wenn beide Matches gefunden sind → break
if match_a.is_some() && match_b.is_some() {
break;
}
}
match (match_a, match_b) {
(Some((idx_a, caps_a)), Some((idx_b, caps_b))) => {
// Unterschiedliche Regex-Indizes → nach Reihenfolge sortieren
if idx_a != idx_b {
return match order {
SortOrder::Asc => idx_a.cmp(&idx_b),
SortOrder::Desc => idx_b.cmp(&idx_a),
};
}
// Gleiches Regex → sortiere nach Captures (c1, c2, …)
let mut named: Vec<_> = regex_list[idx_a]
.capture_names()
.flatten()
.filter(|name| name.starts_with('c'))
.collect();
named.sort_by_key(|name| name[1..].parse::<u32>().unwrap_or(0));
for name in named {
let va = caps_a.name(name).map(|m| m.as_str());
let vb = caps_b.name(name).map(|m| m.as_str());
if let (Some(va), Some(vb)) = (va, vb) {
let o = va.cmp(vb);
if o != Ordering::Equal {
return match order {
SortOrder::Asc => o,
SortOrder::Desc => o.reverse(),
};
}
}
}
Ordering::Equal
}
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => {
// Kein Match → Fallback
let o = value_a.cmp(value_b);
match order {
SortOrder::Asc => o,
SortOrder::Desc => o.reverse(),
}
}
}
} else {
// Kein Regex-Sequence definiert → fallback
let o = value_a.cmp(value_b);
match order {
SortOrder::Asc => o,
SortOrder::Desc => o.reverse(),
}
}
}
fn playlistitem_comparator(a: &PlaylistItem, b: &PlaylistItem, channel_sort: &ConfigSortChannel, match_as_ascii: bool) -> Ordering {
fn playlistgroup_comparator(a: &PlaylistGroup, b: &PlaylistGroup, group_sort: &ConfigSortGroup, match_as_ascii: bool) -> Ordering {
let value_a = if match_as_ascii { deunicode(&a.title) } else { a.title.to_string() };
let value_b = if match_as_ascii { deunicode(&b.title) } else { b.title.to_string() };
playlist_comparator(group_sort.t_sequence.as_ref(), &group_sort.order, &value_a, &value_b)
}
fn playlistitem_comparator(
a: &PlaylistItem,
b: &PlaylistItem,
channel_sort: &ConfigSortChannel,
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 };
channel_sort.sequence.as_ref().map_or_else(|| {
let ordering = value_a.partial_cmp(&value_b).unwrap();
match channel_sort.order {
Asc => ordering,
Desc => ordering.reverse()
}
}, |custom_order| {
// Check indices in the custom order vector
let index_a = custom_order.iter().position(|s| s == &value_a);
let index_b = custom_order.iter().position(|s| s == &value_b);
match (index_a, index_b) {
(Some(idx_a), Some(idx_b)) => {
// Both items found in custom order, compare indices
idx_a.cmp(&idx_b)
}
(Some(_), None) => {
// Only 'a' found in custom order, it comes first
Ordering::Less
}
(None, Some(_)) => {
// Only 'b' found in custom order, it comes first
Ordering::Greater
}
(None, None) => {
// Neither found, fall back to default ordering
let ordering = value_a.partial_cmp(&value_b).unwrap();
match channel_sort.order {
Asc => ordering,
Desc => ordering.reverse(),
}
}
}
})
playlist_comparator(channel_sort.t_sequence.as_ref(), &channel_sort.order, &value_a, &value_b)
}
fn sort_playlist(target: &ConfigTarget, new_playlist: &mut [PlaylistGroup]) {
@@ -625,16 +732,47 @@ pub async fn exec_processing(client: Arc<reqwest::Client>, cfg: Arc<Config>, tar
#[cfg(test)]
mod tests {
#[test]
fn test() {
let data = [("yessport5", "heyessport5gold"), ("yessport5", "heyesport5gold")];
// #[test]
// fn test_jaro_winkeler() {
// let data = [("yessport5", "heyessport5gold"), ("yessport5", "heyesport5gold")];
//
// data.iter().for_each(|(first, second)|
// println!("jaro_winkler {} = {} => {}", first, second, strsim::jaro_winkler(first, second)));
// // println!("jaro {}", strsim::jaro(data.0, data.1));
// // println!("levenhstein {}", strsim::levenshtein(data.0, data.1));
// // println!("damerau_levenshtein {:?}", strsim::damerau_levenshtein(data.0, data.1));
// // println!("osa distance {:?}", strsim::osa_distance(data.0, data.1));
// // println!("sorensen dice {:?}", strsim::sorensen_dice(data.0, data.1));
// }
data.iter().for_each(|(first, second)|
println!("jaro_winkler {} = {} => {}", first, second, strsim::jaro_winkler(first, second)));
// println!("jaro {}", strsim::jaro(data.0, data.1));
// println!("levenhstein {}", strsim::levenshtein(data.0, data.1));
// println!("damerau_levenshtein {:?}", strsim::damerau_levenshtein(data.0, data.1));
// println!("osa distance {:?}", strsim::osa_distance(data.0, data.1));
// println!("sorensen dice {:?}", strsim::sorensen_dice(data.0, data.1));
use regex::Regex;
use crate::model::config::{ConfigSortChannel, ItemField, SortOrder};
use crate::model::playlist::{PlaylistItem, PlaylistItemHeader};
use crate::processing::processor::playlist::playlistitem_comparator;
#[test]
fn test_sort() {
let mut channels: Vec<PlaylistItem> = 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::<Vec<PlaylistItem>>().into();
let channel_sort = ConfigSortChannel {
field: ItemField::Caption,
group_pattern: ".*".to_string(),
order: SortOrder::Asc,
sequence: None,
t_sequence: Some(vec![
Regex::new(r"(?P<c1>.*?)\bUHD\b").unwrap(),
Regex::new(r"(?P<c1>.*?)\bFHD\b").unwrap(),
Regex::new(r"(?P<c1>.*?)\bHD\b").unwrap(),
]),
t_re_group_pattern: Some(Regex::new(".*").unwrap()),
};
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::<Vec<String>>();
assert_eq!(expected, sorted);
}
}