Feature/multi crate (#837)

Refactored to multi crate project
This commit is contained in:
euzu
2026-08-27 15:36:23 +02:00
committed by GitHub
parent eb9b2a01fa
commit 9ec775b35b
686 changed files with 42407 additions and 49530 deletions
@@ -0,0 +1,200 @@
use shared::{
model::{DeduplicateConfig, DeduplicateKeep, DeduplicateMatchBy, PlaylistGroup, PlaylistItem, XtreamCluster},
utils::{deunicode_string, quality_rank, quality_tokens, token_quality},
};
use std::collections::HashMap;
/// Lowercased token join with quality tokens removed, so "News HD" and
/// "NEWS [FHD]" produce the same key.
fn normalized_dedup_key(value: &str, match_as_ascii: bool) -> String {
let value = if match_as_ascii { deunicode_string(value) } else { std::borrow::Cow::Borrowed(value) };
let mut key = String::with_capacity(value.len());
for token in quality_tokens(&value) {
if token_quality(token).is_some() {
continue;
}
if !key.is_empty() {
key.push(' ');
}
for ch in token.chars() {
key.extend(ch.to_lowercase());
}
}
key
}
fn match_value(config: DeduplicateConfig, item: &PlaylistItem) -> String {
normalized_dedup_key(raw_match_value(config, item), config.match_as_ascii)
}
fn raw_match_value(config: DeduplicateConfig, item: &PlaylistItem) -> &str {
let header = &item.header;
match config.match_by {
DeduplicateMatchBy::Caption => {
if header.title.is_empty() {
header.name.as_ref()
} else {
header.title.as_ref()
}
}
DeduplicateMatchBy::Name => header.name.as_ref(),
DeduplicateMatchBy::Title => header.title.as_ref(),
}
}
/// Collapse duplicate channels across the whole target playlist (per cluster).
/// Returns the number of removed channels. Empty match keys are never
/// deduplicated; ties keep the first occurrence in playlist order.
pub(in crate::processor) fn deduplicate_playlist(
config: DeduplicateConfig,
playlist: &mut Vec<PlaylistGroup>,
) -> usize {
// winner per key: (quality rank, group index, channel index)
let mut winners: HashMap<(XtreamCluster, String), (u8, usize, usize)> = HashMap::new();
for (group_idx, group) in playlist.iter().enumerate() {
for (channel_idx, channel) in group.channels.iter().enumerate() {
let key_value = match_value(config, channel);
if key_value.is_empty() {
continue;
}
let rank = quality_rank(raw_match_value(config, channel));
match winners.entry((group.xtream_cluster, key_value)) {
std::collections::hash_map::Entry::Vacant(entry) => {
entry.insert((rank, group_idx, channel_idx));
}
std::collections::hash_map::Entry::Occupied(mut entry) => {
if config.keep == DeduplicateKeep::BestQuality && rank > entry.get().0 {
entry.insert((rank, group_idx, channel_idx));
}
}
}
}
}
let mut removed = 0;
// Only drop groups that deduplication emptied; groups that were already empty stay untouched.
let mut emptied_by_dedup = vec![false; playlist.len()];
for (group_idx, group) in playlist.iter_mut().enumerate() {
let cluster = group.xtream_cluster;
let before = group.channels.len();
let mut channel_idx = 0usize;
group.channels.retain(|channel| {
let idx = channel_idx;
channel_idx += 1;
let key_value = match_value(config, channel);
if key_value.is_empty() {
return true;
}
winners
.get(&(cluster, key_value))
.is_none_or(|(_, winner_group, winner_channel)| *winner_group == group_idx && *winner_channel == idx)
});
removed += before - group.channels.len();
emptied_by_dedup[group_idx] = before > 0 && group.channels.is_empty();
}
let mut group_idx = 0usize;
playlist.retain(|_| {
let keep = !emptied_by_dedup[group_idx];
group_idx += 1;
keep
});
removed
}
#[cfg(test)]
mod tests {
use super::*;
use shared::{model::PlaylistItemHeader, utils::Internable};
fn make_item(title: &str) -> PlaylistItem {
PlaylistItem { header: PlaylistItemHeader { title: title.intern(), ..Default::default() } }
}
fn make_group(title: &str, channels: Vec<PlaylistItem>) -> PlaylistGroup {
PlaylistGroup { id: 1, title: title.intern(), channels, xtream_cluster: XtreamCluster::Live }
}
#[test]
fn quality_rank_recognizes_tokens() {
assert_eq!(quality_rank("News [UHD]"), 5);
assert_eq!(quality_rank("News 1080p"), 3);
assert_eq!(quality_rank("News HD"), 2);
assert_eq!(quality_rank("News"), 0);
assert_eq!(quality_rank("HDTV News"), 0); // no partial token match
}
#[test]
fn dedup_keeps_best_quality() {
let mut playlist = vec![make_group(
"G",
vec![make_item("News HD"), make_item("News [FHD]"), make_item("News"), make_item("Sports HD")],
)];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
assert_eq!(removed, 2);
let titles: Vec<_> = playlist[0].channels.iter().map(|c| c.header.title.to_string()).collect();
assert_eq!(titles, vec!["News [FHD]", "Sports HD"]);
}
#[test]
fn dedup_keep_first_preserves_playlist_order_winner() {
let mut playlist = vec![make_group("G", vec![make_item("News HD"), make_item("News [FHD]")])];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::First,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
assert_eq!(removed, 1);
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "News HD");
}
#[test]
fn dedup_ignores_empty_keys_and_drops_empty_groups() {
let mut playlist = vec![
make_group("Empty", vec![]),
make_group("A", vec![make_item("News HD")]),
make_group("B", vec![make_item("News FHD"), make_item("")]),
];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
let removed = deduplicate_playlist(config, &mut playlist);
assert_eq!(removed, 1);
// "A" was emptied by dedup and dropped, the already-empty group survives
assert_eq!(playlist.len(), 2);
assert_eq!(playlist[0].title.as_ref(), "Empty");
assert_eq!(playlist[1].title.as_ref(), "B");
// the empty-caption item has no dedup key and is retained
let titles: Vec<_> = playlist[1].channels.iter().map(|c| c.header.title.to_string()).collect();
assert_eq!(titles, vec!["News FHD", ""]);
}
#[test]
fn dedup_match_as_ascii_collapses_accented_names() {
let mut playlist = vec![make_group("G", vec![make_item("Café HD"), make_item("Cafe FHD")])];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::BestQuality,
match_as_ascii: true,
};
let removed = deduplicate_playlist(config, &mut playlist);
assert_eq!(removed, 1);
assert_eq!(playlist[0].channels[0].header.title.as_ref(), "Cafe FHD");
// without the flag the accented name stays distinct
let mut playlist = vec![make_group("G", vec![make_item("Café HD"), make_item("Cafe FHD")])];
let config = DeduplicateConfig {
match_by: DeduplicateMatchBy::Caption,
keep: DeduplicateKeep::BestQuality,
match_as_ascii: false,
};
assert_eq!(deduplicate_playlist(config, &mut playlist), 0);
}
}
File diff suppressed because it is too large Load Diff
+704
View File
@@ -0,0 +1,704 @@
use shared::{
concat_string,
error::TuliproxError,
model::{
normalize_episode_title, EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader,
PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties,
SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, UUIDType,
VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster,
},
utils::{concat_path_leading_slash, generate_local_playlist_uuid, Internable},
};
use std::{collections::HashMap, path::Path, sync::Arc};
use tuliprox_core::model::{AppConfig, ConfigInput};
use tuliprox_library::library::{
resolve_metadata_storage_path, Actor, EpisodeMetadata, MediaMetadata, MetadataAsyncIter, MetadataCacheEntry,
TechnicalMetadata, VideoClipMetadata,
};
fn thumbnail_url(entry: &MetadataCacheEntry, api_base_path: &str) -> Option<String> {
entry
.thumbnail_hash
.as_ref()
.map(|_| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{}", entry.uuid)))
}
fn episode_thumbnail_url(episode: &EpisodeMetadata, api_base_path: &str) -> Option<String> {
episode
.thumbnail_id
.as_deref()
.map(|id| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{id}")))
}
fn duration_secs_to_xtream_duration(duration_secs: u32) -> Arc<str> {
let hours = duration_secs / 3600;
let minutes = (duration_secs % 3600) / 60;
let seconds = duration_secs % 60;
format!("{hours:02}:{minutes:02}:{seconds:02}").into()
}
fn technical_duration_secs(technical: Option<&TechnicalMetadata>) -> Option<u32> {
technical.and_then(|metadata| metadata.duration_secs).filter(|value| *value > 0)
}
fn technical_bitrate(technical: Option<&TechnicalMetadata>) -> u32 {
technical.and_then(|metadata| metadata.bitrate).filter(|value| *value > 0).unwrap_or_default()
}
fn join_actor_names(actors: &[Actor]) -> Arc<str> {
let mut names = String::new();
for (idx, actor) in actors.iter().enumerate() {
if idx > 0 {
names.push_str(", ");
}
names.push_str(&actor.name);
}
names.intern()
}
fn youtube_trailer_key(videos: Option<&[VideoClipMetadata]>) -> Option<&str> {
videos?.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.as_str())
}
pub async fn download_library_playlist(
_client: &reqwest::Client,
app_config: &Arc<AppConfig>,
input: &ConfigInput,
) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
let config = &*app_config.config.load();
let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) };
if !library_config.enabled {
return (vec![], vec![]);
}
let api_base_path =
concat_path_leading_slash(config.web_ui.as_ref().and_then(|w| w.path.as_deref()).unwrap_or(""), "api/v1");
let storage_path =
resolve_metadata_storage_path(config.metadata_update.as_ref(), &config.storage_dir).join("library");
let mut metadata_iter = MetadataAsyncIter::new(&storage_path).await;
let mut group_movies = PlaylistGroup {
id: 0,
title: library_config.playlist.movie_category.clone(),
channels: vec![],
xtream_cluster: XtreamCluster::Video,
};
let mut group_series = PlaylistGroup {
id: 0,
title: library_config.playlist.series_category.clone(),
channels: vec![],
xtream_cluster: XtreamCluster::Series,
};
while let Some(entry) = metadata_iter.next().await {
match entry.metadata {
MediaMetadata::Movie(_) => {
to_playlist_item(
&entry,
&input.name,
&library_config.playlist.movie_category,
&api_base_path,
&mut group_movies.channels,
);
}
MediaMetadata::Series(_) => {
to_playlist_item(
&entry,
&input.name,
&library_config.playlist.series_category,
&api_base_path,
&mut group_series.channels,
);
}
}
}
let mut groups = vec![];
if !group_movies.channels.is_empty() {
groups.push(group_movies);
}
if !group_series.channels.is_empty() {
groups.push(group_series);
}
(groups, vec![])
}
fn to_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
channels: &mut Vec<PlaylistItem>,
) {
match &entry.metadata {
MediaMetadata::Movie(_) => {
channels.push(build_movie_playlist_item(entry, input_name, group_name, api_base_path));
}
MediaMetadata::Series(_) => {
if let Some((series_info, episodes)) =
build_series_playlist_items(entry, input_name, group_name, api_base_path)
{
channels.push(series_info);
channels.extend(episodes);
}
}
}
}
fn build_movie_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
) -> PlaylistItem {
let metadata = &entry.metadata;
let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry, api_base_path);
let title = metadata.title().intern();
let group = group_name.intern();
PlaylistItem {
header: PlaylistItemHeader {
uuid: UUIDType::from_valid_uuid(&entry.uuid),
name: Arc::clone(&title),
title,
group,
logo: metadata.poster().or(thumbnail_url(entry, api_base_path).as_deref()).unwrap_or("").intern(),
url: concat_string!("file://", &entry.file_path).into(),
xtream_cluster: XtreamCluster::Video,
additional_properties,
item_type: PlaylistItemType::LocalVideo,
input_name: Arc::clone(input_name),
..PlaylistItemHeader::default()
},
}
}
fn build_series_playlist_items(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
) -> Option<(PlaylistItem, Vec<PlaylistItem>)> {
let additional_properties = metadata_cache_entry_to_xtream_series_info(entry, api_base_path)?;
let series_info =
build_series_info_playlist_item(entry, input_name, group_name, api_base_path, &additional_properties);
let episodes =
build_series_episode_playlist_items(entry, input_name, group_name, api_base_path, &additional_properties);
Some((series_info, episodes))
}
fn build_series_info_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
additional_properties: &StreamProperties,
) -> PlaylistItem {
let metadata = &entry.metadata;
PlaylistItem {
header: PlaylistItemHeader {
uuid: UUIDType::from_valid_uuid(&entry.uuid),
id: entry.uuid.clone().into(),
name: metadata.title().intern(),
group: group_name.intern(),
title: metadata.title().intern(),
logo: metadata.poster().or(thumbnail_url(entry, api_base_path).as_deref()).unwrap_or("").intern(),
url: concat_string!("file://", &entry.file_path).into(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::LocalSeriesInfo,
input_name: Arc::clone(input_name),
additional_properties: Some(additional_properties.clone()),
..PlaylistItemHeader::default()
},
}
}
fn build_series_episode_playlist_items(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
additional_properties: &StreamProperties,
) -> Vec<PlaylistItem> {
let metadata = &entry.metadata;
let group_arc: Arc<str> = group_name.intern();
match additional_properties {
StreamProperties::Series(series_properties) => series_properties
.details
.as_ref()
.and_then(|details| details.episodes.as_ref())
.map(|episodes| {
episodes
.iter()
.map(|episode| {
build_series_episode_playlist_item(
entry,
metadata,
input_name,
&group_arc,
api_base_path,
episode,
)
})
.collect()
})
.unwrap_or_default(),
_ => vec![],
}
}
fn build_series_episode_playlist_item(
entry: &MetadataCacheEntry,
metadata: &MediaMetadata,
input_name: &Arc<str>,
group_name: &Arc<str>,
api_base_path: &str,
episode: &SeriesStreamDetailEpisodeProperties,
) -> PlaylistItem {
let logo: Arc<str> = if episode.movie_image.is_empty() {
metadata.poster().or(thumbnail_url(entry, api_base_path).as_deref()).unwrap_or("").intern()
} else {
episode.movie_image.clone()
};
let container_extension = Path::new(&*episode.direct_source)
.extension()
.and_then(|s| s.to_str())
.map(ToString::to_string)
.unwrap_or_default();
PlaylistItem {
header: PlaylistItemHeader {
id: episode.id.to_string().into(),
parent_code: entry.uuid.clone().into(),
uuid: generate_local_playlist_uuid(input_name, PlaylistItemType::LocalSeries, &episode.direct_source),
logo: logo.clone(),
name: episode.title.clone(),
group: Arc::clone(group_name),
title: episode.title.clone(),
url: episode.direct_source.clone(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::LocalSeries,
category_id: 0,
input_name: Arc::clone(input_name),
additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties {
episode_id: episode.id,
episode: episode.episode_num,
season: episode.season,
added: Some(episode.added.clone()),
release_date: Some(episode.release_date.clone()),
series_release_date: None,
plot: episode.plot.clone(),
tmdb: episode.tmdb,
movie_image: logo,
container_extension: container_extension.intern(),
audio: None,
video: None,
}))),
..Default::default()
},
}
}
pub fn metadata_cache_entry_to_xtream_movie_info(
entry: &MetadataCacheEntry,
api_base_path: &str,
) -> Option<StreamProperties> {
let movie = match &entry.metadata {
MediaMetadata::Movie(m) => m,
MediaMetadata::Series(_) => return None,
};
let binding = thumbnail_url(entry, api_base_path);
let thumbnail_poster = binding.as_deref();
let poster = movie.poster.as_deref().or(movie.fanart.as_deref()).or(thumbnail_poster);
let container_extension =
Path::new(&entry.file_path).extension().and_then(|s| s.to_str()).map(ToString::to_string).unwrap_or_default();
let actor_names = movie.actors.as_deref().map(join_actor_names);
let technical = movie.technical.as_ref();
let duration_secs = technical_duration_secs(technical).or_else(|| movie.runtime.map(|runtime| runtime * 60));
let duration = duration_secs.map(duration_secs_to_xtream_duration);
let youtube_trailer = youtube_trailer_key(movie.videos.as_deref());
let properties = VideoStreamProperties {
name: movie.title.clone().into(),
category_id: 0,
stream_id: 0,
stream_icon: poster.unwrap_or("").to_owned().into(),
direct_source: "".into(),
custom_sid: None,
added: entry.file_modified.intern(),
container_extension: container_extension.intern(),
rating: movie.rating,
rating_5based: None,
stream_type: Some("movie".intern()),
trailer: youtube_trailer.map(Into::into),
tmdb: movie.tmdb_id,
is_adult: 0,
details: Some(VideoStreamDetailProperties {
kinopoisk_url: movie
.tmdb_id
.map(|id| concat_string!("https://www.themoviedb.org/movie/", &id.to_string()).into()),
o_name: movie.original_title.clone().map(Into::into),
cover_big: poster.map(Into::into),
movie_image: poster.map(Into::into),
release_date: movie.year.map(|y| format!("{y}-01-01").into()),
episode_run_time: movie.runtime,
director: movie.directors.as_ref().map(|d| d.join(", ").into()),
youtube_trailer: youtube_trailer.map(Into::into),
actors: actor_names.clone(),
cast: actor_names.clone(),
genre: movie.genres.as_ref().map(|g| g.join(", ").into()),
description: movie.plot.clone().map(Into::into),
plot: movie.plot.clone().map(Into::into),
age: None,
mpaa_rating: movie.mpaa.clone().map(Into::into),
rating_count_kinopoisk: 0,
country: None,
backdrop_path: movie
.fanart
.as_ref()
.filter(|s| !s.is_empty())
.map(|f| vec![f.clone().into()])
.or_else(|| poster.map(|p| vec![p.into()])),
duration_secs: duration_secs.map(|value| value.to_string().into()),
duration: duration.clone(),
video: technical.and_then(|metadata| metadata.video.as_ref()).map(|value| value.as_str().into()),
audio: technical.and_then(|metadata| metadata.audio.as_ref()).map(|value| value.as_str().into()),
bitrate: technical_bitrate(technical),
runtime: duration_secs.map(|value| value.to_string().into()),
status: Some("Released".intern()),
}),
};
Some(StreamProperties::Video(Box::new(properties)))
}
#[allow(clippy::too_many_lines)]
pub fn metadata_cache_entry_to_xtream_series_info(
entry: &MetadataCacheEntry,
api_base_path: &str,
) -> Option<StreamProperties> {
let series = match &entry.metadata {
MediaMetadata::Movie(_) => return None,
MediaMetadata::Series(m) => m,
};
let actor_names: Arc<str> = series.actors.as_deref().map(join_actor_names).unwrap_or_default();
let release_date = series.year.map(|y| format!("{y}-01-01"));
let youtube_trailer = youtube_trailer_key(series.videos.as_deref()).unwrap_or_default();
let series_thumbnail = thumbnail_url(entry, api_base_path);
let series_art = series.poster.clone().or(series_thumbnail.clone());
let mut season_data = HashMap::new();
series.seasons.as_ref().iter().for_each(|seasons| {
seasons.iter().for_each(|season_metadata| {
let season_art = season_metadata.poster_path.clone().or_else(|| series_art.clone());
season_data.insert(
season_metadata.season_number,
SeriesStreamDetailSeasonProperties {
name: season_metadata.name.clone().into(),
season_number: season_metadata.season_number,
episode_count: 0,
overview: season_metadata.overview.clone().map(Into::into),
air_date: season_metadata.air_date.clone().map(Into::into),
cover: season_art.clone().map(Into::into),
cover_tmdb: season_art.clone().map(Into::into),
cover_big: season_art.map(Into::into),
duration: Some(String::from("0").into()),
},
);
});
});
let episodes = series.episodes.as_ref().map(|episodes| {
episodes
.iter()
.filter(|episode| !episode.file_path.is_empty())
.map(|episode| {
let container_extension = Path::new(&episode.file_path)
.extension()
.and_then(|s| s.to_str())
.map(ToString::to_string)
.unwrap_or_default();
let episode_release_date = episode.aired.as_ref().map(ToString::to_string).unwrap_or_default();
let tmdb_id = (episode.tmdb_id > 0).then_some(episode.tmdb_id);
let raw_episode_title: Arc<str> = episode.title.clone().into();
let series_title: Arc<str> = series.title.clone().into();
let technical = episode.technical.as_ref();
let duration_secs =
technical_duration_secs(technical).or_else(|| episode.runtime.map(|runtime| runtime * 60));
let season_entry =
season_data.entry(episode.season).or_insert_with(|| SeriesStreamDetailSeasonProperties {
name: concat_string!(&series.title, " ", &episode.season.to_string()).into(),
season_number: episode.season,
episode_count: 0,
overview: None,
air_date: episode.aired.clone().map(Into::into),
cover: series_art.clone().map(Into::into),
cover_tmdb: series_art.clone().map(Into::into),
cover_big: series_art.clone().map(Into::into),
duration: None,
});
season_entry.episode_count = season_entry.episode_count.saturating_add(1);
SeriesStreamDetailEpisodeProperties {
id: tmdb_id.unwrap_or_default(),
episode_num: episode.episode,
season: episode.season,
title: normalize_episode_title(&raw_episode_title, &series_title, episode.season, episode.episode),
container_extension: container_extension.into(),
custom_sid: None,
added: episode.file_modified.to_string().into(),
direct_source: episode.file_path.clone().into(),
tmdb: tmdb_id,
release_date: episode_release_date.clone().into(),
series_release_date: None,
plot: episode.plot.clone().map(Into::into),
crew: Some(Arc::clone(&actor_names)),
duration_secs: duration_secs.unwrap_or_default(),
duration: duration_secs.map(duration_secs_to_xtream_duration).unwrap_or_default(),
movie_image: episode
.thumb
.clone()
.or_else(|| episode_thumbnail_url(episode, api_base_path))
.or_else(|| series_art.clone())
.unwrap_or_default()
.into(),
audio: technical.and_then(|metadata| metadata.audio.as_ref()).map(|value| value.as_str().into()),
video: technical.and_then(|metadata| metadata.video.as_ref()).map(|value| value.as_str().into()),
bitrate: technical_bitrate(technical),
rating: None,
}
})
.collect::<Vec<_>>()
});
let mut seasons = season_data.into_values().collect::<Vec<_>>();
seasons.sort_by_key(|s| s.season_number);
let properties = SeriesStreamProperties {
name: series.title.clone().into(),
category_id: 0,
series_id: 0,
backdrop_path: series
.fanart
.as_ref()
.filter(|s| !s.is_empty())
.map(|f| vec![f.clone().into()])
.or_else(|| series_art.as_ref().filter(|s| !s.is_empty()).map(|p| vec![p.clone().into()])),
cast: Arc::clone(&actor_names),
cover: series_art.unwrap_or_default().into(),
director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default().into(),
episode_run_time: None,
genre: series.genres.as_ref().map(|d| d.join(", ").into()),
last_modified: Some(series.last_updated.to_string().into()),
plot: series.plot.clone().map(Into::into),
rating: series.rating.unwrap_or(0f64),
rating_5based: 0.0,
release_date: release_date.map(Into::into),
youtube_trailer: youtube_trailer.into(),
tmdb: series.tmdb_id,
details: Some(SeriesStreamDetailProperties::new(series.year, seasons, episodes)),
};
Some(StreamProperties::Series(Box::new(properties)))
}
#[cfg(test)]
mod tests {
use super::{metadata_cache_entry_to_xtream_series_info, thumbnail_url};
use shared::model::StreamProperties;
use tuliprox_library::library::{
EpisodeMetadata, MediaMetadata, MetadataCacheEntry, MovieMetadata, SeriesMetadata, TechnicalMetadata,
};
#[test]
fn thumbnail_url_uses_v1_api_prefix() {
let mut entry = MetadataCacheEntry::new(
"/tmp/video.mkv".to_string(),
123,
456,
MediaMetadata::Movie(MovieMetadata::default()),
);
entry.uuid = "test-uuid-123".to_string();
entry.thumbnail_hash = Some("thumb-hash".to_string());
assert_eq!(thumbnail_url(&entry, "/api/v1").as_deref(), Some("/api/v1/library/thumbnail/test-uuid-123"),);
}
#[test]
fn series_episode_uses_local_thumbnail_when_episode_thumb_missing() {
let entry = MetadataCacheEntry::new(
"/tmp/show/S01E01.mkv".to_string(),
123,
456,
MediaMetadata::Series(SeriesMetadata {
title: "Test Series".to_string(),
episodes: Some(vec![EpisodeMetadata {
title: "Episode 1".to_string(),
season: 1,
episode: 1,
file_path: "/tmp/show/S01E01.mkv".to_string(),
thumbnail_id: Some("31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b".to_string()),
..EpisodeMetadata::default()
}]),
..SeriesMetadata::default()
}),
);
let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1")
else {
panic!("expected series stream properties");
};
let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing");
let episode = episodes.first().expect("episode missing");
assert_eq!(
episode.movie_image.as_ref(),
"/api/v1/library/thumbnail/31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b",
);
}
#[test]
fn series_info_uses_local_thumbnail_when_series_poster_missing() {
let mut entry = MetadataCacheEntry::new(
"/tmp/show/S01E01.mkv".to_string(),
123,
456,
MediaMetadata::Series(SeriesMetadata {
title: "Test Series".to_string(),
episodes: Some(vec![EpisodeMetadata {
title: "Episode 1".to_string(),
season: 1,
episode: 1,
file_path: "/tmp/show/S01E01.mkv".to_string(),
..EpisodeMetadata::default()
}]),
..SeriesMetadata::default()
}),
);
entry.uuid = "series-thumb-uuid".to_string();
entry.thumbnail_hash = Some("thumb-hash".to_string());
let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1")
else {
panic!("expected series stream properties");
};
assert_eq!(series.cover.as_ref(), "/api/v1/library/thumbnail/series-thumb-uuid");
let seasons = series.details.as_ref().and_then(|details| details.seasons.as_ref()).expect("seasons missing");
let season = seasons.first().expect("season missing");
assert_eq!(season.cover.as_deref(), Some("/api/v1/library/thumbnail/series-thumb-uuid"));
assert_eq!(season.cover_tmdb.as_deref(), Some("/api/v1/library/thumbnail/series-thumb-uuid"));
assert_eq!(season.cover_big.as_deref(), Some("/api/v1/library/thumbnail/series-thumb-uuid"));
assert!(season.overview.is_none());
}
#[test]
fn series_episode_falls_back_to_series_thumbnail_when_episode_thumb_missing() {
let mut entry = MetadataCacheEntry::new(
"/tmp/show/S01E01.mkv".to_string(),
123,
456,
MediaMetadata::Series(SeriesMetadata {
title: "Test Series".to_string(),
episodes: Some(vec![EpisodeMetadata {
title: "Episode 1".to_string(),
season: 1,
episode: 1,
file_path: "/tmp/show/S01E01.mkv".to_string(),
..EpisodeMetadata::default()
}]),
..SeriesMetadata::default()
}),
);
entry.uuid = "series-thumb-uuid".to_string();
entry.thumbnail_hash = Some("thumb-hash".to_string());
let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1")
else {
panic!("expected series stream properties");
};
let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing");
let episode = episodes.first().expect("episode missing");
assert_eq!(episode.movie_image.as_ref(), "/api/v1/library/thumbnail/series-thumb-uuid");
}
#[test]
fn movie_info_prefers_local_technical_metadata_for_probe_fields() {
let entry = MetadataCacheEntry::new(
"/tmp/movie/Test.Movie.2024.mkv".to_string(),
123,
456,
MediaMetadata::Movie(MovieMetadata {
title: "Test Movie".to_string(),
runtime: Some(90),
technical: Some(TechnicalMetadata {
video: Some("{\"codec_name\":\"hevc\"}".to_string()),
audio: Some("{\"codec_name\":\"aac\"}".to_string()),
duration_secs: Some(5_430),
bitrate: Some(8_200_000),
}),
..MovieMetadata::default()
}),
);
let Some(StreamProperties::Video(video)) = super::metadata_cache_entry_to_xtream_movie_info(&entry, "/api/v1")
else {
panic!("expected video stream properties");
};
let details = video.details.as_ref().expect("details missing");
assert_eq!(details.video.as_deref(), Some("{\"codec_name\":\"hevc\"}"));
assert_eq!(details.audio.as_deref(), Some("{\"codec_name\":\"aac\"}"));
assert_eq!(details.duration_secs.as_deref(), Some("5430"));
assert_eq!(details.duration.as_deref(), Some("01:30:30"));
assert_eq!(details.runtime.as_deref(), Some("5430"));
assert_eq!(details.bitrate, 8_200_000);
}
#[test]
fn series_episode_prefers_local_technical_metadata_for_probe_fields() {
let entry = MetadataCacheEntry::new(
"/tmp/show/S01E01.mkv".to_string(),
123,
456,
MediaMetadata::Series(SeriesMetadata {
title: "Test Series".to_string(),
episodes: Some(vec![EpisodeMetadata {
title: "Episode 1".to_string(),
season: 1,
episode: 1,
file_path: "/tmp/show/S01E01.mkv".to_string(),
technical: Some(TechnicalMetadata {
video: Some("{\"codec_name\":\"h264\"}".to_string()),
audio: Some("{\"codec_name\":\"ac3\"}".to_string()),
duration_secs: Some(1_541),
bitrate: Some(3_100_000),
}),
..EpisodeMetadata::default()
}]),
..SeriesMetadata::default()
}),
);
let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1")
else {
panic!("expected series stream properties");
};
let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing");
let episode = episodes.first().expect("episode missing");
assert_eq!(episode.video.as_deref(), Some("{\"codec_name\":\"h264\"}"));
assert_eq!(episode.audio.as_deref(), Some("{\"codec_name\":\"ac3\"}"));
assert_eq!(episode.duration_secs, 1_541);
assert_eq!(episode.duration.as_ref(), "00:25:41");
assert_eq!(episode.bitrate, 3_100_000);
}
}
+278
View File
@@ -0,0 +1,278 @@
mod playlist;
mod stalker;
mod stalker_refresh;
mod xtream;
// mod affix;
mod deduplicate;
mod epg;
mod library;
mod probe_handle_guard;
mod resolve_options;
mod sort;
mod stream_probe;
mod trakt;
mod xtream_series;
mod xtream_vod;
pub use self::{
epg::*,
playlist::*,
probe_handle_guard::*,
resolve_options::*,
stalker::{download_stalker_playlist, re_resolve_stalker_url, StalkerCluster},
stalker_refresh::StalkerRefreshMode,
stream_probe::*,
xtream::*,
xtream_series::*,
xtream_vod::*,
};
use tokio_util::sync::CancellationToken;
use tuliprox_core::model::ProviderHandle;
pub const FOREGROUND_BATCH_SIZE: usize = 200;
pub const FOREGROUND_RETRY_BATCH_MAX_SIZE: usize = FOREGROUND_BATCH_SIZE * 4;
pub const FOREGROUND_MIN_RETRY_DELAY_SECS: u64 = 1;
pub fn select_cancel_token<'a>(
acquired_handle: Option<&'a ProviderHandle>,
active_handle: Option<&'a ProviderHandle>,
) -> Option<&'a CancellationToken> {
acquired_handle
.and_then(|h| h.cancel_token.as_ref())
.or_else(|| active_handle.and_then(|h| h.cancel_token.as_ref()))
}
//
// fn get_resolve_<cluster>_options(target: &ConfigTarget, fpl: &FetchedPlaylist) -> bool
//
#[macro_export]
macro_rules! create_resolve_options_function_for_xtream_target {
($cluster:ident) => {
paste::paste! {
fn [<get_resolve_ $cluster _options>](target: &ConfigTarget, fpl: &FetchedPlaylist) -> $crate::processor::ResolveOptions {
match target.get_xtream_output() {
Some(_) => {
let input_options = fpl.input.options.as_ref();
let input_is_xtream = fpl.input.input_type.is_xtream();
let (
resolve_tmdb_missing,
input_resolve_enabled,
input_probe_enabled,
input_resolve_delay,
resolve_background
) = if let Some(options) = input_options {
(
options.has_flag(tuliprox_core::model::ConfigInputFlags::ResolveTmdb),
options.has_flag(tuliprox_core::model::ConfigInputFlags::[<Resolve $cluster:camel>]),
options.has_flag(tuliprox_core::model::ConfigInputFlags::[<Probe $cluster:camel>]),
options.resolve_delay,
options.has_flag(tuliprox_core::model::ConfigInputFlags::ResolveBackground)
)
} else {
(
false,
false,
false,
shared::defaults::default_resolve_delay_secs(),
shared::defaults::default_resolve_background(),
)
};
let resolve_enabled = input_resolve_enabled;
let probe_enabled = input_probe_enabled;
let resolve_delay = input_resolve_delay;
let mut flags = $crate::processor::ResolveOptionsFlagsSet::new();
if resolve_enabled && input_is_xtream {
flags.set($crate::processor::ResolveOptionsFlags::Resolve);
}
if resolve_tmdb_missing {
flags.set($crate::processor::ResolveOptionsFlags::TmdbMissing);
}
if input_is_xtream && probe_enabled {
flags.set($crate::processor::ResolveOptionsFlags::Probe);
}
if resolve_background {
flags.set($crate::processor::ResolveOptionsFlags::Background);
}
$crate::processor::ResolveOptions {
flags,
resolve_delay,
}
},
None => $crate::processor::ResolveOptions::default(),
}
}
}
};
}
use create_resolve_options_function_for_xtream_target;
/// Foreground retry helper that retries each queued item at most once.
///
/// `retry_delay_secs` is applied sequentially per item, so the total wall-clock
/// delay is roughly `retry_delay_secs * retry_item_count` plus network/DB time.
macro_rules! process_foreground_retry_once {
(
ctx: $ctx:expr,
fpl: $fpl:expr,
filter: $filter:expr,
retry_once_ids: $retry_once_ids:ident,
retry_delay_secs: $retry_delay_secs:expr,
xtream_path: $xtream_path:ident,
db_query_holder: $db_query_holder:ident,
db_lock_holder: $db_lock_holder:ident,
batch: $batch:ident,
batch_size: $batch_size:expr,
retry_batch_max_len: $retry_batch_max_len:expr,
processed_count: $processed_count:ident,
query_error_context: $query_error_context:expr,
reasons: |$pli_reasons:ident| $reasons_expr:expr,
update: |$active_provider:ident, $pli_update:ident, $provider_id:ident, $reasons:ident, $db_query_ref:ident| $update_expr:expr,
apply_properties: |$pli_apply:ident, $updated_props:ident| $apply_expr:expr,
persist: |$updates:ident| $persist_expr:expr,
on_persist_error: |$persist_err:ident| $on_persist_error_expr:expr,
on_retry_error: |$pli_error:ident, $retry_err:ident| $on_retry_error_expr:expr,
on_after_attempt: |$pli_after:ident, $retry_succeeded:ident| $on_after_attempt_expr:expr $(,)?
) => {
for __pli in $fpl.items_mut() {
if !($filter)(__pli) {
continue;
}
let __provider_id = if let Ok(__uid) = __pli.header.id.parse::<u32>() {
tuliprox_core::model::ProviderIdType::Id(__uid)
} else {
tuliprox_core::model::ProviderIdType::from(&*__pli.header.id)
};
if !$retry_once_ids.remove(&__provider_id) {
continue;
}
let mut __retry_succeeded = false;
let __reasons = {
let $pli_reasons = &mut *__pli;
$reasons_expr
};
if !__reasons.is_empty() {
if let Some(__active_provider) = $ctx.provider_manager.as_ref() {
// Do not hold a read lock over the retry delay window.
if $db_query_holder.is_some() {
$db_query_holder = None;
$db_lock_holder = None;
}
tokio::time::sleep(std::time::Duration::from_secs($retry_delay_secs)).await;
if $db_query_holder.is_none() && $xtream_path.exists() {
let __file_lock = $ctx.config.file_locks.read_lock(&$xtream_path).await;
let __xtream_path = $xtream_path.clone();
let __query = match tokio::task::spawn_blocking(move || {
tuliprox_repository::BPlusTreeQuery::<u32, shared::model::XtreamPlaylistItem>::try_new(
&__xtream_path,
)
})
.await
{
Ok(Ok(__query)) => Some((__query, __file_lock)),
Ok(Err(__err)) => {
log::error!("Failed to open BPlusTreeQuery for {}: {__err}", $query_error_context);
None
}
Err(__err) => {
log::error!("Failed to open BPlusTreeQuery for {}: {__err}", $query_error_context);
None
}
};
if let Some((__query, __guard)) = __query {
$db_query_holder = Some(std::sync::Arc::new(parking_lot::Mutex::new(__query)));
$db_lock_holder = Some(__guard);
}
}
let __db_query_ref = $db_query_holder.as_ref().map(std::sync::Arc::clone);
let __update_future = {
let $active_provider = __active_provider;
let $pli_update = &mut *__pli;
let $provider_id = __provider_id.clone();
let $reasons = &__reasons;
let $db_query_ref = __db_query_ref;
$update_expr
};
match __update_future.await {
Ok(Some(__updated_props)) => {
{
let $pli_apply = &mut *__pli;
let $updated_props = &__updated_props;
$apply_expr
}
$batch.push((__provider_id.clone(), __updated_props));
if $batch.len() >= $batch_size {
$db_query_holder = None;
$db_lock_holder = None;
let __updates: Vec<(u32, _)> = $batch
.iter()
.filter_map(|(__id, __props)| {
// Foreground retry batches can include text provider IDs (e.g. M3U).
// Persist batch functions for these paths are keyed by numeric Xtream IDs,
// so text IDs are intentionally skipped here.
if let tuliprox_core::model::ProviderIdType::Id(__vid) = __id {
Some((*__vid, __props.clone()))
} else {
None
}
})
.collect();
if __updates.is_empty() {
$batch.clear();
} else {
let __persist_future = {
let $updates = __updates;
$persist_expr
};
match __persist_future.await {
Ok(()) => $batch.clear(),
Err($persist_err) => {
$on_persist_error_expr;
if $batch.len() > $retry_batch_max_len {
let __drop_count = $batch.len().saturating_sub($retry_batch_max_len);
if __drop_count > 0 {
$batch.drain(0..__drop_count);
}
}
}
}
}
}
$processed_count += 1;
__retry_succeeded = true;
}
Ok(None) => {}
Err($retry_err) => {
let $pli_error = &*__pli;
$on_retry_error_expr;
}
}
}
}
{
let $pli_after = &mut *__pli;
let $retry_succeeded = __retry_succeeded;
$on_after_attempt_expr;
}
}
};
}
// `macro_rules!` without `#[macro_export]`: the re-export cannot be wider than
// the crate.
pub(crate) use process_foreground_retry_once;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
use std::sync::Arc;
use tuliprox_core::model::ProviderHandle;
use tuliprox_session::ActiveProviderManager;
pub struct ProbeHandleGuard {
manager: Arc<ActiveProviderManager>,
handle: Option<ProviderHandle>,
}
impl ProbeHandleGuard {
pub fn new(manager: &Arc<ActiveProviderManager>, handle: ProviderHandle) -> Self {
Self { manager: Arc::clone(manager), handle: Some(handle) }
}
#[inline]
pub fn handle(&self) -> Option<&ProviderHandle> { self.handle.as_ref() }
pub async fn release(mut self) {
if let Some(handle) = self.handle.take() {
self.manager.release_handle(&handle).await;
}
}
}
impl Drop for ProbeHandleGuard {
fn drop(&mut self) {
let Some(handle) = self.handle.take() else {
return;
};
let manager = Arc::clone(&self.manager);
if let Ok(runtime_handle) = tokio::runtime::Handle::try_current() {
runtime_handle.spawn(async move {
manager.release_handle(&handle).await;
});
}
}
}
#[cfg(test)]
mod tests {
use super::ProbeHandleGuard;
use arc_swap::{ArcSwap, ArcSwapOption};
use shared::{
defaults::default_probe_user_priority,
model::{ConfigPaths, InputFetchMethod, InputType},
utils::Internable,
};
use std::{collections::HashMap, sync::Arc};
use tuliprox_core::{
model::{AppConfig, Config, ConfigInput, MediaToolCapabilities, SourcesConfig},
utils::FileLockManager,
};
use tuliprox_session::{ActiveProviderManager, EventManager};
fn create_test_app_config() -> AppConfig {
let input = Arc::new(ConfigInput {
id: 1,
name: "provider_1".intern(),
input_type: InputType::Xtream,
headers: HashMap::default(),
url: "http://provider-1.example".to_string(),
username: Some("user1".to_string()),
password: Some("pass1".to_string()),
enabled: true,
priority: 0,
max_connections: 1,
method: InputFetchMethod::default(),
..ConfigInput::default()
});
let sources = SourcesConfig { inputs: vec![input], ..SourcesConfig::default() };
AppConfig {
config: Arc::new(ArcSwap::from_pointee(Config::default())),
sources: Arc::new(ArcSwap::from_pointee(sources)),
hdhomerun: Arc::new(ArcSwapOption::default()),
api_proxy: Arc::new(ArcSwapOption::default()),
file_locks: Arc::new(FileLockManager::default()),
paths: Arc::new(ArcSwap::from_pointee(ConfigPaths {
home_path: String::new(),
config_path: String::new(),
storage_path: String::new(),
config_file_path: String::new(),
sources_file_path: String::new(),
mapping_file_path: None,
mapping_files_used: None,
template_file_path: None,
template_files_used: None,
api_proxy_file_path: String::new(),
custom_stream_response_path: None,
})),
custom_stream_response: Arc::new(ArcSwapOption::default()),
access_token_secret: [0; 32],
encrypt_secret: [0; 16],
media_tools: Arc::new(MediaToolCapabilities::new()),
}
}
#[tokio::test]
async fn probe_handle_guard_releases_provider_slot_on_drop() {
let app_cfg = create_test_app_config();
let event_manager = Arc::new(EventManager::new());
let manager = Arc::new(ActiveProviderManager::new(&app_cfg, &event_manager));
let input_name = "provider_1".intern();
let handle = manager
.acquire_connection_for_probe(&input_name, default_probe_user_priority())
.await
.expect("probe allocation should succeed");
assert_eq!(manager.get_provider_connections_count().await, 1);
let guard = ProbeHandleGuard::new(&manager, handle);
drop(guard);
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert_eq!(manager.get_provider_connections_count().await, 0);
}
}
@@ -0,0 +1,25 @@
use shared::create_bitset;
create_bitset!(u8, ResolveOptionsFlags, Resolve, TmdbMissing, Probe, Background);
pub struct ResolveOptions {
pub flags: ResolveOptionsFlagsSet,
pub resolve_delay: u16,
}
impl ResolveOptions {
#[inline]
pub fn has_flag(&self, flag: ResolveOptionsFlags) -> bool { self.flags.contains(flag) }
#[inline]
pub fn unset_flag(&mut self, flag: ResolveOptionsFlags) { self.flags.unset(flag); }
}
impl Default for ResolveOptions {
fn default() -> Self {
Self {
flags: ResolveOptionsFlags::Background.into(),
resolve_delay: shared::defaults::default_resolve_delay_secs(),
}
}
}
+888
View File
@@ -0,0 +1,888 @@
use shared::{
foundation::ValueProvider,
model::{PlaylistGroup, SortOrder, SortTarget},
utils::natural_cmp,
};
use std::{cmp::Ordering, sync::Arc};
use tuliprox_core::{
model::{ConfigSortRule, ConfigTarget},
utils::normalized_source_ordinal,
};
fn apply_sort_order(order: SortOrder, ordering: Ordering) -> Ordering {
match (order, ordering) {
(SortOrder::None, _) | (_, Ordering::Equal) => Ordering::Equal,
(SortOrder::Asc, o) => o,
(SortOrder::Desc, o) => o.reverse(),
}
}
fn compare_values(natural: bool, left: &str, right: &str) -> Ordering {
if natural {
natural_cmp(left, right)
} else {
left.cmp(right)
}
}
fn parse_capture_group_rank(name: &str) -> Option<u32> {
let suffix = name.strip_prefix('c')?;
if suffix.is_empty() || !suffix.bytes().all(|c| c.is_ascii_digit()) {
return None;
}
suffix.parse::<u32>().ok()
}
#[derive(Debug)]
struct SequencePattern {
regex: Arc<regex::Regex>,
ordered_capture_names: Vec<String>,
}
#[derive(Debug)]
struct SequencePlan {
patterns: Vec<SequencePattern>,
}
impl SequencePlan {
fn new(sequence: &[Arc<regex::Regex>]) -> Self {
let patterns = sequence
.iter()
.map(|regex| {
let mut ordered_capture_names: Vec<(u32, String)> = regex
.capture_names()
.flatten()
.filter_map(|name| parse_capture_group_rank(name).map(|rank| (rank, name.to_owned())))
.collect();
ordered_capture_names.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
SequencePattern {
regex: Arc::clone(regex),
ordered_capture_names: ordered_capture_names.into_iter().map(|(_, name)| name).collect(),
}
})
.collect();
Self { patterns }
}
}
#[derive(Debug)]
enum SequenceMatch {
Matched { sequence_idx: usize, captures: Vec<Option<Arc<str>>> },
Unmatched,
}
#[derive(Debug)]
struct PreparedRule<'a> {
rule: &'a ConfigSortRule,
sequence_plan: Option<SequencePlan>,
}
impl<'a> PreparedRule<'a> {
fn new(rule: &'a ConfigSortRule) -> Self {
let sequence_plan = rule.sequence.as_ref().map(|sequence| SequencePlan::new(sequence));
Self { rule, sequence_plan }
}
}
#[derive(Debug)]
struct RuleCacheEntry {
filter_pass: bool,
value: Option<Arc<str>>,
sequence_match: Option<SequenceMatch>,
}
fn evaluate_sequence(plan: &SequencePlan, value: &str) -> SequenceMatch {
for (sequence_idx, pattern) in plan.patterns.iter().enumerate() {
if let Some(captures) = pattern.regex.captures(value) {
let ordered_captures = pattern
.ordered_capture_names
.iter()
.map(|name| captures.name(name).map(|capture| Arc::<str>::from(capture.as_str())))
.collect();
return SequenceMatch::Matched { sequence_idx, captures: ordered_captures };
}
}
SequenceMatch::Unmatched
}
fn compare_sequence_match(a: &SequenceMatch, b: &SequenceMatch, order: SortOrder, natural: bool) -> Ordering {
match (a, b) {
(
SequenceMatch::Matched { sequence_idx: idx_a, captures: captures_a },
SequenceMatch::Matched { sequence_idx: idx_b, captures: captures_b },
) => {
// Sequence index is an explicit priority list and is never reversed by DESC.
let idx_ord = idx_a.cmp(idx_b);
if idx_ord != Ordering::Equal {
return idx_ord;
}
let capture_count = captures_a.len().max(captures_b.len());
for index in 0..capture_count {
let ord = match (captures_a.get(index), captures_b.get(index)) {
(Some(Some(v1)), Some(Some(v2))) => compare_values(natural, v1, v2),
(Some(Some(_)), Some(None) | None) => Ordering::Greater,
(Some(None) | None, Some(Some(_))) => Ordering::Less,
_ => Ordering::Equal,
};
if ord != Ordering::Equal {
return apply_sort_order(order, ord);
}
}
Ordering::Equal
}
(SequenceMatch::Matched { .. }, SequenceMatch::Unmatched) => Ordering::Less,
(SequenceMatch::Unmatched, SequenceMatch::Matched { .. }) => Ordering::Greater,
(SequenceMatch::Unmatched, SequenceMatch::Unmatched) => Ordering::Equal,
}
}
fn compare_rule_entries(rule: &PreparedRule, left: &RuleCacheEntry, right: &RuleCacheEntry) -> Ordering {
match (left.filter_pass, right.filter_pass) {
(false, false) => return Ordering::Equal,
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
(true, true) => {}
}
match (&left.value, &right.value) {
(None, None) => Ordering::Equal,
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(Some(value_left), Some(value_right)) => {
if rule.sequence_plan.is_some() {
match (&left.sequence_match, &right.sequence_match) {
(Some(seq_left), Some(seq_right)) => {
compare_sequence_match(seq_left, seq_right, rule.rule.order, rule.rule.natural)
}
_ => Ordering::Equal,
}
} else {
apply_sort_order(rule.rule.order, compare_values(rule.rule.natural, value_left, value_right))
}
}
}
}
fn build_rule_cache_entry(
rule: &PreparedRule,
item: Option<&shared::model::PlaylistItem>,
match_as_ascii: bool,
) -> RuleCacheEntry {
let Some(item) = item else {
return RuleCacheEntry { filter_pass: false, value: None, sequence_match: None };
};
let provider = ValueProvider { pli: item, match_as_ascii };
let filter_pass = rule.rule.filter.filter(&provider);
if !filter_pass {
return RuleCacheEntry { filter_pass: false, value: None, sequence_match: None };
}
let value = provider.get(rule.rule.field.as_ref());
let sequence_match = match (&rule.sequence_plan, value.as_deref()) {
(Some(sequence_plan), Some(value)) => Some(evaluate_sequence(sequence_plan, value)),
_ => None,
};
RuleCacheEntry { filter_pass: true, value, sequence_match }
}
fn build_group_rule_cache(rule: &PreparedRule, groups: &[PlaylistGroup], match_as_ascii: bool) -> Vec<RuleCacheEntry> {
groups.iter().map(|group| build_rule_cache_entry(rule, group.channels.first(), match_as_ascii)).collect()
}
fn build_channel_rule_cache(
rule: &PreparedRule,
channels: &[shared::model::PlaylistItem],
match_as_ascii: bool,
) -> Vec<RuleCacheEntry> {
channels.iter().map(|channel| build_rule_cache_entry(rule, Some(channel), match_as_ascii)).collect()
}
fn reorder_by_indices<T>(items: &mut Vec<T>, indices: &[usize]) {
if items.len() != indices.len() {
log::error!(
"Invalid sort permutation: index length {} does not match item length {}",
indices.len(),
items.len()
);
return;
}
let mut seen = vec![false; items.len()];
for &idx in indices {
if idx >= items.len() {
log::error!("Invalid sort permutation: index {idx} out of bounds for {} items", items.len());
return;
}
if std::mem::replace(&mut seen[idx], true) {
log::error!("Invalid sort permutation: duplicate index {idx}");
return;
}
}
let mut original: Vec<Option<T>> = std::mem::take(items).into_iter().map(Some).collect();
items.reserve(indices.len());
for &idx in indices {
if let Some(item) = original[idx].take() {
items.push(item);
} else {
log::error!("Invalid sort permutation: missing index {idx} after validation");
items.extend(original.into_iter().flatten());
return;
}
}
}
#[cfg(test)]
fn playlist_comparator(
sequence: Option<&Vec<Arc<regex::Regex>>>,
order: SortOrder,
value_a: &str,
value_b: &str,
) -> Ordering {
if matches!(order, SortOrder::None) && sequence.is_none() {
return Ordering::Equal;
}
if let Some(sequence) = sequence {
let plan = SequencePlan::new(sequence);
let left = evaluate_sequence(&plan, value_a);
let right = evaluate_sequence(&plan, value_b);
compare_sequence_match(&left, &right, order, false)
} else {
apply_sort_order(order, value_a.cmp(value_b))
}
}
fn get_group_source_ordinal(group: &PlaylistGroup) -> u32 {
group.channels.first().map_or(u32::MAX, |c| normalized_source_ordinal(c.header.source_ordinal))
}
fn compare_cached_rule_entries(
rules: &[PreparedRule<'_>],
rule_caches: &[Vec<RuleCacheEntry>],
left_idx: usize,
right_idx: usize,
) -> Ordering {
for (rule, cache) in rules.iter().zip(rule_caches.iter()) {
let ord = compare_rule_entries(rule, &cache[left_idx], &cache[right_idx]);
if ord != Ordering::Equal {
return ord;
}
}
// Apply raw-value fallback only after all rule-level comparisons are exhausted.
for (rule, cache) in rules.iter().zip(rule_caches.iter()) {
let left = &cache[left_idx];
let right = &cache[right_idx];
if let (Some(va), Some(vb)) = (&left.value, &right.value) {
let fallback = apply_sort_order(rule.rule.order, compare_values(rule.rule.natural, va, vb));
if fallback != Ordering::Equal {
return fallback;
}
}
}
Ordering::Equal
}
fn is_effective_rule(rule: &ConfigSortRule) -> bool {
rule.order != SortOrder::None || rule.sequence.as_ref().is_some_and(|sequence| !sequence.is_empty())
}
pub(in crate::processor) fn sort_playlist(target: &ConfigTarget, playlist: &mut Vec<PlaylistGroup>) -> bool {
let Some(sort) = &target.sort else {
for group in &mut *playlist {
group.channels.sort_by_key(|a| normalized_source_ordinal(a.header.source_ordinal));
}
playlist.sort_by_key(get_group_source_ordinal);
return true;
};
let rules = &sort.rules;
let match_as_ascii = sort.match_as_ascii;
sort_channels_in_groups(playlist.as_mut_slice(), rules, match_as_ascii);
sort_groups(playlist, rules, match_as_ascii);
true
}
fn sort_groups(groups: &mut Vec<PlaylistGroup>, rules: &[ConfigSortRule], match_as_ascii: bool) {
let group_rules: Vec<_> = rules
.iter()
.filter(|r| matches!(r.target, SortTarget::Group))
.filter(|r| is_effective_rule(r))
.map(PreparedRule::new)
.collect();
if group_rules.is_empty() {
groups.sort_by_key(get_group_source_ordinal);
return;
}
let rule_caches: Vec<Vec<RuleCacheEntry>> =
group_rules.iter().map(|rule| build_group_rule_cache(rule, groups.as_slice(), match_as_ascii)).collect();
let mut group_indices: Vec<usize> = (0..groups.len()).collect();
group_indices.sort_by(|left_idx, right_idx| {
let ord = compare_cached_rule_entries(&group_rules, &rule_caches, *left_idx, *right_idx);
if ord != Ordering::Equal {
return ord;
}
get_group_source_ordinal(&groups[*left_idx]).cmp(&get_group_source_ordinal(&groups[*right_idx]))
});
reorder_by_indices(groups, &group_indices);
}
fn sort_channels_in_groups(groups: &mut [PlaylistGroup], rules: &[ConfigSortRule], match_as_ascii: bool) {
let channel_rules: Vec<_> = rules
.iter()
.filter(|r| matches!(r.target, SortTarget::Channel))
.filter(|r| is_effective_rule(r))
.map(PreparedRule::new)
.collect();
if channel_rules.is_empty() {
for group in groups {
group.channels.sort_by_key(|a| normalized_source_ordinal(a.header.source_ordinal));
}
return;
}
for group in groups {
let rule_caches: Vec<Vec<RuleCacheEntry>> =
channel_rules.iter().map(|rule| build_channel_rule_cache(rule, &group.channels, match_as_ascii)).collect();
let mut channel_indices: Vec<usize> = (0..group.channels.len()).collect();
channel_indices.sort_by(|left_idx, right_idx| {
let ord = compare_cached_rule_entries(&channel_rules, &rule_caches, *left_idx, *right_idx);
if ord != Ordering::Equal {
return ord;
}
normalized_source_ordinal(group.channels[*left_idx].header.source_ordinal)
.cmp(&normalized_source_ordinal(group.channels[*right_idx].header.source_ordinal))
});
reorder_by_indices(&mut group.channels, &channel_indices);
}
}
#[cfg(test)]
mod tests {
use super::{
compare_rule_entries, playlist_comparator, sort_channels_in_groups, sort_groups, PreparedRule, RuleCacheEntry,
};
use shared::{
foundation::Filter,
model::{ItemField, PlaylistGroup, PlaylistItem, PlaylistItemHeader, SortOrder, SortTarget, XtreamCluster},
};
use std::{cmp::Ordering, sync::Arc};
use tuliprox_core::model::ConfigSortRule;
fn make_group(id: u32, title: &str, channels: Vec<PlaylistItem>) -> PlaylistGroup {
PlaylistGroup { id, title: title.into(), channels, xtream_cluster: XtreamCluster::Live }
}
#[test]
fn test_sort() {
let channels: Vec<PlaylistItem> = vec![
("D", "HD"),
("A", "FHD"),
("Z", "HD"),
("K", "HD"),
("B", "HD"),
("A", "HD"),
("K", "UHD"),
("C", "HD"),
("L", "FHD"),
("R", "UHD"),
("T", "SD"),
("A", "FHD"),
]
.into_iter()
.enumerate()
.map(|(i, (name, quality))| PlaylistItem {
header: PlaylistItemHeader {
title: format!("Chanel {name} [{quality}]").into(),
source_ordinal: u32::try_from(i).unwrap(),
..Default::default()
},
})
.collect();
let channel_sort = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::Asc,
natural: false,
sequence: Some(vec![
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(),
};
let mut groups = vec![make_group(1, "G1", channels)];
sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false);
let expected = vec![
"Chanel K [UHD]",
"Chanel R [UHD]",
"Chanel A [FHD]",
"Chanel A [FHD]",
"Chanel L [FHD]",
"Chanel A [HD]",
"Chanel B [HD]",
"Chanel C [HD]",
"Chanel D [HD]",
"Chanel K [HD]",
"Chanel Z [HD]",
"Chanel T [SD]",
]
.into_iter()
.map(Into::into)
.collect::<Vec<Arc<str>>>();
let sorted = groups
.into_iter()
.next()
.expect("group should exist")
.channels
.into_iter()
.map(|pli| pli.header.title)
.collect::<Vec<_>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_sort2() {
let channels: Vec<PlaylistItem> = vec![
"US| EAST [FHD] abc",
"US| EAST [FHD] def",
"US| EAST [FHD] ghi",
"US| EAST [HD] jkl",
"US| EAST [HD] mno",
"US| EAST [HD] pqrs",
"US| EAST [HD] tuv",
"US| EAST [HD] wxy",
"US| EAST [HD] z",
"US| EAST [SD] a",
"US| EAST [FHD] bc",
"US| EAST [FHD] de",
"US| EAST [HD] f",
"US| EAST [HD] h",
"US| EAST [SD] ijk",
"US| EAST [SD] l",
"US| EAST [UHD] m",
"US| WEST [FHD] no",
"US| WEST [HD] qrst",
"US| WEST [HD] uvw",
"US| (West) xv",
"US| East d",
"US| West e",
"US| West f",
]
.into_iter()
.enumerate()
.map(|(i, name)| PlaylistItem {
header: PlaylistItemHeader {
title: name.to_string().into(),
source_ordinal: u32::try_from(i).unwrap(),
..Default::default()
},
})
.collect();
let channel_sort = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::Asc,
natural: false,
sequence: Some(vec![
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(),
};
let mut groups = vec![make_group(1, "G1", channels)];
sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false);
let expected = vec![
"US| EAST [UHD] m",
"US| EAST [FHD] abc",
"US| EAST [FHD] bc",
"US| EAST [FHD] de",
"US| EAST [FHD] def",
"US| EAST [FHD] ghi",
"US| EAST [HD] f",
"US| EAST [HD] h",
"US| EAST [HD] jkl",
"US| EAST [HD] mno",
"US| EAST [HD] pqrs",
"US| EAST [HD] tuv",
"US| EAST [HD] wxy",
"US| EAST [HD] z",
"US| EAST [SD] a",
"US| EAST [SD] ijk",
"US| EAST [SD] l",
"US| WEST [FHD] no",
"US| WEST [HD] qrst",
"US| WEST [HD] uvw",
"US| (West) xv",
"US| East d",
"US| West e",
"US| West f",
]
.into_iter()
.map(Into::into)
.collect::<Vec<Arc<str>>>();
let sorted = groups
.into_iter()
.next()
.expect("group should exist")
.channels
.into_iter()
.map(|pli| pli.header.title)
.collect::<Vec<_>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_sequence_priority_is_not_reversed_for_desc() {
let mut channels: Vec<PlaylistItem> = vec!["A-1", "B-9", "A-7", "B-2"]
.into_iter()
.enumerate()
.map(|(i, title)| PlaylistItem {
header: PlaylistItemHeader {
title: title.to_string().into(),
source_ordinal: u32::try_from(i).unwrap(),
..Default::default()
},
})
.collect();
let sequence = vec![
shared::model::REGEX_CACHE.get_or_compile(r"^A-(?P<c1>\d+)$").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^B-(?P<c1>\d+)$").unwrap(),
];
channels.sort_by(|a, b| {
let ord = playlist_comparator(Some(&sequence), SortOrder::Desc, &a.header.title, &b.header.title);
if ord == Ordering::Equal {
a.header.source_ordinal.cmp(&b.header.source_ordinal)
} else {
ord
}
});
let sorted = channels.into_iter().map(|pli| pli.header.title).collect::<Vec<_>>();
let expected = vec!["A-7", "A-1", "B-9", "B-2"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_compare_rule_entries_desc_filter_mismatch() {
let rule = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::Desc,
natural: false,
sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-(?P<c1>\d+)$").unwrap()]),
filter: Filter::default(),
};
let prepared = PreparedRule::new(&rule);
let pass_with_value = RuleCacheEntry { filter_pass: true, value: Some(Arc::from("A-2")), sequence_match: None };
let pass_without_value = RuleCacheEntry { filter_pass: true, value: None, sequence_match: None };
let fail_filter = RuleCacheEntry { filter_pass: false, value: None, sequence_match: None };
assert_eq!(compare_rule_entries(&prepared, &pass_with_value, &fail_filter), Ordering::Less);
assert_eq!(compare_rule_entries(&prepared, &fail_filter, &pass_with_value), Ordering::Greater);
assert_eq!(compare_rule_entries(&prepared, &pass_with_value, &pass_without_value), Ordering::Less);
assert_eq!(compare_rule_entries(&prepared, &pass_without_value, &pass_with_value), Ordering::Greater);
}
#[test]
fn test_sort_channels_does_not_short_circuit_later_rules_with_raw_value_fallback() {
let channels: Vec<PlaylistItem> = vec!["A-2-x", "A-1-y"]
.into_iter()
.enumerate()
.map(|(i, title)| PlaylistItem {
header: PlaylistItemHeader {
title: title.to_string().into(),
source_ordinal: u32::try_from(i).unwrap(),
..Default::default()
},
})
.collect();
let first_rule = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::Asc,
natural: false,
// Both values match the same sequence item and produce equal sequence priority.
sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-\d+-.$").unwrap()]),
filter: Filter::default(),
};
let second_rule = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::Desc,
natural: false,
sequence: None,
filter: Filter::default(),
};
let mut groups = vec![make_group(1, "G1", channels)];
sort_channels_in_groups(groups.as_mut_slice(), &[first_rule, second_rule], false);
let sorted = groups[0].channels.iter().map(|pli| pli.header.title.clone()).collect::<Vec<_>>();
let expected = vec!["A-2-x", "A-1-y"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_sort_groups_uses_raw_value_fallback_before_source_ordinal() {
let group_a = make_group(
1,
"A",
vec![PlaylistItem {
header: PlaylistItemHeader { title: Arc::from("A-2-x"), source_ordinal: 0, ..Default::default() },
}],
);
let group_b = make_group(
2,
"B",
vec![PlaylistItem {
header: PlaylistItemHeader { title: Arc::from("A-1-y"), source_ordinal: 1, ..Default::default() },
}],
);
let group_rule = ConfigSortRule {
target: SortTarget::Group,
field: ItemField::Caption,
order: SortOrder::Asc,
natural: false,
// Both groups match the same sequence item and produce equal sequence priority.
sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^A-\d+-.$").unwrap()]),
filter: Filter::default(),
};
let mut groups = vec![group_a, group_b];
sort_groups(&mut groups, &[group_rule], false);
let sorted = groups.iter().map(|group| group.channels[0].header.title.clone()).collect::<Vec<_>>();
let expected = vec!["A-1-y", "A-2-x"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_sort_groups_normalized_source_ordinal_tiebreaker_pushes_zero_last() {
let group_zero = make_group(
1,
"zero",
vec![PlaylistItem {
header: PlaylistItemHeader {
title: Arc::from("Same Caption"),
source_ordinal: 0,
..Default::default()
},
}],
);
let group_non_zero = make_group(
2,
"non-zero",
vec![PlaylistItem {
header: PlaylistItemHeader {
title: Arc::from("Same Caption"),
source_ordinal: 7,
..Default::default()
},
}],
);
let group_rule = ConfigSortRule {
target: SortTarget::Group,
field: ItemField::Caption,
order: SortOrder::Asc,
natural: false,
// Both groups have the same sequence/rule priority and same caption value.
sequence: Some(vec![shared::model::REGEX_CACHE.get_or_compile(r"^Same Caption$").unwrap()]),
filter: Filter::default(),
};
let mut groups = vec![group_zero, group_non_zero];
sort_groups(&mut groups, &[group_rule], false);
assert_eq!(groups[0].title.as_ref(), "non-zero");
assert_eq!(groups[0].channels[0].header.source_ordinal, 7);
assert_eq!(groups[1].title.as_ref(), "zero");
assert_eq!(groups[1].channels[0].header.source_ordinal, 0);
}
#[test]
fn test_channel_sequence_applies_even_when_order_is_none() {
let channels: Vec<PlaylistItem> = vec!["HD", "UHD", "FHD", "SD"]
.into_iter()
.enumerate()
.map(|(i, title)| PlaylistItem {
header: PlaylistItemHeader {
title: title.to_string().into(),
source_ordinal: u32::try_from(i).unwrap(),
..Default::default()
},
})
.collect();
let channel_sort = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::None,
natural: false,
sequence: Some(vec![
shared::model::REGEX_CACHE.get_or_compile(r"^UHD$").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^FHD$").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^HD$").unwrap(),
]),
filter: Filter::default(),
};
let mut groups = vec![make_group(1, "G1", channels)];
sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false);
let sorted = groups[0].channels.iter().map(|pli| pli.header.title.clone()).collect::<Vec<_>>();
let expected = vec!["UHD", "FHD", "HD", "SD"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_group_sequence_applies_even_when_order_is_none() {
let group_hd = make_group(
1,
"hd",
vec![PlaylistItem {
header: PlaylistItemHeader { title: Arc::from("HD"), source_ordinal: 2, ..Default::default() },
}],
);
let group_ultra_hd = make_group(
2,
"uhd",
vec![PlaylistItem {
header: PlaylistItemHeader { title: Arc::from("UHD"), source_ordinal: 3, ..Default::default() },
}],
);
let group_full_hd = make_group(
3,
"fhd",
vec![PlaylistItem {
header: PlaylistItemHeader { title: Arc::from("FHD"), source_ordinal: 1, ..Default::default() },
}],
);
let group_sort = ConfigSortRule {
target: SortTarget::Group,
field: ItemField::Caption,
order: SortOrder::None,
natural: false,
sequence: Some(vec![
shared::model::REGEX_CACHE.get_or_compile(r"^UHD$").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^FHD$").unwrap(),
shared::model::REGEX_CACHE.get_or_compile(r"^HD$").unwrap(),
]),
filter: Filter::default(),
};
let mut groups = vec![group_hd, group_ultra_hd, group_full_hd];
sort_groups(&mut groups, &[group_sort], false);
let sorted = groups.iter().map(|group| group.channels[0].header.title.clone()).collect::<Vec<_>>();
let expected = vec!["UHD", "FHD", "HD"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_empty_sequence_with_none_order_is_ignored() {
let channels: Vec<PlaylistItem> = vec!["B", "A"]
.into_iter()
.enumerate()
.map(|(i, title)| PlaylistItem {
header: PlaylistItemHeader {
title: title.to_string().into(),
source_ordinal: u32::try_from(i + 1).unwrap(),
..Default::default()
},
})
.collect();
let channel_sort = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::None,
natural: false,
sequence: Some(vec![]),
filter: Filter::default(),
};
let mut groups = vec![make_group(1, "G1", channels)];
sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false);
let sorted = groups[0].channels.iter().map(|pli| pli.header.title.clone()).collect::<Vec<_>>();
let expected = vec!["B", "A"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
#[test]
fn test_natural_sort_orders_embedded_numbers_numerically() {
let channels: Vec<PlaylistItem> = vec!["Chan 10", "Chan 2", "Chan 1", "Chan 002"]
.into_iter()
.enumerate()
.map(|(i, title)| PlaylistItem {
header: PlaylistItemHeader {
title: title.to_string().into(),
source_ordinal: u32::try_from(i + 1).unwrap(),
..Default::default()
},
})
.collect();
let channel_sort = ConfigSortRule {
target: SortTarget::Channel,
field: ItemField::Caption,
order: SortOrder::Asc,
natural: true,
sequence: None,
filter: Filter::default(),
};
let mut groups = vec![make_group(1, "G1", channels)];
sort_channels_in_groups(groups.as_mut_slice(), &[channel_sort], false);
let sorted = groups[0].channels.iter().map(|pli| pli.header.title.clone()).collect::<Vec<_>>();
let expected =
vec!["Chan 1", "Chan 2", "Chan 002", "Chan 10"].into_iter().map(Into::into).collect::<Vec<Arc<str>>>();
assert_eq!(expected, sorted);
}
}
+554
View File
@@ -0,0 +1,554 @@
//! Stalker/Ministra playlist processor.
//!
//! Orchestrates Stalker authentication, resumable catalog refresh and B+Tree persistence. The
//! processor is the single entry point the playlist dispatcher uses for `InputType::Stalker`
//! — mirror of `xtream::download_xtream_playlist`.
//!
//! Reverse-proxy re-resolve (when a 4xx upstream error is observed) is implemented in the
//! HLS/Xtream endpoints and reaches back into the API client via the helper
//! [`tuliprox_iptv::stalker::client::StalkerApiClient::create_link`].
#![allow(clippy::too_many_lines, clippy::needless_pass_by_value)]
use super::stalker_refresh::{
advance_stalker_refresh, StalkerClusterSelection, StalkerRefreshMode, StalkerRefreshOutcome,
};
use log::{debug, info, warn};
use lru::LruCache;
use parking_lot::Mutex;
use shared::{
error::TuliproxError,
model::{stalker::StalkerStreamKind, stalker_item::StalkerPlaylistItem, PlaylistGroup, PlaylistItem},
utils::Internable,
};
use std::{
borrow::Cow,
collections::HashMap,
num::NonZeroUsize,
sync::{Arc, LazyLock, Weak},
time::{Duration, Instant},
};
use tuliprox_core::model::{AppConfig, ConfigInput, ConfigInputFlags, StalkerInputConfig};
use tuliprox_iptv::stalker::{client::StalkerApiClient, error::StalkerError, parser};
use tuliprox_repository::{
stalker_generation_repository::{load_active_manifest, load_checkpoint},
stalker_repository::{ensure_stalker_storage_path, load_stalker_items_at, read_stalker_item_at},
};
/// Cluster selector used by the Stalker processor. Mirrors the Xtream cluster split
/// (Live/Video/Series) but uses Stalker-native kind names.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum StalkerCluster {
Live,
Vod,
Series,
}
const DEFAULT_STALKER_CLUSTERS: [StalkerCluster; 3] =
[StalkerCluster::Live, StalkerCluster::Vod, StalkerCluster::Series];
static RUNTIME_STALKER_CLIENTS: LazyLock<Mutex<LruCache<String, Arc<StalkerApiClient>>>> =
LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(64).unwrap_or(NonZeroUsize::MIN))));
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
struct RuntimeLinkKey {
fingerprint: u64,
generation: u64,
provider_id: u32,
kind: StalkerStreamKind,
}
struct RuntimeLink {
url: Arc<str>,
expires_at: Instant,
}
static RUNTIME_STALKER_LINKS: LazyLock<Mutex<LruCache<RuntimeLinkKey, RuntimeLink>>> =
LazyLock::new(|| Mutex::new(LruCache::new(NonZeroUsize::new(4096).unwrap_or(NonZeroUsize::MIN))));
static STALKER_REFRESH_LOCKS: LazyLock<tokio::sync::Mutex<HashMap<Arc<str>, Weak<tokio::sync::Semaphore>>>> =
LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
async fn try_acquire_stalker_refresh(input_name: &Arc<str>) -> Option<tokio::sync::OwnedSemaphorePermit> {
let semaphore = {
let mut locks = STALKER_REFRESH_LOCKS.lock().await;
let semaphore = locks.get(input_name).and_then(Weak::upgrade).unwrap_or_else(|| {
let semaphore = Arc::new(tokio::sync::Semaphore::new(1));
locks.insert(Arc::clone(input_name), Arc::downgrade(&semaphore));
semaphore
});
locks.retain(|_, lock| lock.strong_count() > 0);
semaphore
};
semaphore.try_acquire_owned().ok()
}
fn cached_resolved_link(key: RuntimeLinkKey, force_refresh: bool) -> Option<Arc<str>> {
let mut cache = RUNTIME_STALKER_LINKS.lock();
let expired = cache.peek(&key).is_some_and(|entry| entry.expires_at <= Instant::now());
if force_refresh || expired {
cache.pop(&key);
return None;
}
cache.get(&key).map(|entry| Arc::clone(&entry.url))
}
fn cache_resolved_link(key: RuntimeLinkKey, url: Arc<str>) {
RUNTIME_STALKER_LINKS.lock().put(key, RuntimeLink { url, expires_at: Instant::now() + Duration::from_secs(45) });
}
/// Top-level orchestrator. Mirrors the `download_xtream_playlist` signature so the
/// dispatcher can swap the call in place.
#[allow(clippy::too_many_lines)]
pub async fn download_stalker_playlist(
app_config: &Arc<AppConfig>,
client: &reqwest::Client,
input: &ConfigInput,
clusters: Option<&[StalkerCluster]>,
refresh_mode: StalkerRefreshMode,
materialize_active: bool,
) -> (Vec<PlaylistGroup>, Vec<TuliproxError>, bool, bool) {
let stalker_cfg = match input.stalker.as_ref() {
Some(cfg) => cfg.clone(),
None => {
return (
vec![],
vec![TuliproxError::ConfigInput(format!(
"Stalker input '{}' has no stalker configuration block",
input.name
))],
false,
false,
);
}
};
let skip_clusters = skip_clusters_for(input);
let resolved_clusters: Vec<StalkerCluster> = clusters
.map_or_else(|| DEFAULT_STALKER_CLUSTERS.to_vec(), <[StalkerCluster]>::to_vec)
.into_iter()
.filter(|c| !skip_clusters.contains(c))
.collect();
if resolved_clusters.is_empty() {
info!("Stalker input '{}' has all clusters skipped", input.name);
return (vec![], vec![], false, false);
}
let refresh_selection = StalkerClusterSelection::requested(input, &resolved_clusters);
let portal_url = match resolve_stalker_portal_url(input) {
Ok(url) => url,
Err(err) => return (vec![], vec![err], false, false),
};
let identity_fingerprint = stalker_identity_fingerprint(&portal_url, &stalker_cfg);
let api_client = match cached_runtime_stalker_client(client, portal_url, &stalker_cfg) {
Ok(client) => client,
Err(err) => {
return (
vec![],
vec![TuliproxError::ConfigInput(format!(
"failed to build Stalker client for input '{}': {err}",
input.name
))],
false,
false,
);
}
};
let storage_path = match ensure_stalker_storage_path(app_config, &input.name).await {
Ok(p) => p,
Err(err) => {
return (
vec![],
vec![TuliproxError::Io(format!("could not prepare Stalker storage for input '{}': {err}", input.name))],
false,
false,
);
}
};
let outcome = if let Some(_refresh_permit) = try_acquire_stalker_refresh(&input.name).await {
let handshake = match api_client.handshake().await {
Ok(handshake) => handshake,
Err(err) => {
return (
vec![],
vec![TuliproxError::ProviderConnection(format!(
"Stalker handshake for input '{}' failed: {err}",
input.name
))],
false,
false,
);
}
};
loop {
let refresh = advance_stalker_refresh(
app_config,
api_client.as_ref(),
&handshake,
refresh_selection,
&storage_path,
identity_fingerprint,
refresh_mode.budget(),
);
let result = if refresh_mode == StalkerRefreshMode::ServerSlice {
if let Ok(result) = tokio::time::timeout(Duration::from_mins(45), refresh).await {
result
} else {
let checkpoint = match load_checkpoint(&storage_path, identity_fingerprint).await {
Ok(checkpoint) => checkpoint,
Err(err) => return (Vec::new(), vec![err], false, false),
};
break StalkerRefreshOutcome::Yielded {
phase: checkpoint.as_ref().map_or(
tuliprox_repository::stalker_generation_repository::StalkerRefreshPhase::LiveBulk,
|state| state.phase.clone(),
),
processed: checkpoint.as_ref().map_or(0, |state| state.processed),
skipped: checkpoint.as_ref().map_or(0, |state| state.skipped_count),
error: None,
};
}
} else {
refresh.await
};
match result {
Ok(StalkerRefreshOutcome::Yielded { error: None, .. })
if refresh_mode == StalkerRefreshMode::Complete => {}
Ok(StalkerRefreshOutcome::Yielded { .. }) if refresh_mode == StalkerRefreshMode::Parallel => {
tokio::task::yield_now().await;
}
Ok(outcome) => break outcome,
Err(err) => return (Vec::new(), vec![err], false, false),
}
}
} else {
return (Vec::new(), Vec::new(), app_config.config.load().disk_based_processing, true);
};
let yielded = matches!(&outcome, StalkerRefreshOutcome::Yielded { .. });
let terminal = matches!(&outcome, StalkerRefreshOutcome::Terminal(_));
let mut errors = Vec::new();
match outcome {
StalkerRefreshOutcome::Complete => {}
StalkerRefreshOutcome::Yielded { phase, processed, skipped, error } => {
info!(
"Stalker input '{}' yielded in phase {phase:?} after {processed} records ({skipped} skipped)",
input.name
);
if let Some(error) = error {
warn!("Stalker input '{}': resumable refresh paused after error: {error}", input.name);
}
}
StalkerRefreshOutcome::Terminal(error) => errors.push(error),
}
let manifest = match load_active_manifest(&storage_path, identity_fingerprint).await {
Ok(manifest) => manifest,
Err(err) => {
errors.push(err);
return (Vec::new(), errors, false, yielded);
}
};
if terminal {
if let Err(err) =
tuliprox_repository::stalker_generation_repository::cleanup_obsolete_generations(&storage_path, &manifest)
.await
{
errors.push(err);
}
}
let use_disk_based_processing = app_config.config.load().disk_based_processing;
if !materialize_active {
return (Vec::new(), errors, use_disk_based_processing, yielded);
}
let mut groups = Vec::new();
let mut counts = [0_usize; 3];
for cluster in resolved_clusters {
let items_result = match cluster {
StalkerCluster::Live => match manifest.live.as_ref() {
Some(files) => load_stalker_items_at(app_config, &files.data).await,
None => Ok(Vec::new()),
},
StalkerCluster::Vod => match manifest.vod.as_ref() {
Some(files) => load_stalker_items_at(app_config, &files.data).await,
None => Ok(Vec::new()),
},
StalkerCluster::Series => match manifest.series.as_ref() {
Some(files) => {
match (
load_stalker_items_at(app_config, &files.roots).await,
load_stalker_items_at(app_config, &files.episodes).await,
) {
(Ok(mut roots), Ok(episodes)) => {
roots.extend(episodes);
Ok(roots)
}
(Err(err), _) | (_, Err(err)) => Err(err),
}
}
None => Ok(Vec::new()),
},
};
match items_result {
Ok(items) => {
counts[cluster as usize] = items.len();
let cluster_groups = groups_for_cluster(items, cluster, &input.name);
groups.extend(cluster_groups);
}
Err(err) => errors.push(err),
}
}
parser::log_stalker_download_summary(&input.name, counts[0], counts[1], counts[2]);
(groups, errors, use_disk_based_processing, yielded)
}
/// Resolve a per-input portal URL using the standard `provider://` resolution pipeline.
fn resolve_stalker_portal_url(input: &ConfigInput) -> Result<String, TuliproxError> {
let url = input.url.as_str();
if url.trim().is_empty() {
return Err(TuliproxError::ConfigInput(format!("Stalker input '{}' has no URL configured", input.name)));
}
input.resolve_url(url).map(Cow::into_owned)
}
fn skip_clusters_for(input: &ConfigInput) -> Vec<StalkerCluster> {
let mut out = Vec::new();
if input.has_flag(ConfigInputFlags::SkipLive) {
out.push(StalkerCluster::Live);
}
if input.has_flag(ConfigInputFlags::SkipVod) {
out.push(StalkerCluster::Vod);
}
if input.has_flag(ConfigInputFlags::SkipSeries) {
out.push(StalkerCluster::Series);
}
out
}
/// Convert the items of one cluster into per-category `PlaylistGroup`s,
/// mirroring the Xtream pipeline and the disk-based source: items are grouped
/// by their portal category id (falling back to a cluster default title for
/// items without a category) instead of collapsing the whole cluster into a
/// single group.
fn groups_for_cluster(
items: Vec<StalkerPlaylistItem>,
cluster: StalkerCluster,
input_name: &str,
) -> Vec<PlaylistGroup> {
use shared::model::XtreamCluster;
let xtream_cluster = match cluster {
StalkerCluster::Live => XtreamCluster::Live,
StalkerCluster::Vod => XtreamCluster::Video,
StalkerCluster::Series => XtreamCluster::Series,
};
let mut groups_map: indexmap::IndexMap<u32, PlaylistGroup> = indexmap::IndexMap::new();
for item in items {
let category_id = item.category_id;
let pli = PlaylistItem::from_stalker(&item, input_name);
let group = groups_map.entry(category_id).or_insert_with(|| PlaylistGroup {
id: category_id,
title: Arc::clone(&pli.header.group),
channels: Vec::new(),
xtream_cluster,
});
group.channels.push(pli);
}
groups_map.into_values().collect()
}
fn stalker_err_to_repo(err: StalkerError) -> TuliproxError {
TuliproxError::ProviderConnection(format!("Stalker client error: {err}"))
}
/// Cache key for the runtime client map. Built from explicit, non-secret
/// fields — `StalkerInputConfig` carries the portal account credentials, so
/// formatting the whole config with `{:?}` would leak username and password
/// into a long-lived in-memory map key. Credentials still differentiate the
/// key, but only as a non-reversible short hash.
fn runtime_client_cache_key(portal_url: &str, cfg: &StalkerInputConfig) -> String {
format!("{portal_url}|{:016x}", cfg.identity_fingerprint(portal_url))
}
pub fn stalker_identity_fingerprint(portal_url: &str, cfg: &StalkerInputConfig) -> u64 {
cfg.identity_fingerprint(portal_url)
}
fn cached_runtime_stalker_client(
http_client: &reqwest::Client,
portal_url: String,
cfg: &StalkerInputConfig,
) -> Result<Arc<StalkerApiClient>, TuliproxError> {
let key = runtime_client_cache_key(&portal_url, cfg);
if let Some(client) = RUNTIME_STALKER_CLIENTS.lock().get(&key).cloned() {
return Ok(client);
}
let client =
Arc::new(StalkerApiClient::new(http_client.clone(), portal_url, cfg.clone()).map_err(stalker_err_to_repo)?);
RUNTIME_STALKER_CLIENTS.lock().put(key, Arc::clone(&client));
Ok(client)
}
pub async fn re_resolve_stalker_url(
app_config: &Arc<AppConfig>,
http_client: &reqwest::Client,
input: &ConfigInput,
provider_id: u32,
kind: StalkerStreamKind,
force_refresh: bool,
) -> Result<Option<Arc<str>>, TuliproxError> {
if provider_id == 0 {
return Ok(None);
}
let stalker_cfg = input.stalker.as_ref().ok_or_else(|| {
TuliproxError::ConfigInput(format!("Stalker input '{}' has no stalker configuration block", input.name))
})?;
let portal_url = resolve_stalker_portal_url(input)?;
let identity_fingerprint = stalker_cfg.identity_fingerprint(&portal_url);
let storage_path = ensure_stalker_storage_path(app_config, &input.name).await?;
let manifest = load_active_manifest(&storage_path, identity_fingerprint).await?;
let generation_and_path = match kind {
StalkerStreamKind::Live | StalkerStreamKind::Archive => {
manifest.live.as_ref().map(|files| (files.generation, &files.data))
}
StalkerStreamKind::Movie => manifest.vod.as_ref().map(|files| (files.generation, &files.data)),
StalkerStreamKind::Episode => manifest.series.as_ref().map(|files| (files.generation, &files.episodes)),
};
let Some((generation, item_path)) = generation_and_path else { return Ok(None) };
let link_key = RuntimeLinkKey { fingerprint: identity_fingerprint, generation, provider_id, kind };
if let Some(url) = cached_resolved_link(link_key, force_refresh) {
return Ok(Some(url));
}
let Some(item) = read_stalker_item_at(app_config, item_path, provider_id).await? else {
return Ok(None);
};
let Some(descriptor) = item.playback_descriptor.as_ref() else {
debug!("Stalker re-resolve skipped: stream_id={} has no playback_descriptor", item.stream_id);
return Ok(None);
};
if descriptor.candidates.is_empty() {
debug!("Stalker re-resolve skipped: stream_id={} descriptor has no candidate", item.stream_id);
return Ok(None);
}
if descriptor.candidates.iter().all(|candidate| candidate.cmd.trim().is_empty()) {
debug!("Stalker re-resolve skipped: stream_id={} descriptor candidates are empty", item.stream_id);
return Ok(None);
}
let api_client = cached_runtime_stalker_client(http_client, portal_url, stalker_cfg)?;
let handshake = api_client.handshake().await.map_err(stalker_err_to_repo)?;
let series_number = (kind == StalkerStreamKind::Episode).then_some(item.number);
for candidate in &descriptor.candidates {
if candidate.cmd.trim().is_empty() {
continue;
}
match api_client
.create_link(&handshake, kind, candidate.playback_mode, &candidate.cmd, series_number, None, None)
.await
{
Ok(resolved) => {
let url = Internable::intern(resolved.stream_url);
cache_resolved_link(link_key, Arc::clone(&url));
return Ok(Some(url));
}
Err(err) => {
debug!(
"Stalker runtime re-resolve candidate failed for stream_id={}, mode={:?}: {err}",
item.stream_id, candidate.playback_mode
);
}
}
}
warn!("Stalker runtime re-resolve failed for stream_id={}, invalidating stale stream_url", item.stream_id);
RUNTIME_STALKER_LINKS.lock().pop(&link_key);
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
fn runtime_cfg() -> StalkerInputConfig { StalkerInputConfig::default() }
#[test]
fn runtime_client_cache_key_changes_with_endpoint_preference() {
let mut cfg = runtime_cfg();
let key_a = runtime_client_cache_key("http://portal.example", &cfg);
cfg.endpoint_preference = shared::model::stalker::StalkerEndpointPreference::Portal;
let key_b = runtime_client_cache_key("http://portal.example", &cfg);
assert_ne!(key_a, key_b);
}
#[test]
fn runtime_client_cache_key_changes_with_size_caps() {
let mut cfg = runtime_cfg();
let key_a = runtime_client_cache_key("http://portal.example", &cfg);
cfg.size_caps =
Some(tuliprox_core::model::StalkerSizeCaps { create_link_kb: 128, ordered_list_mb: 8, get_epg_mb: 64 });
let key_b = runtime_client_cache_key("http://portal.example", &cfg);
assert_ne!(key_a, key_b);
}
#[test]
fn runtime_client_cache_key_does_not_leak_credentials() {
let mut cfg = runtime_cfg();
cfg.username = Some("secret_user".to_string());
cfg.password = Some("secret_pass".to_string());
let key = runtime_client_cache_key("http://portal.example", &cfg);
assert!(!key.contains("secret_user"));
assert!(!key.contains("secret_pass"));
// Different credentials must still produce a different cache key so two
// inputs against the same portal never share a client.
let mut other = cfg.clone();
other.password = Some("other_pass".to_string());
let other_key = runtime_client_cache_key("http://portal.example", &other);
assert_ne!(key, other_key);
}
#[test]
fn runtime_client_cache_keeps_session_client_alive() -> Result<(), TuliproxError> {
let http = reqwest::Client::new();
let cfg = runtime_cfg();
let first = cached_runtime_stalker_client(&http, "http://portal.example".to_string(), &cfg)?;
let weak = Arc::downgrade(&first);
drop(first);
let second = cached_runtime_stalker_client(&http, "http://portal.example".to_string(), &cfg)?;
assert!(weak.upgrade().is_some());
let upgraded = weak.upgrade().ok_or_else(|| TuliproxError::ProviderConnection("client dropped".to_string()))?;
assert!(Arc::ptr_eq(&upgraded, &second));
Ok(())
}
#[test]
fn resolved_link_cache_can_be_forced_stale() {
let key = RuntimeLinkKey { fingerprint: 8, generation: 1, provider_id: 43, kind: StalkerStreamKind::Live };
cache_resolved_link(key, "http://stream.example/live".into());
assert_eq!(cached_resolved_link(key, false).as_deref(), Some("http://stream.example/live"));
assert!(cached_resolved_link(key, true).is_none());
}
#[test]
fn resolved_link_cache_is_scoped_to_the_published_generation() {
let old = RuntimeLinkKey { fingerprint: 7, generation: 1, provider_id: 42, kind: StalkerStreamKind::Live };
let current = RuntimeLinkKey { generation: 2, ..old };
cache_resolved_link(old, "http://stream.example/old".into());
assert!(cached_resolved_link(current, false).is_none());
}
#[tokio::test]
async fn refresh_ownership_is_non_blocking_across_invocations() {
let name: Arc<str> = "portal-a".into();
let first = try_acquire_stalker_refresh(&name).await;
assert!(first.is_some());
assert!(try_acquire_stalker_refresh(&name).await.is_none());
drop(first);
assert!(try_acquire_stalker_refresh(&name).await.is_some());
}
}
@@ -0,0 +1,666 @@
use super::stalker::StalkerCluster;
use shared::{
error::TuliproxError,
model::{stalker::StalkerStreamKind, stalker_item::StalkerPlaylistItem},
};
use std::{
collections::HashMap,
path::Path,
sync::Arc,
time::{Instant, SystemTime, UNIX_EPOCH},
};
use tuliprox_core::model::{AppConfig, ConfigInput, ConfigInputFlags};
use tuliprox_iptv::stalker::{
catalog::{StalkerCategory, StalkerRawItem},
client::StalkerApiClient,
error::StalkerError,
parser,
profile::StalkerHandshake,
};
use tuliprox_repository::{
stalker_generation_repository::{
cleanup_obsolete_generations, clear_checkpoint, generation_data_path, load_checkpoint, publish_selection,
save_checkpoint, StalkerCheckpoint, StalkerGenerationData, StalkerRefreshPhase,
},
stalker_repository::{
load_stalker_items_after, prepare_stalker_episode_series_at, promote_stalker_file, remove_stalker_file,
snapshot_stalker_epg_at, snapshot_stalker_items_at, upsert_stalker_epg_at, upsert_stalker_items_at,
},
};
const MAX_RETRIES: u8 = 3;
const SKIPPED_SAMPLE_LIMIT: usize = 32;
pub enum StalkerRefreshLimit {
Deadline(Instant),
Units { remaining: usize },
Unlimited,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StalkerRefreshMode {
ServerSlice,
Parallel,
Complete,
}
impl StalkerRefreshMode {
pub fn budget(self) -> StalkerRefreshBudget {
match self {
Self::ServerSlice => StalkerRefreshBudget::deadline(Instant::now() + std::time::Duration::from_mins(45)),
Self::Parallel => StalkerRefreshBudget::units(8),
Self::Complete => StalkerRefreshBudget::unlimited(),
}
}
}
pub struct StalkerRefreshBudget {
limit: StalkerRefreshLimit,
}
impl StalkerRefreshBudget {
pub fn deadline(deadline: Instant) -> Self { Self { limit: StalkerRefreshLimit::Deadline(deadline) } }
pub fn unlimited() -> Self { Self { limit: StalkerRefreshLimit::Unlimited } }
fn units(remaining: usize) -> Self { Self { limit: StalkerRefreshLimit::Units { remaining } } }
fn completed_unit(&mut self) {
if let StalkerRefreshLimit::Units { remaining } = &mut self.limit {
*remaining = remaining.saturating_sub(1);
}
}
fn should_yield(&self) -> bool {
match self.limit {
StalkerRefreshLimit::Deadline(deadline) => Instant::now() >= deadline,
StalkerRefreshLimit::Units { remaining } => remaining == 0,
StalkerRefreshLimit::Unlimited => false,
}
}
}
#[derive(Clone, Copy)]
#[allow(clippy::struct_excessive_bools)]
pub struct StalkerClusterSelection {
live: bool,
vod: bool,
series: bool,
epg: bool,
}
impl From<&ConfigInput> for StalkerClusterSelection {
fn from(input: &ConfigInput) -> Self {
let live = !input.has_flag(ConfigInputFlags::SkipLive);
Self {
live,
vod: !input.has_flag(ConfigInputFlags::SkipVod),
series: !input.has_flag(ConfigInputFlags::SkipSeries),
epg: live && input.has_flag(ConfigInputFlags::StalkerBulkEpg),
}
}
}
impl StalkerClusterSelection {
pub fn requested(input: &ConfigInput, requested: &[StalkerCluster]) -> Self {
let configured = Self::from(input);
let live = configured.live && requested.contains(&StalkerCluster::Live);
Self {
live,
vod: configured.vod && requested.contains(&StalkerCluster::Vod),
series: configured.series && requested.contains(&StalkerCluster::Series),
epg: configured.epg && live,
}
}
fn mask(self) -> u8 {
u8::from(self.live) | (u8::from(self.vod) << 1) | (u8::from(self.series) << 2) | (u8::from(self.epg) << 3)
}
}
fn first_phase(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
if selection.live {
StalkerRefreshPhase::LiveBulk
} else {
next_phase_after_live(selection)
}
}
fn next_phase_after_live(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
if selection.vod {
StalkerRefreshPhase::Vod { page: 1 }
} else {
next_phase_after_vod(selection)
}
}
fn next_phase_after_vod(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
if selection.series {
StalkerRefreshPhase::SeriesRoots { page: 1 }
} else if selection.epg {
StalkerRefreshPhase::Epg
} else {
StalkerRefreshPhase::Complete
}
}
fn next_phase_after_series(selection: StalkerClusterSelection) -> StalkerRefreshPhase {
if selection.epg {
StalkerRefreshPhase::Epg
} else {
StalkerRefreshPhase::Complete
}
}
#[derive(Debug)]
pub enum StalkerRefreshOutcome {
Complete,
Yielded { phase: StalkerRefreshPhase, processed: u64, skipped: u64, error: Option<TuliproxError> },
Terminal(TuliproxError),
}
fn generation_id() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX))
}
async fn load_or_start_checkpoint(
storage_path: &Path,
identity_fingerprint: u64,
selection: StalkerClusterSelection,
) -> Result<StalkerCheckpoint, TuliproxError> {
if let Some(state) = load_checkpoint(storage_path, identity_fingerprint).await? {
if state.selection_mask == selection.mask() {
return Ok(state);
}
}
let mut state =
StalkerCheckpoint::new(identity_fingerprint, generation_id(), selection.mask(), chrono::Utc::now().timestamp());
state.phase = first_phase(selection);
save_checkpoint(storage_path, &state).await?;
Ok(state)
}
async fn finish_completed_refresh(
storage_path: &Path,
identity_fingerprint: u64,
checkpoint: &StalkerCheckpoint,
) -> Result<(), TuliproxError> {
let manifest =
publish_selection(storage_path, identity_fingerprint, checkpoint.generation, checkpoint.selection_mask).await?;
clear_checkpoint(storage_path).await?;
cleanup_obsolete_generations(storage_path, &manifest).await
}
fn category_map(categories: Vec<StalkerCategory>) -> HashMap<u32, StalkerCategory> {
categories.into_iter().filter_map(|category| category.id.parse().ok().map(|id| (id, category))).collect()
}
fn category_map_result(
result: Result<Vec<StalkerCategory>, StalkerError>,
) -> Result<HashMap<u32, StalkerCategory>, TuliproxError> {
result.map(category_map).map_err(|err| provider_error(&err))
}
fn map_items(
raw_items: &[StalkerRawItem],
categories: &HashMap<u32, StalkerCategory>,
kind: StalkerStreamKind,
added_at: i64,
) -> Vec<StalkerPlaylistItem> {
raw_items
.iter()
.map(|raw| {
let category =
raw.category_id().and_then(|value| value.parse::<u32>().ok()).and_then(|id| categories.get(&id));
parser::map_stalker_to_playlist_item(raw, category, kind, added_at)
})
.collect()
}
fn catalog_page_signature<'a>(ids: impl Iterator<Item = Option<&'a str>>) -> u64 {
ids.fold(14_695_981_039_346_656_037_u64, |hash, id| {
id.unwrap_or_default()
.bytes()
.chain([0])
.fold(hash, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(1_099_511_628_211))
})
}
fn ensure_page_advanced(
next_page: Option<u32>,
previous_signature: Option<u64>,
signature: u64,
page: u32,
portal_type: &'static str,
) -> Result<(), TuliproxError> {
if next_page.is_some() && previous_signature == Some(signature) {
return Err(provider_error(&StalkerError::CatalogIncomplete {
portal_type,
reason: format!("page {page} repeated"),
}));
}
Ok(())
}
fn provider_error(err: &StalkerError) -> TuliproxError {
TuliproxError::ProviderConnection(format!("Stalker client error: {err}"))
}
async fn finish_terminal_refresh(storage_path: &Path) -> Result<StalkerRefreshOutcome, TuliproxError> {
clear_checkpoint(storage_path).await?;
Ok(StalkerRefreshOutcome::Terminal(TuliproxError::ProviderConnection(
"Stalker refresh reached a terminal state".to_string(),
)))
}
async fn yield_after_error(
storage_path: &Path,
mut checkpoint: StalkerCheckpoint,
error: TuliproxError,
) -> Result<StalkerRefreshOutcome, TuliproxError> {
checkpoint.retry_count = checkpoint.retry_count.saturating_add(1);
if checkpoint.retry_count >= MAX_RETRIES {
checkpoint.phase = StalkerRefreshPhase::Terminal;
save_checkpoint(storage_path, &checkpoint).await?;
return Ok(StalkerRefreshOutcome::Terminal(error));
}
save_checkpoint(storage_path, &checkpoint).await?;
Ok(StalkerRefreshOutcome::Yielded {
phase: checkpoint.phase,
processed: checkpoint.processed,
skipped: checkpoint.skipped_count,
error: Some(error),
})
}
#[allow(clippy::too_many_lines)]
pub async fn advance_stalker_refresh(
app_config: &Arc<AppConfig>,
api_client: &StalkerApiClient,
handshake: &StalkerHandshake,
selection: StalkerClusterSelection,
storage_path: &Path,
identity_fingerprint: u64,
mut budget: StalkerRefreshBudget,
) -> Result<StalkerRefreshOutcome, TuliproxError> {
let mut checkpoint = load_or_start_checkpoint(storage_path, identity_fingerprint, selection).await?;
if checkpoint.phase == StalkerRefreshPhase::Terminal {
return finish_terminal_refresh(storage_path).await;
}
let added_at = checkpoint.started_at;
let mut live_categories = None;
let mut vod_categories = None;
let mut series_categories = None;
let mut used_episode_ids = None;
loop {
if budget.should_yield() {
return Ok(StalkerRefreshOutcome::Yielded {
phase: checkpoint.phase,
processed: checkpoint.processed,
skipped: checkpoint.skipped_count,
error: None,
});
}
match checkpoint.phase.clone() {
StalkerRefreshPhase::LiveBulk => {
let categories = if let Some(categories) = &live_categories {
categories
} else {
match category_map_result(api_client.get_live_categories(handshake).await) {
Ok(categories) => live_categories.insert(categories),
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
}
};
match api_client.get_all_channels(handshake).await {
Ok(raw) if raw.is_empty() => {
checkpoint.phase = StalkerRefreshPhase::Live { page: 1 };
checkpoint.retry_count = 0;
}
Ok(raw) => {
let items = map_items(&raw, categories, StalkerStreamKind::Live, added_at);
let path =
generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
snapshot_stalker_items_at(app_config, path.clone(), &items).await?;
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
checkpoint.phase = next_phase_after_live(selection);
checkpoint.retry_count = 0;
}
Err(err) if err.is_unsupported_catalog_action() => {
checkpoint.phase = StalkerRefreshPhase::Live { page: 1 };
checkpoint.retry_count = 0;
}
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
}
}
StalkerRefreshPhase::Live { page } => {
let categories = if let Some(categories) = &live_categories {
categories
} else {
match category_map_result(api_client.get_live_categories(handshake).await) {
Ok(categories) => live_categories.insert(categories),
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
}
};
let response = match api_client.get_live_streams_page(handshake, page).await {
Ok(response) => response,
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
};
let signature = catalog_page_signature(response.items.iter().map(|item| item.id.as_deref()));
if let Err(err) =
ensure_page_advanced(response.next_page, checkpoint.page_signature, signature, page, "itv")
{
return yield_after_error(storage_path, checkpoint, err).await;
}
let items = map_items(&response.items, categories, StalkerStreamKind::Live, added_at);
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Live);
upsert_stalker_items_at(app_config, &path, &items).await?;
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
checkpoint.phase = if let Some(next_page) = response.next_page {
checkpoint.page_signature = Some(signature);
StalkerRefreshPhase::Live { page: next_page }
} else {
checkpoint.page_signature = None;
next_phase_after_live(selection)
};
checkpoint.retry_count = 0;
}
StalkerRefreshPhase::Vod { page } => {
let categories = if let Some(categories) = &vod_categories {
categories
} else {
match category_map_result(api_client.get_vod_categories(handshake).await) {
Ok(categories) => vod_categories.insert(categories),
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
}
};
let response = match api_client.get_vod_streams_page(handshake, page).await {
Ok(response) => response,
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
};
let signature = catalog_page_signature(response.items.iter().map(|item| item.id.as_deref()));
if let Err(err) =
ensure_page_advanced(response.next_page, checkpoint.page_signature, signature, page, "vod")
{
return yield_after_error(storage_path, checkpoint, err).await;
}
let items = map_items(&response.items, categories, StalkerStreamKind::Movie, added_at);
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Vod);
upsert_stalker_items_at(app_config, &path, &items).await?;
checkpoint.processed = checkpoint.processed.saturating_add(items.len() as u64);
checkpoint.phase = if let Some(next_page) = response.next_page {
checkpoint.page_signature = Some(signature);
StalkerRefreshPhase::Vod { page: next_page }
} else {
checkpoint.page_signature = None;
next_phase_after_vod(selection)
};
checkpoint.retry_count = 0;
}
StalkerRefreshPhase::SeriesRoots { page } => {
let categories = if let Some(categories) = &series_categories {
categories
} else {
match category_map_result(api_client.get_series_categories(handshake).await) {
Ok(categories) => series_categories.insert(categories),
Err(err) => return yield_after_error(storage_path, checkpoint, err).await,
}
};
let response = match api_client.get_series_list_page(handshake, page).await {
Ok(response) => response,
Err(err) => return yield_after_error(storage_path, checkpoint, provider_error(&err)).await,
};
let signature = catalog_page_signature(response.items.iter().map(|item| item.id.as_deref()));
if let Err(err) =
ensure_page_advanced(response.next_page, checkpoint.page_signature, signature, page, "series")
{
return yield_after_error(storage_path, checkpoint, err).await;
}
let roots: Vec<_> = response
.items
.iter()
.map(|raw| {
let category = raw
.category_id
.as_deref()
.and_then(|value| value.parse::<u32>().ok())
.and_then(|id| categories.get(&id));
let mut root = parser::map_stalker_series_root(raw, category, added_at);
let capabilities = &handshake.profile.portal_capabilities;
root.nginx_secure_link = capabilities.nginx_secure_link;
root.flussonic_tmp_link = capabilities.flussonic_temporary_link;
root.wowza_tmp_link = capabilities.wowza_temporary_link;
root.use_http_tmp_link = capabilities.use_http_temporary_link;
root
})
.collect();
let roots_path =
generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::SeriesRoots);
upsert_stalker_items_at(app_config, &roots_path, &roots).await?;
checkpoint.processed = checkpoint.processed.saturating_add(roots.len() as u64);
checkpoint.phase = match response.next_page {
None => {
checkpoint.page_signature = None;
StalkerRefreshPhase::SeriesDetails { provider_id: None }
}
Some(next_page) => {
checkpoint.page_signature = Some(signature);
StalkerRefreshPhase::SeriesRoots { page: next_page }
}
};
checkpoint.retry_count = 0;
}
StalkerRefreshPhase::SeriesDetails { provider_id } => {
let roots_path =
generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::SeriesRoots);
let mut roots = load_stalker_items_after(app_config, &roots_path, provider_id, 1).await?;
let Some(root) = roots.pop() else {
checkpoint.phase = next_phase_after_series(selection);
checkpoint.retry_count = 0;
save_checkpoint(storage_path, &checkpoint).await?;
budget.completed_unit();
continue;
};
let series_id = root.series_id.unwrap_or(root.stream_id);
match api_client.get_series_details(handshake, series_id).await {
Ok(details) => {
let path = generation_data_path(
storage_path,
checkpoint.generation,
StalkerGenerationData::SeriesEpisodes,
);
let used = if let Some(used) = &mut used_episode_ids {
used
} else {
used_episode_ids
.insert(prepare_stalker_episode_series_at(app_config, &path, series_id).await?)
};
let episodes = parser::map_stalker_series_details(&details, &root, added_at, used);
upsert_stalker_items_at(app_config, &path, &episodes).await?;
checkpoint.processed = checkpoint.processed.saturating_add(episodes.len() as u64);
checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) };
checkpoint.retry_count = 0;
}
Err(err) => {
checkpoint.retry_count = checkpoint.retry_count.saturating_add(1);
if checkpoint.retry_count < MAX_RETRIES {
save_checkpoint(storage_path, &checkpoint).await?;
return Ok(StalkerRefreshOutcome::Yielded {
phase: checkpoint.phase,
processed: checkpoint.processed,
skipped: checkpoint.skipped_count,
error: Some(provider_error(&err)),
});
}
checkpoint.skipped_count = checkpoint.skipped_count.saturating_add(1);
if checkpoint.skipped_sample.len() < SKIPPED_SAMPLE_LIMIT {
checkpoint.skipped_sample.push(series_id);
}
checkpoint.phase = StalkerRefreshPhase::SeriesDetails { provider_id: Some(root.stream_id) };
checkpoint.retry_count = 0;
}
}
}
StalkerRefreshPhase::Epg => {
let path = generation_data_path(storage_path, checkpoint.generation, StalkerGenerationData::Epg);
let attempt_path = path.with_extension("attempt.db");
snapshot_stalker_epg_at(app_config, &attempt_path, &[]).await?;
let app_config = Arc::clone(app_config);
let sink_app_config = Arc::clone(&app_config);
let batch_path = attempt_path.clone();
let epg_result = api_client
.stream_bulk_epg(handshake, 24, 512, move |batch| {
let app_config = Arc::clone(&sink_app_config);
let batch_path = batch_path.clone();
async move {
upsert_stalker_epg_at(&app_config, &batch_path, &batch)
.await
.map(|_| ())
.map_err(|err| StalkerError::Io(std::io::Error::other(err.to_string())))
}
})
.await;
if let Err(err) = epg_result {
remove_stalker_file(&app_config, &attempt_path).await?;
return yield_after_error(storage_path, checkpoint, provider_error(&err)).await;
}
promote_stalker_file(&app_config, &attempt_path, &path).await?;
checkpoint.phase = StalkerRefreshPhase::Complete;
checkpoint.retry_count = 0;
}
StalkerRefreshPhase::Complete => {
finish_completed_refresh(storage_path, identity_fingerprint, &checkpoint).await?;
return Ok(StalkerRefreshOutcome::Complete);
}
StalkerRefreshPhase::Terminal => {
return finish_terminal_refresh(storage_path).await;
}
}
save_checkpoint(storage_path, &checkpoint).await?;
budget.completed_unit();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn terminal_checkpoint_is_cleared_after_restart() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let selection = StalkerClusterSelection { live: true, vod: false, series: false, epg: false };
let mut checkpoint = StalkerCheckpoint::new(17, 23, selection.mask(), 123);
checkpoint.phase = StalkerRefreshPhase::Terminal;
save_checkpoint(temp.path(), &checkpoint).await?;
let outcome = finish_terminal_refresh(temp.path()).await?;
assert!(matches!(outcome, StalkerRefreshOutcome::Terminal(_)));
assert!(load_checkpoint(temp.path(), 17).await?.is_none());
Ok(())
}
#[tokio::test]
async fn complete_checkpoint_is_resumed_without_new_generation() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let selection = StalkerClusterSelection { live: true, vod: true, series: false, epg: false };
let mut checkpoint = StalkerCheckpoint::new(17, 23, selection.mask(), 123);
checkpoint.phase = StalkerRefreshPhase::Complete;
save_checkpoint(temp.path(), &checkpoint).await?;
let resumed = load_or_start_checkpoint(temp.path(), 17, selection).await?;
assert_eq!(resumed.generation, 23);
assert_eq!(resumed.phase, StalkerRefreshPhase::Complete);
Ok(())
}
#[tokio::test]
async fn complete_publication_is_idempotent_after_restart() -> Result<(), Box<dyn std::error::Error>> {
let temp = tempfile::tempdir()?;
let mut checkpoint = StalkerCheckpoint::new(17, 23, 0b0011, 123);
checkpoint.phase = StalkerRefreshPhase::Complete;
save_checkpoint(temp.path(), &checkpoint).await?;
finish_completed_refresh(temp.path(), 17, &checkpoint).await?;
save_checkpoint(temp.path(), &checkpoint).await?;
finish_completed_refresh(temp.path(), 17, &checkpoint).await?;
let manifest =
tuliprox_repository::stalker_generation_repository::load_active_manifest(temp.path(), 17).await?;
assert_eq!(manifest.live.as_ref().map(|files| files.generation), Some(23));
assert_eq!(manifest.vod.as_ref().map(|files| files.generation), Some(23));
assert!(load_checkpoint(temp.path(), 17).await?.is_none());
Ok(())
}
#[test]
fn unit_budget_yields_after_completed_unit() {
let mut budget = StalkerRefreshBudget::units(1);
assert!(!budget.should_yield());
budget.completed_unit();
assert!(budget.should_yield());
}
#[test]
fn parallel_mode_uses_a_bounded_work_unit_budget() {
let mut budget = StalkerRefreshMode::Parallel.budget();
assert!(!budget.should_yield());
for _ in 0..64 {
budget.completed_unit();
}
assert!(budget.should_yield());
}
#[test]
fn phase_progression_skips_disabled_clusters() {
let selection = StalkerClusterSelection { live: false, vod: true, series: false, epg: false };
assert_eq!(next_phase_after_live(selection), StalkerRefreshPhase::Vod { page: 1 });
assert_eq!(next_phase_after_vod(selection), StalkerRefreshPhase::Complete);
}
#[test]
fn requested_clusters_limit_the_refresh_selection() {
let input = ConfigInput::default();
let selection = StalkerClusterSelection::requested(&input, &[StalkerCluster::Series]);
assert!(!selection.live);
assert!(!selection.vod);
assert!(selection.series);
assert!(!selection.epg);
}
#[test]
fn category_errors_are_not_silently_downgraded() {
let result = category_map_result(Err(StalkerError::BodyDecode { message: "broken".to_string() }));
assert!(result.is_err());
}
#[test]
fn map_items_uses_tv_genre_id_for_live_category() {
let raw: StalkerRawItem = serde_json::from_value(serde_json::json!({
"id": "590",
"name": "News",
"tv_genre_id": "10"
}))
.expect("raw item");
let categories = HashMap::from([(
10,
StalkerCategory { id: "10".to_string(), title: "News".to_string(), alias: None, number: 1 },
)]);
let items = map_items(&[raw], &categories, StalkerStreamKind::Live, 0);
assert_eq!(items.len(), 1);
assert_eq!(items[0].category_id, 10);
assert_eq!(items[0].category_name.as_ref(), "News");
}
#[test]
fn repeated_page_is_rejected_while_more_pages_are_advertised() {
assert!(ensure_page_advanced(Some(3), Some(17), 17, 2, "vod").is_err());
assert!(ensure_page_advanced(None, Some(17), 17, 2, "vod").is_ok());
}
}
@@ -0,0 +1,587 @@
use crate::processor::{select_cancel_token, ProbeHandleGuard};
use log::{debug, info, warn};
use shared::{
error::TuliproxError,
model::{
EpisodeStreamProperties, InputType, LiveStreamProperties, M3uPlaylistItem, PlaylistItemType, StreamProperties,
UUIDType, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, XtreamPlaylistItem,
},
utils::sanitize_sensitive_info,
};
use std::{path::PathBuf, sync::Arc};
use tuliprox_core::{
model::{AppConfig, ConfigInput},
utils::{
debug_if_enabled,
ffmpeg::{is_supported_probe_url, FfmpegExecutor, ProbeFailureKind, ProbeStreamStats, ProbeUrlOutcome},
},
};
use tuliprox_repository::{
get_input_local_library_playlist_file_path, get_input_m3u_playlist_file_path, get_input_storage_path,
xtream_get_file_path, BPlusTreeUpdate,
};
use tuliprox_session::ActiveProviderManager;
enum ProbeStorageKind {
M3u,
Library,
Xtream,
}
struct PreparedGenericProbe {
db_path: PathBuf,
storage_kind: ProbeStorageKind,
raw_video: Option<Arc<str>>,
raw_audio: Option<Arc<str>>,
stats: ProbeStreamStats,
}
enum PreparedGenericProbeOutcome {
Prepared(PreparedGenericProbe),
Noop,
ProbeFailed,
}
pub struct GenericProbeMetadata {
pub raw_video: Option<Arc<str>>,
pub raw_audio: Option<Arc<str>>,
pub stats: ProbeStreamStats,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GenericProbeOutcome {
Updated,
Noop,
ProbeFailed,
}
pub enum GenericProbeMetadataOutcome {
Metadata(GenericProbeMetadata),
Noop,
ProbeFailed,
}
fn requires_provider_connection_for_generic_probe(input_type: InputType) -> bool {
input_type.capabilities().requires_provider_connection_for_probe
}
fn uses_seekable_remote_probe(item_type: PlaylistItemType, is_remote_probe: bool) -> bool {
is_remote_probe && !item_type.is_live()
}
/// Updates metadata (Probing) for a stream URL (M3U, Xtream, Library) and persists it.
/// - `unique_id`: For M3U this is the `provider_id` (String). For Library this is the `UUID` string.
/// For Xtream this is the numeric provider id as string.
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub async fn update_generic_stream_metadata(
app_config: &Arc<AppConfig>,
client: &reqwest::Client,
input: &ConfigInput,
unique_id: &str,
stream_url: &str,
item_type: PlaylistItemType,
active_provider: &Arc<ActiveProviderManager>,
active_handle: Option<&tuliprox_core::model::ProviderHandle>,
probe_priority: i8,
) -> Result<GenericProbeOutcome, TuliproxError> {
let prepared = match prepare_generic_stream_metadata(
app_config,
client,
input,
unique_id,
stream_url,
item_type,
active_provider,
active_handle,
probe_priority,
)
.await?
{
PreparedGenericProbeOutcome::Prepared(prepared) => prepared,
PreparedGenericProbeOutcome::Noop => return Ok(GenericProbeOutcome::Noop),
PreparedGenericProbeOutcome::ProbeFailed => return Ok(GenericProbeOutcome::ProbeFailed),
};
persist_prepared_generic_stream_metadata(app_config, unique_id, item_type, prepared).await
}
/// Probes a generic stream and returns metadata without writing to storage.
///
/// This is used by metadata workers that can batch the eventual B+Tree writes.
#[allow(clippy::too_many_arguments)]
pub async fn probe_generic_stream_metadata(
app_config: &Arc<AppConfig>,
client: &reqwest::Client,
input: &ConfigInput,
unique_id: &str,
stream_url: &str,
item_type: PlaylistItemType,
active_provider: &Arc<ActiveProviderManager>,
active_handle: Option<&tuliprox_core::model::ProviderHandle>,
probe_priority: i8,
) -> Result<GenericProbeMetadataOutcome, TuliproxError> {
let prepared = match prepare_generic_stream_metadata(
app_config,
client,
input,
unique_id,
stream_url,
item_type,
active_provider,
active_handle,
probe_priority,
)
.await?
{
PreparedGenericProbeOutcome::Prepared(prepared) => prepared,
PreparedGenericProbeOutcome::Noop => return Ok(GenericProbeMetadataOutcome::Noop),
PreparedGenericProbeOutcome::ProbeFailed => return Ok(GenericProbeMetadataOutcome::ProbeFailed),
};
Ok(GenericProbeMetadataOutcome::Metadata(GenericProbeMetadata {
raw_video: prepared.raw_video,
raw_audio: prepared.raw_audio,
stats: prepared.stats,
}))
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
async fn prepare_generic_stream_metadata(
app_config: &Arc<AppConfig>,
client: &reqwest::Client,
input: &ConfigInput,
unique_id: &str,
stream_url: &str,
item_type: PlaylistItemType,
active_provider: &Arc<ActiveProviderManager>,
active_handle: Option<&tuliprox_core::model::ProviderHandle>,
probe_priority: i8,
) -> Result<PreparedGenericProbeOutcome, TuliproxError> {
let storage_dir = &app_config.config.load().storage_dir;
// Check if probing is enabled globally
let ffprobe_enabled = app_config.is_ffprobe_enabled().await;
if !ffprobe_enabled {
return Ok(PreparedGenericProbeOutcome::Noop);
}
// Determine storage file path based on input type
let storage_path = get_input_storage_path(&input.name, storage_dir)
.await
.map_err(|e| TuliproxError::Io(format!("Storage path error: {e}")))?;
let (db_path, storage_kind) = match input.input_type {
InputType::M3u | InputType::M3uBatch => {
(get_input_m3u_playlist_file_path(&storage_path, &input.name), ProbeStorageKind::M3u)
}
InputType::Library => {
(get_input_local_library_playlist_file_path(&storage_path, &input.name), ProbeStorageKind::Library)
}
InputType::Xtream | InputType::XtreamBatch => {
let cluster = if item_type.is_live() {
XtreamCluster::Live
} else if item_type.is_video() {
XtreamCluster::Video
} else if item_type.is_series() {
XtreamCluster::Series
} else {
// Generic probing currently supports live/video/series payload shapes.
return Ok(PreparedGenericProbeOutcome::Noop);
};
(xtream_get_file_path(&storage_path, cluster), ProbeStorageKind::Xtream)
}
InputType::Emby
| InputType::Jellyfin
| InputType::Plex
| InputType::Stalker
| InputType::StalkerBatch
| InputType::Staged => return Ok(PreparedGenericProbeOutcome::Noop),
};
if !db_path.exists() {
return Err(shared::error::TuliproxError::Config(format!(
"Playlist DB file not found for input {input_name}: {db_path_display}",
input_name = input.name,
db_path_display = db_path.display()
)));
}
let needs_provider_connection = requires_provider_connection_for_generic_probe(input.input_type);
let acquired_handle = if !needs_provider_connection || active_handle.is_some() {
None
} else {
active_provider
.acquire_connection_for_probe(&input.name, probe_priority)
.await
.map(|handle| ProbeHandleGuard::new(active_provider, handle))
};
if needs_provider_connection && active_handle.is_none() && acquired_handle.is_none() {
warn!("Skipping probe for generic stream {unique_id} due to connection limits");
return Err(TuliproxError::Probe(format!(
"Skipping probe for generic stream {unique_id} due to connection limits"
)));
}
let probe_url = input.resolve_url(stream_url)?.into_owned();
if !is_supported_probe_url(&probe_url) {
let safe_probe_url = sanitize_sensitive_info(&probe_url).into_owned();
debug!("Skipping unsupported generic stream probe for {unique_id}: {safe_probe_url}");
return Ok(PreparedGenericProbeOutcome::Noop);
}
let is_remote_probe = reqwest::Url::parse(&probe_url).is_ok_and(|url| matches!(url.scheme(), "http" | "https"));
let config = app_config.config.load();
let metadata_update = config.metadata_update.clone().unwrap_or_default();
let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60);
let user_agent = config.default_user_agent.clone();
let (analyze_duration, probe_size) = if item_type.is_live() {
(metadata_update.ffprobe.live_analyze_duration_micros, metadata_update.ffprobe.live_probe_size_bytes)
} else {
(metadata_update.ffprobe.analyze_duration_micros, metadata_update.ffprobe.probe_size_bytes)
};
debug_if_enabled!("Probing Generic Stream '{unique_id}'");
let cancel_token = select_cancel_token(acquired_handle.as_ref().and_then(ProbeHandleGuard::handle), active_handle);
let params = tuliprox_core::utils::ffmpeg::ProbeParams {
url: &probe_url,
user_agent: user_agent.as_deref(),
analyze_duration,
probe_size,
timeout_secs: ffprobe_timeout,
};
let probe_data = if uses_seekable_remote_probe(item_type, is_remote_probe) {
FfmpegExecutor::new().probe_remote_seekable_url_with_cancel(client, &params, cancel_token).await
} else if is_remote_probe {
FfmpegExecutor::new().probe_remote_url_with_cancel(client, &params, cancel_token).await
} else {
FfmpegExecutor::new().probe_url_with_cancel(&params, config.proxy.as_ref(), cancel_token).await
};
if let Some(handle) = acquired_handle {
handle.release().await;
}
let (raw_video, raw_audio, stats) = match probe_data {
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => (
raw_video.map(|value| Arc::<str>::from(value.to_string())),
raw_audio.map(|value| Arc::<str>::from(value.to_string())),
stats,
),
ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => {
warn!("Probe target not found (404) for generic stream: {unique_id}");
return Err(shared::error::TuliproxError::Probe(format!(
"Probe target returned 404 Not Found for stream {unique_id}"
)));
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => {
warn!("Probe failed or timed out for generic stream: {unique_id}");
return Ok(PreparedGenericProbeOutcome::ProbeFailed);
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Cancelled) => {
warn!("Probe cancelled for generic stream: {unique_id}");
return Ok(PreparedGenericProbeOutcome::ProbeFailed);
}
};
Ok(PreparedGenericProbeOutcome::Prepared(PreparedGenericProbe {
db_path,
storage_kind,
raw_video,
raw_audio,
stats,
}))
}
async fn persist_prepared_generic_stream_metadata(
app_config: &Arc<AppConfig>,
unique_id: &str,
item_type: PlaylistItemType,
prepared: PreparedGenericProbe,
) -> Result<GenericProbeOutcome, TuliproxError> {
// Hold the async file lock while the blocking DB update runs in a blocking thread.
let file_lock = app_config.file_locks.write_lock(&prepared.db_path).await;
let db_path_for_update = prepared.db_path;
let unique_id_for_update = unique_id.to_string();
let raw_video = prepared.raw_video;
let raw_audio = prepared.raw_audio;
let stats = prepared.stats;
let updated = tokio::task::spawn_blocking(move || -> Result<bool, String> {
let mut updated = false;
match prepared.storage_kind {
ProbeStorageKind::M3u => {
let key: Arc<str> = Arc::from(unique_id_for_update.as_str());
let mut tree_update = BPlusTreeUpdate::<Arc<str>, M3uPlaylistItem>::try_new(&db_path_for_update)
.map_err(|e| format!("Failed to open M3U tree update: {e}"))?;
if let Some(mut item) = tree_update.query(&key).map_err(|e| format!("Tree query error: {e}"))? {
update_properties(
&mut item.additional_properties,
item_type,
&item.name,
item.virtual_id,
raw_video,
raw_audio,
stats,
);
tree_update.update(&key, item).map_err(|e| format!("Tree update error: {e}"))?;
info!("Successfully updated M3U metadata for: {unique_id_for_update}");
updated = true;
} else {
warn!("Item not found in M3U DB: {unique_id_for_update}");
}
}
ProbeStorageKind::Library => {
let mut tree_update = BPlusTreeUpdate::<UUIDType, XtreamPlaylistItem>::try_new(&db_path_for_update)
.map_err(|e| format!("Failed to open Library tree update: {e}"))?;
let uuid = UUIDType::from_valid_uuid(&unique_id_for_update);
if let Some(mut item) = tree_update.query(&uuid).map_err(|e| format!("Tree query error: {e}"))? {
update_properties(
&mut item.additional_properties,
item_type,
&item.name,
item.virtual_id,
raw_video,
raw_audio,
stats,
);
tree_update.update(&uuid, item).map_err(|e| format!("Tree update error: {e}"))?;
info!("Successfully updated Library metadata for: {unique_id_for_update}");
updated = true;
} else {
warn!("Item not found in Library DB: {unique_id_for_update}");
}
}
ProbeStorageKind::Xtream => {
let Ok(provider_id) = unique_id_for_update.parse::<u32>() else {
warn!("Skipping xtream generic probe update with non-numeric id: {unique_id_for_update}");
return Ok(false);
};
let mut tree_update = BPlusTreeUpdate::<u32, XtreamPlaylistItem>::try_new(&db_path_for_update)
.map_err(|e| format!("Failed to open Xtream tree update: {e}"))?;
if let Some(mut item) = tree_update.query(&provider_id).map_err(|e| format!("Tree query error: {e}"))? {
update_properties(
&mut item.additional_properties,
item_type,
&item.name,
item.virtual_id,
raw_video,
raw_audio,
stats,
);
tree_update.update(&provider_id, item).map_err(|e| format!("Tree update error: {e}"))?;
info!("Successfully updated Xtream metadata for: {unique_id_for_update}");
updated = true;
} else {
warn!("Item not found in Xtream DB: {unique_id_for_update}");
}
}
}
Ok(updated)
})
.await
.map_err(|e| shared::error::TuliproxError::Config(format!("Failed to join generic probe DB update task: {e}")))?
.map_err(shared::error::TuliproxError::Config)?;
drop(file_lock);
if updated {
Ok(GenericProbeOutcome::Updated)
} else {
Ok(GenericProbeOutcome::Noop)
}
}
pub fn update_properties(
props_opt: &mut Option<StreamProperties>,
item_type: PlaylistItemType,
name: &str,
virtual_id: u32,
raw_video: Option<Arc<str>>,
raw_audio: Option<Arc<str>>,
stats: ProbeStreamStats,
) {
if item_type.is_video() {
let mut props = if let Some(StreamProperties::Video(p)) = props_opt {
*p.clone()
} else {
VideoStreamProperties {
name: name.into(),
stream_id: virtual_id,
container_extension: "".into(),
..Default::default()
}
};
if props.details.is_none() {
props.details = Some(VideoStreamDetailProperties::default());
}
if let Some(details) = props.details.as_mut() {
if let Some(v) = raw_video {
details.video = Some(v);
}
if let Some(a) = raw_audio {
details.audio = Some(a);
}
if let Some(duration_secs) = stats.duration_secs {
details.duration_secs = Some(duration_secs.to_string().into());
}
if let Some(bitrate) = stats.bitrate {
details.bitrate = bitrate;
}
}
*props_opt = Some(StreamProperties::Video(Box::new(props)));
} else if item_type.is_series() {
let mut props = if let Some(StreamProperties::Episode(p)) = props_opt {
*p.clone()
} else {
EpisodeStreamProperties {
episode_id: virtual_id,
episode: 0,
season: 0,
added: None,
release_date: None,
series_release_date: None,
plot: None,
tmdb: None,
movie_image: "".into(),
container_extension: "".into(),
video: None,
audio: None,
}
};
if let Some(v) = raw_video {
props.video = Some(v);
}
if let Some(a) = raw_audio {
props.audio = Some(a);
}
*props_opt = Some(StreamProperties::Episode(Box::new(props)));
} else if matches!(item_type, PlaylistItemType::Live | PlaylistItemType::LiveHls | PlaylistItemType::LiveDash) {
let mut props = if let Some(StreamProperties::Live(p)) = props_opt {
*p.clone()
} else {
LiveStreamProperties { name: name.into(), stream_id: virtual_id, ..LiveStreamProperties::default() }
};
if let Some(v) = raw_video {
props.video = Some(v);
}
if let Some(a) = raw_audio {
props.audio = Some(a);
}
if let Some(bitrate) = stats.bitrate.filter(|bitrate| *bitrate > 0) {
props.bitrate = props.bitrate.max(bitrate);
}
let now = chrono::Utc::now().timestamp();
props.last_probed_timestamp = Some(now);
props.last_success_timestamp = Some(now);
*props_opt = Some(StreamProperties::Live(Box::new(props)));
}
}
#[cfg(test)]
mod tests {
use super::*;
use tuliprox_core::utils::ffmpeg::ProbeStreamStats;
#[test]
fn library_probe_does_not_require_provider_connection() {
assert!(!requires_provider_connection_for_generic_probe(InputType::Library));
}
#[test]
fn media_server_probe_does_not_require_provider_connection() {
assert!(!requires_provider_connection_for_generic_probe(InputType::Emby));
assert!(!requires_provider_connection_for_generic_probe(InputType::Jellyfin));
assert!(!requires_provider_connection_for_generic_probe(InputType::Plex));
}
#[test]
fn m3u_probe_requires_provider_connection() {
assert!(requires_provider_connection_for_generic_probe(InputType::M3u));
assert!(requires_provider_connection_for_generic_probe(InputType::M3uBatch));
}
#[test]
fn xtream_probe_requires_provider_connection() {
assert!(requires_provider_connection_for_generic_probe(InputType::Xtream));
assert!(requires_provider_connection_for_generic_probe(InputType::XtreamBatch));
}
#[test]
fn seekable_remote_probe_is_used_only_for_remote_non_live_items() {
assert!(!uses_seekable_remote_probe(PlaylistItemType::Live, true));
assert!(!uses_seekable_remote_probe(PlaylistItemType::LiveUnknown, true));
assert!(uses_seekable_remote_probe(PlaylistItemType::Video, true));
assert!(uses_seekable_remote_probe(PlaylistItemType::Series, true));
assert!(!uses_seekable_remote_probe(PlaylistItemType::Video, false));
}
#[test]
fn update_properties_applies_probe_stats_to_video_details() {
let mut props_opt = None;
update_properties(
&mut props_opt,
PlaylistItemType::Video,
"Example",
77,
None,
None,
ProbeStreamStats { duration_secs: Some(1_541), bitrate: Some(3_100_000) },
);
let Some(StreamProperties::Video(video)) = props_opt else {
panic!("expected video properties");
};
let details = video.details.as_ref().unwrap_or_else(|| unreachable!());
assert_eq!(details.duration_secs.as_deref().map(std::convert::AsRef::as_ref), Some("1541"));
assert_eq!(details.bitrate, 3_100_000);
}
#[test]
fn update_properties_applies_positive_probe_bitrate_to_live_stream() {
let mut props_opt = Some(StreamProperties::Live(Box::new(LiveStreamProperties {
bitrate: 1_500_000,
..LiveStreamProperties::default()
})));
update_properties(
&mut props_opt,
PlaylistItemType::Live,
"Example",
77,
None,
None,
ProbeStreamStats { duration_secs: None, bitrate: Some(3_100_000) },
);
let Some(StreamProperties::Live(live)) = props_opt else {
panic!("expected live properties");
};
assert_eq!(live.bitrate, 3_100_000);
let mut props_opt = Some(StreamProperties::Live(live));
update_properties(
&mut props_opt,
PlaylistItemType::Live,
"Example",
77,
None,
None,
ProbeStreamStats { duration_secs: None, bitrate: Some(2_000_000) },
);
let Some(StreamProperties::Live(live)) = props_opt else {
panic!("expected live properties");
};
assert_eq!(live.bitrate, 3_100_000);
}
}
+687
View File
@@ -0,0 +1,687 @@
use indexmap::IndexMap;
use log::{debug, info, trace, warn};
use shared::{
error::TuliproxError,
model::{
FieldGetAccessor, FieldSetAccessor, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType,
TraktContentType, UUIDType, XtreamCluster,
},
utils::{hash_string, Internable, CONSTANTS},
};
use std::{collections::HashMap, sync::Arc};
use strsim::normalized_levenshtein;
use tuliprox_core::{
model::{ConfigTarget, TraktCategoryConfig, TraktConfig, TraktListItem, TraktMatchItem, TraktMatchResult},
utils::{extract_year_from_title, normalize_title_for_matching, trace_if_enabled, with, TraktClient},
};
fn extract_quality(value: &str) -> Option<&str> {
if let Some(caps) = CONSTANTS.re_quality.captures(value) {
if let Some(val) = caps.get(0) {
return Some(val.as_str());
}
}
None
}
/// Utility functions for content type compatibility
fn should_include_item(item: &TraktListItem, content_type: TraktContentType) -> bool {
match content_type {
TraktContentType::Vod => item.content_type == TraktContentType::Vod,
TraktContentType::Series => item.content_type == TraktContentType::Series,
TraktContentType::Both => true,
}
}
fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContentType) -> bool {
match content_type {
TraktContentType::Vod => cluster == XtreamCluster::Video,
TraktContentType::Series => cluster == XtreamCluster::Series,
TraktContentType::Both => matches!(cluster, XtreamCluster::Video | XtreamCluster::Series),
}
}
fn is_matchable_playlist_item(item_type: PlaylistItemType, content_type: TraktContentType) -> bool {
match content_type {
TraktContentType::Vod => item_type.is_video(),
TraktContentType::Series => {
matches!(item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo)
}
TraktContentType::Both => matches!(
item_type,
PlaylistItemType::Video
| PlaylistItemType::LocalVideo
| PlaylistItemType::SeriesInfo
| PlaylistItemType::LocalSeriesInfo
),
}
}
fn calculate_year_bonus(playlist_year: Option<u32>, trakt_year: Option<u32>) -> f64 {
if let (Some(p_year), Some(t_year)) = (playlist_year, trakt_year) {
if p_year == t_year {
// Perfect year match gets substantial bonus
return 0.5;
}
return -0.5;
}
0.0
}
fn find_best_fuzzy_match_for_item<'a>(
channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>),
trakt_items: &'a [TraktMatchItem],
category_config: &'a TraktCategoryConfig,
) -> Option<TraktMatchResult<'a>> {
// Try fuzzy matching if no exact match found
let normalized_playlist_title = channel.1;
let playlist_year = channel.2;
let threshold = f64::from(category_config.fuzzy_match_threshold) / 100.0;
let mut best_match: Option<(&TraktMatchItem, f64)> = None;
for trakt_item in trakt_items {
let title_score = normalized_levenshtein(&normalized_playlist_title, &trakt_item.normalized_title);
if title_score >= threshold {
// Calculate year bonus
let year_bonus = calculate_year_bonus(playlist_year, trakt_item.year);
let mut combined_score = title_score + year_bonus;
// Clamp score to [0.0, 1.0]
combined_score = combined_score.clamp(0.0, 1.0);
// Check if this is the best match so far and meets threshold
if combined_score >= threshold {
if let Some((_, current_best_score)) = &best_match {
if combined_score > *current_best_score {
best_match = Some((trakt_item, combined_score));
}
} else {
best_match = Some((trakt_item, combined_score));
}
// early exit strategy
if combined_score >= 0.99 {
break;
}
}
}
}
if let Some((trakt_item, combined_score)) = best_match {
// let match_type = if playlist_year.is_some() && trakt_item.year.is_some() {
// MatchType::FuzzyTitleYear
// } else {
// MatchType::FuzzyTitle
// };
trace_if_enabled!(
"Fuzzy match: '{}' -> '{}' (final: {combined_score:.3}", /*, type: {match_type:?})"*/
channel.0.header.title,
trakt_item.title
);
return Some(TraktMatchResult {
playlist_item: channel.0,
trakt_item,
match_score: combined_score,
// match_type: match_type.clone(),
});
}
None
}
fn find_best_match_for_item<'a>(
channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>),
trakt_items: &'a [TraktMatchItem<'a>],
category_config: &'a TraktCategoryConfig,
) -> Option<TraktMatchResult<'a>> {
// Try TMDB exact matching first
if let Some(playlist_tmdb_id) = channel.3 {
for trakt_item in trakt_items {
if Some(playlist_tmdb_id) == trakt_item.tmdb_id {
trace!("TMDB exact match: '{}' (TMDB: {})", channel.0.header.title, playlist_tmdb_id);
return Some(TraktMatchResult {
playlist_item: channel.0,
trakt_item,
match_score: 1.0,
// match_type: MatchType::TmdbExact,
});
}
}
}
if category_config.tmdb_only {
return None;
}
find_best_fuzzy_match_for_item(channel, trakt_items, category_config)
}
fn create_category_from_matches<'a>(
matches: Vec<TraktMatchResult<'a>>,
category_config: &'a TraktCategoryConfig,
series_children_by_parent_code: &HashMap<Arc<str>, Vec<&'a PlaylistItem>>,
) -> Vec<PlaylistGroup> {
if matches.is_empty() {
return vec![];
}
let mut matched_items_by_cluster: IndexMap<XtreamCluster, Vec<PlaylistItem>> = IndexMap::new();
let mut sorted_matches = matches;
sorted_matches.sort_by(|a, b| {
(a.trakt_item.rank.unwrap_or(9999), a.trakt_item.title.to_lowercase())
.cmp(&(b.trakt_item.rank.unwrap_or(9999), b.trakt_item.title.to_lowercase()))
});
let group_title = category_config.category_name.as_str().intern();
for match_result in sorted_matches {
let modified_item = clone_item_for_trakt_category(
match_result.playlist_item,
category_config.category_name.as_str(),
&group_title,
);
let parent_uuid = modified_item.header.uuid.intern();
let is_series_info =
matches!(modified_item.header.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo);
let child_lookup_keys =
if is_series_info { series_info_child_lookup_keys(match_result.playlist_item) } else { Vec::new() };
let cluster = modified_item.header.xtream_cluster;
matched_items_by_cluster.entry(cluster).or_default().push(modified_item);
if let Some(children) = child_lookup_keys.iter().find_map(|key| series_children_by_parent_code.get(key)) {
for child in children {
let mut child =
clone_item_for_trakt_category(child, category_config.category_name.as_str(), &group_title);
child.header.parent_code = parent_uuid.clone();
matched_items_by_cluster.entry(child.header.xtream_cluster).or_default().push(child);
}
}
}
matched_items_by_cluster
.into_iter()
.map(|(cluster, channels)| PlaylistGroup {
id: 0,
title: group_title.clone(),
channels,
xtream_cluster: cluster,
})
.collect()
}
fn clone_item_for_trakt_category(item: &PlaylistItem, category_name: &str, group_title: &Arc<str>) -> PlaylistItem {
let mut modified_item = item.clone();
let source_uuid = if modified_item.header.uuid == UUIDType::default() {
modified_item.get_uuid()
} else {
modified_item.header.uuid
};
with!(mut modified_item.header => header {
let title = header.get_field("caption").unwrap_or_else(|| Arc::clone(&header.title));
if extract_quality(&title).is_none() {
if let Some(quality) = extract_quality(&header.group) {
let mut caption = String::with_capacity(title.len() + 6);
caption.push('[');
caption.push_str(quality);
caption.push_str("] ");
caption.push_str(&title);
header.set_field("caption", &caption);
}
}
header.group = group_title.clone();
header.uuid = hash_string(&format!("trakt-category:{category_name}:{source_uuid}"));
});
modified_item
}
fn series_info_child_lookup_keys(series_info: &PlaylistItem) -> Vec<Arc<str>> {
match series_info.header.item_type {
PlaylistItemType::LocalSeriesInfo => vec![series_info.header.id.clone(), series_info.header.uuid.intern()],
PlaylistItemType::SeriesInfo => vec![series_info.get_uuid().intern(), series_info.header.uuid.intern()],
_ => Vec::new(),
}
}
fn series_children_by_parent_code(playlist: &[PlaylistGroup]) -> HashMap<Arc<str>, Vec<&PlaylistItem>> {
let mut children = HashMap::<Arc<str>, Vec<&PlaylistItem>>::new();
for playlist_group in playlist {
for channel in &playlist_group.channels {
if channel.header.item_type.is_series() && !channel.header.parent_code.is_empty() {
children.entry(channel.header.parent_code.clone()).or_default().push(channel);
}
}
}
children
}
fn match_trakt_items_with_playlist<'a>(
trakt_items: &'a [TraktListItem],
playlist: &'a [PlaylistGroup],
category_config: &'a TraktCategoryConfig,
) -> Vec<PlaylistGroup> {
let trakt_match_items: Vec<TraktMatchItem<'a>> = trakt_items
.iter()
.filter(|item| should_include_item(item, category_config.content_type))
.filter_map(TraktMatchItem::from_trakt_list_item)
.collect();
debug!(
"Matching {} Trakt items against playlist for content type {:?}",
trakt_match_items.len(),
category_config.content_type
);
let mut matches = Vec::new();
for playlist_group in playlist {
for channel in &playlist_group.channels {
if is_compatible_content_type(channel.header.xtream_cluster, category_config.content_type)
&& is_matchable_playlist_item(channel.header.item_type, category_config.content_type)
{
let normalized_title = normalize_title_for_matching(&channel.header.title);
let channel_year = extract_year_from_title(&channel.header.title);
let channel_tmdb_id = channel.get_tmdb_id();
if let Some(matched) = find_best_match_for_item(
(channel, normalized_title, channel_year, channel_tmdb_id),
&trakt_match_items,
category_config,
) {
matches.push(matched);
}
}
}
}
let series_children_by_parent_code = series_children_by_parent_code(playlist);
create_category_from_matches(matches, category_config, &series_children_by_parent_code)
}
pub struct TraktCategoriesProcessor {
client: TraktClient,
}
impl TraktCategoriesProcessor {
pub fn new(http_client: &reqwest::Client, trakt_config: &TraktConfig) -> Self {
let client = TraktClient::new(http_client.clone(), trakt_config.api.clone());
Self { client }
}
pub async fn process_trakt_categories(
&self,
playlist: &[PlaylistGroup],
target: &ConfigTarget,
trakt_config: &TraktConfig,
) -> Result<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
if trakt_config.lists.is_empty() && trakt_config.charts.is_empty() {
debug!("No Trakt lists or charts configured for target {}", target.name);
return Ok(None);
}
info!(
"Processing {} Trakt lists and {} Trakt charts for target {}",
trakt_config.lists.len(),
trakt_config.charts.len(),
target.name
);
let mut new_categories = Vec::new();
let mut total_matches = 0;
for list_config in &trakt_config.lists {
let cache_key = format!("{}:{}", list_config.user, list_config.list_slug);
let category_config = TraktCategoryConfig::from(list_config);
match self.client.get_list_items(list_config).await {
Ok(trakt_items) => {
debug!("Processing Trakt list {cache_key} with {} items", trakt_items.len());
let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config);
for category in categories {
if !category.channels.is_empty() {
total_matches += category.channels.len();
let category_len = category.channels.len();
new_categories.push(category);
debug!(
"Created Trakt category '{}' with {category_len} items",
category_config.category_name
);
}
}
}
Err(err) => {
warn!("Failed to fetch Trakt list {cache_key}: {}", err.message());
}
}
}
for chart_config in &trakt_config.charts {
let cache_key = format!("{}:{}", chart_config.kind, chart_config.chart);
let category_config = TraktCategoryConfig::from(chart_config);
match self.client.get_chart_items(chart_config).await {
Ok(trakt_items) => {
debug!("Processing Trakt chart {cache_key} with {} items", trakt_items.len());
let categories = match_trakt_items_with_playlist(&trakt_items, playlist, &category_config);
for category in categories {
if !category.channels.is_empty() {
total_matches += category.channels.len();
let category_len = category.channels.len();
new_categories.push(category);
debug!(
"Created Trakt category '{}' with {category_len} items",
category_config.category_name
);
}
}
}
Err(err) => {
warn!("Failed to fetch Trakt chart {cache_key}: {}", err.message());
}
}
}
info!(
"Trakt processing complete: created {} categories with {total_matches} total matches",
new_categories.len()
);
Ok(Some(new_categories))
}
}
pub async fn process_trakt_categories_for_target(
http_client: &reqwest::Client,
playlist: &[PlaylistGroup],
target: &ConfigTarget,
) -> Result<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
let Some(trakt_config) = target.get_xtream_output().and_then(|output| output.trakt.as_ref()) else {
trace!("No Trakt configuration found for target {}", target.name);
return Ok(None);
};
if !trakt_config.enabled {
return Ok(None);
}
let processor = TraktCategoriesProcessor::new(http_client, trakt_config);
processor.process_trakt_categories(playlist, target, trakt_config).await
}
#[cfg(test)]
mod tests {
use super::*;
use shared::model::{
EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, TraktContentType,
VideoStreamProperties,
};
#[test]
pub fn test_quality() {
let quality = extract_quality("Hello HD UHD 720p");
assert!(quality.is_some());
assert_eq!("UHD", quality.unwrap());
}
#[test]
fn tmdb_only_list_skips_title_fallback_matches() {
let playlist_item = video_item("The Captive", None);
let trakt_items = vec![trakt_movie("The Captive", Some(1915), Some(123), 1)];
let list_config = list_config(true);
let matched = find_best_match_for_item(
(&playlist_item, normalize_title_for_matching("The Captive"), None, playlist_item.get_tmdb_id()),
&trakt_items,
&list_config,
);
assert!(matched.is_none());
}
#[test]
fn tmdb_only_list_keeps_tmdb_exact_matches() {
let playlist_item = video_item("Cautivos", Some(456));
let trakt_items = vec![trakt_movie("The Captive", Some(2014), Some(456), 1)];
let list_config = list_config(true);
let matched = find_best_match_for_item(
(&playlist_item, normalize_title_for_matching("Cautivos"), None, playlist_item.get_tmdb_id()),
&trakt_items,
&list_config,
);
assert!(matched.is_some());
assert_eq!(matched.expect("tmdb match").trakt_item.tmdb_id, Some(456));
}
#[test]
fn same_playlist_item_can_appear_in_multiple_trakt_categories() {
let playlist = vec![PlaylistGroup {
id: 1,
title: "Original".intern(),
channels: vec![video_item("The Smashing Machine", Some(760_329))],
xtream_cluster: XtreamCluster::Video,
}];
let trakt_items = vec![trakt_list_movie("The Smashing Machine", Some(2025), Some(760_329), 1)];
let a24_config = named_list_config("▸ A24", true);
let renoir_config = named_list_config("▸ Cines Renoir", true);
let a24 = match_trakt_items_with_playlist(&trakt_items, &playlist, &a24_config);
let renoir = match_trakt_items_with_playlist(&trakt_items, &playlist, &renoir_config);
assert_eq!(a24.len(), 1);
assert_eq!(renoir.len(), 1);
let a24_item = &a24[0].channels[0];
let renoir_item = &renoir[0].channels[0];
assert_eq!(a24_item.header.group.as_ref(), "▸ A24");
assert_eq!(renoir_item.header.group.as_ref(), "▸ Cines Renoir");
assert_ne!(a24_item.header.uuid, renoir_item.header.uuid);
}
#[test]
fn trakt_series_categories_clone_episode_children() {
let mut series = series_item("Slow Horses", Some(12345));
let source_parent_code = series.get_uuid().intern();
series.header.uuid = series.get_uuid();
let episode = episode_item("Old Scores", &source_parent_code, 7001);
let playlist = vec![PlaylistGroup {
id: 1,
title: "Media Server Series".intern(),
channels: vec![series, episode],
xtream_cluster: XtreamCluster::Series,
}];
let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)];
let config = named_series_config("Trending", true);
let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config);
assert_eq!(categories.len(), 1);
assert_eq!(categories[0].channels.len(), 2);
let cloned_series = categories[0]
.channels
.iter()
.find(|item| item.header.item_type == PlaylistItemType::SeriesInfo)
.expect("series info clone");
let cloned_episode = categories[0]
.channels
.iter()
.find(|item| item.header.item_type == PlaylistItemType::Series)
.expect("episode clone");
assert_eq!(cloned_series.header.group.as_ref(), "Trending");
assert_eq!(cloned_episode.header.group.as_ref(), "Trending");
assert_eq!(cloned_episode.header.parent_code, cloned_series.header.uuid.intern());
}
#[test]
fn trakt_series_matching_ignores_episode_rows() {
let episode = episode_item("Slow Horses", &"series-parent".intern(), 7001);
let playlist = vec![PlaylistGroup {
id: 1,
title: "Media Server Series".intern(),
channels: vec![episode],
xtream_cluster: XtreamCluster::Series,
}];
let trakt_items = vec![trakt_list_show("Slow Horses", Some(2022), Some(12345), 1)];
let config = TraktCategoryConfig {
category_name: "Trending".to_string(),
content_type: TraktContentType::Series,
tmdb_only: false,
fuzzy_match_threshold: 100,
};
let categories = match_trakt_items_with_playlist(&trakt_items, &playlist, &config);
assert!(categories.is_empty());
}
fn list_config(tmdb_only: bool) -> TraktCategoryConfig { named_list_config("category", tmdb_only) }
fn named_list_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig {
TraktCategoryConfig {
category_name: category_name.to_string(),
content_type: TraktContentType::Vod,
tmdb_only,
fuzzy_match_threshold: 100,
}
}
fn named_series_config(category_name: &str, tmdb_only: bool) -> TraktCategoryConfig {
TraktCategoryConfig {
category_name: category_name.to_string(),
content_type: TraktContentType::Series,
tmdb_only,
fuzzy_match_threshold: 100,
}
}
fn video_item(title: &str, tmdb: Option<u32>) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
title: title.intern(),
xtream_cluster: XtreamCluster::Video,
item_type: PlaylistItemType::Video,
additional_properties: Some(StreamProperties::Video(Box::new(VideoStreamProperties {
name: title.intern(),
tmdb,
..VideoStreamProperties::default()
}))),
..PlaylistItemHeader::default()
},
}
}
fn series_item(title: &str, tmdb: Option<u32>) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
id: format!("series-{title}").intern(),
input_name: "input".intern(),
title: title.intern(),
name: title.intern(),
url: format!("media-server://unavailable/server/shows/{title}").intern(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::SeriesInfo,
additional_properties: Some(StreamProperties::Series(Box::new(SeriesStreamProperties {
name: title.intern(),
tmdb,
..SeriesStreamProperties::default()
}))),
..PlaylistItemHeader::default()
},
}
}
fn episode_item(title: &str, parent_code: &Arc<str>, virtual_id: u32) -> PlaylistItem {
PlaylistItem {
header: PlaylistItemHeader {
uuid: hash_string(&format!("episode:{title}:{virtual_id}")),
id: format!("episode-{virtual_id}").intern(),
input_name: "input".intern(),
parent_code: parent_code.clone(),
title: title.intern(),
name: title.intern(),
url: format!("media-server://plex/server/{virtual_id}?part_key=%2Flibrary%2Fparts%2Fredacted").intern(),
virtual_id,
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::Series,
additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties {
episode_id: virtual_id,
episode: 1,
season: 1,
added: None,
release_date: None,
series_release_date: None,
tmdb: None,
movie_image: "".intern(),
container_extension: "mkv".intern(),
video: None,
audio: None,
plot: None,
}))),
..PlaylistItemHeader::default()
},
}
}
fn trakt_movie(
title: &'static str,
year: Option<u32>,
tmdb_id: Option<u32>,
trakt_id: u32,
) -> TraktMatchItem<'static> {
TraktMatchItem {
title,
normalized_title: normalize_title_for_matching(title),
year,
tmdb_id,
trakt_id,
content_type: TraktContentType::Vod,
rank: Some(trakt_id),
}
}
fn trakt_list_movie(title: &str, year: Option<u32>, tmdb_id: Option<u32>, trakt_id: u32) -> TraktListItem {
TraktListItem {
id: u64::from(trakt_id),
rank: Some(trakt_id),
listed_at: String::new(),
notes: None,
item_type: "movie".to_string(),
movie: Some(tuliprox_core::model::TraktMovie {
ids: trakt_ids(title, tmdb_id, trakt_id),
title: title.to_string(),
year,
}),
show: None,
content_type: TraktContentType::Vod,
}
}
fn trakt_list_show(title: &str, year: Option<u32>, tmdb_id: Option<u32>, trakt_id: u32) -> TraktListItem {
TraktListItem {
id: u64::from(trakt_id),
rank: Some(trakt_id),
listed_at: String::new(),
notes: None,
item_type: "show".to_string(),
movie: None,
show: Some(tuliprox_core::model::TraktShow {
ids: trakt_ids(title, tmdb_id, trakt_id),
title: title.to_string(),
year,
}),
content_type: TraktContentType::Series,
}
}
fn trakt_ids(title: &str, tmdb_id: Option<u32>, trakt_id: u32) -> tuliprox_core::model::TraktIds {
tuliprox_core::model::TraktIds {
trakt: trakt_id,
slug: title.to_string(),
tvdb: None,
imdb: None,
tmdb: tmdb_id,
tvrage: None,
}
}
}
+262
View File
@@ -0,0 +1,262 @@
use log::{debug, warn};
use parking_lot::Mutex;
use shared::{
error::TuliproxError,
model::{LiveStreamProperties, StreamProperties, XtreamCluster, XtreamPlaylistItem},
};
use std::sync::Arc;
use tuliprox_core::{
model::{AppConfig, ConfigInput, ConfigInputFlags, ProviderHandle, ProviderIdType},
utils::{
debug_if_enabled,
ffmpeg::{is_supported_probe_url, FfmpegExecutor, ProbeFailureKind, ProbeStreamStats, ProbeUrlOutcome},
},
};
use tuliprox_parser::xtream::create_xtream_url;
use tuliprox_repository::{get_input_storage_path, persist_input_live_info, xtream_get_file_path, BPlusTreeQuery};
use tuliprox_session::ActiveProviderManager;
/// Updates metadata for a single Live stream (primarily probing)
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub async fn update_live_stream_metadata(
app_config: &Arc<AppConfig>,
client: &reqwest::Client,
input: &ConfigInput,
id: ProviderIdType,
save: bool,
db_query: Option<Arc<Mutex<BPlusTreeQuery<u32, XtreamPlaylistItem>>>>,
_active_handle: Option<&ProviderHandle>,
_active_provider: &Arc<ActiveProviderManager>,
) -> Result<Option<LiveStreamProperties>, TuliproxError> {
let storage_dir = &app_config.config.load().storage_dir;
let storage_path = get_input_storage_path(&input.name, storage_dir)
.await
.map_err(|e| TuliproxError::Io(format!("Storage path error: {e}")))?;
// Try to load existing info first to preserve data
let mut props: Option<LiveStreamProperties> = None;
let mut existing_item: Option<XtreamPlaylistItem> = None;
let stream_id_opt = if let ProviderIdType::Id(vid) = id { Some(vid) } else { None };
if let Some(stream_id) = stream_id_opt {
if let Some(query) = db_query {
let query = Arc::clone(&query);
let item = match tokio::task::spawn_blocking(move || {
let mut guard = query.lock();
guard.query_zero_copy(&stream_id).ok().flatten()
})
.await
{
Ok(item) => item,
Err(err) => {
warn!("Failed to query Live metadata from disk for {stream_id}: {err}");
None
}
};
if let Some(item) = item {
existing_item = Some(item.clone());
if let Some(StreamProperties::Live(p)) = item.additional_properties.as_ref() {
props = Some(*p.clone());
}
}
} else {
let xtream_path = xtream_get_file_path(&storage_path, XtreamCluster::Live);
if xtream_path.exists() {
let file_lock = app_config.file_locks.read_lock(&xtream_path).await;
let xtream_path = xtream_path.clone();
let item = match tokio::task::spawn_blocking(move || {
let _guard = file_lock;
let mut query = BPlusTreeQuery::<u32, XtreamPlaylistItem>::try_new(&xtream_path)?;
query.query_zero_copy(&stream_id)
})
.await
{
Ok(Ok(item)) => item,
Ok(Err(err)) => {
warn!("Failed to query Live metadata from disk for {stream_id}: {err}");
None
}
Err(err) => {
warn!("Failed to query Live metadata from disk for {stream_id}: {err}");
None
}
};
if let Some(item) = item {
existing_item = Some(item.clone());
if let Some(StreamProperties::Live(p)) = item.additional_properties.as_ref() {
props = Some(*p.clone());
}
}
}
}
}
// Initialize props if missing
let mut properties = if let Some(p) = props {
p
} else {
LiveStreamProperties {
stream_id: stream_id_opt.unwrap_or(0),
// If item exists but no props, try to recover name
name: existing_item.as_ref().map_or_else(|| "".into(), |i| i.name.clone()),
..LiveStreamProperties::default()
}
};
// 1. Create dummy properties to generate URL for probing
// We construct a temporary property object just for URL generation to ensure we match the stream config
// (prefix/extension) correctly, even if we modify `properties` later.
let temp_stream_prop = StreamProperties::Live(Box::new(properties.clone()));
let input_url = input.url.as_str();
let username = input.username.as_deref().unwrap_or("");
let password = input.password.as_deref().unwrap_or("");
let use_prefix = input.has_flag(ConfigInputFlags::XtreamLiveStreamUsePrefix);
let no_ext = input.has_flag(ConfigInputFlags::XtreamLiveStreamWithoutExtension);
// We generate the URL to probe directly on the provider
let stream_url =
create_xtream_url(XtreamCluster::Live, input_url, username, password, &temp_stream_prop, use_prefix, no_ext);
let probe_url_cow = input.resolve_url(&stream_url)?;
if !is_supported_probe_url(probe_url_cow.as_ref()) {
debug!("Skipping unsupported live probe for input {}: {}", input.name, probe_url_cow.as_ref());
return Ok(None);
}
let config = app_config.config.load();
let metadata_update = config.metadata_update.clone().unwrap_or_default();
let ffprobe_timeout = metadata_update.ffprobe.timeout.unwrap_or(60);
let user_agent = config.default_user_agent.clone();
let analyze_duration = metadata_update.ffprobe.live_analyze_duration_micros;
let probe_size = metadata_update.ffprobe.live_probe_size_bytes;
let display_id = stream_id_opt.map_or_else(|| "StringID".to_string(), |v| v.to_string());
debug!("Probing Live Stream ID {} for input {}", display_id, input.name);
// Update last_probed_timestamp BEFORE probing to ensure we record the attempt even if it crashes/panics (unlikely but safe)
// Actually, update it before persisting.
let now = chrono::Utc::now().timestamp();
properties.last_probed_timestamp = Some(now);
let mut success = false;
let mut not_found = false;
let is_remote_probe =
reqwest::Url::parse(probe_url_cow.as_ref()).is_ok_and(|u| matches!(u.scheme(), "http" | "https"));
let probe_params = tuliprox_core::utils::ffmpeg::ProbeParams {
url: probe_url_cow.as_ref(),
user_agent: user_agent.as_deref(),
analyze_duration,
probe_size,
timeout_secs: ffprobe_timeout,
};
let probe_result = if is_remote_probe {
FfmpegExecutor::new().probe_remote_url(client, &probe_params).await
} else {
FfmpegExecutor::new().probe_url(&probe_params, config.proxy.as_ref()).await
};
match probe_result {
ProbeUrlOutcome::Success(_quality, raw_video, raw_audio, stats) => {
apply_live_probe_success(&mut properties, raw_video, raw_audio, stats, now);
success = true;
debug_if_enabled!("Successfully probed Live Stream ID {}", display_id);
}
ProbeUrlOutcome::Failed(ProbeFailureKind::NotFound) => {
warn!("Live stream probe target returned 404 for ID {} (Input: {})", display_id, input.name);
not_found = true;
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Other) => {
warn!("Probe failed for Live Stream ID {} (Input: {})", display_id, input.name);
// We still persist the updated last_probed_timestamp so we don't retry immediately
}
ProbeUrlOutcome::Failed(ProbeFailureKind::Cancelled) => {
warn!("Probe cancelled for Live Stream ID {} (Input: {})", display_id, input.name);
// We still persist the updated last_probed_timestamp so we don't retry immediately
}
}
// 4. Persist
if save {
if let Some(stream_id) = stream_id_opt {
persist_input_live_info(
app_config,
&storage_path,
XtreamCluster::Live,
&input.name,
stream_id,
&properties,
)
.await
.map_err(|e| shared::error::TuliproxError::Io(format!("Persist error: {e}")))?;
}
}
if !success {
if not_found {
return Err(shared::error::TuliproxError::Probe(format!(
"Probe failed with 404 Not Found for stream {display_id}"
)));
}
// Return error to propagate failure up to task manager/logs
return Err(shared::error::TuliproxError::Probe(format!("Probe failed for stream {display_id}")));
}
Ok(Some(properties))
}
fn apply_live_probe_success(
properties: &mut LiveStreamProperties,
raw_video: Option<serde_json::Value>,
raw_audio: Option<serde_json::Value>,
stats: ProbeStreamStats,
now: i64,
) {
if let Some(video) = raw_video {
properties.video = Some(video.to_string().into());
}
if let Some(audio) = raw_audio {
properties.audio = Some(audio.to_string().into());
}
if let Some(bitrate) = stats.bitrate.filter(|bitrate| *bitrate > 0) {
properties.bitrate = properties.bitrate.max(bitrate);
}
properties.last_success_timestamp = Some(now);
}
#[cfg(test)]
mod tests {
use super::apply_live_probe_success;
use serde_json::json;
use shared::model::LiveStreamProperties;
use tuliprox_core::utils::ffmpeg::ProbeStreamStats;
#[test]
fn apply_live_probe_success_persists_positive_bitrate() {
let mut properties = LiveStreamProperties { bitrate: 1_500_000, ..LiveStreamProperties::default() };
apply_live_probe_success(
&mut properties,
Some(json!({ "codec_name": "h264" })),
Some(json!({ "codec_name": "aac" })),
ProbeStreamStats { duration_secs: None, bitrate: Some(2_500_000) },
123,
);
assert_eq!(properties.bitrate, 2_500_000);
assert_eq!(properties.last_success_timestamp, Some(123));
assert!(properties.video.is_some());
assert!(properties.audio.is_some());
apply_live_probe_success(
&mut properties,
None,
None,
ProbeStreamStats { duration_secs: None, bitrate: Some(2_000_000) },
124,
);
assert_eq!(properties.bitrate, 2_500_000);
assert_eq!(properties.last_success_timestamp, Some(124));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff