Feature/local media (#671)

* local library default extensions
* series info generation
* local library series episodes
This commit is contained in:
euzu
2026-03-26 23:32:22 +01:00
committed by GitHub
parent f1e96cd4cd
commit 69e7ae8680
21 changed files with 1339 additions and 167 deletions
+260 -104
View File
@@ -1,19 +1,36 @@
use crate::library::{MediaMetadata, MetadataAsyncIter, MetadataCacheEntry};
use crate::library::{EpisodeMetadata, MediaMetadata, MetadataAsyncIter, MetadataCacheEntry};
use crate::library::resolve_metadata_storage_path;
use crate::model::{AppConfig, ConfigInput};
use shared::concat_string;
use shared::error::TuliproxError;
use shared::model::UUIDType;
use shared::model::{EpisodeStreamProperties, PlaylistGroup, PlaylistItem, PlaylistItemHeader, PlaylistItemType, SeriesStreamDetailEpisodeProperties, SeriesStreamDetailProperties, SeriesStreamDetailSeasonProperties, SeriesStreamProperties, StreamProperties, VideoStreamDetailProperties, VideoStreamProperties, XtreamCluster, normalize_episode_title};
use shared::utils::{generate_playlist_uuid, Internable};
use shared::utils::{concat_path_leading_slash, generate_playlist_uuid, Internable};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
fn thumbnail_url(entry: &MetadataCacheEntry, api_base_path: &str) -> Option<String> {
entry.thumbnail_hash
.as_ref()
.map(|_| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{}", entry.uuid)))
}
fn episode_thumbnail_url(episode: &EpisodeMetadata, api_base_path: &str) -> Option<String> {
episode
.thumbnail_id
.as_deref()
.map(|id| concat_path_leading_slash(api_base_path, &format!("library/thumbnail/{id}")))
}
pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &Arc<AppConfig>, input: &ConfigInput) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
let config = &*app_config.config.load();
let Some(library_config) = config.library.as_ref() else { return (vec![], vec![]) };
if !library_config.enabled { return (vec![], vec![]); }
let api_base_path = concat_path_leading_slash(
config.web_ui.as_ref().and_then(|w| w.path.as_deref()).unwrap_or(""),
"api/v1",
);
let storage_path = resolve_metadata_storage_path(config.metadata_update.as_ref(), &config.storage_dir)
.join("library");
@@ -33,10 +50,10 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A
while let Some(entry) = metadata_iter.next().await {
match entry.metadata {
MediaMetadata::Movie(_) => {
to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category, &mut group_movies.channels);
to_playlist_item(&entry, &input.name, &library_config.playlist.movie_category, &api_base_path, &mut group_movies.channels);
}
MediaMetadata::Series(_) => {
to_playlist_item(&entry, &input.name, &library_config.playlist.series_category, &mut group_series.channels);
to_playlist_item(&entry, &input.name, &library_config.playlist.series_category, &api_base_path, &mut group_series.channels);
}
}
}
@@ -52,96 +69,17 @@ pub async fn download_library_playlist(_client: &reqwest::Client, app_config: &A
(groups, vec![])
}
fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &Arc<str>, group_name: &str, channels: &mut Vec<PlaylistItem>) {
let metadata = &entry.metadata;
match metadata {
MediaMetadata::Movie(_) => {
let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry);
let title = metadata.title().intern();
let group = group_name.intern();
channels.push(PlaylistItem {
header: PlaylistItemHeader {
uuid: UUIDType::from_valid_uuid(&entry.uuid),
name: Arc::clone(&title),
title,
group,
logo: metadata.poster().unwrap_or("").intern(),
url: concat_string!("file://", &entry.file_path).into(),
xtream_cluster: XtreamCluster::Video,
additional_properties,
item_type: PlaylistItemType::LocalVideo,
input_name: Arc::clone(input_name),
..PlaylistItemHeader::default()
}
});
}
MediaMetadata::Series(_series) => {
if let Some(additional_properties) = metadata_cache_entry_to_xtream_series_info(entry) {
let mut episodes = vec![];
let group_arc: Arc<str> = group_name.intern();
if let StreamProperties::Series(series_properties) = &additional_properties {
if let Some(details_props) = series_properties.details.as_ref() {
if let Some(prop_episodes) = details_props.episodes.as_ref() {
for episode in prop_episodes {
let logo: Arc<str> = if episode.movie_image.is_empty() { metadata.poster().unwrap_or("").intern() } else { episode.movie_image.clone() };
let container_extension = Path::new(&*episode.direct_source)
.extension()
.and_then(|s| s.to_str())
.map(ToString::to_string).unwrap_or_default();
episodes.push(PlaylistItem {
header: PlaylistItemHeader {
id: episode.id.to_string().into(),
// we use parent_code for local series to find the parent series info and straighten the virtual_ids
parent_code: entry.uuid.clone().into(),
uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source),
logo: logo.clone(),
name: episode.title.clone(),
group: Arc::clone(&group_arc),
title: episode.title.clone(),
url: episode.direct_source.clone(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::LocalSeries,
category_id: 0,
input_name: Arc::clone(input_name),
additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties {
episode_id: episode.id,
episode: episode.episode_num,
season: episode.season,
added: Some(episode.added.clone()),
release_date: Some(episode.release_date.clone()),
series_release_date: None,
tmdb: episode.tmdb,
movie_image: logo,
container_extension: container_extension.intern(),
audio: None,
video: None,
}))),
..Default::default()
}
});
}
}
}
}
let series_info = PlaylistItem {
header: PlaylistItemHeader {
uuid: UUIDType::from_valid_uuid(&entry.uuid),
id: entry.uuid.clone().into(),
name: metadata.title().intern(),
group: group_name.intern(),
title: metadata.title().intern(),
logo: metadata.poster().unwrap_or("").intern(),
url: concat_string!("file://", &entry.file_path).into(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::LocalSeriesInfo,
input_name: Arc::clone(input_name),
additional_properties: Some(additional_properties),
..PlaylistItemHeader::default()
}
};
fn to_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
channels: &mut Vec<PlaylistItem>,
) {
match &entry.metadata {
MediaMetadata::Movie(_) => channels.push(build_movie_playlist_item(entry, input_name, group_name, api_base_path)),
MediaMetadata::Series(_) => {
if let Some((series_info, episodes)) = build_series_playlist_items(entry, input_name, group_name, api_base_path) {
channels.push(series_info);
channels.extend(episodes);
}
@@ -149,14 +87,175 @@ fn to_playlist_item(entry: &MetadataCacheEntry, input_name: &Arc<str>, group_nam
}
}
fn build_movie_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
) -> PlaylistItem {
let metadata = &entry.metadata;
let additional_properties = metadata_cache_entry_to_xtream_movie_info(entry, api_base_path);
let title = metadata.title().intern();
let group = group_name.intern();
PlaylistItem {
header: PlaylistItemHeader {
uuid: UUIDType::from_valid_uuid(&entry.uuid),
name: Arc::clone(&title),
title,
group,
logo: metadata
.poster()
.or(thumbnail_url(entry, api_base_path).as_deref())
.unwrap_or("")
.intern(),
url: concat_string!("file://", &entry.file_path).into(),
xtream_cluster: XtreamCluster::Video,
additional_properties,
item_type: PlaylistItemType::LocalVideo,
input_name: Arc::clone(input_name),
..PlaylistItemHeader::default()
},
}
}
fn build_series_playlist_items(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
) -> Option<(PlaylistItem, Vec<PlaylistItem>)> {
let additional_properties = metadata_cache_entry_to_xtream_series_info(entry, api_base_path)?;
let series_info = build_series_info_playlist_item(entry, input_name, group_name, api_base_path, &additional_properties);
let episodes = build_series_episode_playlist_items(entry, input_name, group_name, api_base_path, &additional_properties);
Some((series_info, episodes))
}
fn build_series_info_playlist_item(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
additional_properties: &StreamProperties,
) -> PlaylistItem {
let metadata = &entry.metadata;
PlaylistItem {
header: PlaylistItemHeader {
uuid: UUIDType::from_valid_uuid(&entry.uuid),
id: entry.uuid.clone().into(),
name: metadata.title().intern(),
group: group_name.intern(),
title: metadata.title().intern(),
logo: metadata
.poster()
.or(thumbnail_url(entry, api_base_path).as_deref())
.unwrap_or("")
.intern(),
url: concat_string!("file://", &entry.file_path).into(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::LocalSeriesInfo,
input_name: Arc::clone(input_name),
additional_properties: Some(additional_properties.clone()),
..PlaylistItemHeader::default()
},
}
}
fn build_series_episode_playlist_items(
entry: &MetadataCacheEntry,
input_name: &Arc<str>,
group_name: &str,
api_base_path: &str,
additional_properties: &StreamProperties,
) -> Vec<PlaylistItem> {
let metadata = &entry.metadata;
let group_arc: Arc<str> = group_name.intern();
match additional_properties {
StreamProperties::Series(series_properties) => series_properties
.details
.as_ref()
.and_then(|details| details.episodes.as_ref())
.map(|episodes| {
episodes
.iter()
.map(|episode| build_series_episode_playlist_item(entry, metadata, input_name, &group_arc, api_base_path, episode))
.collect()
})
.unwrap_or_default(),
_ => vec![],
}
}
fn build_series_episode_playlist_item(
entry: &MetadataCacheEntry,
metadata: &MediaMetadata,
input_name: &Arc<str>,
group_name: &Arc<str>,
api_base_path: &str,
episode: &SeriesStreamDetailEpisodeProperties,
) -> PlaylistItem {
let logo: Arc<str> = if episode.movie_image.is_empty() {
metadata
.poster()
.or(thumbnail_url(entry, api_base_path).as_deref())
.unwrap_or("")
.intern()
} else {
episode.movie_image.clone()
};
let container_extension = Path::new(&*episode.direct_source)
.extension()
.and_then(|s| s.to_str())
.map(ToString::to_string)
.unwrap_or_default();
PlaylistItem {
header: PlaylistItemHeader {
id: episode.id.to_string().into(),
parent_code: entry.uuid.clone().into(),
uuid: generate_playlist_uuid(input_name, &episode.id.to_string(), PlaylistItemType::LocalSeries, &episode.direct_source),
logo: logo.clone(),
name: episode.title.clone(),
group: Arc::clone(group_name),
title: episode.title.clone(),
url: episode.direct_source.clone(),
xtream_cluster: XtreamCluster::Series,
item_type: PlaylistItemType::LocalSeries,
category_id: 0,
input_name: Arc::clone(input_name),
additional_properties: Some(StreamProperties::Episode(Box::new(EpisodeStreamProperties {
episode_id: episode.id,
episode: episode.episode_num,
season: episode.season,
added: Some(episode.added.clone()),
release_date: Some(episode.release_date.clone()),
series_release_date: None,
tmdb: episode.tmdb,
movie_image: logo,
container_extension: container_extension.intern(),
audio: None,
video: None,
}))),
..Default::default()
},
}
}
pub fn metadata_cache_entry_to_xtream_movie_info(
entry: &MetadataCacheEntry,
api_base_path: &str,
) -> Option<StreamProperties> {
let movie = match &entry.metadata {
MediaMetadata::Movie(m) => m,
MediaMetadata::Series(_) => return None,
};
let binding = thumbnail_url(entry, api_base_path);
let thumbnail_poster = binding.as_deref();
let poster = movie.poster.as_deref().or(movie.fanart.as_deref()).or(thumbnail_poster);
let container_extension = Path::new(&entry.file_path)
.extension()
.and_then(|s| s.to_str())
@@ -168,7 +267,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info(
name: movie.title.clone().into(),
category_id: 0,
stream_id: 0,
stream_icon: movie.poster.as_deref().or(movie.fanart.as_deref()).unwrap_or("").to_owned().into(),
stream_icon: poster.unwrap_or("").to_owned().into(),
direct_source: "".into(),
custom_sid: None,
added: entry.file_modified.intern(),
@@ -182,8 +281,8 @@ pub fn metadata_cache_entry_to_xtream_movie_info(
details: Some(VideoStreamDetailProperties {
kinopoisk_url: movie.tmdb_id.map(|id| concat_string!("https://www.themoviedb.org/movie/", &id.to_string()).into()),
o_name: movie.original_title.clone().map(Into::into),
cover_big: movie.poster.clone().map(Into::into),
movie_image: movie.poster.clone().map(Into::into),
cover_big: poster.map(Into::into),
movie_image: poster.map(Into::into),
release_date: movie.year.map(|y| format!("{y}-01-01").into()),
episode_run_time: movie.runtime,
director: movie.directors.as_ref().map(|d| d.join(", ").into()),
@@ -202,12 +301,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info(
.as_ref()
.filter(|s| !s.is_empty())
.map(|f| vec![f.clone().into()])
.or_else(|| {
movie.poster
.as_ref()
.filter(|s| !s.is_empty())
.map(|p| vec![p.clone().into()])
}),
.or_else(|| poster.map(|p| vec![p.into()])),
duration_secs: movie.runtime.map(|r| (r * 60).to_string().into()),
duration: movie.runtime.map(|r| {
let h = r / 60;
@@ -229,6 +323,7 @@ pub fn metadata_cache_entry_to_xtream_movie_info(
#[allow(clippy::too_many_lines)]
pub fn metadata_cache_entry_to_xtream_series_info(
entry: &MetadataCacheEntry,
api_base_path: &str,
) -> Option<StreamProperties> {
let series = match &entry.metadata {
MediaMetadata::Movie(_) => return None,
@@ -299,7 +394,12 @@ pub fn metadata_cache_entry_to_xtream_series_info(
duration: episode.runtime
.map(|r| format!("{:02}:{:02}:00", r / 60, r % 60))
.unwrap_or_default().into(),
movie_image: episode.thumb.clone().unwrap_or_default().into(),
movie_image: episode
.thumb
.clone()
.or_else(|| episode_thumbnail_url(episode, api_base_path))
.unwrap_or_default()
.into(),
audio: None,
video: None,
bitrate: 0,
@@ -347,3 +447,59 @@ pub fn metadata_cache_entry_to_xtream_series_info(
Some(StreamProperties::Series(Box::new(properties)))
}
#[cfg(test)]
mod tests {
use super::{metadata_cache_entry_to_xtream_series_info, thumbnail_url};
use crate::library::{MediaMetadata, MetadataCacheEntry, MovieMetadata, SeriesMetadata, EpisodeMetadata};
use shared::model::StreamProperties;
#[test]
fn thumbnail_url_uses_v1_api_prefix() {
let mut entry = MetadataCacheEntry::new(
"/tmp/video.mkv".to_string(),
123,
456,
MediaMetadata::Movie(MovieMetadata::default()),
);
entry.uuid = "test-uuid-123".to_string();
entry.thumbnail_hash = Some("thumb-hash".to_string());
assert_eq!(
thumbnail_url(&entry, "/api/v1").as_deref(),
Some("/api/v1/library/thumbnail/test-uuid-123"),
);
}
#[test]
fn series_episode_uses_local_thumbnail_when_episode_thumb_missing() {
let entry = MetadataCacheEntry::new(
"/tmp/show/S01E01.mkv".to_string(),
123,
456,
MediaMetadata::Series(SeriesMetadata {
title: "Test Series".to_string(),
episodes: Some(vec![EpisodeMetadata {
title: "Episode 1".to_string(),
season: 1,
episode: 1,
file_path: "/tmp/show/S01E01.mkv".to_string(),
thumbnail_id: Some("31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b".to_string()),
..EpisodeMetadata::default()
}]),
..SeriesMetadata::default()
}),
);
let Some(StreamProperties::Series(series)) = metadata_cache_entry_to_xtream_series_info(&entry, "/api/v1") else {
panic!("expected series stream properties");
};
let episodes = series.details.as_ref().and_then(|details| details.episodes.as_ref()).expect("episodes missing");
let episode = episodes.first().expect("episode missing");
assert_eq!(
episode.movie_image.as_ref(),
"/api/v1/library/thumbnail/31fedbc18dca3fa273fba98afda584486ad4f1d8e1ca06740435b97b14f2ec8b",
);
}
}