mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-08 08:52:05 +02:00
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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").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);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms, cancel_token).await
|
||||
} else if is_remote_probe {
|
||||
FfmpegExecutor::new().probe_remote_url_with_cancel(client, ¶ms, cancel_token).await
|
||||
} else {
|
||||
FfmpegExecutor::new().probe_url_with_cancel(¶ms, 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user