mirror of
https://github.com/euzu/tuliprox.git
synced 2026-10-23 08:12:34 +02:00
feat: improve Trakt list curation matching (#747)
* feat: paginate Trakt list fetching * feat: allow TMDB-only Trakt list matching * chore: address Trakt review comments * feat: add public Trakt charts * fix: keep Trakt category clones distinct * fix: avoid truncating Trakt chart ranks * fix: keep Trakt series category episodes * trakt improvements
This commit is contained in:
@@ -1,13 +1,16 @@
|
||||
use crate::model::{ConfigTarget, TraktListItem, TraktMatchItem};
|
||||
use crate::model::{TraktConfig, TraktListConfig, TraktMatchResult};
|
||||
use crate::model::{TraktCategoryConfig, TraktConfig, TraktMatchResult};
|
||||
use crate::utils::{extract_year_from_title, normalize_title_for_matching, TraktClient};
|
||||
use crate::utils::{trace_if_enabled, with};
|
||||
use log::{debug, info, trace, warn};
|
||||
use shared::error::TuliproxError;
|
||||
use shared::model::{FieldGetAccessor, FieldSetAccessor, PlaylistGroup, PlaylistItem, TraktContentType, XtreamCluster};
|
||||
use shared::utils::{Internable, CONSTANTS};
|
||||
use shared::model::{
|
||||
FieldGetAccessor, FieldSetAccessor, PlaylistEntry, PlaylistGroup, PlaylistItem, PlaylistItemType,
|
||||
TraktContentType, UUIDType, XtreamCluster,
|
||||
};
|
||||
use shared::utils::{hash_string, Internable, CONSTANTS};
|
||||
use indexmap::IndexMap;
|
||||
use std::sync::Arc;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use strsim::normalized_levenshtein;
|
||||
|
||||
fn extract_quality(value: &str) -> Option<&str> {
|
||||
@@ -37,6 +40,20 @@ fn is_compatible_content_type(cluster: XtreamCluster, content_type: TraktContent
|
||||
}
|
||||
}
|
||||
|
||||
fn is_matchable_playlist_item(item_type: PlaylistItemType, content_type: TraktContentType) -> bool {
|
||||
match content_type {
|
||||
TraktContentType::Vod => matches!(item_type, PlaylistItemType::Video | PlaylistItemType::LocalVideo),
|
||||
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 {
|
||||
@@ -48,11 +65,11 @@ fn calculate_year_bonus(playlist_year: Option<u32>, trakt_year: Option<u32>) ->
|
||||
0.0
|
||||
}
|
||||
|
||||
fn find_best_fuzzy_match_for_item<'a>(channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>), trakt_items: &'a [TraktMatchItem], list_config: &'a TraktListConfig) -> Option<TraktMatchResult<'a>> {
|
||||
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(list_config.fuzzy_match_threshold) / 100.0;
|
||||
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 {
|
||||
@@ -106,7 +123,7 @@ fn find_best_fuzzy_match_for_item<'a>(channel: (&'a PlaylistItem, String, Option
|
||||
fn find_best_match_for_item<'a>(
|
||||
channel: (&'a PlaylistItem, String, Option<u32>, Option<u32>),
|
||||
trakt_items: &'a [TraktMatchItem<'a>],
|
||||
list_config: &'a TraktListConfig,
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
) -> Option<TraktMatchResult<'a>> {
|
||||
// Try TMDB exact matching first
|
||||
if let Some(playlist_tmdb_id) = channel.3 {
|
||||
@@ -123,12 +140,17 @@ fn find_best_match_for_item<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
find_best_fuzzy_match_for_item(channel, trakt_items, list_config)
|
||||
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>>,
|
||||
list_config: &'a TraktListConfig,
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
series_children_by_parent_code: &HashMap<Arc<str>, Vec<&'a PlaylistItem>>,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
if matches.is_empty() { return vec![]; }
|
||||
|
||||
@@ -145,26 +167,34 @@ fn create_category_from_matches<'a>(
|
||||
))
|
||||
});
|
||||
|
||||
let group_title = list_config.category_name.as_str().intern();
|
||||
let group_title = category_config.category_name.as_str().intern();
|
||||
|
||||
for match_result in sorted_matches {
|
||||
let mut modified_item = match_result.playlist_item.clone();
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
header.group = group_title.clone();
|
||||
header.gen_uuid();
|
||||
matched_items_by_cluster.entry(header.xtream_cluster).or_default().push(modified_item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
matched_items_by_cluster.into_iter().map(|(cluster, channels)| {
|
||||
@@ -177,34 +207,86 @@ fn create_category_from_matches<'a>(
|
||||
}).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 matches!(channel.header.item_type, PlaylistItemType::Series | PlaylistItemType::LocalSeries)
|
||||
&& !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],
|
||||
list_config: &'a TraktListConfig,
|
||||
category_config: &'a TraktCategoryConfig,
|
||||
) -> Vec<PlaylistGroup> {
|
||||
let trakt_match_items: Vec<TraktMatchItem<'a>> = trakt_items
|
||||
.iter()
|
||||
.filter(|item| should_include_item(item, list_config.content_type))
|
||||
.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(), list_config.content_type);
|
||||
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, list_config.content_type) {
|
||||
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, list_config) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
create_category_from_matches(matches, list_config)
|
||||
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 {
|
||||
@@ -223,29 +305,35 @@ impl TraktCategoriesProcessor {
|
||||
target: &ConfigTarget,
|
||||
trakt_config: &TraktConfig,
|
||||
) -> Result<Option<Vec<PlaylistGroup>>, Vec<TuliproxError>> {
|
||||
if trakt_config.lists.is_empty() {
|
||||
debug!("No Trakt lists configured for target {}", target.name);
|
||||
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 for target {}", trakt_config.lists.len(), target.name);
|
||||
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, list_config);
|
||||
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", list_config.category_name);
|
||||
debug!("Created Trakt category '{}' with {category_len} items", category_config.category_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,9 +343,34 @@ impl TraktCategoriesProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
for chart_config in &trakt_config.charts {
|
||||
let cache_key = format!("{}:{}", chart_config.kind, chart_config.chart);
|
||||
let category_config = TraktCategoryConfig::from(chart_config);
|
||||
|
||||
info!("Trakt processing complete: created {} categories with {total_matches} total matches",
|
||||
new_categories.len());
|
||||
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))
|
||||
}
|
||||
@@ -283,6 +396,10 @@ pub async fn process_trakt_categories_for_target(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::model::{
|
||||
EpisodeStreamProperties, PlaylistItemHeader, SeriesStreamProperties, StreamProperties, TraktContentType,
|
||||
VideoStreamProperties,
|
||||
};
|
||||
|
||||
#[test]
|
||||
pub fn test_quality() {
|
||||
@@ -290,4 +407,262 @@ mod tests {
|
||||
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(crate::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(crate::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) -> crate::model::TraktIds {
|
||||
crate::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