mirror of
https://github.com/euzu/tuliprox.git
synced 2026-09-26 19:12:25 +02:00
refactor: extract source-neutral playlist curation (#860)
* refactor: extract source-neutral playlist curation * fix: keep curation matches within media kind --------- Co-authored-by: Juanjo Presa <juanjop@gmail.com>
This commit is contained in:
co-authored by
Juanjo Presa
parent
019fb7fde2
commit
145b082a4f
@@ -11,7 +11,6 @@ mod providers;
|
||||
mod resolve_options;
|
||||
mod sort;
|
||||
mod stream_probe;
|
||||
mod trakt;
|
||||
mod xtream_series;
|
||||
mod xtream_vod;
|
||||
pub use self::{
|
||||
|
||||
@@ -9,7 +9,6 @@ use crate::{
|
||||
processor::{
|
||||
epg::{clear_invalid_live_epg_ids, process_playlist_epg, retain_epg_referenced_by_groups},
|
||||
sort::sort_playlist,
|
||||
trakt::process_trakt_categories_for_target,
|
||||
xtream_series::playlist_resolve_series,
|
||||
xtream_vod::playlist_resolve_vod,
|
||||
StalkerRefreshMode,
|
||||
@@ -17,7 +16,7 @@ use crate::{
|
||||
};
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use indexmap::IndexMap;
|
||||
use log::{debug, error, info, log_enabled, warn, Level};
|
||||
use log::{debug, error, info, log_enabled, trace, warn, Level};
|
||||
use path_clean::PathClean;
|
||||
use shared::{
|
||||
concat_string,
|
||||
@@ -52,6 +51,7 @@ use tuliprox_core::{
|
||||
},
|
||||
utils::{debug_if_enabled, log_memory_snapshot, trace_if_enabled, StepMeasure, StepMeasureCallback},
|
||||
};
|
||||
use tuliprox_curation::curate_trakt_categories;
|
||||
use tuliprox_iptv::{
|
||||
epg::{CountingEpgSink, EpgFetchRequest, EpgProvider},
|
||||
error::ProviderErrorKind,
|
||||
|
||||
@@ -387,7 +387,7 @@ pub(crate) async fn finalize_prepared_target<E: EventSink + Clone + 'static, M:
|
||||
(Ok(()), errors)
|
||||
} else {
|
||||
// Process Trakt categories
|
||||
if trakt_playlist(&ctx.client, target, &mut errors, &mut new_playlist).await {
|
||||
if trakt_playlist(&ctx.client, target, &mut new_playlist).await {
|
||||
step.tick("trakt categories");
|
||||
log_memory_snapshot(format!("target '{}' after_trakt", target.name).as_str());
|
||||
}
|
||||
@@ -767,23 +767,18 @@ pub fn process_favourites(playlist: &mut Vec<PlaylistGroup>, favourites_cfg: Opt
|
||||
pub(crate) async fn trakt_playlist(
|
||||
client: &reqwest::Client,
|
||||
target: &ConfigTarget,
|
||||
errors: &mut Vec<TuliproxError>,
|
||||
playlist: &mut Vec<PlaylistGroup>,
|
||||
) -> bool {
|
||||
match process_trakt_categories_for_target(client, playlist, target).await {
|
||||
Ok(Some(trakt_categories)) => {
|
||||
if !trakt_categories.is_empty() {
|
||||
info!("Adding {} Trakt categories to playlist", trakt_categories.len());
|
||||
playlist.extend(trakt_categories);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return false;
|
||||
}
|
||||
Err(trakt_errors) => {
|
||||
warn!("Trakt processing failed with {} errors", trakt_errors.len());
|
||||
errors.extend(trakt_errors);
|
||||
}
|
||||
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 false;
|
||||
};
|
||||
let Some(trakt_categories) = curate_trakt_categories(client, playlist, &target.name, trakt_config).await else {
|
||||
return false;
|
||||
};
|
||||
if !trakt_categories.is_empty() {
|
||||
info!("Adding {} Trakt categories to playlist", trakt_categories.len());
|
||||
playlist.extend(trakt_categories);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1483,6 +1483,17 @@ match {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trakt_finalization_stage_is_a_noop_without_xtream_configuration() {
|
||||
let target = ConfigTarget::from(&ConfigTargetDto::default());
|
||||
let mut playlist = Vec::new();
|
||||
|
||||
let applied = trakt_playlist(&reqwest::Client::new(), &target, &mut playlist).await;
|
||||
|
||||
assert!(!applied);
|
||||
assert!(playlist.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod disk_epg_wireup_tests {
|
||||
use super::spill_epg_to_disk;
|
||||
|
||||
@@ -1,895 +0,0 @@
|
||||
use indexmap::IndexMap;
|
||||
use log::{debug, info, trace, warn};
|
||||
use shared::{
|
||||
error::TuliproxError,
|
||||
model::{
|
||||
FieldGet, FieldSet, HeaderField, 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(HeaderField::Caption)
|
||||
.map_or_else(|| Arc::clone(&header.title), |value| value.to_arc());
|
||||
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(HeaderField::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) -> Result<Self, TuliproxError> {
|
||||
let client = TraktClient::new(http_client.clone(), trakt_config.api.clone())?;
|
||||
Ok(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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
let processor = match TraktCategoriesProcessor::new(http_client, trakt_config) {
|
||||
Ok(processor) => processor,
|
||||
Err(error) => {
|
||||
warn!("Skipping Trakt curation for target '{}': {}", target.name, error.message());
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
processor.process_trakt_categories(playlist, target, trakt_config).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::model::{
|
||||
ConfigTargetDto, EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties,
|
||||
TargetOutputDto, TraktApiConfigDto, TraktChartConfigDto, TraktChartKind, TraktChartType, TraktConfigDto,
|
||||
TraktContentType, TraktListConfigDto, VideoStreamProperties, XtreamTargetOutputDto,
|
||||
};
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Mutex,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::{TcpListener, TcpStream},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
#[test]
|
||||
pub fn test_quality() {
|
||||
let quality = extract_quality("Hello HD UHD 720p");
|
||||
assert!(quality.is_some());
|
||||
assert_eq!("UHD", quality.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_trakt_curation_without_client_id_makes_no_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target("", base_url, true, vec![remote_list_config("Missing")], Vec::new());
|
||||
|
||||
let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target)
|
||||
.await
|
||||
.expect("missing Client ID should not fail target processing");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_trakt_config_with_sources_makes_no_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target("", base_url, false, vec![remote_list_config("Disabled")], Vec::new());
|
||||
|
||||
let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target)
|
||||
.await
|
||||
.expect("disabled Trakt config should be a no-op");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trakt_config_without_lists_or_charts_makes_no_request() {
|
||||
let requests = Arc::new(AtomicUsize::new(0));
|
||||
let (base_url, server) = spawn_counting_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target("", base_url, true, Vec::new(), Vec::new());
|
||||
|
||||
let result = process_trakt_categories_for_target(&reqwest::Client::new(), &[], &target)
|
||||
.await
|
||||
.expect("empty Trakt config should be a no-op");
|
||||
|
||||
assert!(result.is_none());
|
||||
assert_eq!(requests.load(Ordering::SeqCst), 0);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_list_does_not_suppress_successful_chart() {
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let (base_url, server) = spawn_partial_success_trakt_server(Arc::clone(&requests)).await;
|
||||
let target = trakt_target(
|
||||
"test-client-id",
|
||||
base_url,
|
||||
true,
|
||||
vec![remote_list_config("Unavailable List")],
|
||||
vec![remote_chart_config("Available Chart")],
|
||||
);
|
||||
let playlist = vec![PlaylistGroup {
|
||||
id: 1,
|
||||
title: "Original".intern(),
|
||||
channels: vec![video_item("Movie 1", Some(11))],
|
||||
xtream_cluster: XtreamCluster::Video,
|
||||
}];
|
||||
|
||||
let categories = process_trakt_categories_for_target(&reqwest::Client::new(), &playlist, &target)
|
||||
.await
|
||||
.expect("a failed Trakt source should not fail target processing")
|
||||
.expect("configured Trakt sources should produce a result");
|
||||
|
||||
assert_eq!(categories.len(), 1);
|
||||
assert_eq!(categories[0].title.as_ref(), "Available Chart");
|
||||
assert_eq!(categories[0].channels.len(), 1);
|
||||
assert_eq!(categories[0].channels[0].header.title.as_ref(), "Movie 1");
|
||||
assert_eq!(requests.lock().expect("requests").len(), 2);
|
||||
server.await.expect("test server should finish");
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
async fn spawn_counting_trakt_server(requests: Arc<AtomicUsize>) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else { return };
|
||||
let _ = read_request(&mut stream).await;
|
||||
requests.fetch_add(1, Ordering::SeqCst);
|
||||
write_response(&mut stream, "200 OK", "[]").await;
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}"), server)
|
||||
}
|
||||
|
||||
async fn spawn_partial_success_trakt_server(requests: Arc<Mutex<Vec<String>>>) -> (String, JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("local addr");
|
||||
let server = tokio::spawn(async move {
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().await.expect("accept test request");
|
||||
let request = read_request(&mut stream).await;
|
||||
let is_list_request = request.contains("/users/test-user/lists/test-list/items");
|
||||
requests.lock().expect("requests").push(request);
|
||||
if is_list_request {
|
||||
write_response(&mut stream, "403 Forbidden", "response body must not affect the next source").await;
|
||||
} else {
|
||||
write_response(
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
r#"[{"title":"Movie 1","year":2026,"ids":{"trakt":1,"slug":"movie-1","tvdb":null,"imdb":null,"tmdb":11,"tvrage":null}}]"#,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}"), server)
|
||||
}
|
||||
|
||||
async fn read_request(stream: &mut TcpStream) -> String {
|
||||
let mut request_bytes = Vec::new();
|
||||
loop {
|
||||
let mut buffer = [0; 1024];
|
||||
let read = stream.read(&mut buffer).await.expect("read request");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request_bytes.extend_from_slice(&buffer[..read]);
|
||||
if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&request_bytes).to_string()
|
||||
}
|
||||
|
||||
async fn write_response(stream: &mut TcpStream, status: &str, body: &str) {
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.expect("write response");
|
||||
}
|
||||
|
||||
fn trakt_target(
|
||||
client_id: &str,
|
||||
url: String,
|
||||
enabled: bool,
|
||||
lists: Vec<TraktListConfigDto>,
|
||||
charts: Vec<TraktChartConfigDto>,
|
||||
) -> ConfigTarget {
|
||||
ConfigTarget::from(&ConfigTargetDto {
|
||||
name: "test-target".to_string(),
|
||||
output: vec![TargetOutputDto::Xtream(XtreamTargetOutputDto {
|
||||
trakt: Some(TraktConfigDto {
|
||||
enabled,
|
||||
api: TraktApiConfigDto {
|
||||
api_key: client_id.to_string(),
|
||||
version: "2".to_string(),
|
||||
url,
|
||||
user_agent: "tuliprox-test".to_string(),
|
||||
},
|
||||
lists,
|
||||
charts,
|
||||
}),
|
||||
..XtreamTargetOutputDto::default()
|
||||
})],
|
||||
..ConfigTargetDto::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn remote_list_config(category_name: &str) -> TraktListConfigDto {
|
||||
TraktListConfigDto {
|
||||
user: "test-user".to_string(),
|
||||
list_slug: "test-list".to_string(),
|
||||
category_name: category_name.to_string(),
|
||||
content_type: TraktContentType::Vod,
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_chart_config(category_name: &str) -> TraktChartConfigDto {
|
||||
TraktChartConfigDto {
|
||||
kind: TraktChartKind::Movies,
|
||||
chart: TraktChartType::Popular,
|
||||
category_name: category_name.to_string(),
|
||||
tmdb_only: true,
|
||||
fuzzy_match_threshold: 100,
|
||||
}
|
||||
}
|
||||
|
||||
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: shared::model::VirtualId::new(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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user