Merge pull request #514 from euzu/feature/sort_and_regex_cache

Refactored sort and added regex cache
This commit is contained in:
euzu
2026-01-13 17:03:57 +01:00
committed by GitHub
52 changed files with 1328 additions and 306 deletions
Generated
+6 -5
View File
@@ -3284,9 +3284,9 @@ dependencies = [
[[package]]
name = "quick-xml"
version = "0.38.4"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
checksum = "f2e3bf4aa9d243beeb01a7b3bc30b77cfe2c44e24ec02d751a7104a53c2c49a1"
dependencies = [
"memchr",
"tokio",
@@ -3854,9 +3854,9 @@ dependencies = [
[[package]]
name = "serde-saphyr"
version = "0.0.13"
version = "0.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be7f28bb35aab6f9cdc5f464f9bbda0b1e3908766e819557111cc13e58ce7915"
checksum = "45400dbf0a2c4c2af106c08eb1028b3e45bea3cd45517d3a0e8170b86597122f"
dependencies = [
"ahash",
"annotate-snippets",
@@ -3864,11 +3864,11 @@ dependencies = [
"encoding_rs_io",
"nohash-hasher",
"num-traits",
"ryu",
"saphyr-parser",
"serde",
"serde_json",
"smallvec 2.0.0-alpha.12",
"zmij",
]
[[package]]
@@ -3991,6 +3991,7 @@ dependencies = [
"bytes",
"chrono",
"ciborium",
"dashmap",
"deunicode",
"enum-iterator",
"fastrand",
+3 -1
View File
@@ -1448,6 +1448,7 @@ sources:
### 2.2.2.8 `favourites`
Allows you to explicitly add items to a favorite group based on a filter. This is processed after mapping and resolution.
- `cluster`: can be Series, Movie or Live.
- `group`: The name of the group to add the favorite items to.
- `filter`: A filter statement to select the original items.
- `match_as_ascii`: _optional_ (default `false`). If `true`, the filter matching will be case-insensitive and normalized (e.g., "Cinema" matches "Cinéma").
@@ -1455,7 +1456,8 @@ Allows you to explicitly add items to a favorite group based on a filter. This i
Example:
```yaml
favourites:
- group: "My Favourites"
- cluster: series
group: "My Favourites"
filter: 'Name ~ "Cinema"'
match_as_ascii: true
```
+2 -2
View File
@@ -11,9 +11,9 @@ lol_html = "2.7"
handlebars = "6"
shared = { version = "3", path = "../shared" }
serde = { version = "1.0", features = ["derive", "rc"] }
serde-saphyr = "0.0.13"
serde-saphyr = "0.0.14"
serde_json = { version = "1", features = ["raw_value"] }
quick-xml = { version = "0.38", features = ["async-tokio"] }
quick-xml = { version = "0.39", features = ["async-tokio"] }
regex = "1.12"
clap = { version = "4", features = ["derive"] }
reqwest = { version = "0", features = ["blocking", "json", "stream", "rustls-tls", "socks"] }
+3 -1
View File
@@ -10,7 +10,7 @@ use arc_swap::{ArcSwap, ArcSwapOption};
use log::{error, info};
use reqwest::Client;
use shared::error::TuliproxError;
use shared::model::UserConnectionPermission;
use shared::model::{UserConnectionPermission};
use shared::utils::small_vecs_equal_unordered;
use std::collections::HashMap;
use std::sync::atomic::AtomicI8;
@@ -293,6 +293,7 @@ impl AppState {
self.geoip.store(new_geoip);
}
shared::model::REGEX_CACHE.sweep();
Ok(changes)
}
@@ -326,6 +327,7 @@ impl AppState {
self.app_config.set_sources(sources)?;
self.active_provider.update_config(&self.app_config).await;
shared::model::REGEX_CACHE.sweep();
Ok(changes)
}
+7 -4
View File
@@ -1,3 +1,4 @@
use std::sync::Arc;
use shared::utils::CONSTANTS;
use regex::Regex;
use shared::model::{EpgNamePrefix, EpgSmartMatchConfigDto};
@@ -6,14 +7,13 @@ use crate::model::macros;
#[derive(Debug, Clone)]
pub struct EpgSmartMatchConfig {
pub enabled: bool,
pub normalize_regex: Regex,
pub normalize_regex: Arc<Regex>,
pub strip: Vec<String>,
pub name_prefix: EpgNamePrefix,
pub name_prefix_separator: Vec<char>,
pub fuzzy_matching: bool,
pub match_threshold: u16,
pub best_match_threshold: u16,
}
macros::from_impl!(EpgSmartMatchConfig);
@@ -22,8 +22,11 @@ impl From<&EpgSmartMatchConfigDto> for EpgSmartMatchConfig {
Self {
enabled: dto.enabled,
normalize_regex: match &dto.normalize_regex {
Some(regex_str) => regex::Regex::new(regex_str).unwrap_or_else(|_| CONSTANTS.re_epg_normalize.clone()),
None => CONSTANTS.re_epg_normalize.clone(),
Some(regex_str) => shared::model::REGEX_CACHE.get_or_compile(regex_str).unwrap_or_else(|e| {
log::warn!("Invalid normalize_regex '{regex_str}': {e}, using default");
CONSTANTS.re_epg_normalize.clone()
}),
None => CONSTANTS.re_epg_normalize.clone(),
},
strip: match &dto.strip {
Some(list) => list.iter().map(|s| s.to_lowercase()).collect(),
+5 -3
View File
@@ -1,11 +1,11 @@
use std::sync::Arc;
use crate::model::macros;
use regex::Regex;
use shared::foundation::filter::{CompiledRegex, Filter};
use shared::model::{ConfigFavouritesDto, ItemField};
use shared::model::{ConfigFavouritesDto, ItemField, XtreamCluster};
#[derive(Debug, Clone)]
pub struct ConfigFavourites {
pub cluster: XtreamCluster,
pub group: Arc<str>,
pub filter: Filter,
pub match_as_ascii: bool,
@@ -17,7 +17,7 @@ impl ConfigFavourites {
ItemField::Group,
CompiledRegex {
restr: String::new(),
re: Regex::new(".*").unwrap(),
re: shared::model::REGEX_CACHE.get_or_compile(".*").expect("default regex '.*' must compile"),
},
)
}
@@ -28,6 +28,7 @@ macros::from_impl!(ConfigFavourites);
impl From<&ConfigFavouritesDto> for ConfigFavourites {
fn from(dto: &ConfigFavouritesDto) -> Self {
Self {
cluster: dto.cluster,
group: dto.group.clone(),
filter: dto.t_filter.as_ref().map_or_else(Self::default_filter, Clone::clone),
match_as_ascii: dto.match_as_ascii,
@@ -38,6 +39,7 @@ impl From<&ConfigFavouritesDto> for ConfigFavourites {
impl From<&ConfigFavourites> for ConfigFavouritesDto {
fn from(instance: &ConfigFavourites) -> Self {
Self {
cluster: instance.cluster,
group: instance.group.clone(),
filter: instance.filter.to_string(),
match_as_ascii: instance.match_as_ascii,
+14 -5
View File
@@ -1,5 +1,6 @@
use std::sync::Arc;
use regex::Regex;
use shared::model::IpCheckConfigDto;
use shared::model::{IpCheckConfigDto};
use crate::model::macros;
#[derive(Debug, Clone)]
@@ -7,8 +8,8 @@ pub struct IpCheckConfig {
pub url: Option<String>,
pub url_ipv4: Option<String>,
pub url_ipv6: Option<String>,
pub pattern_ipv4: Option<Regex>,
pub pattern_ipv6: Option<Regex>,
pub pattern_ipv4: Option<Arc<Regex>>,
pub pattern_ipv6: Option<Arc<Regex>>,
}
macros::from_impl!(IpCheckConfig);
@@ -18,8 +19,16 @@ impl From<&IpCheckConfigDto> for IpCheckConfig {
url: dto.url.clone(),
url_ipv4: dto.url_ipv4.clone(),
url_ipv6: dto.url_ipv6.clone(),
pattern_ipv4: dto.pattern_ipv4.as_ref().and_then(|s| Regex::new(s).ok()),
pattern_ipv6: dto.pattern_ipv6.as_ref().and_then(|s| Regex::new(s).ok()),
pattern_ipv4: dto.pattern_ipv4.as_ref().and_then(|s| {
shared::model::REGEX_CACHE.get_or_compile(s)
.map_err(|e| log::warn!("Invalid pattern_ipv4 regex '{s}': {e}"))
.ok()
}),
pattern_ipv6: dto.pattern_ipv6.as_ref().and_then(|s| {
shared::model::REGEX_CACHE.get_or_compile(s)
.map_err(|e| log::warn!("Invalid pattern_ipv6 regex '{s}': {e}"))
.ok()
}),
}
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
use std::sync::Arc;
use shared::model::{ConfigRenameDto, ItemField};
use crate::model::macros;
@@ -5,7 +6,7 @@ use crate::model::macros;
pub struct ConfigRename {
pub field: ItemField,
pub new_name: String,
pub pattern: regex::Regex,
pub pattern: Arc<regex::Regex>,
}
macros::from_impl!(ConfigRename);
@@ -14,7 +15,7 @@ impl From<&ConfigRenameDto> for ConfigRename {
Self {
field: dto.field,
new_name: dto.new_name.clone(),
pattern: regex::Regex::new(&dto.pattern).unwrap()
pattern: shared::model::REGEX_CACHE.get_or_compile(&dto.pattern).unwrap_or_else(|_| panic!("Invalid regex pattern {}", dto.pattern)),
}
}
}
+3 -2
View File
@@ -1,3 +1,4 @@
use std::sync::Arc;
use regex::Regex;
use shared::model::{ConfigSortRuleDto, ConfigSortDto, ItemField, SortOrder, SortTarget};
use shared::foundation::filter::Filter;
@@ -10,7 +11,7 @@ pub struct ConfigSortRule {
pub target: SortTarget,
pub order: SortOrder,
pub field: ItemField,
pub sequence: Option<Vec<Regex>>,
pub sequence: Option<Vec<Arc<Regex>>>,
pub filter: Filter,
}
@@ -33,7 +34,7 @@ impl From<&ConfigSortRule> for ConfigSortRuleDto {
target: instance.target,
order: instance.order,
field: instance.field,
sequence: instance.sequence.as_ref().map(|l: &Vec<Regex>| l.iter().map(ToString::to_string).collect()),
sequence: instance.sequence.as_ref().map(|l: &Vec<Arc<Regex>>| l.iter().map(ToString::to_string).collect()),
filter: instance.filter.to_string(),
t_sequence: None,
t_filter: Some(instance.filter.clone()),
+19 -15
View File
@@ -1,15 +1,14 @@
use crate::model::mapping::Mapping;
use crate::model::config::favourites::ConfigFavourites;
use crate::model::config::trakt::TraktConfig;
use crate::model::mapping::Mapping;
use crate::model::{macros, ConfigRename, ConfigSort};
use arc_swap::ArcSwapOption;
use shared::model::{ConfigTargetDto, ConfigTargetOptions, HdHomeRunTargetOutputDto, M3uTargetOutputDto,
ProcessingOrder, StrmExportStyle, StrmTargetOutputDto, TargetOutputDto, TargetType, TraktConfigDto, XtreamTargetOutputDto};
use shared::model::PlaylistItemType;
use std::sync::Arc;
use regex::Regex;
use shared::foundation::filter::Filter;
use shared::foundation::filter::ValueProvider;
use crate::model::{macros, ConfigRename, ConfigSort};
use crate::model::config::favourites::ConfigFavourites;
#[derive(Clone, Debug)]
pub struct ProcessTargets {
@@ -197,9 +196,9 @@ impl From<&TargetOutputDto> for TargetOutput {
fn from(dto: &TargetOutputDto) -> Self {
match dto {
TargetOutputDto::Xtream(o) => TargetOutput::Xtream(XtreamTargetOutput::from(o)),
TargetOutputDto::M3u(o) => TargetOutput::M3u(M3uTargetOutput::from(o)),
TargetOutputDto::Strm(o) => TargetOutput::Strm(StrmTargetOutput::from(o)),
TargetOutputDto::HdHomeRun(o) => TargetOutput::HdHomeRun(HdHomeRunTargetOutput::from(o)),
TargetOutputDto::M3u(o) => TargetOutput::M3u(M3uTargetOutput::from(o)),
TargetOutputDto::Strm(o) => TargetOutput::Strm(StrmTargetOutput::from(o)),
TargetOutputDto::HdHomeRun(o) => TargetOutput::HdHomeRun(HdHomeRunTargetOutput::from(o)),
}
}
}
@@ -208,9 +207,9 @@ impl From<&TargetOutput> for TargetOutputDto {
fn from(instance: &TargetOutput) -> Self {
match instance {
TargetOutput::Xtream(o) => TargetOutputDto::Xtream(XtreamTargetOutputDto::from(o)),
TargetOutput::M3u(o) => TargetOutputDto::M3u(M3uTargetOutputDto::from(o)),
TargetOutput::Strm(o) => TargetOutputDto::Strm(StrmTargetOutputDto::from(o)),
TargetOutput::HdHomeRun(o) => TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto::from(o)),
TargetOutput::M3u(o) => TargetOutputDto::M3u(M3uTargetOutputDto::from(o)),
TargetOutput::Strm(o) => TargetOutputDto::Strm(StrmTargetOutputDto::from(o)),
TargetOutput::HdHomeRun(o) => TargetOutputDto::HdHomeRun(HdHomeRunTargetOutputDto::from(o)),
}
}
}
@@ -229,14 +228,13 @@ pub struct ConfigTarget {
pub mapping: Arc<ArcSwapOption<Vec<Mapping>>>,
pub favourites: Option<Vec<ConfigFavourites>>,
pub processing_order: ProcessingOrder,
pub watch: Option<Vec<regex::Regex>>,
pub watch: Option<Vec<Arc<regex::Regex>>>,
pub use_memory_cache: bool,
}
impl ConfigTarget {
pub fn filter(&self, provider: &ValueProvider) -> bool {
self.filter.filter(provider)
self.filter.filter(provider)
}
pub(crate) fn get_xtream_output(&self) -> Option<&XtreamTargetOutput> {
@@ -297,7 +295,6 @@ impl ConfigTarget {
macros::from_impl!(ConfigTarget);
impl From<&ConfigTargetDto> for ConfigTarget {
fn from(dto: &ConfigTargetDto) -> Self {
Self {
id: dto.id,
enabled: dto.enabled,
@@ -311,7 +308,14 @@ impl From<&ConfigTargetDto> for ConfigTarget {
mapping: Arc::new(ArcSwapOption::new(None)),
favourites: dto.favourites.as_ref().map(|f| f.iter().map(Into::into).collect()),
processing_order: dto.processing_order,
watch: dto.watch.as_ref().map(|list| list.iter().filter_map(|s| Regex::new(s).ok()).collect()),
watch: dto.watch.as_ref().map(|list| list.iter().filter_map(|s|
match shared::model::REGEX_CACHE.get_or_compile(s) {
Ok(re) => Some(re),
Err(e) => {
log::warn!("Invalid watch regex pattern '{s}': {e}");
None
}
}).collect()),
use_memory_cache: dto.use_memory_cache,
}
}
+5 -2
View File
@@ -1,5 +1,6 @@
use regex::Regex;
use std::collections::HashMap;
use std::sync::Arc;
use shared::model::{VideoConfigDto, VideoDownloadConfigDto};
use crate::model::macros;
@@ -8,7 +9,7 @@ pub struct VideoDownloadConfig {
pub headers: HashMap<String, String>,
pub directory: String,
pub organize_into_directories: bool,
pub episode_pattern: Option<Regex>,
pub episode_pattern: Option<Arc<Regex>>,
}
macros::from_impl!(VideoDownloadConfig);
@@ -18,7 +19,9 @@ impl From<&VideoDownloadConfigDto> for VideoDownloadConfig {
headers: dto.headers.clone(),
directory: dto.directory.as_ref().map_or_else(|| "downloads".to_string(), ToString::to_string),
organize_into_directories: dto.organize_into_directories,
episode_pattern: dto.episode_pattern.as_ref().and_then(|s| Regex::new(s).ok()),
episode_pattern: dto.episode_pattern.as_ref().and_then(|s| shared::model::REGEX_CACHE.get_or_compile(s)
.map_err(|e| log::warn!("Invalid episode_pattern regex '{s}': {e}"))
.ok()),
}
}
}
+13 -12
View File
@@ -40,7 +40,7 @@ use shared::foundation::filter::{get_field_value, set_field_value, Filter, Value
use shared::model::xtream_const::XTREAM_CLUSTER;
use shared::model::{CounterModifier, FieldGetAccessor, FieldSetAccessor, InputType, ItemField, MsgKind,
PlaylistGroup, PlaylistItem, PlaylistItemType, PlaylistUpdateState,
ProcessingOrder, UUIDType};
ProcessingOrder, UUIDType, XtreamCluster};
use shared::utils::{create_alias_uuid, default_as_default, StringInterner};
use std::time::Instant;
@@ -50,13 +50,14 @@ fn is_valid(pli: &PlaylistItem, filter: &Filter, match_as_ascii: bool) -> bool {
}
pub fn apply_filter_to_source(source: &mut dyn PlaylistSource, filter: &Filter) -> Option<Vec<PlaylistGroup>> {
let mut groups: IndexMap<Arc<str>, PlaylistGroup> = IndexMap::new();
let mut groups: IndexMap<CategoryKey, PlaylistGroup> = IndexMap::new();
for pli in source.into_items() {
if is_valid(&pli, filter, false) {
let group_title = pli.header.group.clone();
let cluster = pli.header.xtream_cluster;
let cat_id = pli.header.category_id;
groups.entry(group_title.clone())
let key = (cluster, group_title.clone());
groups.entry(key)
.or_insert_with(|| PlaylistGroup {
id: cat_id,
title: group_title,
@@ -130,7 +131,7 @@ fn exec_rename(pli: &mut PlaylistItem, rename: Option<&Vec<ConfigRename>>, inter
fn rename_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget, interner: &mut StringInterner) -> Option<Vec<PlaylistGroup>> {
match &target.rename {
Some(renames) if !renames.is_empty() => {
let mut groups: IndexMap<Arc<str>, PlaylistGroup> = IndexMap::new();
let mut groups: IndexMap<(XtreamCluster, Arc<str>), PlaylistGroup> = IndexMap::new();
for mut pli in source.into_items() {
// Handle group rename first if it's in the renames
for r in renames {
@@ -146,7 +147,7 @@ fn rename_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget, inter
let group_title = pli.header.group.clone();
let cluster = pli.header.xtream_cluster;
let cat_id = pli.header.category_id;
groups.entry(group_title.clone())
groups.entry((cluster, group_title.clone()))
.or_insert_with(|| PlaylistGroup {
id: cat_id,
title: group_title,
@@ -205,12 +206,12 @@ fn map_playlist(source: &mut dyn PlaylistSource, target: &ConfigTarget, _interne
Box::new(iter.flat_map(move |chan| map_channel_and_flatten(chan, mapping)))
as Box<dyn Iterator<Item=PlaylistItem>>
});
let mut next_groups: IndexMap<Arc<str>, PlaylistGroup> = IndexMap::new();
let mut next_groups: IndexMap<CategoryKey, PlaylistGroup> = IndexMap::new();
let mut grp_id: u32 = 0;
for channel in mapped_iter {
let group_title = channel.header.group.clone();
let cluster = channel.header.xtream_cluster;
next_groups.entry(group_title.clone())
next_groups.entry((cluster, group_title.clone()))
.or_insert_with(|| {
grp_id += 1;
PlaylistGroup {
@@ -774,7 +775,7 @@ async fn process_playlist_for_target(ctx: &PlaylistProcessingContext,
pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Option<&[ConfigFavourites]>) {
if let Some(favourites) = favourites_cfg {
let mut fav_groups: IndexMap<Arc<str>, Vec<PlaylistItem>> = IndexMap::new();
let mut fav_groups: IndexMap<CategoryKey, Vec<PlaylistItem>> = IndexMap::new();
for pg in playlist.iter() {
for pli in &pg.channels {
// series episodes can't be included in favourites
@@ -782,13 +783,13 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
continue;
}
for fav in favourites {
if is_valid(pli, &fav.filter, fav.match_as_ascii) {
if pli.header.xtream_cluster == fav.cluster && is_valid(pli, &fav.filter, fav.match_as_ascii) {
let mut channel = pli.clone();
channel.header.group.clone_from(&fav.group);
// Update UUID to be an alias of the original
channel.header.uuid = create_alias_uuid(&pli.header.uuid, &fav.group);
fav_groups
.entry(fav.group.clone())
.entry((fav.cluster, fav.group.clone()))
.or_default()
.push(channel);
}
@@ -796,9 +797,9 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
}
}
for (group_name, channels) in fav_groups {
for (fav_group, channels) in fav_groups {
if !channels.is_empty() {
let xtream_cluster = channels[0].header.xtream_cluster;
let (xtream_cluster, group_name) = fav_group;
playlist.push(PlaylistGroup {
id: 0,
title: group_name,
+81 -96
View File
@@ -2,6 +2,7 @@ use crate::model::{ConfigSortRule, ConfigTarget};
use shared::foundation::filter::ValueProvider;
use shared::model::{PlaylistGroup, SortOrder, SortTarget};
use std::cmp::Ordering;
use std::sync::Arc;
fn direction(order: SortOrder, ordering: Ordering) -> Ordering {
match (order, ordering) {
@@ -12,7 +13,7 @@ fn direction(order: SortOrder, ordering: Ordering) -> Ordering {
}
fn playlist_comparator(
sequence: Option<&Vec<regex::Regex>>,
sequence: Option<&Vec<Arc<regex::Regex>>>,
order: SortOrder,
value_a: &str,
value_b: &str,
@@ -77,24 +78,11 @@ fn playlist_comparator(
}
}
let o = value_a.cmp(value_b);
match order {
SortOrder::Asc => o,
SortOrder::Desc => o.reverse(),
SortOrder::None => Ordering::Equal,
}
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);
match order {
SortOrder::Asc => o,
SortOrder::Desc => o.reverse(),
SortOrder::None => Ordering::Equal,
}
}
(None, None) => Ordering::Equal,
}
} else {
// No Regex-Sequence defined → fallback
@@ -123,65 +111,64 @@ pub(in crate::processing::processor) fn sort_playlist(
true
}
fn sort_groups(groups: &mut [PlaylistGroup], rules: &[ConfigSortRule], match_as_ascii: bool) {
fn sort_groups(
groups: &mut [PlaylistGroup],
rules: &[ConfigSortRule],
match_as_ascii: bool,
) {
let group_rules: Vec<_> = rules
.iter()
.filter(|r| matches!(r.target, SortTarget::Group))
.filter(|r| r.order != SortOrder::None)
.collect();
if group_rules.is_empty() {
return;
}
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,
groups.sort_by(|a_grp, b_grp| {
let a_chan = a_grp.channels.first();
let b_chan = b_grp.channels.first();
for rule in &group_rules {
let (vp_a, vp_b) = match (a_chan, b_chan) {
(Some(a), Some(b)) => (
ValueProvider { pli: a, match_as_ascii },
ValueProvider { pli: b, match_as_ascii },
),
(Some(_), None) => return direction(rule.order, Ordering::Less),
(None, Some(_)) => return direction(rule.order, Ordering::Greater),
(Some(a), Some(b)) => (a, b),
(None, None) => continue,
};
let provider_a = ValueProvider {
pli: a_chan,
match_as_ascii,
};
let provider_b = ValueProvider {
pli: b_chan,
match_as_ascii,
};
let fa = rule.filter.filter(&vp_a);
let fb = rule.filter.filter(&vp_b);
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 */ }
_ => return Ordering::Equal,
match (fa, fb) {
(false, false) => continue,
(true, false) => return direction(rule.order, Ordering::Less),
(false, true) => return direction(rule.order, Ordering::Greater),
_ => {}
}
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),
let va = vp_a.get(rule.field.as_str());
let vb = vp_b.get(rule.field.as_str());
let ord = 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)) => {
let ord = playlist_comparator(rule.sequence.as_ref(), rule.order, &va, &vb);
if ord != Ordering::Equal {
return ord;
}
playlist_comparator(rule.sequence.as_ref(), rule.order, &va, &vb)
}
};
if ord != Ordering::Equal {
return ord;
}
Ordering::Equal
});
}
}
Ordering::Equal
});
}
fn sort_channels_in_groups(
@@ -192,50 +179,48 @@ fn sort_channels_in_groups(
let channel_rules: Vec<_> = rules
.iter()
.filter(|r| matches!(r.target, SortTarget::Channel))
.filter(|r| r.order != SortOrder::None)
.collect();
if channel_rules.is_empty() {
return;
}
for rule in channel_rules {
for group in &mut *groups {
if rule.order == SortOrder::None {
continue;
}
group.channels.sort_by(|a, b| {
let provider_a = ValueProvider {
pli: a,
match_as_ascii,
};
let provider_b = ValueProvider {
pli: b,
match_as_ascii,
};
for group in groups {
group.channels.sort_by(|a, b| {
let vp_a = ValueProvider { pli: a, match_as_ascii };
let vp_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 */ }
_ => return Ordering::Equal,
for rule in &channel_rules {
let fa = rule.filter.filter(&vp_a);
let fb = rule.filter.filter(&vp_b);
match (fa, fb) {
(false, false) => continue,
(true, false) => return direction(rule.order, Ordering::Less),
(false, true) => return direction(rule.order, Ordering::Greater),
_ => {}
}
let va = provider_a.get(rule.field.as_str());
let vb = provider_b.get(rule.field.as_str());
match (va, vb) {
let va = vp_a.get(rule.field.as_str());
let vb = vp_b.get(rule.field.as_str());
let ord = 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)
}
};
if ord != Ordering::Equal {
return ord;
}
});
}
}
Ordering::Equal
});
}
}
@@ -243,7 +228,6 @@ fn sort_channels_in_groups(
mod tests {
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 std::cmp::Ordering;
@@ -280,9 +264,9 @@ mod tests {
field: ItemField::Caption,
order: SortOrder::Asc,
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(),
shared::model::REGEX_CACHE.get_or_compile(r"(?P<c1>.*?)\bUHD\b").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"(?P<c1>.*?)\bFHD\b").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"(?P<c1>.*?)\bHD\b").unwrap(),
]),
filter: Filter::default(),
};
@@ -368,14 +352,14 @@ mod tests {
field: ItemField::Caption,
order: SortOrder::Asc,
sequence: Some(vec![
Regex::new(r"^US\| EAST.*?\[\bUHD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| EAST.*?\[\bFHD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| EAST.*?\[\bHD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| EAST.*?\[\bSD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| WEST.*?\[\bUHD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| WEST.*?\[\bFHD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| WEST.*?\[\bHD\b\](?P<c1>.*)").unwrap(),
Regex::new(r"^US\| WEST.*?\[\bSD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| EAST.*?\[\bUHD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| EAST.*?\[\bFHD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| EAST.*?\[\bHD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| EAST.*?\[\bSD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| WEST.*?\[\bUHD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| WEST.*?\[\bFHD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| WEST.*?\[\bHD\b\](?P<c1>.*)").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^US\| WEST.*?\[\bSD\b\](?P<c1>.*)").unwrap(),
]),
filter: Filter::default(),
};
@@ -429,4 +413,5 @@ mod tests {
assert_eq!(expected, sorted);
}
}
+15 -27
View File
@@ -7,6 +7,7 @@ use shared::error::TuliproxError;
use shared::utils::StringInterner;
use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster};
use shared::utils::CONSTANTS;
use indexmap::IndexMap;
use std::borrow::Cow;
use strsim::normalized_levenshtein;
@@ -130,10 +131,10 @@ fn create_category_from_matches<'a>(
matches: Vec<TraktMatchResult<'a>>,
list_config: &'a TraktListConfig,
interner: &mut StringInterner,
) -> Option<PlaylistGroup> {
if matches.is_empty() { return None; }
) -> Vec<PlaylistGroup> {
if matches.is_empty() { return vec![]; }
let mut matched_items = Vec::new();
let mut matched_items_by_cluster: IndexMap<XtreamCluster, Vec<PlaylistItem>> = IndexMap::new();
let mut sorted_matches = matches;
sorted_matches.sort_by(|a, b| {
@@ -150,11 +151,7 @@ fn create_category_from_matches<'a>(
for match_result in sorted_matches {
let mut modified_item = match_result.playlist_item.clone();
// Use the (possibly numbered) title from the match result (which now contains the original playlist title)
with!(mut modified_item.header => header {
// Synchronize name with title so both fields show the same value
// header.title.clone_from(&match_result.trakt_item.title.to_string());
// header.name.clone_from(&match_result.trakt_item.title.to_string());
let title = header.get_field("caption").unwrap_or_else(|| Cow::Borrowed(&header.title));
if extract_quality(&title).is_none() {
if let Some(quality) = extract_quality(&header.group) {
@@ -168,28 +165,18 @@ fn create_category_from_matches<'a>(
}
header.group = interner.intern(group_title);
header.gen_uuid();
matched_items_by_cluster.entry(header.xtream_cluster).or_default().push(modified_item);
});
matched_items.push(modified_item);
}
if matched_items.is_empty() { return None; }
let cluster = match list_config.content_type {
TraktContentType::Vod => XtreamCluster::Video,
TraktContentType::Series => XtreamCluster::Series,
TraktContentType::Both => {
matched_items.first()
.map_or(XtreamCluster::Video, |item| item.header.xtream_cluster)
matched_items_by_cluster.into_iter().map(|(cluster, channels)| {
PlaylistGroup {
id: 0,
title: interner.intern(group_title),
channels,
xtream_cluster: cluster,
}
};
Some(PlaylistGroup {
id: 0,
title: interner.intern(group_title),
channels: matched_items,
xtream_cluster: cluster,
})
}).collect()
}
fn match_trakt_items_with_playlist<'a>(
@@ -197,7 +184,7 @@ fn match_trakt_items_with_playlist<'a>(
playlist: &'a [PlaylistGroup],
list_config: &'a TraktListConfig,
interner: &mut StringInterner,
) -> Option<PlaylistGroup> {
) -> Vec<PlaylistGroup> {
let trakt_match_items: Vec<TraktMatchItem<'a>> = trakt_items
.iter()
.filter(|item| should_include_item(item, list_config.content_type))
@@ -256,7 +243,8 @@ impl TraktCategoriesProcessor {
Ok(trakt_items) => {
debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len());
if let Some(category) = match_trakt_items_with_playlist(&trakt_items, playlist, list_config, &mut interner) {
let categories = match_trakt_items_with_playlist(&trakt_items, playlist, list_config, &mut interner);
for category in categories {
if !category.channels.is_empty() {
total_matches += category.channels.len();
let category_len = category.channels.len();
+162 -3
View File
@@ -12,10 +12,11 @@ use std::marker::PhantomData;
use std::mem::size_of;
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use crate::repository::storage::get_file_path_for_db_index;
// Constants (Restored)
const PAGE_SIZE: u16 = 4096;
const PAGE_SIZE_USIZE: usize = PAGE_SIZE as usize;
pub const PAGE_SIZE_USIZE: usize = PAGE_SIZE as usize;
const LEN_SIZE: usize = 4;
const FLAG_SIZE: usize = 1;
const MAGIC: &[u8; 4] = b"BTRE";
@@ -41,7 +42,7 @@ const PACK_VALUE_HEADER_SIZE: usize = 4;
const COMPRESSION_MIN_SIZE: usize = 64;
const COMPRESSION_THRESHOLD_PERCENT: usize = 85;
const COMPRESSION_FLAG_NONE: u8 = 0x00;
const COMPRESSION_FLAG_LZ4: u8 = 0x01;
pub const COMPRESSION_FLAG_LZ4: u8 = 0x01;
// Page Configuration
const PAGE_HEADER_SIZE: u16 = 16;
@@ -1444,6 +1445,64 @@ where
}
}
/// Store the tree and build a sorted index file.
///
/// # Arguments
/// * `filepath` - Path to store the `BPlusTree`
/// * `sort_key_extractor` - Closure that extracts the sort key from a value
///
/// # Example
/// ```ignore
/// tree.store_with_index(&db_path, |v| v.name.clone())?;
/// ```
pub fn store_with_index<SortKey, F>(
&mut self,
filepath: &Path,
sort_key_extractor: F,
) -> io::Result<u64>
where
SortKey: Ord + Serialize,
F: Fn(&V) -> SortKey,
{
// Store the tree first
let result = self.store(filepath)?;
if result > 0 {
Self::store_index(filepath, sort_key_extractor)?;
}
Ok(result)
}
pub fn store_index<SortKey, F>(filepath: &Path, sort_key_extractor: F) -> io::Result<()>
where
SortKey: Ord + Serialize,
F: Fn(&V) -> SortKey
{
let index_path = get_file_path_for_db_index(filepath);
// Re-open the stored tree to get value locations
let mut query = BPlusTreeQuery::<K, V>::try_new(filepath)?;
let entries_with_locations = query.collect_with_locations()?;
// Collect (sort_key, primary_key, location) and sort
let mut sorted_entries: Vec<(SortKey, K, super::sorted_index::ValueLocation)> =
entries_with_locations
.into_iter()
.map(|(k, v, loc)| (sort_key_extractor(&v), k, loc))
.collect();
// Sort by sort key
sorted_entries.sort_by(|a, b| a.0.cmp(&b.0));
// Write index file
let mut writer = super::sorted_index::SortedIndexWriter::new(&index_path)?;
for (sort_key, primary_key, location) in &sorted_entries {
writer.push(sort_key, primary_key, *location)?;
}
writer.finish()?;
Ok(())
}
/// Internal store without locking, used for compaction or initial save.
fn store_internal(&mut self, filepath: &Path) -> io::Result<u64> {
let tempfile = NamedTempFile::new()?;
@@ -1798,6 +1857,7 @@ where
/// at the cost of higher memory usage.
pub struct BPlusTreeQuery<K, V> {
file: BufReader<File>,
filepath: PathBuf,
buffer: Vec<u8>,
cache: IndexMap<u64, Vec<u8>>,
root_offset: u64,
@@ -1827,6 +1887,7 @@ where
Ok(Self {
file: utils::file_reader(file),
filepath: PathBuf::new(), // Unknown when created from file directly
buffer: vec![0u8; PAGE_SIZE_USIZE],
cache: IndexMap::with_capacity(CACHE_CAPACITY),
root_offset,
@@ -1837,7 +1898,14 @@ where
pub fn try_new(filepath: &Path) -> io::Result<Self> {
let file = File::open(filepath)?;
Self::try_from_file(file)
let mut query = Self::try_from_file(file)?;
query.filepath = filepath.to_path_buf();
Ok(query)
}
/// Returns the filepath this query was opened from.
pub fn filepath(&self) -> &Path {
&self.filepath
}
pub fn query(&mut self, key: &K) -> Result<Option<V>, BPlusTreeError> {
@@ -1867,6 +1935,29 @@ where
pub fn iter(&mut self) -> BPlusTreeDiskIterator<'_, K, V> {
BPlusTreeDiskIterator::new(self)
}
/// Owned iterator that traverses the tree in order defined by a secondary sorted index.
///
/// The index path is automatically derived from the tree filepath by changing
/// the extension to `.idx`. For example, if the tree is at `/data/items.bin`,
/// the index is expected at `/data/items.idx`.
///
/// This iterator reads values directly from stored offsets in O(1) time,
/// avoiding O(log n) tree traversal per item.
pub fn disk_iter_sorted<SortKey>(self) -> io::Result<super::sorted_index::BPlusTreeSortedIteratorOwned<K, V, SortKey>>
where
SortKey: for<'de> Deserialize<'de>,
{
super::sorted_index::BPlusTreeSortedIteratorOwned::new(self.filepath.clone(), self.file)
}
/// Owned iterator with explicit index path.
pub fn disk_iter_sorted_with_path<SortKey>(self, index_path: &Path) -> io::Result<super::sorted_index::BPlusTreeSortedIteratorOwned<K, V, SortKey>>
where
SortKey: for<'de> Deserialize<'de>,
{
super::sorted_index::BPlusTreeSortedIteratorOwned::with_index_path(self.filepath.clone(), self.file, index_path)
}
/// Traverses the tree and calls the provided closure for each leaf's keys and values.
pub fn traverse<F>(&mut self, mut f: F) -> io::Result<()>
@@ -1882,12 +1973,59 @@ where
Ok(())
}
/// Collects all entries with their value locations.
/// Used for building sorted indexes that need direct value access.
pub fn collect_with_locations(&mut self) -> io::Result<Vec<(K, V, super::sorted_index::ValueLocation)>> {
let mut result = Vec::new();
let mut stack = vec![self.root_offset];
while let Some(offset) = stack.pop() {
let (node, pointers) = BPlusTreeNode::<K, V>::deserialize_from_block(
&mut self.file,
&mut self.buffer,
offset,
false,
)?;
if node.is_leaf {
for (key, info) in node.keys.into_iter().zip(node.value_info.iter()) {
let value = BPlusTreeNode::<K, V>::load_value_from_info(&mut self.file, info)?;
let location = match info.mode {
ValueStorageMode::Single(offset) => {
super::sorted_index::ValueLocation::Single {
offset,
length: info.length,
}
}
ValueStorageMode::Packed(block_offset, index) => {
super::sorted_index::ValueLocation::Packed {
block_offset,
index,
length: info.length,
}
}
};
result.push((key, value, location));
}
} else if let Some(ptrs) = pointers {
// Process children in reverse order so we pop them in correct order
for ptr in ptrs.into_iter().rev() {
stack.push(ptr);
}
}
}
Ok(result)
}
/// Provides an owned disk-backed iterator.
pub fn disk_iter(self) -> BPlusTreeDiskIteratorOwned<K, V> {
BPlusTreeDiskIteratorOwned::new(self)
}
}
pub struct BPlusTreeDiskIteratorOwned<K, V> {
query: BPlusTreeQuery<K, V>,
stack: Vec<(u64, usize)>,
@@ -1973,6 +2111,27 @@ where
}
}
/// Generic reader that can be either a sorted index iterator or a regular disk iterator.
/// Used for fallback logic (Sorted -> Unsorted).
pub enum PlaylistIteratorReader<V> {
Sorted(super::sorted_index::BPlusTreeSortedIteratorOwned<u32, V, u32>),
Unsorted(BPlusTreeDiskIteratorOwned<u32, V>),
}
impl<V> Iterator for PlaylistIteratorReader<V>
where
V: Serialize + for<'de> Deserialize<'de> + Clone,
{
type Item = io::Result<(u32, V)>;
fn next(&mut self) -> Option<Self::Item> {
match self {
PlaylistIteratorReader::Sorted(iter) => iter.next(),
PlaylistIteratorReader::Unsorted(iter) => iter.next().map(Ok),
}
}
}
pub struct BPlusTreeDiskIterator<'a, K, V> {
query: &'a mut BPlusTreeQuery<K, V>,
stack: Vec<(u64, usize)>, // (node_offset, next_child_index)
+7 -5
View File
@@ -1,10 +1,11 @@
use crate::model::AppConfig;
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery};
use shared::error::{TuliproxError, notify_err_res};
use shared::model::{PlaylistGroup, PlaylistItem, UUIDType, XtreamPlaylistItem};
use shared::model::{PlaylistGroup, PlaylistItem, UUIDType, XtreamCluster, XtreamPlaylistItem};
use std::path::Path;
use std::sync::Arc;
use indexmap::IndexMap;
use crate::repository::xtream_repository::CategoryKey;
pub async fn persist_input_library_playlist(app_config: &Arc<AppConfig>, library_path: &Path, playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> {
if playlist.is_empty() {
@@ -26,7 +27,7 @@ pub async fn persist_input_library_playlist(app_config: &Arc<AppConfig>, library
pub async fn load_input_local_library_playlist(app_config: &Arc<AppConfig>, lib_path: &Path) -> Result<Vec<PlaylistGroup>, TuliproxError> {
let mut groups: IndexMap<Arc<str>, PlaylistGroup> = IndexMap::new();
let mut groups: IndexMap<CategoryKey, PlaylistGroup> = IndexMap::new();
if tokio::fs::try_exists(lib_path).await.unwrap_or(false) {
// Load Items
@@ -34,15 +35,16 @@ pub async fn load_input_local_library_playlist(app_config: &Arc<AppConfig>, lib_
if let Ok(mut query) = BPlusTreeQuery::<UUIDType, XtreamPlaylistItem>::try_new(lib_path) {
let mut group_cnt = 0;
for (_, ref item) in query.iter() {
let cat_id = item.group.clone();
groups.entry(cat_id)
let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live);
let key = (cluster, item.group.clone());
groups.entry(key)
.or_insert_with(|| {
group_cnt += 1;
PlaylistGroup {
id: group_cnt,
title: item.group.clone(),
channels: Vec::new(),
xtream_cluster: item.xtream_cluster,
xtream_cluster: cluster,
}
})
.channels.push(PlaylistItem::from(item));
+58 -14
View File
@@ -3,18 +3,20 @@ use shared::error::{TuliproxError};
use crate::model::{AppConfig, ProxyUserCredentials};
use crate::model::{ConfigTarget};
use shared::model::{ConfigTargetOptions, M3uPlaylistItem, PlaylistItemType, ProxyType, TargetType, XtreamCluster};
use crate::repository::bplustree::{BPlusTreeDiskIteratorOwned, BPlusTreeQuery};
use crate::repository::m3u_repository::m3u_get_file_path;
use crate::repository::storage::ensure_target_storage_path;
use crate::repository::bplustree::{BPlusTreeQuery, PlaylistIteratorReader};
use crate::repository::m3u_repository::m3u_get_file_path_for_db;
use crate::repository::storage::{ensure_target_storage_path, get_file_path_for_db_index};
use crate::repository::storage_const;
use crate::repository::user_repository::user_get_bouquet_filter;
use crate::utils::FileReadGuard;
use std::collections::HashSet;
use std::iter::Peekable;
use log::error;
// concat_string! macro from shared utils is used for efficient String building
#[allow(clippy::struct_excessive_bools)]
pub struct M3uPlaylistIterator {
reader: BPlusTreeDiskIteratorOwned<u32, M3uPlaylistItem>,
reader: Peekable<PlaylistIteratorReader<M3uPlaylistItem>>,
base_url: String,
username: String,
password: String,
@@ -40,13 +42,28 @@ impl M3uPlaylistIterator {
let m3u_output = target.get_m3u_output().ok_or_else(|| info_err!("Unexpected failure, missing m3u target output for target {}", target.name))?;
let config = cfg.config.load();
let target_path = ensure_target_storage_path(&config, target.name.as_str())?;
let m3u_path = m3u_get_file_path(&target_path);
let m3u_path = m3u_get_file_path_for_db(&target_path);
let file_lock = cfg.file_locks.read_lock(&m3u_path).await;
let query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {m3u_path:?} - {err}"))?;
let reader = query.disk_iter();
let index_path = get_file_path_for_db_index(&m3u_path);
let reader = if index_path.exists() {
let query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {m3u_path:?} - {err}"))?;
match query.disk_iter_sorted() {
Ok(reader) => PlaylistIteratorReader::Sorted(reader),
Err(err) => {
error!("Sorted index error for m3u, fallback: {err}");
let query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {m3u_path:?} - {err}"))?;
PlaylistIteratorReader::Unsorted(query.disk_iter())
}
}
} else {
let query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {m3u_path:?} - {err}"))?;
PlaylistIteratorReader::Unsorted(query.disk_iter())
}.peekable();
let filter = user_get_bouquet_filter(&config, &user.username, None, TargetType::M3u, XtreamCluster::Live).await;
@@ -112,18 +129,35 @@ impl M3uPlaylistIterator {
self.get_rewritten_url(m3u_pli, false, storage_const::M3U_RESOURCE_PATH)
}
fn find_next_matching(reader: &mut Peekable<PlaylistIteratorReader<M3uPlaylistItem>>, set: &HashSet<String>) -> Option<M3uPlaylistItem> {
loop {
match reader.next() {
Some(Ok((_, item))) => {
if set.contains(&*item.group) {
return Some(item);
}
}
Some(Err(e)) => {
error!("Iterator error: {e}");
return None;
}
None => return None,
}
}
}
fn get_next(&mut self) -> Option<(M3uPlaylistItem, bool)> {
let entry = if let Some(set) = &self.filter {
if let Some((current_item, _)) = self.lookup_item.take() {
// Avoid cloning strings while filtering
let next_valid = self.reader.find(|(_, pli)| set.contains(&*pli.group));
self.lookup_item = next_valid.map(|(_k, v)| (v, true)); // has_next handled by iterator usually, but here we just need the item
let next_valid = Self::find_next_matching(&mut self.reader, set);
self.lookup_item = next_valid.map(|v| (v, true)); // has_next handled by iterator usually, but here we just need the item
let has_next = self.lookup_item.is_some();
Some((current_item, has_next))
} else {
let current_item = self.reader.find(|(_, item)| set.contains(&*item.group));
if let Some((_, item)) = current_item {
self.lookup_item = self.reader.find(|(_, item)| set.contains(&*item.group)).map(|(_, item)| (item, true));
let current_item = Self::find_next_matching(&mut self.reader, set);
if let Some(item) = current_item {
self.lookup_item = Self::find_next_matching(&mut self.reader, set).map(|item| (item, true));
let has_next = self.lookup_item.is_some();
Some((item, has_next))
} else {
@@ -131,7 +165,17 @@ impl M3uPlaylistIterator {
}
}
} else {
self.reader.next().map(|(_, v)| (v, !self.reader.is_empty()))
match self.reader.next() {
Some(Ok((_, v))) => {
let has_next = matches!(self.reader.peek(), Some(Ok(_)));
Some((v, has_next))
},
Some(Err(e)) => {
error!("Iterator error: {e}");
None
}
None => None,
}
};
// TODO hls and unknown reverse proxy
+39 -18
View File
@@ -3,13 +3,15 @@ use crate::model::{AppConfig, ProxyUserCredentials};
use crate::model::{Config, ConfigTarget, M3uTargetOutput};
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery};
use crate::repository::m3u_playlist_iterator::M3uPlaylistM3uTextIterator;
use crate::repository::storage::get_target_storage_path;
use crate::repository::storage::{get_file_path_for_db_index, get_target_storage_path};
use crate::repository::storage_const;
use crate::repository::xtream_repository::CategoryKey;
use crate::utils;
use crate::utils::{async_file_writer, IO_BUFFER_SIZE};
use indexmap::IndexMap;
use log::error;
use shared::error::{notify_err, info_err, string_to_io_error, str_to_io_error, TuliproxError};
use shared::concat_string;
use shared::error::{info_err, notify_err, str_to_io_error, string_to_io_error, TuliproxError};
use shared::model::{M3uPlaylistItem, PlaylistGroup};
use shared::model::{PlaylistItem, PlaylistItemType, XtreamCluster};
use std::io::Error;
@@ -25,12 +27,12 @@ macro_rules! cant_write_result {
}
}
pub fn m3u_get_file_path(target_path: &Path) -> PathBuf {
target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_M3U, storage_const::FILE_SUFFIX_DB)))
pub fn m3u_get_file_path_for_db(target_path: &Path) -> PathBuf {
target_path.join(PathBuf::from(concat_string!(storage_const::FILE_M3U, ".", storage_const::FILE_SUFFIX_DB)))
}
pub fn m3u_get_epg_file_path(target_path: &Path) -> PathBuf {
let path = target_path.join(PathBuf::from(format!("{}.{}", storage_const::FILE_M3U, storage_const::FILE_SUFFIX_DB)));
let path = target_path.join(PathBuf::from(concat_string!(storage_const::FILE_M3U, ".", storage_const::FILE_SUFFIX_DB)));
utils::add_prefix_to_filename(&path, "epg_", Some("xml"))
}
@@ -86,7 +88,7 @@ pub async fn m3u_write_playlist(
return Ok(());
}
let m3u_path = m3u_get_file_path(target_path);
let m3u_path = m3u_get_file_path_for_db(target_path);
let m3u_playlist = Arc::new(
new_playlist
.iter()
@@ -111,7 +113,7 @@ pub async fn m3u_write_playlist(
for m3u in playlist.iter() {
tree.insert(m3u.virtual_id, m3u.clone());
}
tree.store(&m3u_path_clone).map_err(|err| cant_write_result!(&m3u_path_clone, err))?;
tree.store_with_index(&m3u_path_clone, |pli| pli.source_ordinal).map_err(|err| cant_write_result!(&m3u_path_clone, err))?;
Ok(())
})
.await
@@ -144,7 +146,7 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, app_state: &AppState, ta
let cfg: &AppConfig = &app_state.app_config;
let target_path = get_target_storage_path(&cfg.config.load(), target.name.as_str()).ok_or_else(|| string_to_io_error(format!("Could not find path for target {}", &target.name)))?;
let m3u_path = m3u_get_file_path(&target_path);
let m3u_path = m3u_get_file_path_for_db(&target_path);
let _file_lock = cfg.file_locks.read_lock(&m3u_path).await;
let mut query = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)?;
@@ -158,7 +160,7 @@ pub async fn m3u_get_item_for_stream_id(stream_id: u32, app_state: &AppState, ta
pub async fn iter_raw_m3u_playlist(config: &AppConfig, target: &ConfigTarget) -> Option<(utils::FileReadGuard, impl Iterator<Item=(M3uPlaylistItem, bool)>)> {
let target_path = get_target_storage_path(&config.config.load(), target.name.as_str())?;
let m3u_path = m3u_get_file_path(&target_path);
let m3u_path = m3u_get_file_path_for_db(&target_path);
if let Ok(false) = tokio::fs::try_exists(&m3u_path).await {
return None;
}
@@ -166,16 +168,34 @@ pub async fn iter_raw_m3u_playlist(config: &AppConfig, target: &ConfigTarget) ->
match BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {m3u_path:?} - {err}")) {
Ok(mut query) => {
let items: Vec<M3uPlaylistItem> = query.iter().map(|(_, v)| v).collect();
let index_path = get_file_path_for_db_index(&m3u_path);
let items: Vec<M3uPlaylistItem> = if index_path.exists() {
match query.disk_iter_sorted::<u32>() {
Ok(iter) => iter.filter_map(Result::ok).map(|(_, v)| v).collect(),
Err(err) => {
error!("Sorted index error {}: {err}", m3u_path.display());
// Re-open query for fallback
match BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path) {
Ok(mut query) => query.iter().map(|(_, v)| v).collect(),
Err(fallback_err) => {
error!("Fallback query also failed {}: {fallback_err}", m3u_path.display());
Vec::new()
}
}
}
}
} else {
query.iter().map(|(_, v)| v).collect()
};
let len = items.len();
Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i < len - 1))))
Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i + 1 < len))))
}
Err(_) => None
}
}
pub async fn persist_input_m3u_playlist(app_config: &Arc<AppConfig>, m3u_path: &Path, playlist: &[PlaylistGroup]) -> Result<(), TuliproxError> {
let file_lock = app_config.file_locks.write_lock(m3u_path).await;
let m3u_path_clone = m3u_path.to_path_buf();
@@ -194,14 +214,14 @@ pub async fn persist_input_m3u_playlist(app_config: &Arc<AppConfig>, m3u_path: &
tree.store(&m3u_path_clone).map_err(|err| cant_write_result!(&m3u_path_clone, err))?;
Ok(())
})
.await
.map_err(|err| notify_err!("failed to write m3u playlist: {} - {err}", m3u_path.display()))??;
.await
.map_err(|err| notify_err!("failed to write m3u playlist: {} - {err}", m3u_path.display()))??;
Ok(())
}
pub async fn load_input_m3u_playlist(app_config: &Arc<AppConfig>, m3u_path: &Path) -> Result<Vec<PlaylistGroup>, TuliproxError> {
let mut groups: IndexMap<Arc<str>, PlaylistGroup> = IndexMap::new();
let mut groups: IndexMap<CategoryKey, PlaylistGroup> = IndexMap::new();
if tokio::fs::try_exists(m3u_path).await.unwrap_or(false) {
// Load Items
@@ -209,15 +229,16 @@ pub async fn load_input_m3u_playlist(app_config: &Arc<AppConfig>, m3u_path: &Pat
if let Ok(mut query) = BPlusTreeQuery::<String, M3uPlaylistItem>::try_new(m3u_path) {
let mut group_cnt = 0;
for (_, ref item) in query.iter() {
let cat_id = item.group.clone();
groups.entry(cat_id)
let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live);
let key = (cluster, item.group.clone());
groups.entry(key)
.or_insert_with(|| {
group_cnt += 1;
PlaylistGroup {
id: group_cnt,
title: item.group.clone(),
channels: Vec::new(),
xtream_cluster: XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live),
xtream_cluster: cluster,
}
})
.channels.push(PlaylistItem::from(item));
+1
View File
@@ -13,6 +13,7 @@ pub mod storage_const;
mod playlist_scratch;
mod playlist_source;
mod library_repository;
pub mod sorted_index;
pub use playlist_source::*;
@@ -4,7 +4,7 @@ use crate::model::{AppConfig, ConfigInput, ConfigTarget, TargetOutput};
use crate::processing::processor::playlist::{apply_filter_to_playlist, PlaylistProcessingContext};
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery};
use crate::repository::epg_repository::epg_write;
use crate::repository::m3u_repository::{load_input_m3u_playlist, m3u_get_file_path, m3u_write_playlist, persist_input_m3u_playlist};
use crate::repository::m3u_repository::{load_input_m3u_playlist, m3u_get_file_path_for_db, m3u_write_playlist, persist_input_m3u_playlist};
use crate::repository::storage::{ensure_target_storage_path, get_input_storage_path, get_target_id_mapping_file, get_target_storage_path};
use crate::repository::storage_const::FILE_SUFFIX_DB;
use crate::repository::strm_repository::write_strm_playlist;
@@ -47,10 +47,13 @@ pub async fn persist_playlist(app_config: &Arc<AppConfig>, playlist: &mut [Playl
let mut local_library_series = HashMap::<String, Vec<LocalEpisodeKey>>::new();
let mut provider_series = HashMap::<String, Vec<ProviderEpisodeKey>>::new();
let mut source_ordinal: u32 = 0;
// Virtual IDs assignment
for group in playlist.iter_mut() {
for channel in &mut group.channels {
let header = &mut channel.header;
source_ordinal += 1;
header.source_ordinal = source_ordinal;
let provider_id = header.get_provider_id().unwrap_or_default();
if provider_id == 0 {
header.item_type = match (is_hls_url(&header.url), header.item_type) {
@@ -286,7 +289,7 @@ async fn load_m3u_target_storage(app_config: &AppConfig, target: &ConfigTarget)
let target_path = get_target_storage_path(&config, target.name.as_str()).ok_or_else(||
info_err!("Could not find path for target {}", &target.name))?;
let m3u_path = m3u_get_file_path(&target_path);
let m3u_path = m3u_get_file_path_for_db(&target_path);
let _file_lock = app_config.file_locks.read_lock(&m3u_path).await;
let mut tree = BPlusTree::<u32, M3uPlaylistItem>::new();
if let Ok(mut query) = BPlusTreeQuery::<u32, M3uPlaylistItem>::try_new(&m3u_path) {
+7 -5
View File
@@ -188,7 +188,7 @@ impl PlaylistSource for XtreamDiskPlaylistSource {
fn take_groups(&mut self) -> Vec<PlaylistGroup> {
// Build groups on-the-fly using disk iterator (streams one leaf at a time)
let mut groups_map: IndexMap<u32, PlaylistGroup> = IndexMap::new();
let mut groups_map: IndexMap<(XtreamCluster, u32), PlaylistGroup> = IndexMap::new();
let mut iters: Vec<(XtreamCluster, Box<dyn Iterator<Item=XtreamPlaylistItem> + Send>)> = vec![];
if let Some((q, _)) = self.live.as_mut() {
iters.push((XtreamCluster::Live, Box::new(q.iter().map(|(_, item)| item))));
@@ -202,7 +202,7 @@ impl PlaylistSource for XtreamDiskPlaylistSource {
for (cluster, iter) in iters {
for item in iter {
groups_map.entry(item.category_id)
groups_map.entry((cluster, item.category_id))
.or_insert_with(|| PlaylistGroup {
id: item.category_id,
title: item.group.clone(),
@@ -351,14 +351,16 @@ macro_rules! impl_single_file_disk_source {
fn take_groups(&mut self) -> Vec<PlaylistGroup> {
// Build groups on-the-fly using disk iterator (streams one leaf at a time)
if let Some(q) = self.playlist.as_mut() {
let mut groups_map: IndexMap<Arc<str>, PlaylistGroup> = IndexMap::new();
let mut groups_map: IndexMap<(XtreamCluster, Arc<str>), PlaylistGroup> = IndexMap::new();
for (_, item) in q.iter() {
groups_map.entry(item.group.clone())
let cluster = XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live);
let key = (cluster, item.group.clone());
groups_map.entry(key)
.or_insert_with(|| PlaylistGroup {
id: 0,
title: item.group.clone(),
channels: vec![],
xtream_cluster: XtreamCluster::try_from(item.item_type).unwrap_or(XtreamCluster::Live),
xtream_cluster: cluster,
})
.channels.push(PlaylistItem::from(&item));
}
+612
View File
@@ -0,0 +1,612 @@
//! Sorted Index for `BPlusTree`
//!
//! This module provides a compact sorted index file that enables iteration
//! over a `BPlusTree` in order of a secondary sort key rather than the primary key.
//!
//! # File Format (v3)
//! ```text
//! [magic: 4 bytes]["SIDX"]
//! [version: u32]
//! [count: u64]
//! [entry0][entry1]...
//!
//! Entry format (with value location for O(1) access):
//! [sort_key_len: u32][sort_key_bytes][primary_key_len: u32][primary_key_bytes][value_location]
//!
//! Value location format:
//! - Single mode: [mode: u8 = 0][offset: u64][length: u32]
//! - Packed mode: [mode: u8 = 1][block_offset: u64][index: u16][length: u32]
//! ```
//!
//! # Optimization
//! By storing the value location directly in the index, iteration can
//! read values in O(1) time by seeking directly to the offset, avoiding O(log n)
//! tree traversal for each lookup.
//!
//! > **Important**: The index is tightly coupled to the B+Tree file structure.
//! > It must be rebuilt after any operation that changes value offsets (e.g., `compact()`).
use crate::utils::{binary_deserialize, binary_serialize};
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{self, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use crate::repository::bplustree::{COMPRESSION_FLAG_LZ4, PAGE_SIZE_USIZE};
use indexmap::IndexMap;
use crate::repository::storage::get_file_path_for_db_index;
const MAGIC: &[u8; 4] = b"SIDX";
const VERSION: u32 = 3; // Bumped for new format with flexible value location
const HEADER_SIZE: usize = 16; // 4 (magic) + 4 (version) + 8 (count)
const MODE_SINGLE: u8 = 0;
const MODE_PACKED: u8 = 1;
/// Represents how a value can be located and read from the tree file.
#[derive(Debug, Clone, Copy)]
pub enum ValueLocation {
/// Single value stored at a specific offset with a given length.
Single { offset: u64, length: u32 },
/// Value packed in a block with other values, identified by block offset and index.
Packed { block_offset: u64, index: u16, length: u32 },
}
impl ValueLocation {
/// Serialize the value location to bytes.
fn to_bytes(self) -> Vec<u8> {
match self {
ValueLocation::Single { offset, length } => {
let mut bytes = vec![MODE_SINGLE];
bytes.extend_from_slice(&offset.to_le_bytes());
bytes.extend_from_slice(&length.to_le_bytes());
bytes
}
ValueLocation::Packed { block_offset, index, length } => {
let mut bytes = vec![MODE_PACKED];
bytes.extend_from_slice(&block_offset.to_le_bytes());
bytes.extend_from_slice(&index.to_le_bytes());
bytes.extend_from_slice(&length.to_le_bytes());
bytes
}
}
}
/// Deserialize a value location from a reader.
fn from_reader<R: Read>(reader: &mut R) -> io::Result<Self> {
let mut mode = [0u8; 1];
reader.read_exact(&mut mode)?;
match mode[0] {
MODE_SINGLE => {
let mut offset_buf = [0u8; 8];
let mut length_buf = [0u8; 4];
reader.read_exact(&mut offset_buf)?;
reader.read_exact(&mut length_buf)?;
Ok(ValueLocation::Single {
offset: u64::from_le_bytes(offset_buf),
length: u32::from_le_bytes(length_buf),
})
}
MODE_PACKED => {
let mut block_offset_buf = [0u8; 8];
let mut index_buf = [0u8; 2];
let mut length_buf = [0u8; 4];
reader.read_exact(&mut block_offset_buf)?;
reader.read_exact(&mut index_buf)?;
reader.read_exact(&mut length_buf)?;
Ok(ValueLocation::Packed {
block_offset: u64::from_le_bytes(block_offset_buf),
index: u16::from_le_bytes(index_buf),
length: u32::from_le_bytes(length_buf),
})
}
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Unknown value location mode: {}", mode[0]),
)),
}
}
}
/// Entry containing value location information for direct access.
#[derive(Debug, Clone)]
pub struct IndexEntry<SortKey, K> {
pub sort_key: SortKey,
pub primary_key: K,
pub location: ValueLocation,
}
/// Writer for building a sorted index file.
///
/// Entries must be pushed in sorted order. The writer buffers writes
/// and flushes on `finish()`.
pub struct SortedIndexWriter<SortKey, K> {
writer: BufWriter<File>,
count: u64,
_marker: PhantomData<(SortKey, K)>,
}
impl<SortKey, K> SortedIndexWriter<SortKey, K>
where
SortKey: Serialize,
K: Serialize,
{
/// Create a new index writer at the given path.
/// Overwrites any existing file.
pub fn new(path: &Path) -> io::Result<Self> {
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
let mut writer = BufWriter::new(file);
// Write header (count will be updated on finish)
writer.write_all(MAGIC)?;
writer.write_all(&VERSION.to_le_bytes())?;
writer.write_all(&0u64.to_le_bytes())?; // placeholder count
Ok(Self {
writer,
count: 0,
_marker: PhantomData,
})
}
/// Append an entry to the index with value location for O(1) access.
/// Caller must ensure entries are pushed in sorted order.
pub fn push(
&mut self,
sort_key: &SortKey,
primary_key: &K,
location: ValueLocation,
) -> io::Result<()> {
let sk_bytes = binary_serialize(sort_key)?;
let pk_bytes = binary_serialize(primary_key)?;
// Write sort key
let sk_len = u32::try_from(sk_bytes.len())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
self.writer.write_all(&sk_len.to_le_bytes())?;
self.writer.write_all(&sk_bytes)?;
// Write primary key
let pk_len = u32::try_from(pk_bytes.len())
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
self.writer.write_all(&pk_len.to_le_bytes())?;
self.writer.write_all(&pk_bytes)?;
// Write value location
self.writer.write_all(&location.to_bytes())?;
self.count += 1;
Ok(())
}
/// Finalize the index file by writing the count to the header.
pub fn finish(mut self) -> io::Result<u64> {
self.writer.flush()?;
// Seek back to count position and write final count
let mut file = self.writer.into_inner()?;
file.seek(SeekFrom::Start(8))?; // After magic + version
file.write_all(&self.count.to_le_bytes())?;
file.sync_all()?;
Ok(self.count)
}
}
/// Reader for iterating over a sorted index file.
pub struct SortedIndexReader<SortKey, K> {
reader: BufReader<File>,
remaining: u64,
_marker: PhantomData<(SortKey, K)>,
}
impl<SortKey, K> SortedIndexReader<SortKey, K>
where
SortKey: for<'de> Deserialize<'de>,
K: for<'de> Deserialize<'de>,
{
/// Open an existing index file for reading.
pub fn open(path: &Path) -> io::Result<Self> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
// Verify header
let mut header = [0u8; HEADER_SIZE];
reader.read_exact(&mut header)?;
if &header[0..4] != MAGIC {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"Invalid sorted index magic",
));
}
let version = u32::from_le_bytes(header[4..8].try_into().unwrap_or_default());
if version != VERSION {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Unsupported sorted index version: {version} (expected {VERSION})"),
));
}
let count = match header[8..16].try_into() {
Ok(count) => u64::from_le_bytes(count),
Err(e) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Invalid count {e}"),
));
}
};
Ok(Self {
reader,
remaining: count,
_marker: PhantomData,
})
}
/// Returns the number of remaining entries to read.
pub fn remaining(&self) -> u64 {
self.remaining
}
/// Returns true if the index is empty.
pub fn is_empty(&self) -> bool {
self.remaining == 0
}
/// Read the next entry from the index.
pub fn read_next(&mut self) -> io::Result<Option<IndexEntry<SortKey, K>>> {
if self.remaining == 0 {
return Ok(None);
}
// Read sort key
let mut len_buf = [0u8; 4];
self.reader.read_exact(&mut len_buf)?;
let sk_len = u32::from_le_bytes(len_buf) as usize;
let mut sk_bytes = vec![0u8; sk_len];
self.reader.read_exact(&mut sk_bytes)?;
let sort_key: SortKey = binary_deserialize(&sk_bytes)?;
// Read primary key
self.reader.read_exact(&mut len_buf)?;
let pk_len = u32::from_le_bytes(len_buf) as usize;
let mut pk_bytes = vec![0u8; pk_len];
self.reader.read_exact(&mut pk_bytes)?;
let primary_key: K = binary_deserialize(&pk_bytes)?;
// Read value location
let location = ValueLocation::from_reader(&mut self.reader)?;
self.remaining -= 1;
Ok(Some(IndexEntry {
sort_key,
primary_key,
location,
}))
}
}
impl<SortKey, K> Iterator for SortedIndexReader<SortKey, K>
where
SortKey: for<'de> Deserialize<'de>,
K: for<'de> Deserialize<'de>,
{
type Item = io::Result<IndexEntry<SortKey, K>>;
fn next(&mut self) -> Option<Self::Item> {
match self.read_next() {
Ok(Some(entry)) => Some(Ok(entry)),
Ok(None) => None,
Err(e) => Some(Err(e)),
}
}
}
/// Owned iterator that combines a sorted index with a primary `BPlusTree` file.
///
/// Iterates through the index in order and reads values directly from the
/// tree file using stored offsets - no tree traversal needed (O(1) per item).
pub struct BPlusTreeSortedIteratorOwned<K, V, SortKey> {
index_reader: SortedIndexReader<SortKey, K>,
tree_file: BufReader<File>,
filepath: PathBuf,
block_cache: IndexMap<u64, Vec<u8>>,
_marker: PhantomData<V>,
}
const CACHE_CAPACITY: usize = 8;
impl<K, V, SortKey> BPlusTreeSortedIteratorOwned<K, V, SortKey>
where
K: for<'de> Deserialize<'de>,
V: for<'de> Deserialize<'de>,
SortKey: for<'de> Deserialize<'de>,
{
/// Create a new owned sorted iterator.
///
/// The index path is automatically derived from the tree filepath
/// by changing the extension to `.idx`.
pub fn new(filepath: PathBuf, tree_file: BufReader<File>) -> io::Result<Self> {
let index_path = get_file_path_for_db_index(&filepath);
let index_reader = SortedIndexReader::open(&index_path)?;
Ok(Self {
index_reader,
tree_file,
filepath,
block_cache: IndexMap::with_capacity(CACHE_CAPACITY),
_marker: PhantomData,
})
}
/// Create from an explicit index path.
pub fn with_index_path(
filepath: PathBuf,
tree_file: BufReader<File>,
index_path: &Path,
) -> io::Result<Self> {
let index_reader = SortedIndexReader::open(index_path)?;
Ok(Self {
index_reader,
tree_file,
filepath,
block_cache: IndexMap::with_capacity(CACHE_CAPACITY),
_marker: PhantomData,
})
}
/// Returns the number of remaining items.
pub fn remaining(&self) -> u64 {
self.index_reader.remaining
}
/// Returns the path to the tree file.
pub fn filepath(&self) -> &Path {
&self.filepath
}
/// Read a value from the tree file using the given `ValueLocation`.
fn read_value(&mut self, location: ValueLocation) -> io::Result<V> {
match location {
ValueLocation::Single { offset, length } => {
self.read_value_single(offset, length)
}
ValueLocation::Packed { block_offset, index, length: _ } => {
self.read_value_packed(block_offset, index)
}
}
}
/// Read a single value directly from the tree file at the given offset.
fn read_value_single(&mut self, offset: u64, length: u32) -> io::Result<V> {
self.tree_file.seek(SeekFrom::Start(offset))?;
// Read compression flag
let mut flag = [0u8; 1];
self.tree_file.read_exact(&mut flag)?;
let data = if flag[0] == COMPRESSION_FLAG_LZ4 {
// Compressed: [flag:1][lz4_payload_with_prepended_size]
let compressed_len = length as usize - 1;
let mut compressed = vec![0u8; compressed_len];
self.tree_file.read_exact(&mut compressed)?;
lz4_flex::decompress_size_prepended(&compressed).map_err(|e| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("LZ4 decompression failed: {e}"),
)
})?
} else {
// Uncompressed: [flag:1][payload]
let payload_len = length as usize - 1;
let mut data = vec![0u8; payload_len];
self.tree_file.read_exact(&mut data)?;
data
};
crate::utils::binary_deserialize(&data)
}
/// Read a value from a packed block at the given index.
fn read_value_packed(&mut self, block_offset: u64, value_index: u16) -> io::Result<V> {
// Try cache first
if !self.block_cache.contains_key(&block_offset) {
// Miss - read from disk
self.tree_file.seek(SeekFrom::Start(block_offset))?;
let mut buf = vec![0u8; PAGE_SIZE_USIZE];
self.tree_file.read_exact(&mut buf)?;
// Update cache
if self.block_cache.len() >= CACHE_CAPACITY {
self.block_cache.shift_remove_index(0); // FIFO eviction
}
self.block_cache.insert(block_offset, buf);
}
let block_buffer = self.block_cache.get(&block_offset).unwrap();
// Read count (first 4 bytes)
let count = u32::from_le_bytes(block_buffer[0..4].try_into().map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("Invalid count: {e}"))
})?);
if u32::from(value_index) >= count {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Value index {value_index} out of bounds (count: {count})"),
));
}
let mut pos = 4;
// Skip to target value
for i in 0..=value_index {
if pos + 4 > PAGE_SIZE_USIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Packed block corrupted: position {pos} exceeds block size"),
));
}
let len = u32::from_le_bytes(block_buffer[pos..pos + 4].try_into().map_err(|e| {
io::Error::new(io::ErrorKind::InvalidData, format!("Invalid length: {e}"))
})?) as usize;
pos += 4;
if i == value_index {
// Found target value
if pos + len > PAGE_SIZE_USIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Packed value corrupted: length {len} at position {pos} exceeds block size"),
));
}
let value_data = &block_buffer[pos..pos + len];
return crate::utils::binary_deserialize(value_data);
}
pos += len;
}
Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("Value index {value_index} not found in packed block (count: {count})"),
))
}
}
impl<K, V, SortKey> Iterator for BPlusTreeSortedIteratorOwned<K, V, SortKey>
where
K: for<'de> Deserialize<'de> + Clone,
V: for<'de> Deserialize<'de>,
SortKey: for<'de> Deserialize<'de>,
{
type Item = io::Result<(K, V)>;
fn next(&mut self) -> Option<Self::Item> {
// Get next entry from index
let entry = match self.index_reader.read_next() {
Ok(Some(entry)) => entry,
Ok(None) => return None,
Err(e) => return Some(Err(e)),
};
// Read value from tree file using the stored location
match self.read_value(entry.location) {
Ok(value) => Some(Ok((entry.primary_key, value))),
Err(e) => Some(Err(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_sorted_index_write_read() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.idx");
// Write entries with value locations
let mut writer = SortedIndexWriter::<String, u32>::new(&path).unwrap();
writer.push(
&"apple".to_string(),
&1u32,
ValueLocation::Single { offset: 100, length: 50 }
).unwrap();
writer.push(
&"banana".to_string(),
&2u32,
ValueLocation::Packed { block_offset: 200, index: 3, length: 75 }
).unwrap();
writer.push(
&"cherry".to_string(),
&3u32,
ValueLocation::Single { offset: 300, length: 100 }
).unwrap();
let count = writer.finish().unwrap();
assert_eq!(count, 3);
// Read entries
let reader = SortedIndexReader::<String, u32>::open(&path).unwrap();
let entries: Vec<_> = reader.map(|r| r.unwrap()).collect();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].sort_key, "apple".to_string());
assert_eq!(entries[0].primary_key, 1u32);
match entries[0].location {
ValueLocation::Single { offset, length } => {
assert_eq!(offset, 100);
assert_eq!(length, 50);
}
_ => panic!("Expected Single location"),
}
assert_eq!(entries[1].sort_key, "banana".to_string());
assert_eq!(entries[1].primary_key, 2u32);
match entries[1].location {
ValueLocation::Packed { block_offset, index, length } => {
assert_eq!(block_offset, 200);
assert_eq!(index, 3);
assert_eq!(length, 75);
}
_ => panic!("Expected Packed location"),
}
assert_eq!(entries[2].sort_key, "cherry".to_string());
assert_eq!(entries[2].primary_key, 3u32);
match entries[2].location {
ValueLocation::Single { offset, length } => {
assert_eq!(offset, 300);
assert_eq!(length, 100);
}
_ => panic!("Expected Single location"),
}
}
#[test]
fn test_empty_index() {
let dir = tempdir().unwrap();
let path = dir.path().join("empty.idx");
let writer = SortedIndexWriter::<String, u32>::new(&path).unwrap();
let count = writer.finish().unwrap();
assert_eq!(count, 0);
let reader = SortedIndexReader::<String, u32>::open(&path).unwrap();
assert!(reader.is_empty());
// Note: collect() consumes the reader, so we check is_empty() and remaining first
let remaining = reader.remaining;
assert_eq!(remaining, 0);
let entries: Vec<_> = reader.collect();
assert!(entries.is_empty());
}
#[test]
fn test_index_path_derivation() {
let tree_path = Path::new("/data/my_tree.bin");
let idx_path = get_file_path_for_db_index(tree_path);
assert_eq!(idx_path, PathBuf::from("/data/my_tree.idx"));
let tree_path2 = Path::new("/data/items");
let idx_path2 = get_file_path_for_db_index(tree_path2);
assert_eq!(idx_path2, PathBuf::from("/data/items.idx"));
}
}
+4 -1
View File
@@ -53,7 +53,10 @@ pub fn ensure_input_storage_path(cfg: &Config, input_name: &str) -> Result<PathB
})
}
pub fn get_geoip_path(working_dir: &str) -> PathBuf {
Path::new(working_dir).join("geoip.db")
}
pub fn get_file_path_for_db_index(db_path: &Path) -> PathBuf {
db_path.with_extension(storage_const::FILE_SUFFIX_INDEX)
}
+1
View File
@@ -1,5 +1,6 @@
pub const FILE_EPG: &str = "epg.xml";
pub(in crate::repository) const FILE_SUFFIX_DB: &str = "db";
pub(in crate::repository) const FILE_SUFFIX_INDEX: &str = "idx";
pub(in crate::repository) const FILE_ID_MAPPING: &str = "id_mapping.db";
pub(in crate::repository) const FILE_STRM: &str = "strm";
pub(in crate::repository) const FILE_M3U: &str = "m3u";
@@ -1,6 +1,7 @@
use crate::model::ConfigTarget;
use crate::model::{xtream_mapping_option_from_target_options, AppConfig, ProxyUserCredentials};
use crate::repository::bplustree::{BPlusTreeDiskIteratorOwned, BPlusTreeQuery};
use crate::repository::bplustree::{BPlusTreeQuery, PlaylistIteratorReader};
use crate::repository::user_repository::user_get_bouquet_filter;
use crate::repository::xtream_repository::{xtream_get_file_path, xtream_get_storage_path};
use crate::utils::FileReadGuard;
@@ -8,9 +9,10 @@ use log::error;
use shared::error::{TuliproxError, info_err, info_err_res};
use shared::model::{PlaylistItemType, TargetType, XtreamCluster, XtreamMappingOptions, XtreamPlaylistItem};
use std::collections::HashSet;
use crate::repository::storage::get_file_path_for_db_index;
pub struct XtreamPlaylistIterator {
reader: BPlusTreeDiskIteratorOwned<u32, XtreamPlaylistItem>,
reader: PlaylistIteratorReader<XtreamPlaylistItem>,
options: XtreamMappingOptions,
cluster: XtreamCluster,
// Use parsed numeric filter to avoid per-item String allocations (no to_string per check)
@@ -39,9 +41,25 @@ impl XtreamPlaylistIterator {
}
let file_lock = app_config.file_locks.read_lock(&xtream_path).await;
let query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {xtream_path:?} - {err}"))?;
let reader = query.disk_iter();
let index_path = get_file_path_for_db_index(&xtream_path);
let reader = if index_path.exists() {
let query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {xtream_path:?} - {err}"))?;
match query.disk_iter_sorted() {
Ok(reader) => PlaylistIteratorReader::Sorted(reader),
Err(err) => {
error!("Sorted index error, falling back to unsorted: {err}");
// Query was consumed, re-open for fallback
let query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {xtream_path:?} - {err}"))?;
PlaylistIteratorReader::Unsorted(query.disk_iter())
}
}
} else {
let query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {xtream_path:?} - {err}"))?;
PlaylistIteratorReader::Unsorted(query.disk_iter())
};
let server_info = app_config.get_user_server_info(user);
let options = xtream_mapping_option_from_target_options(target, xtream_output, app_config, user, Some(server_info.get_base_url().as_str()));
@@ -87,31 +105,37 @@ impl XtreamPlaylistIterator {
true
}
fn get_next(&mut self) -> Option<(XtreamPlaylistItem, bool)> {
/// Helper to find the next matching item from the iterator, handling `io::Result`.
fn find_next_matching(&mut self) -> Option<(XtreamPlaylistItem, bool)> {
let filter_ids = self.filter_ids.as_ref();
let cluster = self.cluster;
let predicate = |(_, item): &(u32, XtreamPlaylistItem)| {
Self::matches_filters(cluster, filter_ids, item)
};
loop {
match self.reader.next() {
Some(Ok((_, item))) => {
if Self::matches_filters(cluster, filter_ids, &item) {
return Some((item, false));
}
// Continue to next item if filter doesn't match
}
Some(Err(err)) => {
error!("Error reading sorted index: {err}");
return None;
}
None => return None,
}
}
}
fn get_next(&mut self) -> Option<(XtreamPlaylistItem, bool)> {
if self.lookup_item.is_none() {
self.lookup_item = if self.cluster == XtreamCluster::Series || self.filter_ids.is_some() {
self.reader.find(predicate).map(|(_, item)| (item, false))
} else {
self.reader.next().map(|(_, item)| (item, false))
};
self.lookup_item = self.find_next_matching();
}
let (current_item, _) = self.lookup_item.take()?;
// prefetch next
self.lookup_item = if self.cluster == XtreamCluster::Series || self.filter_ids.is_some() {
self.reader.find(predicate).map(|(_, item)| (item, false))
} else {
self.reader.next().map(|(_, item)| (item, false))
};
self.lookup_item = self.find_next_matching();
let has_next = self.lookup_item.is_some();
Some((current_item, has_next))
+36 -10
View File
@@ -4,7 +4,7 @@ use crate::model::{AppConfig, ProxyUserCredentials};
use crate::model::{Config, ConfigTarget};
use crate::repository::bplustree::{BPlusTree, BPlusTreeQuery, BPlusTreeUpdate};
use crate::repository::playlist_scratch::PlaylistScratch;
use crate::repository::storage::{get_target_id_mapping_file, get_target_storage_path};
use crate::repository::storage::{get_file_path_for_db_index, get_target_id_mapping_file, get_target_storage_path};
use crate::repository::storage_const;
use crate::repository::target_id_mapping::VirtualIdRecord;
use crate::repository::xtream_playlist_iterator::XtreamPlaylistJsonIterator;
@@ -83,6 +83,7 @@ enum StorageKey {
async fn write_playlists_to_file(
app_config: &Arc<AppConfig>,
storage_path: &Path,
with_index: bool,
storage_key: StorageKey,
collections: Vec<(XtreamCluster, Vec<XtreamPlaylistItem>)>,
) -> Result<(), TuliproxError> {
@@ -100,7 +101,11 @@ async fn write_playlists_to_file(
StorageKey::ProviderId => item.provider_id,
}, item);
}
tree.store(&xtream_path).map_err(|err| cant_write_result!(&xtream_path, err))?;
if with_index {
tree.store_with_index(&xtream_path, |pli| pli.source_ordinal).map_err(|err| cant_write_result!(&xtream_path, err))?;
} else {
tree.store(&xtream_path).map_err(|err| cant_write_result!(&xtream_path, err))?;
}
}
}
Ok(())
@@ -297,6 +302,7 @@ pub async fn xtream_write_playlist(
if let Err(err) = write_playlists_to_file(
app_cfg,
&path,
true,
StorageKey::VirtualId,
vec![
(XtreamCluster::Live, live_col.iter().map(|item| XtreamPlaylistItem::from(&**item)).collect::<Vec<XtreamPlaylistItem>>()),
@@ -582,11 +588,30 @@ pub async fn iter_raw_xtream_playlist(app_config: &AppConfig, target: &ConfigTar
let file_lock = app_config.file_locks.read_lock(&xtream_path).await;
match BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)
.map_err(|err| info_err!("Could not open BPlusTreeQuery {xtream_path:?} - {err}")) {
Ok(mut query) => {
let items: Vec<XtreamPlaylistItem> = query.iter().map(|(_, v)| v).collect();
let len = items.len();
Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i < len - 1))))
}
Ok(mut query) => {
let index_path = get_file_path_for_db_index(&xtream_path);
let items: Vec<XtreamPlaylistItem> = if index_path.exists() {
match query.disk_iter_sorted::<u32>() {
Ok(iter) => iter.filter_map(Result::ok).map(|(_, v)| v).collect(),
Err(err) => {
error!("Sorted index error {}: {err}", xtream_path.display());
// Re-open query for fallback
match BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path) {
Ok(mut query) => query.iter().map(|(_, v)| v).collect(),
Err(fallback_err) => {
error!("Fallback query also failed {}: {fallback_err}", xtream_path.display());
Vec::new()
}
}
}
}
} else {
query.iter().map(|(_, v)| v).collect()
};
let len = items.len();
Some((file_lock, items.into_iter().enumerate().map(move |(i, v)| (v, i + 1 < len))))
}
Err(_) => None
}
} else {
@@ -732,6 +757,7 @@ pub async fn persist_input_xtream_playlist(app_config: &Arc<AppConfig>, storage_
if let Err(err) = write_playlists_to_file(
app_config,
storage_path,
false,
StorageKey::ProviderId,
vec![(cluster, col.iter().map(Into::into).collect::<Vec<XtreamPlaylistItem>>())],
).await {
@@ -869,7 +895,7 @@ pub async fn persist_input_series_info_batch(app_config: &Arc<AppConfig>, storag
}
pub async fn load_input_xtream_playlist(app_config: &Arc<AppConfig>, storage_path: &Path, clusters: &[XtreamCluster]) -> Result<Vec<PlaylistGroup>, TuliproxError> {
let mut groups: IndexMap<u32, PlaylistGroup> = IndexMap::new();
let mut groups: IndexMap<(XtreamCluster, u32), PlaylistGroup> = IndexMap::new();
for &cluster in clusters {
let xtream_path = xtream_get_file_path(storage_path, cluster);
@@ -885,7 +911,7 @@ pub async fn load_input_xtream_playlist(app_config: &Arc<AppConfig>, storage_pat
if let Ok(content) = tokio::fs::read_to_string(&cat_path).await {
if let Ok(cats) = serde_json::from_str::<Vec<CategoryEntry>>(&content) {
for cat in cats {
groups.insert(cat.category_id, PlaylistGroup {
groups.insert((cluster, cat.category_id), PlaylistGroup {
id: cat.category_id,
title: cat.category_name,
channels: Vec::new(),
@@ -901,7 +927,7 @@ pub async fn load_input_xtream_playlist(app_config: &Arc<AppConfig>, storage_pat
if let Ok(mut query) = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path) {
for (_, ref item) in query.iter() {
let cat_id = item.category_id;
groups.entry(cat_id)
groups.entry((cluster, cat_id))
.or_insert_with(|| PlaylistGroup {
id: cat_id,
title: intern("Unknown"),
+2 -1
View File
@@ -1,9 +1,10 @@
use std::sync::Arc;
use shared::error::{TuliproxError, TuliproxErrorKind};
use crate::model::IpCheckConfig;
use regex::Regex;
use shared::utils::sanitize_sensitive_info;
async fn fetch_ip(client: &reqwest::Client, url: &str, regex: Option<&Regex>) -> Result<String, TuliproxError> {
async fn fetch_ip(client: &reqwest::Client, url: &str, regex: Option<&Arc<Regex>>) -> Result<String, TuliproxError> {
let response = client.get(url).send().await
.map_err(|e| TuliproxError::new(TuliproxErrorKind::Info, format!("Failed to request {}: {e}", sanitize_sensitive_info(url))))?;
+3 -2
View File
@@ -21,7 +21,6 @@
@forward "components/list_view";
@forward "components/reveal_content";
@forward "components/hide_content";
@forward "components/userlist/userlist_view";
@forward "components/playlist/playlist_editor_view";
@forward "components/playlist/playlist_explorer_view";
@forward "components/playlist/playlist_explorer";
@@ -53,10 +52,12 @@
@forward "components/filter";
@forward "components/mapper_script";
@forward "components/mapper_counter";
@forward "components/userlist/userlist_view";
@forward "components/userlist/user_state";
@forward "components/userlist/proxy_type";
@forward "components/userlist/max_connections";
@forward "components/userlist/proxy-user-credentials-form";
@forward "components/userlist/proxy_user_credentials_form";
@forward "components/userlist/user_table";
@forward "components/radio_button_group";
@forward "components/no_content";
@forward "components/search";
@@ -0,0 +1,24 @@
.tp__user-table {
display: flex;
flex-flow: column;
box-sizing: border-box;
overflow: hidden;
.tp__icon-button {
width: 2rem;
}
.tp__filter {
max-width: var(--max-table-cell-width);
overflow: hidden !important;
.tp__filter__code {
overflow: hidden !important;
}
}
&__invalid-target {
font-weight: bold;
background-color: var(--attention-color);
}
}
@@ -30,8 +30,6 @@
}
.tp__userlist-edit {
max-width: 800px;
max-width: 800px;
}
}
+3 -2
View File
@@ -45,11 +45,12 @@ pub fn Search(props: &SearchProps) -> Html {
RegexState::Inactive => {
if let Some(input) = input.cast::<HtmlInputElement>() {
let text = input.value();
if regex::Regex::new(&text).is_ok() {
if shared::model::REGEX_CACHE.get_or_compile(&text).is_ok() {
regex_active.set(RegexState::Active);
} else {
regex_active.set(RegexState::Invalid);
}
shared::model::REGEX_CACHE.sweep();
}
}
}
@@ -83,7 +84,7 @@ pub fn Search(props: &SearchProps) -> Html {
let text = input.value();
if text.len() >= min_length {
if !matches!(*regex, RegexState::Inactive) {
if regex::Regex::new(&text).is_ok() {
if shared::model::REGEX_CACHE.get_or_compile(&text).is_ok() {
regex.set(RegexState::Active);
cb_search.emit(SearchRequest::Regexp(text, (*search_fields).clone()));
} else {
@@ -1,7 +1,9 @@
use std::cmp::Ordering;
use crate::app::components::menu_item::MenuItem;
use crate::app::components::popup_menu::PopupMenu;
use crate::app::components::{convert_bool_to_chip_style, AppIcon, CellValue, Chip, HideContent, MaxConnections, ProxyTypeView, RevealContent, Table, TableDefinition, UserStatus, UserlistContext, UserlistPage};
use crate::app::components::{convert_bool_to_chip_style, AppIcon, CellValue, Chip, HideContent,
MaxConnections, ProxyTypeView, RevealContent, Table, TableDefinition,
UserStatus, UserlistContext, UserlistPage};
use crate::app::context::TargetUser;
use crate::model::DialogResult;
use crate::services::DialogService;
@@ -11,11 +13,12 @@ use shared::utils::{unix_ts_to_str, Substring};
use std::fmt::Display;
use std::rc::Rc;
use std::str::FromStr;
use std::collections::HashSet;
use yew::platform::spawn_local;
use yew::prelude::*;
use yew_hooks::use_clipboard;
use yew_i18n::use_translation;
use crate::app::TargetUserList;
use crate::app::{ConfigContext, TargetUserList};
use crate::hooks::use_service_context;
const HEADERS: [&str; 15] = [
@@ -102,12 +105,21 @@ pub fn UserTable(props: &UserTableProps) -> Html {
let translate = use_translation();
let clipboard = use_clipboard();
let service_ctx = use_service_context();
let config_ctx = use_context::<ConfigContext>().expect("Config context not found");
let dialog = use_context::<DialogService>().expect("Dialog service not found");
let userlist_context = use_context::<UserlistContext>().expect("Userlist context not found");
let popup_anchor_ref = use_state(|| None::<web_sys::Element>);
let popup_is_open = use_state(|| false);
let selected_dto = use_state(|| None::<Rc<TargetUser>>);
let user_list = use_state(|| props.users.clone());
let target_names = use_memo(config_ctx.clone(), |cfg|
cfg.config.as_ref().map(|c| c.sources.sources.iter().flat_map(|s| s.targets.iter())
.map(|t| t.name.clone())
.collect::<HashSet<String>>()
)
.unwrap_or_default()
);
{
let user_list = user_list.clone();
@@ -156,6 +168,7 @@ pub fn UserTable(props: &UserTableProps) -> Html {
let render_data_cell = {
let translator = translate.clone();
let popup_onclick = handle_popup_onclick.clone();
let target_names = target_names.clone();
Callback::<(usize, usize, Rc<TargetUser>), Html>::from(
move |(row, col, dto): (usize, usize, Rc<TargetUser>)| {
let user_active = dto.credentials.is_active();
@@ -174,7 +187,7 @@ pub fn UserTable(props: &UserTableProps) -> Html {
label={if user_active {translator.t("LABEL.ENABLED")} else { translator.t("LABEL.DISABLED")} }
/> },
2 => html! { <UserStatus status={ dto.credentials.status } /> },
3 => html! { dto.target.as_str() },
3 => html! { <span class={if target_names.contains(dto.target.as_str()) {""} else {"tp__user-table__invalid-target"} }>{dto.target.as_str()}</span> },
4 => html! { dto.credentials.username.as_str() },
5 => html! { <HideContent content={dto.credentials.password.to_string()}></HideContent> },
6 => html! { dto.credentials.token.as_ref().map_or_else(|| html!{}, |token| html! { <HideContent content={token.to_string()}></HideContent>}) },
@@ -317,7 +330,7 @@ pub fn UserTable(props: &UserTableProps) -> Html {
};
html! {
<div class="tp__target-table">
<div class="tp__user-table">
{
html! {
<>
+1 -3
View File
@@ -1,5 +1,4 @@
use std::rc::Rc;
use regex::Regex;
use yew::UseStateHandle;
use shared::model::{AppConfigDto, ConfigTargetDto, PlaylistRequest, ProxyUserCredentialsDto, SearchRequest, StatusCheck, SystemInfo, UiPlaylistCategories};
use crate::app::components::{InputRow, PlaylistEditorPage, PlaylistExplorerPage, UserlistPage};
@@ -23,7 +22,6 @@ pub struct PlaylistExplorerContext {
pub playlist_request: UseStateHandle<Option<PlaylistRequest>>,
}
#[derive(Clone, PartialEq)]
pub struct TargetUser {
pub target: String,
@@ -79,7 +77,7 @@ impl UserlistContext {
self.filtered_users.set(Some(Rc::new(filtered)));
}
SearchRequest::Regexp(text, search_fields) => {
if let Ok(regex) = Regex::new(text) {
if let Ok(regex) = shared::model::REGEX_CACHE.get_or_compile(text) {
let filter_username = search_fields.as_ref().is_none_or(|f| f.iter().any(|s| s == "username"));
let filter_server = search_fields.as_ref().is_none_or(|f| f.iter().any(|s| s == "server"));
let filter_playlist = search_fields.as_ref().is_none_or(|f| f.iter().any(|s| s == "playlist"));
+2 -1
View File
@@ -26,7 +26,8 @@ ciborium = "0.2.2"
hex = "0.4.3"
lz4_flex = "0.12.0"
deunicode = "1.6.2"
serde-saphyr = "0.0.13"
serde-saphyr = "0.0.14"
dashmap = "6.1.0"
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3.83"
+4 -3
View File
@@ -9,6 +9,7 @@ use pest::iterators::Pair;
use pest::Parser;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::sync::Arc;
use crate::error::{info_err_res, TuliproxError};
use crate::info_err;
pub use crate::model::{ItemField, PatternTemplate, PlaylistItem, PlaylistItemType, FieldGetAccessor, FieldSetAccessor, TemplateValue};
@@ -86,7 +87,7 @@ impl ValueAccessor<'_> {
#[derive(Debug, Clone)]
pub struct CompiledRegex {
pub restr: String,
pub re: regex::Regex,
pub re: Arc<regex::Regex>,
}
impl PartialEq for CompiledRegex {
@@ -157,7 +158,7 @@ pub enum Filter {
impl Default for Filter {
fn default() -> Self {
Self::Group(Box::new(Filter::FieldComparison(ItemField::Group, CompiledRegex { restr: ".*".to_string(), re: regex::Regex::new(".*").unwrap() })))
Self::Group(Box::new(Filter::FieldComparison(ItemField::Group, CompiledRegex { restr: ".*".to_string(), re: crate::model::REGEX_CACHE.get_or_compile(".*").unwrap() })))
}
}
@@ -296,7 +297,7 @@ fn get_parser_regexp(
parsed_text.pop();
parsed_text.remove(0);
let regstr = apply_templates_to_pattern_single(&parsed_text, templates)?;
let re = regex::Regex::new(regstr.as_str());
let re = crate::model::REGEX_CACHE.get_or_compile(regstr.as_str());
if re.is_err() {
return info_err_res!("can't parse regex: {}", regstr);
}
+3 -2
View File
@@ -17,6 +17,7 @@ use std::fmt::Display;
use std::fmt::Write;
use std::ops::Deref;
use std::str::FromStr;
use std::sync::Arc;
#[derive(Parser)]
#[grammar_inline = r##"
@@ -181,7 +182,7 @@ pub enum Expression {
NumberLiteral(f64),
FieldAccess(String),
VarAccess(String, String),
RegexExpr { field: RegexSource, pattern: String, re_pattern: Regex },
RegexExpr { field: RegexSource, pattern: String, re_pattern: Arc<Regex> },
FunctionCall { name: BuiltInFunction, args: Vec<ExprId> },
Assignment { target: AssignmentTarget, expr: ExprId },
MatchBlock(Vec<MatchCase>),
@@ -506,7 +507,7 @@ impl MapperScript {
};
let pattern_raw = inner.next().unwrap().as_str();
let pattern = &pattern_raw[1..pattern_raw.len() - 1]; // Strip quotes
match Regex::new(pattern) {
match crate::model::REGEX_CACHE.get_or_compile(pattern) {
Ok(re) => Ok(Some(Expression::RegexExpr { field, pattern: pattern.to_string(), re_pattern: re })),
Err(_) => info_err_res!("Invalid regex {}", pattern),
}
+1 -1
View File
@@ -227,7 +227,7 @@ impl ConfigDto {
if let Some(download) = &video.download {
if let Some(episode_pattern) = &download.episode_pattern {
if !episode_pattern.is_empty() {
let re = regex::Regex::new(episode_pattern);
let re = crate::model::REGEX_CACHE.get_or_compile(episode_pattern);
if re.is_err() {
return false;
}
+16 -12
View File
@@ -1,9 +1,10 @@
use crate::error::{TuliproxError};
use crate::info_err;
use crate::utils::is_blank_optional_string;
use std::fmt::Display;
use crate::utils::{default_best_match_threshold, default_match_threshold, is_default_best_match_threshold,
is_default_match_threshold, is_false};
use log::warn;
use crate::error::{info_err_res, TuliproxError};
use crate::utils::{is_false, default_match_threshold, default_best_match_threshold,
is_default_match_threshold, is_default_best_match_threshold};
use std::fmt::Display;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
@@ -45,9 +46,15 @@ pub struct EpgSmartMatchConfigDto {
pub name_prefix_separator: Option<Vec<char>>,
#[serde(default, skip_serializing_if = "is_false")]
pub fuzzy_matching: bool,
#[serde(default = "default_match_threshold", skip_serializing_if = "is_default_match_threshold")]
#[serde(
default = "default_match_threshold",
skip_serializing_if = "is_default_match_threshold"
)]
pub match_threshold: u16,
#[serde(default = "default_best_match_threshold", skip_serializing_if = "is_default_best_match_threshold")]
#[serde(
default = "default_best_match_threshold",
skip_serializing_if = "is_default_best_match_threshold"
)]
pub best_match_threshold: u16,
}
impl Default for EpgSmartMatchConfigDto {
@@ -66,7 +73,6 @@ impl Default for EpgSmartMatchConfigDto {
}
impl EpgSmartMatchConfigDto {
/// # Panics
///
/// Prepares the EPG smart match configuration by validating thresholds, compiling normalization regex, and setting default values as needed.
@@ -96,11 +102,9 @@ impl EpgSmartMatchConfigDto {
}
if let Some(regstr) = self.normalize_regex.as_ref() {
let re = regex::Regex::new(regstr.as_str());
if re.is_err() {
return info_err_res!("can't parse regex: {}", regstr);
}
};
crate::model::REGEX_CACHE.get_or_compile(regstr.as_str())
.map_err(|_| info_err!("can't parse regex: {}", regstr))?;
}
Ok(())
}
+4 -2
View File
@@ -1,12 +1,14 @@
use std::sync::Arc;
use crate::error::{TuliproxError};
use crate::foundation::filter::{get_filter, Filter};
use crate::model::{PatternTemplate};
use crate::utils::arc_str_serde;
use crate::model::{PatternTemplate, XtreamCluster};
use crate::utils::{arc_str_serde, xtream_cluster_serde};
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ConfigFavouritesDto {
#[serde(with = "xtream_cluster_serde")]
pub cluster: XtreamCluster,
#[serde(with = "arc_str_serde")]
pub group: Arc<str>,
#[serde(default)]
+2 -3
View File
@@ -1,6 +1,5 @@
use crate::error::{TuliproxError, TuliproxErrorKind};
use crate::utils::{is_blank_optional_str, is_blank_optional_string};
use regex::Regex;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default, PartialEq)]
#[serde(deny_unknown_fields)]
@@ -68,7 +67,7 @@ impl IpCheckConfigDto {
// }
if let Some(p4) = &self.pattern_ipv4 {
Regex::new(p4).map_err(|err| {
crate::model::REGEX_CACHE.get_or_compile(p4).map_err(|err| {
TuliproxError::new(
TuliproxErrorKind::Info,
format!("Invalid IPv4 regex: {p4} {err}"),
@@ -76,7 +75,7 @@ impl IpCheckConfigDto {
})?;
}
if let Some(p6) = &self.pattern_ipv6 {
Regex::new(p6).map_err(|err| {
crate::model::REGEX_CACHE.get_or_compile(p6).map_err(|err| {
TuliproxError::new(
TuliproxErrorKind::Info,
format!("Invalid IPv6 regex: {p6} {err}"),
+1
View File
@@ -36,6 +36,7 @@ mod proxy_user_status;
mod favourites;
mod geoip;
mod library;
pub use proxy_type::*;
pub use proxy_user_status::*;
pub use base::*;
+1 -1
View File
@@ -13,7 +13,7 @@ pub struct ConfigRenameDto {
impl ConfigRenameDto {
pub fn prepare(&mut self, templates: Option<&Vec<PatternTemplate>>) -> Result<(), TuliproxError> {
self.pattern = apply_templates_to_pattern_single(&self.pattern, templates)?;
if let Err(err) = regex::Regex::new(&self.pattern) {
if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(&self.pattern) {
return info_err_res!("can't parse regex: {} {err}", &self.pattern);
}
Ok(())
+4 -3
View File
@@ -6,12 +6,13 @@ use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize};
use std::fmt::{Display, Formatter};
use std::str::FromStr;
use std::sync::Arc;
fn compile_regex_vec(patterns: Option<&Vec<String>>) -> Result<Option<Vec<Regex>>, TuliproxError> {
fn compile_regex_vec(patterns: Option<&Vec<String>>) -> Result<Option<Vec<Arc<Regex>>>, TuliproxError> {
patterns.as_ref()
.map(|seq| {
seq.iter()
.map(|s| Regex::new(s).map_err(|err| {
.map(|s| crate::model::REGEX_CACHE.get_or_compile(s).map_err(|err| {
info_err!("can't parse regex: {s} {err}")
}))
.collect::<Result<Vec<_>, _>>()
@@ -107,7 +108,7 @@ pub struct ConfigSortRuleDto {
pub sequence: Option<Vec<String>>,
pub filter: String,
#[serde(skip)]
pub t_sequence: Option<Vec<Regex>>,
pub t_sequence: Option<Vec<Arc<Regex>>>,
#[serde(skip)]
pub t_filter: Option<Filter>,
}
+1 -1
View File
@@ -379,7 +379,7 @@ impl ConfigTargetDto {
if let Some(watch) = &self.watch {
for pat in watch {
if let Err(err) = regex::Regex::new(pat) {
if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(pat) {
return info_err_res!("Invalid watch regular expression: {}", err);
}
}
+1 -1
View File
@@ -67,7 +67,7 @@ impl VideoConfigDto {
}
if let Some(episode_pattern) = &downl.episode_pattern {
if let Err(err) = regex::Regex::new(episode_pattern) {
if let Err(err) = crate::model::REGEX_CACHE.get_or_compile(episode_pattern) {
return info_err_res!("can't parse regex: {episode_pattern} {err}");
}
}
+3 -1
View File
@@ -28,6 +28,7 @@ mod stream_properties;
mod xtream;
mod info_doc_utils;
mod playlist_info_document;
mod regex_cache;
pub use self::cluster_flags::*;
pub use self::playlist::*;
@@ -55,4 +56,5 @@ pub use self::system_info::*;
pub use self::library_request::*;
pub use self::stream_properties::*;
pub use self::xtream::*;
pub use self::playlist_info_document::*;
pub use self::playlist_info_document::*;
pub use self::regex_cache::*;
-1
View File
@@ -1232,7 +1232,6 @@ pub struct PlaylistGroup {
#[serde(with = "arc_str_serde")]
pub title: Arc<str>,
pub channels: Vec<PlaylistItem>,
#[serde(skip)]
pub xtream_cluster: XtreamCluster,
}
+1 -1
View File
@@ -211,7 +211,7 @@ impl UiPlaylistCategories {
build_result(live, video, series)
}
SearchRequest::Regexp(text, _search_fields) => {
if let Ok(regex) = Regex::new(text) {
if let Ok(regex) = crate::model::REGEX_CACHE.get_or_compile(text) {
let live = filter_channels_re(self.live.as_ref(), &regex);
let video = filter_channels_re(self.vod.as_ref(), &regex);
let series = filter_channels_re(self.series.as_ref(), &regex);
+50
View File
@@ -0,0 +1,50 @@
use std::sync::{Arc, LazyLock};
use dashmap::DashMap;
use regex::Regex;
use crate::error::TuliproxError;
use crate::info_err;
pub static REGEX_CACHE: LazyLock<RegexCache> = LazyLock::new(RegexCache::new);
pub struct RegexCache {
cache: DashMap<String, Arc<Regex>>,
}
impl Default for RegexCache {
fn default() -> Self {
Self::new()
}
}
impl RegexCache {
pub fn new() -> Self {
Self {
cache: DashMap::new(),
}
}
pub fn get_or_compile(
&self,
pattern: &str,
) -> Result<Arc<Regex>, TuliproxError> {
// Try to get existing entry first
if let Some(cached) = self.cache.get(pattern) {
return Ok(cached.clone());
}
// Compile outside the lock
let regex = Regex::new(pattern).map_err(|e| {
info_err!("can't parse regex: {pattern} {e}")
})?;
let arc_regex = Arc::new(regex);
// Use entry API to avoid overwriting if another thread inserted
Ok(self.cache.entry(pattern.to_owned())
.or_insert(arc_regex)
.clone())
}
/// Removes regexes that are only held by the cache itself (strong_count == 1).
pub fn sweep(&self) {
self.cache.retain(|_k, v| Arc::strong_count(v) > 1);
}
}
+3 -3
View File
@@ -2,7 +2,7 @@ use regex::Regex;
use std::collections::HashSet;
use std::string::ToString;
use std::sync::atomic::AtomicBool;
use std::sync::LazyLock;
use std::sync::{Arc, LazyLock};
pub const USER_FILE: &str = "user.txt";
@@ -83,7 +83,7 @@ pub struct Constants {
pub re_base_href_wasm: Regex,
pub re_env_var: Regex,
pub re_memory_usage: Regex,
pub re_epg_normalize: Regex,
pub re_epg_normalize: Arc<Regex>,
pub re_template_var: Regex,
pub re_template_tag: Regex,
pub re_template_attribute: Regex,
@@ -119,7 +119,7 @@ pub static CONSTANTS: LazyLock<Constants> = LazyLock::new(||
re_base_href_wasm: Regex::new("'(/frontend\\-)").unwrap(),
re_env_var: Regex::new(r"\$\{env:(?P<var>[a-zA-Z_][a-zA-Z0-9_]*)}").unwrap(),
re_memory_usage: Regex::new(r"VmRSS:\s+(\d+) kB").unwrap(),
re_epg_normalize: Regex::new(r"[^a-zA-Z0-9\-]").unwrap(),
re_epg_normalize: Arc::new(Regex::new(r"[^a-zA-Z0-9\-]").unwrap()),
re_template_var: Regex::new("!(.*?)!").unwrap(),
re_template_tag: Regex::new("<tag:(.*?)>").unwrap(),
re_template_attribute: Regex::new("<(.*?)>").unwrap(),
+25
View File
@@ -398,3 +398,28 @@ where
{
serializer.serialize_str(&value.to_string())
}
/// Serde support for `XtreamCluster` fields.
/// Serializes as string (e.g., "live", "video", "series") and deserializes via `FromStr`.
pub mod xtream_cluster_serde {
use std::str::FromStr;
use serde::{Deserialize, Deserializer, Serializer};
use serde::de::Error;
use crate::model::XtreamCluster;
pub fn serialize<S>(value: &XtreamCluster, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(value.as_str())
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<XtreamCluster, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
XtreamCluster::from_str(&raw).map_err(D::Error::custom)
}
}