diff --git a/backend/src/api/api_utils.rs b/backend/src/api/api_utils.rs index cdf0d37fc..079dfd8a7 100644 --- a/backend/src/api/api_utils.rs +++ b/backend/src/api/api_utils.rs @@ -705,7 +705,7 @@ fn prepare_body_stream( stream: S, ) -> axum::body::Body where - S: futures::Stream> + Send + 'static, + S: futures::Stream> + Send + 'static, { let throttle_kbps = usize::try_from(get_stream_throttle(app_state)).unwrap_or_default(); let body_stream = if is_throttled_stream(item_type, throttle_kbps) { @@ -1032,6 +1032,19 @@ pub async fn local_stream_response( trace!("Try to open stream {}", sanitize_sensitive_info(&pli.url)); } + let Some(library_paths) = app_state.app_config.config.load() + .library.as_ref() + .map(|lib| { + lib.scan_directories + .iter() + .map(|dir| dir.path.clone()) + .collect::>() + }) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + + if connection_permission == UserConnectionPermission::Exhausted { return create_custom_video_stream_response( app_state, @@ -1047,13 +1060,13 @@ pub async fn local_stream_response( Ok(canonical) => canonical, Err(err) => { error!("Local library file path is corrupt {}: {err}", path.display()); - return StatusCode::NOT_FOUND.into_response() - }, + return StatusCode::NOT_FOUND.into_response(); + } }; // Verify path is within allowed media directories // (requires configuration of allowed base paths) - if !is_path_within_allowed_directories(&path, &app_state.app_config.paths.load().library_file_path) { + if !is_path_within_allowed_directories(&path, &library_paths) { return StatusCode::FORBIDDEN.into_response(); } @@ -1131,17 +1144,13 @@ pub async fn local_stream_response( response } -fn is_path_within_allowed_directories(sub_path: &PathBuf, root_path: &str) -> bool { - let sub_path = match sub_path.canonicalize() { - Ok(p) => p, - Err(_) => return false, - }; - let root_path = match PathBuf::from(root_path).canonicalize() { - Ok(p) => p, - Err(_) => return false, - }; - - sub_path.starts_with(&root_path) +fn is_path_within_allowed_directories(sub_path: &Path, root_paths: &[String]) -> bool { + for root_path in root_paths { + if sub_path.starts_with(&PathBuf::from(root_path)) { + return true; + } + } + false } pub fn is_stream_share_enabled(item_type: PlaylistItemType, target: &ConfigTarget) -> bool { diff --git a/backend/src/api/endpoints/api_playlist_utils.rs b/backend/src/api/endpoints/api_playlist_utils.rs index 188e89fda..59a5516c7 100644 --- a/backend/src/api/endpoints/api_playlist_utils.rs +++ b/backend/src/api/endpoints/api_playlist_utils.rs @@ -110,7 +110,7 @@ async fn grouped_channels( xtream_repository::iter_raw_xtream_playlist(cfg, target, cluster).await .map(|(_guard, iter)| group_playlist_items::( cluster, - iter.map(|(v, _)| v.to_common()), + iter.filter(|(item, _)| item.item_type != PlaylistItemType::LocalSeries).map(|(v, _)| v.to_common()), |item| item.group.clone(), )) } diff --git a/backend/src/library/classifier.rs b/backend/src/library/classifier.rs index ed74f36c5..ed73c5e47 100644 --- a/backend/src/library/classifier.rs +++ b/backend/src/library/classifier.rs @@ -275,7 +275,7 @@ mod tests { #[test] fn test_extract_show_name() { let file = create_test_file("Breaking.Bad.S01E01.mkv", "/tv/Breaking.Bad"); - let show_name = MediaClassifier::extract_show_name(&file); + let (_, show_name) = MediaClassifier::extract_show_name(&file); assert_eq!(show_name, "Breaking Bad"); } diff --git a/backend/src/library/metadata.rs b/backend/src/library/metadata.rs index 9bf12c324..4198c6f8e 100644 --- a/backend/src/library/metadata.rs +++ b/backend/src/library/metadata.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::library::ScannedMediaFile; +use crate::library::tmdb::{TmdbCredits, TmdbNetwork, TmdbSeriesInfoEpisodeDetails}; // Source of metadata information #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -101,6 +102,10 @@ pub struct SeriesMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub genres: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub directors: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub writers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub actors: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub studios: Option>, @@ -112,8 +117,12 @@ pub struct SeriesMetadata { #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub seasons: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub episodes: Option>, pub source: MetadataSource, + pub number_of_episodes: u32, + pub number_of_seasons: u32, // Last updated timestamp (Unix epoch) pub last_updated: i64, pub videos: Option> @@ -122,6 +131,8 @@ pub struct SeriesMetadata { // Episode metadata for TV series #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EpisodeMetadata { + pub id: u32, + pub tmdb_id: u32, pub title: String, pub season: u32, pub episode: u32, @@ -142,6 +153,26 @@ pub struct EpisodeMetadata { pub file_modified: i64, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SeasonMetadata { + pub id: u32, + pub air_date: Option, + #[serde(default)] + pub episode_count: u32, + #[serde(default)] + pub name: String, + pub overview: Option, + pub poster_path: Option, + #[serde(default)] + pub(crate) season_number: u32, + #[serde(default)] + pub vote_average: f64, + + pub episodes: Option>, + pub networks: Option>, + pub credits: Option, +} + // Actor information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Actor { diff --git a/backend/src/library/metadata_storage.rs b/backend/src/library/metadata_storage.rs index 77b9873ac..cb074c1fd 100644 --- a/backend/src/library/metadata_storage.rs +++ b/backend/src/library/metadata_storage.rs @@ -155,10 +155,6 @@ impl MetadataStorage { self.storage_dir.join(format!("series_{tmdb_id}.tmdb")) } - fn get_tmdb_series_seson_data_file_path(&self, tmdb_id: u32, season: u32) -> PathBuf { - self.storage_dir.join(format!("series_{tmdb_id}_{season}.tmdb")) - } - // write raw tmdb movie info pub async fn store_tmdb_movie_info(&self, movie_id: u32, content: &[u8]) -> std::io::Result { let file_path = self.get_tmdb_movie_data_file_path(movie_id); @@ -172,13 +168,6 @@ impl MetadataStorage { debug!("Storing raw tmdb series metadata for {}", file_path.display()); self.store_file(content, file_path).await } - - // write raw tmdb series season info - pub async fn store_tmdb_series_info_season(&self, series_id: u32, season: u32, content: &[u8]) -> std::io::Result { - let file_path = self.get_tmdb_series_seson_data_file_path(series_id, season); - debug!("Storing raw tmdb series season metadata for {}", file_path.display()); - self.store_file(content, file_path).await - } async fn store_file(&self, content: &[u8], file_path: PathBuf) -> std::io::Result { let mut file = fs::File::create(&file_path).await?; diff --git a/backend/src/library/tmdb.rs b/backend/src/library/tmdb.rs index 6204873aa..31a327fa8 100644 --- a/backend/src/library/tmdb.rs +++ b/backend/src/library/tmdb.rs @@ -1,6 +1,6 @@ use crate::library::metadata::{Actor, MetadataSource, MovieMetadata, SeriesMetadata}; -use crate::library::{EpisodeMetadata, VideoClipMetadata}; -use serde::Deserialize; +use crate::library::{EpisodeMetadata, SeasonMetadata, VideoClipMetadata}; +use serde::{Deserialize, Serialize}; const TMDB_IMAGE_BASE_URL: &str = "https://image.tmdb.org/t/p/w500"; @@ -172,10 +172,10 @@ impl TmdbMovieDetails { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbSeriesInfoEpisodeDetails { - // pub id: u32, - // pub show_id: u32, + pub id: u32, + pub show_id: u32, pub air_date: Option, pub episode_number: u32, @@ -200,6 +200,8 @@ pub struct TmdbSeriesInfoEpisodeDetails { impl TmdbSeriesInfoEpisodeDetails { pub fn to_meta_data(&self) -> EpisodeMetadata { EpisodeMetadata { + id: self.id, + tmdb_id: self.show_id, title: self.name.clone(), season: self.season_number, episode: self.episode_number, @@ -216,11 +218,11 @@ impl TmdbSeriesInfoEpisodeDetails { } #[derive(Debug, Deserialize)] -pub struct TmdbSeriesInfoSeasonDetails { +pub struct TmdbSeriesInfoSeasonDetails { // #[serde(rename = "_id")] // pub internal_id: String, // pub id: u32, - + // // pub air_date: Option, // pub name: String, // pub overview: String, @@ -233,7 +235,6 @@ pub struct TmdbSeriesInfoSeasonDetails { pub credits: Option, } - #[derive(Debug, Deserialize)] pub struct TmdbSeriesInfoDetails { id: u32, @@ -244,8 +245,8 @@ pub struct TmdbSeriesInfoDetails { #[serde(default)] first_air_date: String, // last_air_date: Option, - // #[serde(default)] - // number_of_episodes: u32, + #[serde(default)] + pub number_of_episodes: u32, #[serde(default)] pub(crate) number_of_seasons: u32, #[serde(default)] @@ -294,6 +295,8 @@ impl TmdbSeriesInfoDetails { Some(result) } }), + directors: crew_names(self.credits.as_ref(), &["Director"]), + writers: crew_names(self.credits.as_ref(), &["Writer", "Screenplay"]), actors: self.credits .as_ref() .and_then(|c| c.cast.as_ref()) @@ -325,6 +328,7 @@ impl TmdbSeriesInfoDetails { .backdrop_path.clone() .map(|p| format!("{TMDB_IMAGE_BASE_URL}{p}")), status: Some(self.status.clone()), + seasons: self.seasons.as_ref().map(|seasons| seasons.iter().map(TmdbSeason::to_meta_data).collect()), episodes: self.seasons .as_ref() .map(|season_list| { @@ -339,6 +343,8 @@ impl TmdbSeriesInfoDetails { .collect::>() }), source: MetadataSource::Tmdb, + number_of_episodes: self.number_of_episodes, + number_of_seasons: self.number_of_seasons, last_updated: chrono::Utc::now().timestamp(), videos: self.videos .as_ref() @@ -362,7 +368,7 @@ pub struct TmdbCompany { name: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbNetwork { name: String, // id: u32, @@ -370,13 +376,13 @@ pub struct TmdbNetwork { // origin_country: String, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbCredits { cast: Option>, crew: Option>, } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct TmdbCast { name: String, #[serde(default)] @@ -393,7 +399,7 @@ struct TmdbCast { // order: u32, } -#[derive(Debug, Clone,Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TmdbCrew { pub name: String, pub job: Option, @@ -411,20 +417,39 @@ pub struct TmdbCrew { #[derive(Debug, Deserialize)] pub struct TmdbSeason { - // id: u32, - // air_date: Option, - // #[serde(default)] - // episode_count: u32, - // #[serde(default)] - // name: String, - // overview: Option, - // poster_path: Option, + id: u32, + air_date: Option, + #[serde(default)] + episode_count: u32, + #[serde(default)] + name: String, + overview: Option, + poster_path: Option, #[serde(default)] pub(crate) season_number: u32, - // #[serde(default)] - // vote_average: f64, + #[serde(default)] + vote_average: f64, pub episodes: Option>, pub networks: Option>, pub credits: Option, } + +impl TmdbSeason { + pub fn to_meta_data(&self) -> SeasonMetadata { + SeasonMetadata { + id: self.id.clone(), + air_date: self.air_date.clone(), + episode_count: self.episode_count.clone(), + name: self.name.clone(), + overview: self.overview.clone(), + poster_path: self.poster_path.clone(), + season_number: self.season_number.clone(), + vote_average: self.vote_average.clone(), + episodes: self.episodes.clone(), + networks: self.networks.clone(), + credits: self.credits.clone(), + } + } +} + diff --git a/backend/src/library/tmdb_client.rs b/backend/src/library/tmdb_client.rs index a9a25f866..3d63ce8e8 100644 --- a/backend/src/library/tmdb_client.rs +++ b/backend/src/library/tmdb_client.rs @@ -278,7 +278,6 @@ impl TmdbClient { let Ok(response) = self.client.get(&url).send().await else { return (None, None) }; let Ok(bytes) = response.bytes().await else { return (None, None) }; - //let _ = self.storage.store_tmdb_series_info_season(series_id, season, &bytes).await; (match serde_json::from_slice::(&bytes) { Ok(details) => Some(details), Err(e) => { diff --git a/backend/src/main.rs b/backend/src/main.rs index 763e528d1..9f36d49fe 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -148,7 +148,6 @@ fn print_info(app_config: &AppConfig) { info!("Config file: {:?}", &paths.config_file_path); info!("Source file: {:?}", &paths.sources_file_path); info!("Api Proxy File: {:?}", &paths.api_proxy_file_path); - info!("Library File: {:?}", &paths.library_file_path); info!("Mapping file: {:?}", &paths.mapping_file_path.as_ref().map_or_else(|| "not used", |v| v.as_str())); if let Some(cache) = config.reverse_proxy.as_ref().and_then(|r| r.cache.as_ref()) { @@ -167,7 +166,6 @@ fn get_file_paths(args: &Args) -> ConfigPaths { let api_proxy_file = resolve_env_var(&args.api_proxy.as_ref().map_or_else(|| utils::get_default_api_proxy_config_path(config_path.as_str()), ToString::to_string)); let sources_file: String = resolve_env_var(&args.source_file.as_ref().map_or_else(|| utils::get_default_sources_file_path(&config_path), ToString::to_string)); let mappings_file = args.mapping_file.as_ref().map(|p| resolve_env_var(p)); - let library_file = resolve_env_var(&utils::get_default_library_file_path(config_path.as_str())); ConfigPaths { config_path, @@ -175,7 +173,6 @@ fn get_file_paths(args: &Args) -> ConfigPaths { sources_file_path: sources_file, mapping_file_path: mappings_file, // need to be set after config read api_proxy_file_path: api_proxy_file, - library_file_path: library_file, custom_stream_response_path: None, } } diff --git a/backend/src/model/config/base.rs b/backend/src/model/config/base.rs index ded0a37ba..af227e81c 100644 --- a/backend/src/model/config/base.rs +++ b/backend/src/model/config/base.rs @@ -79,6 +79,16 @@ impl Config { webui.prepare(config_path)?; } + if let Some(library) = self.library.as_mut() { + for dir in &mut library.scan_directories { + if let Ok(path_buf) = PathBuf::from(&dir.path).canonicalize() { + if let Some(path) = path_buf.to_str() { + dir.path = path.to_string(); + } + } + } + } + Ok(()) } diff --git a/backend/src/model/xtream.rs b/backend/src/model/xtream.rs index 7063ab6fe..4aa35ba9e 100644 --- a/backend/src/model/xtream.rs +++ b/backend/src/model/xtream.rs @@ -1,12 +1,14 @@ +use std::collections::BTreeMap; use crate::model::{AppConfig, ProxyUserCredentials}; use crate::model::{ConfigTarget, XtreamTargetOutput}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value}; use shared::model::{xtream_const, PlaylistItem, ProxyUserStatus, XtreamPlaylistItem}; use shared::model::{ClusterFlags, PlaylistEntry, XtreamCluster}; use shared::utils::{deserialize_as_option_string, deserialize_as_string, deserialize_as_string_array, deserialize_number_from_string, get_non_empty_str, opt_string_or_number_u32, string_default_on_null, string_or_number_f64, string_or_number_u32}; use std::iter::FromIterator; +use serde::ser::SerializeMap; use serde_json::value::RawValue; macro_rules! add_str_property_if_exists { @@ -262,7 +264,6 @@ pub struct XtreamMovieData { pub direct_source: String, } - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamSeriesInfoSeason { #[serde(default, deserialize_with = "string_default_on_null")] @@ -283,7 +284,6 @@ pub struct XtreamSeriesInfoSeason { pub cover: String, #[serde(default, deserialize_with = "string_default_on_null")] pub cover_big: String, - } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -292,46 +292,50 @@ pub struct XtreamSeriesInfoInfo { #[serde(default, deserialize_with = "string_default_on_null")] pub(crate) name: String, #[serde(default, deserialize_with = "string_default_on_null")] - cover: String, + pub cover: String, #[serde(default, deserialize_with = "string_default_on_null")] - plot: String, + pub plot: String, #[serde(default, deserialize_with = "string_default_on_null")] - cast: String, + pub cast: String, #[serde(default, deserialize_with = "string_default_on_null")] - director: String, + pub director: String, #[serde(default, deserialize_with = "string_default_on_null")] - genre: String, + pub genre: String, #[serde(default, deserialize_with = "string_default_on_null")] - release_date: String, + pub release_date: String, #[serde(default, deserialize_with = "string_default_on_null")] - releaseDate: String, + pub releaseDate: String, #[serde(default, deserialize_with = "string_default_on_null")] - releasedate: String, + pub releasedate: String, #[serde(default, deserialize_with = "string_default_on_null")] - last_modified: String, + pub last_modified: String, #[serde(default, deserialize_with = "string_or_number_f64")] - rating: f64, + pub rating: f64, #[serde(default, deserialize_with = "string_or_number_f64")] - rating_5based: f64, + pub rating_5based: f64, #[serde(default, deserialize_with = "deserialize_as_string_array")] pub backdrop_path: Option>, #[serde(default, deserialize_with = "string_default_on_null")] - trailer: String, + pub trailer: String, #[serde(default, deserialize_with = "string_default_on_null")] - youtube_trailer: String, + pub youtube_trailer: String, #[serde(default, deserialize_with = "string_default_on_null")] - episode_run_time: String, + pub episode_run_time: String, #[serde(default, deserialize_with = "string_or_number_u32")] - category_id: u32, + pub category_id: u32, #[serde(default, deserialize_with = "opt_string_or_number_u32")] pub tmdb_id: Option, #[serde(default, deserialize_with = "opt_string_or_number_u32")] pub tmdb: Option, + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub year: Option, } #[allow(non_snake_case)] #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct XtreamSeriesInfoEpisodeInfo { + #[serde(default, deserialize_with = "opt_string_or_number_u32")] + pub id: Option, #[serde(default, deserialize_with = "opt_string_or_number_u32")] pub tmdb_id: Option, #[serde(default, deserialize_with = "opt_string_or_number_u32")] @@ -351,9 +355,9 @@ pub struct XtreamSeriesInfoEpisodeInfo { #[serde(default, deserialize_with = "string_default_on_null")] pub movie_image: String, #[serde(default)] - pub video: Value, + pub video: Option>, #[serde(default)] - pub audio: Value, + pub audio: Option>, #[serde(default, deserialize_with = "string_or_number_u32")] pub bitrate: u32, #[serde(default, deserialize_with = "string_or_number_f64")] @@ -421,6 +425,7 @@ impl XtreamSeriesEpisode { } } +// sometimes episodes are a map with season as key, sometimes an array fn deserialize_episodes<'de, D>(deserializer: D) -> Result>, D::Error> where D: Deserializer<'de>, @@ -467,13 +472,41 @@ where } } +fn serialize_episodes( + episodes: &Option>, + serializer: S, +) -> Result +where + S: Serializer, +{ + match episodes { + None => { + let map = serializer.serialize_map(Some(0))?; + map.end() + }, + Some(list) => { + if list.is_empty() { + let map = serializer.serialize_map(Some(0))?; + return map.end(); + } + let mut seasons: BTreeMap> = BTreeMap::new(); + for ep in list { + seasons.entry(ep.season.to_string()).or_default().push(ep); + } + + seasons.serialize(serializer) + } + } +} + + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct XtreamSeriesInfo { #[serde(default)] pub seasons: Option>, #[serde(default)] pub info: Option, - #[serde(default, deserialize_with = "deserialize_episodes")] + #[serde(default, serialize_with = "serialize_episodes", deserialize_with = "deserialize_episodes")] pub episodes: Option>, } @@ -645,7 +678,7 @@ pub fn xtream_playlistitem_to_document(pli: &XtreamPlaylistItem, url: &str, opti let props = pli.additional_properties.as_ref().and_then(|add_props| serde_json::from_str::>(add_props).ok()); if let Some(ref add_props) = props { - let doc = if pli.xtream_cluster == XtreamCluster::Video { + let doc = if matches!(pli.xtream_cluster, XtreamCluster::Video | XtreamCluster::Series) { match add_props.get("info") { Some(Value::Object(info_doc)) => info_doc, _ => add_props, diff --git a/backend/src/processing/parser/xtream.rs b/backend/src/processing/parser/xtream.rs index ab07cc403..59cae85c6 100644 --- a/backend/src/processing/parser/xtream.rs +++ b/backend/src/processing/parser/xtream.rs @@ -49,6 +49,9 @@ pub fn parse_xtream_series_info(info: &Value, group_title: &str, series_name: &s let username = input.username.as_ref().map_or("", |v| v); let password = input.password.as_ref().map_or("", |v| v); + + + match serde_json::from_value::(info.to_owned()) { Ok(series_info) => { if let Some(episodes) = &series_info.episodes { @@ -213,7 +216,7 @@ pub async fn parse_xtream(input: &ConfigInput, mod tests { use std::fs; use shared::model::XtreamCluster; - use crate::model::XtreamSeriesInfo; + use crate::model::{XtreamSeriesInfo}; use crate::processing::parser::xtream::map_to_xtream_streams; use crate::utils::async_file_reader; diff --git a/backend/src/processing/processor/library.rs b/backend/src/processing/processor/library.rs index 7a198da79..b598f82ae 100644 --- a/backend/src/processing/processor/library.rs +++ b/backend/src/processing/processor/library.rs @@ -1,12 +1,12 @@ use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry}; +use crate::model::{AppConfig, ConfigInput, XtreamSeriesInfo, XtreamSeriesInfoEpisode, XtreamSeriesInfoEpisodeInfo, XtreamSeriesInfoInfo}; use crate::model::{XtreamMovieData, XtreamMovieInfo, XtreamMovieInfoDetails}; -use crate::model::{AppConfig, ConfigInput}; use serde_json::value::RawValue; use shared::error::TuliproxError; use shared::model::{PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, XtreamCluster}; -use shared::utils::string_to_uuid_type; -use std::sync::Arc; +use shared::utils::{generate_playlist_uuid, string_to_uuid_type}; use std::path::Path; +use std::sync::Arc; pub async fn get_library_playlist(_client: &reqwest::Client, app_config: &Arc, input: &Arc) -> (Vec, Vec) { let config = &*app_config.config.load(); @@ -31,12 +31,12 @@ pub async fn get_library_playlist(_client: &reqwest::Client, app_config: &Arc { let pli = to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category); - group_movies.channels.push(pli); - }, + group_movies.channels.extend(pli); + } MediaMetadata::Series(_) => { let pli = to_playlist_item(&entry, &input.name, &library_config.playlist.series_category); - group_series.channels.push(pli); - }, + group_series.channels.extend(pli); + } } } @@ -51,47 +51,78 @@ pub async fn get_library_playlist(_client: &reqwest::Client, app_config: &Arc PlaylistItem { +fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &str, group_name: &str) -> Vec { let metadata = &entry.metadata; - let (xtream_cluster, item_type, additional_props): (XtreamCluster, PlaylistItemType, Option>) = { - match metadata { - MediaMetadata::Movie(_) => { - let add_props = metadata_cache_entry_to_xtream_movie_info(entry).and_then(|info| { - let raw = serde_json::to_string(&info).ok()?; - RawValue::from_string(raw).ok() - }); - (XtreamCluster::Video, PlaylistItemType::LocalVideo, add_props) - } - MediaMetadata::Series(_s) => { - (XtreamCluster::Series, PlaylistItemType::LocalSeriesInfo, None) - } + match metadata { + MediaMetadata::Movie(_) => { + let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry).and_then(|info| { + let raw = serde_json::to_string(&info).ok()?; + RawValue::from_string(raw).ok() + }); + vec![PlaylistItem { + header: PlaylistItemHeader { + uuid: string_to_uuid_type(&entry.uuid), + name: metadata.title().to_string(), + group: group_name.to_string(), + title: metadata.title().to_string(), + url: format!("file://{}", entry.file_path), + xtream_cluster: XtreamCluster::Video, + additional_properties, + item_type: PlaylistItemType::LocalVideo, + input_name: input_name.to_string(), + ..PlaylistItemHeader::default() + } + }] } - }; + MediaMetadata::Series(_series) => { + let mut items = vec![]; + if let Some(xtream_series_info) = metadata_cache_entry_to_xtream_series_info(entry) { + let additional_properties = serde_json::to_string(&xtream_series_info).ok().and_then(|r| RawValue::from_string(r).ok()); - PlaylistItem { - header: PlaylistItemHeader { - uuid: string_to_uuid_type(&entry.uuid), - id: String::new(), - virtual_id: 0, - name: metadata.title().to_string(), - chno: String::new(), - logo: String::new(), - logo_small: String::new(), - group: group_name.to_string(), - title: metadata.title().to_string(), - parent_code: String::new(), - audio_track: String::new(), - time_shift: String::new(), - rec: String::new(), - url: format!("file://{}", entry.file_path), - epg_channel_id: None, - xtream_cluster, - additional_properties: additional_props, - item_type, - category_id: 0, - input_name: input_name.to_string(), - }, + let series_info = PlaylistItem { + header: PlaylistItemHeader { + uuid: string_to_uuid_type(&entry.uuid), + id: entry.uuid.clone(), + name: metadata.title().to_string(), + group: group_name.to_string(), + title: metadata.title().to_string(), + url: format!("file://{}", entry.file_path), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::LocalSeriesInfo, + input_name: input_name.to_string(), + additional_properties, + ..PlaylistItemHeader::default() + } + }; + + items.push(series_info); + if let Some(episodes) = xtream_series_info.episodes.as_ref() { + for episode in episodes { + items.push(PlaylistItem { + header: PlaylistItemHeader { + id: episode.id.to_string(), + // we use parent_code for local series to find the parent series info and straighten the virtual_ids + parent_code: entry.uuid.clone(), + uuid: generate_playlist_uuid(&input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source), + name: episode.title.clone(), + group: group_name.to_string(), + title: episode.title.clone(), + url: episode.direct_source.clone(), + xtream_cluster: XtreamCluster::Series, + item_type: PlaylistItemType::LocalSeries, + category_id: 0, + input_name: input_name.to_string(), + additional_properties: episode.get_additional_properties(&xtream_series_info), + ..Default::default() + } + }); + } + } + } + + items + } } } @@ -112,33 +143,24 @@ pub fn metadata_cache_entry_to_xtream_movie_info( let info = XtreamMovieInfoDetails { kinopoisk_url: movie.tmdb_id.map(|id| format!("https://www.themoviedb.org/movie/{id}")), - tmdb_id: movie.tmdb_id.map(|id|id.to_string()), - + tmdb_id: movie.tmdb_id.map(|id| id.to_string()), name: Some(movie.title.clone()), o_name: movie.original_title.clone(), - cover_big: movie.poster.clone(), movie_image: movie.poster.clone(), - releasedate: movie.year.map(|y| format!("{y}-01-01")), episode_run_time: movie.runtime, - - youtube_trailer: None, director: movie.directors.as_ref().map(|d| d.join(", ")), + youtube_trailer: movie.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())), actors: actor_names.clone(), cast: actor_names, genre: movie.genres.as_ref().map(|g| g.join(", ")), - description: movie.plot.clone(), plot: movie.plot.clone(), - age: None, mpaa_rating: movie.mpaa.clone(), - rating_count_kinopoisk: 0, - country: None, - backdrop_path: movie .fanart .as_ref() @@ -151,7 +173,6 @@ pub fn metadata_cache_entry_to_xtream_movie_info( .map(|p| vec![p.clone()]) }) .unwrap_or_default(), - duration_secs: movie.runtime.map(|r| (r * 60).to_string()), duration: movie.runtime.map(|r| { let h = r / 60; @@ -161,9 +182,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info( video: Vec::new(), audio: Vec::new(), - bitrate: 0, - rating: movie.rating.map(|r| format!("{r:.2}")), runtime: movie.runtime.map(|r| (r * 60).to_string()), status: Some("Released".to_string()), @@ -172,12 +191,9 @@ pub fn metadata_cache_entry_to_xtream_movie_info( let movie_data = XtreamMovieData { stream_id: 0, name: movie.title.clone(), - added: Some(entry.file_modified.to_string()), - category_id: 0, category_ids: vec![0], - container_extension, custom_sid: None, direct_source: String::new(), @@ -185,3 +201,109 @@ pub fn metadata_cache_entry_to_xtream_movie_info( Some(XtreamMovieInfo { info, movie_data }) } + +pub fn metadata_cache_entry_to_xtream_series_info( + entry: &MetadataCacheEntry, +) -> Option { + let series = match &entry.metadata { + MediaMetadata::Movie(_) => return None, + MediaMetadata::Series(m) => m, + }; + + let actor_names = series.actors.as_ref().map(|a| a.iter().map(|a| a.name.clone()).collect::>().join(", ")).unwrap_or_default(); + let release_date = series.year.map(|y| format!("{y}-01-01")).unwrap_or_default(); + let youtube_trailer = series.videos.as_ref().and_then(|v| v.iter().find(|video| video.site.eq_ignore_ascii_case("youtube")).map(|video| video.key.clone())).unwrap_or_default(); + + let info = XtreamSeriesInfoInfo { + name: series.title.clone(), + cover: series.poster.clone().unwrap_or_default(), + plot: series.plot.clone().unwrap_or_default(), + cast: actor_names, + director: series.directors.as_ref().map(|d| d.join(", ")).unwrap_or_default(), + genre: series.genres.as_ref().map(|d| d.join(", ")).unwrap_or_default(), + release_date: release_date.clone(), + releaseDate: release_date.clone(), + releasedate: release_date.clone(), + last_modified: series.last_updated.to_string(), + rating: series.rating.unwrap_or(0f64), + rating_5based: 0.0, + backdrop_path: series + .fanart + .as_ref() + .filter(|s| !s.is_empty()) + .map(|f| vec![f.clone()]) + .or_else(|| { + series.poster.as_ref() + .filter(|s| !s.is_empty()) + .map(|p| vec![p.clone()]) + }), + trailer: youtube_trailer.clone(), + youtube_trailer, + episode_run_time: String::from("0"), + category_id: 0, + tmdb_id: series.tmdb_id.clone(), + tmdb: series.tmdb_id.clone(), + year: series.year.clone(), + }; + + // seasons are not delivered through xtream get_series_info. + let seasons = Some(vec![]); /*series.seasons.as_ref() + .map(|seasons| seasons.iter().map(|season| XtreamSeriesInfoSeason { + air_date: season.air_date.clone().unwrap_or_default(), + episode_count: season.episode_count, + id: season.id, + name: season.name.clone(), + overview: season.overview.clone().unwrap_or_default(), + season_number: season.season_number, + vote_average: season.vote_average, + cover: season.poster_path.as_ref().cloned().unwrap_or_default(), + cover_big: season.poster_path.as_ref().cloned().unwrap_or_default(), + }).collect()); + */ + + 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); + + XtreamSeriesInfoEpisode { + id: episode.id, + episode_num: episode.episode, + season: episode.season, + title: episode.title.clone(), + container_extension, + info: Some(XtreamSeriesInfoEpisodeInfo { + id: tmdb_id, + tmdb_id, + tmdb: tmdb_id, + season: episode.season, + release_date: episode_release_date.clone(), + releaseDate: episode_release_date.clone(), + releasedate: episode_release_date, + plot: episode.plot.clone().unwrap_or_default(), + duration_secs: episode.runtime.map(|r| r * 60).unwrap_or(0), + duration: episode.runtime + .map(|r| format!("{:02}:{:02}:00", r / 60, r % 60)) + .unwrap_or_default(), + movie_image: episode.thumb.clone().unwrap_or_default(), + video: None, + audio: None, + bitrate: 0, + rating: episode.rating.unwrap_or(0.0), + }), + custom_sid: String::new(), + added: episode.file_modified.to_string(), + direct_source: episode.file_path.clone(), + } + }).collect::>() + }); + + Some(XtreamSeriesInfo { seasons, info: Some(info), episodes }) +} \ No newline at end of file diff --git a/backend/src/processing/processor/playlist.rs b/backend/src/processing/processor/playlist.rs index b461fda78..63463bc42 100644 --- a/backend/src/processing/processor/playlist.rs +++ b/backend/src/processing/processor/playlist.rs @@ -334,7 +334,6 @@ async fn process_source(client: &reqwest::Client, app_config: Arc, so let (mut playlistgroups, mut error_list) = playlist_download_from_input(client, &app_config, input).await; let (tvguide, mut tvguide_errors) = if error_list.is_empty() { let working_dir = &app_config.config.load().working_dir; - epg::get_xmltv(client, input, working_dir).await } else { (None, vec![]) diff --git a/backend/src/repository/playlist_repository.rs b/backend/src/repository/playlist_repository.rs index 327f4d89a..0a29bef33 100644 --- a/backend/src/repository/playlist_repository.rs +++ b/backend/src/repository/playlist_repository.rs @@ -1,5 +1,6 @@ +use std::collections::HashMap; use crate::api::model::{AppState, PlaylistM3uStorage, PlaylistStorage, PlaylistStorageState, PlaylistXtreamStorage}; -use crate::model::Epg; +use crate::model::{Epg, XtreamSeriesInfo}; use crate::model::{AppConfig, ConfigTarget, TargetOutput}; use crate::repository::bplustree::BPlusTree; use crate::repository::epg_repository::epg_write; @@ -12,14 +13,20 @@ use crate::repository::xtream_repository::{xtream_get_file_paths, xtream_get_sto use crate::utils; use shared::error::{info_err, TuliproxErrorKind}; use shared::error::TuliproxError; -use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{M3uPlaylistItem, PlaylistGroup, PlaylistItemHeader, PlaylistItemType, XtreamCluster, XtreamPlaylistItem}; use shared::utils::{is_dash_url, is_hls_url}; use shared::create_tuliprox_error; use std::path::Path; use std::sync::Arc; use log::info; +use serde_json::value::RawValue; use crate::processing::processor::playlist::apply_filter_to_playlist; +struct LocalEpisodeKey { + path: String, + virtual_id: u32, +} + pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGroup], epg: Option<&Epg>, target: &ConfigTarget, playlist_state: Option<&Arc>) -> Result<(), Vec> { let mut errors = vec![]; @@ -31,6 +38,8 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr let (mut target_id_mapping, file_lock) = get_target_id_mapping(app_config, &target_path).await; + let mut local_library_series = HashMap::>::new(); + // Virtual IDs assignment for group in playlist.iter_mut() { for channel in &mut group.channels { @@ -53,9 +62,13 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr let uuid = header.get_uuid(); let item_type = header.item_type; header.virtual_id = target_id_mapping.get_and_update_virtual_id(uuid, provider_id, item_type, 0); + + assign_local_series_info_episode_key(&mut local_library_series, header, item_type); } } + rewrite_local_series_info_episode_virtual_id(playlist, &mut local_library_series); + for output in &target.output { let mut filtered = match output { TargetOutput::Xtream(out) => out.filter.as_ref().and_then(|flt| apply_filter_to_playlist(playlist, flt)), @@ -117,6 +130,58 @@ pub async fn persist_playlist(app_config: &AppConfig, playlist: &mut [PlaylistGr if errors.is_empty() { Ok(()) } else { Err(errors) } } +fn assign_local_series_info_episode_key(local_library_series: &mut HashMap>, header: &mut PlaylistItemHeader, item_type: PlaylistItemType) { + // we need to rewrite local series info with the new virtual ids + if item_type == PlaylistItemType::LocalSeries { + match local_library_series.entry(header.parent_code.clone()) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + let episode_keys = entry.get_mut(); + episode_keys.push(LocalEpisodeKey { + path: header.url.clone(), + virtual_id: header.virtual_id, + }); + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(vec![LocalEpisodeKey { + path: header.url.clone(), + virtual_id: header.virtual_id, + }]); + } + } + } +} + +fn rewrite_local_series_info_episode_virtual_id(playlist: &mut [PlaylistGroup], local_library_series: &mut HashMap>) { + // assign local series virtual ids + for group in playlist.iter_mut() { + for channel in &mut group.channels { + let header = &mut channel.header; + if header.item_type == PlaylistItemType::LocalSeriesInfo { + if let Some(episode_keys) = local_library_series.get(&header.id) { + if let Some(props) = &header.additional_properties { + let props_str = props.get(); + if let Ok(mut series_info) = serde_json::from_str::(props_str) { + if let Some(episodes) = series_info.episodes.as_mut() { + for episode in episodes.iter_mut() { + for episode_key in episode_keys { + if episode.direct_source == episode_key.path { + episode.id = episode_key.virtual_id; + break; + } + } + } + if let Ok(s) = serde_json::to_string(&series_info) { + header.additional_properties = RawValue::from_string(s).ok() + } + } + } + } + } + } + } + } +} + pub async fn get_target_id_mapping(cfg: &AppConfig, target_path: &Path) -> (TargetIdMapping, utils::FileWriteGuard) { let target_id_mapping_file = get_target_id_mapping_file(target_path); let file_lock = cfg.file_locks.write_lock(&target_id_mapping_file).await; diff --git a/backend/src/repository/xtream_playlist_iterator.rs b/backend/src/repository/xtream_playlist_iterator.rs index 9d0b1619f..50ff7a895 100644 --- a/backend/src/repository/xtream_playlist_iterator.rs +++ b/backend/src/repository/xtream_playlist_iterator.rs @@ -9,12 +9,13 @@ use log::error; use serde_json::Value; use shared::error::info_err; use shared::error::TuliproxError; -use shared::model::{TargetType, XtreamCluster, XtreamPlaylistItem}; +use shared::model::{PlaylistItemType, TargetType, XtreamCluster, XtreamPlaylistItem}; use std::collections::HashSet; pub struct XtreamPlaylistIterator { reader: IndexedDocumentIterator, options: XtreamMappingOptions, + cluster: XtreamCluster, // Use parsed numeric filter to avoid per-item String allocations (no to_string per check) filter_ids: Option>, base_url: String, @@ -63,6 +64,7 @@ impl XtreamPlaylistIterator { Ok(Self { reader, options, + cluster, filter_ids, _file_lock: file_lock, base_url: server_info.get_base_url(), @@ -74,21 +76,47 @@ impl XtreamPlaylistIterator { } } + fn matches_filters(cluster: XtreamCluster, filter_ids: Option<&HashSet>, item: &XtreamPlaylistItem) -> bool { + + if cluster == XtreamCluster::Series { + if !matches!(item.item_type, PlaylistItemType::SeriesInfo | PlaylistItemType::LocalSeriesInfo) { + return false; + } + } + + // category_id-Filter + if let Some(set) = filter_ids { + if !set.contains(&item.category_id) { + return false; + } + } + + true + } + fn get_next(&mut self) -> Option<(XtreamPlaylistItem, bool)> { if self.reader.has_error() { error!("Could not deserialize xtream item: {}", self.reader.get_path().display()); return None; } - if let Some(set) = &self.filter_ids { + + let filter_ids = self.filter_ids.as_ref(); + let cluster = self.cluster; + + let predicate = |(item, _): &(XtreamPlaylistItem, bool)| { + Self::matches_filters(cluster, filter_ids, item) + }; + + if self.cluster == XtreamCluster::Series || self.filter_ids.is_some() { if let Some((current_item, _)) = self.lookup_item.take() { - let next_valid = self.reader.find(|(pli, _)| set.contains(&pli.category_id)); + let next_valid = self.reader.find(predicate); self.lookup_item = next_valid; let has_next = self.lookup_item.is_some(); Some((current_item, has_next)) } else { - let current_item = self.reader.find(|(item, _)| set.contains(&item.category_id)); + let current_item = self.reader.find(predicate); if let Some((item, _)) = current_item { - self.lookup_item = self.reader.find(|(item, _)| set.contains(&item.category_id)); + self.lookup_item = self.reader.find(predicate); let has_next = self.lookup_item.is_some(); Some((item, has_next)) } else { diff --git a/backend/src/repository/xtream_repository.rs b/backend/src/repository/xtream_repository.rs index b740e398e..575219944 100644 --- a/backend/src/repository/xtream_repository.rs +++ b/backend/src/repository/xtream_repository.rs @@ -111,6 +111,9 @@ async fn write_playlists_to_file( collections: Vec<(XtreamCluster, &[&mut PlaylistItem])>, ) -> Result<(), TuliproxError> { for (cluster, playlist) in collections { + if playlist.is_empty() { + continue; + } let (xtream_path, idx_path) = xtream_get_file_paths(storage_path, cluster); { let _file_lock = cfg.file_locks.write_lock(&xtream_path).await; @@ -214,6 +217,13 @@ async fn xtream_garbage_collect(config: &AppConfig, target_name: &str) -> std::i Ok(()) } +#[derive(Serialize)] +struct CategoryEntry { + category_id: u32, + category_name: String, + parent_id: u32, +} + pub async fn xtream_write_playlist( cfg: &AppConfig, target: &ConfigTarget, @@ -247,10 +257,10 @@ pub async fn xtream_write_playlist( XtreamCluster::Live => &mut cat_live_col, XtreamCluster::Series => &mut cat_series_col, XtreamCluster::Video => &mut cat_vod_col, - }.push(json!({ - crate::model::XC_TAG_CATEGORY_ID: format!("{}", &cat_id), - crate::model::XC_TAG_CATEGORY_NAME: plg.title.clone(), - crate::model::XC_TAG_PARENT_ID: 0 + }.push(json!(CategoryEntry { + category_id: *cat_id, + category_name: plg.title.clone(), + parent_id: 0 })); for pli in &mut plg.channels { diff --git a/backend/src/utils/file/file_utils.rs b/backend/src/utils/file/file_utils.rs index b7298f5d0..9dc19881d 100644 --- a/backend/src/utils/file/file_utils.rs +++ b/backend/src/utils/file/file_utils.rs @@ -2,7 +2,7 @@ use crate::utils::debug_if_enabled; use log::{debug, error}; use path_clean::PathClean; use shared::error::str_to_io_error; -use shared::utils::{API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, LIBRARY_FILE, MAPPING_FILE, SOURCE_FILE, USER_FILE}; +use shared::utils::{API_PROXY_FILE, CONFIG_FILE, CONFIG_PATH, MAPPING_FILE, SOURCE_FILE, USER_FILE}; use std::borrow::Cow; use std::collections::HashSet; use std::fs::{File, OpenOptions}; @@ -106,11 +106,6 @@ pub fn get_default_api_proxy_config_path(config_path: &str) -> String { get_default_file_path(config_path, API_PROXY_FILE) } -#[inline] -pub fn get_default_library_file_path(config_path: &str) -> String { - get_default_file_path(config_path, LIBRARY_FILE) -} - pub fn resolve_directory_path(input: &str) -> String { let current_dir = std::env::current_dir().unwrap_or_default(); diff --git a/shared/src/model/config/paths.rs b/shared/src/model/config/paths.rs index cd16d181b..06e28186a 100644 --- a/shared/src/model/config/paths.rs +++ b/shared/src/model/config/paths.rs @@ -5,7 +5,6 @@ pub struct ConfigPaths { pub sources_file_path: String, pub mapping_file_path: Option, pub api_proxy_file_path: String, - pub library_file_path: String, #[serde(skip_serializing_if = "Option::is_none")] pub custom_stream_response_path: Option, } diff --git a/shared/src/model/config/proxy_type.rs b/shared/src/model/config/proxy_type.rs index 1fcf2cb34..967630c49 100644 --- a/shared/src/model/config/proxy_type.rs +++ b/shared/src/model/config/proxy_type.rs @@ -66,6 +66,9 @@ impl ProxyType { const REDIRECT: &'static str = "redirect"; pub fn is_redirect(&self, item_type: PlaylistItemType) -> bool { + if item_type.is_local() { + return false; + } match self { ProxyType::Reverse(Some(flags)) => { if flags.is_empty() { @@ -82,6 +85,9 @@ impl ProxyType { } pub fn is_reverse(&self, item_type: PlaylistItemType) -> bool { + if item_type.is_local() { + return true; + } !self.is_redirect(item_type) } } diff --git a/shared/src/utils/constants.rs b/shared/src/utils/constants.rs index a5c5c92f7..ad8e9268e 100644 --- a/shared/src/utils/constants.rs +++ b/shared/src/utils/constants.rs @@ -11,8 +11,6 @@ pub const CONFIG_FILE: &str = "config.yml"; pub const SOURCE_FILE: &str = "source.yml"; pub const MAPPING_FILE: &str = "mapping.yml"; pub const API_PROXY_FILE: &str = "api-proxy.yml"; -pub const LIBRARY_FILE: &str = "library.yml"; - pub const ENCODING_GZIP: &str = "gzip"; pub const ENCODING_DEFLATE: &str = "deflate";