addded Emby/Jellyfin catalog import to media-server (#927)

- Emby/Jellyfin client: token/api_key/login auth, paging, Range playback, artwork
- Jellyfin uses only Authorization: MediaBrowser header (Jellyfin 12 compatible)
- AnyMediaServerCatalogClient dispatch, shared helpers reused by Plex
- Update/Refresh for Emby/Jellyfin inputs in the Web UI
- BREAKING: remove unused media_server options (playback, image_policy,
  catalog.refresh_mode/refresh_on_startup/include_paths/include_user_state)
This commit is contained in:
euzu
2026-10-20 19:56:10 +02:00
committed by GitHub
parent 0c13a0d701
commit bdd5850cd4
45 changed files with 4844 additions and 674 deletions
@@ -9,8 +9,8 @@ use super::{
telemetry::{
finalize_input_telemetry, input_telemetry_for_fetch, is_cluster_input, persist_finalized_cluster_snapshots,
},
CacheStatusScope, InputDownloadResult, LibraryProvider, PendingInputStatusUpdate, PlaylistDownloadResult,
PlaylistProcessingContext, PlaylistUpdateExecutionRef, PlexProvider, StalkerProvider,
CacheStatusScope, InputDownloadResult, LibraryProvider, MediaServerProvider, PendingInputStatusUpdate,
PlaylistDownloadResult, PlaylistProcessingContext, PlaylistUpdateExecutionRef, StalkerProvider,
PIPELINE_TRANSPARENCY_CLUSTERS,
};
use crate::{input_cache, metadata_sink::MetadataUpdateSink, processor::StalkerRefreshMode};
@@ -171,15 +171,7 @@ async fn playlist_download_from_input<E: EventSink>(
StalkerProvider::new(stalker_refresh_mode, !config.disk_based_processing).fetch(&request).await
}
InputType::Library => LibraryProvider.fetch(&request).await,
InputType::Plex => PlexProvider.fetch(&request).await,
InputType::Emby | InputType::Jellyfin => {
UnsupportedProvider::new(
"media-server",
format!("media-server input '{}' is configured but catalog import is not implemented yet", input.name),
)
.fetch(&request)
.await
}
InputType::Plex | InputType::Emby | InputType::Jellyfin => MediaServerProvider.fetch(&request).await,
InputType::Staged => {
UnsupportedProvider::new(
"staged",
@@ -1,4 +1,4 @@
use super::providers::{LibraryProvider, PlexProvider, StalkerProvider, XmltvEpgProvider};
use super::providers::{LibraryProvider, MediaServerProvider, StalkerProvider, XmltvEpgProvider};
use crate::{
fetched_playlist::FetchedPlaylist,
input_cache,
+38 -10
View File
@@ -1,8 +1,8 @@
//! The [`PlaylistProvider`] implementations whose orchestration lives in this crate.
//!
//! The M3U and Xtream providers ship with the client code in `tuliprox-iptv`. These three
//! cannot: Stalker's resumable refresh, the local library scan and the Plex catalog import
//! all reach into `tuliprox-repository` and this crate's own processors, and `tuliprox-iptv`
//! cannot: Stalker's resumable refresh, the local library scan and the media-server catalog
//! import (Plex, Emby, Jellyfin) all reach into `tuliprox-repository` and this crate's own processors, and `tuliprox-iptv`
//! sits below both. The trait is what lets them keep that dependency direction and still
//! answer the dispatcher in one shape.
@@ -67,20 +67,20 @@ impl PlaylistProvider for LibraryProvider {
}
}
/// Plex media server catalog import.
/// Media server catalog import for Plex, Emby and Jellyfin inputs.
#[derive(Debug, Clone, Copy, Default)]
pub struct PlexProvider;
pub struct MediaServerProvider;
impl PlaylistProvider for PlexProvider {
fn name(&self) -> &'static str { "plex" }
impl PlaylistProvider for MediaServerProvider {
fn name(&self) -> &'static str { "media-server" }
async fn fetch(&self, request: &PlaylistFetchRequest<'_>) -> PlaylistFetch {
let (groups, errors) = download_plex_media_server_playlist(request.client, request.input).await;
let (groups, errors) = download_media_server_playlist(request.client, request.input).await;
PlaylistFetch::groups(groups).with_errors(errors)
}
}
async fn download_plex_media_server_playlist(
async fn download_media_server_playlist(
client: &reqwest::Client,
input: &ConfigInput,
) -> (Vec<PlaylistGroup>, Vec<TuliproxError>) {
@@ -94,7 +94,7 @@ async fn download_plex_media_server_playlist(
);
};
let http_client = MediaServerHttpClient::new(client.clone());
let plex_client = match input.plex_catalog_client(http_client) {
let catalog_client = match input.media_server_catalog_client(http_client) {
Ok(client) => client,
Err(error) => return (vec![], vec![TuliproxError::Download(error.to_string())]),
};
@@ -103,7 +103,7 @@ async fn download_plex_media_server_playlist(
request_delay_ms: media_server.catalog.request_delay_ms,
};
match refresh_media_server_catalog_complete_before_publish(&plex_client, policy).await {
match refresh_media_server_catalog_complete_before_publish(&catalog_client, policy).await {
Ok(snapshot) => (media_server_catalog_snapshot_to_playlist(&snapshot), vec![]),
Err(error) => (vec![], vec![TuliproxError::Download(error.to_string())]),
}
@@ -154,3 +154,31 @@ impl<E: EventSink + Clone + 'static, M: MetadataUpdateSink> EpgProvider for Xmlt
Ok(guide.map_or(EpgOutcome::NotConfigured, EpgOutcome::Guide))
}
}
#[cfg(test)]
mod tests {
use super::{download_media_server_playlist, MediaServerProvider};
use shared::{error::ErrorKind, model::InputType};
use tuliprox_core::model::ConfigInput;
use tuliprox_iptv::provider::PlaylistProvider;
#[tokio::test]
async fn media_server_inputs_without_media_server_block_report_download_error() {
let client = reqwest::Client::new();
for input_type in [InputType::Plex, InputType::Emby, InputType::Jellyfin] {
let input = ConfigInput { name: "ms".into(), input_type, media_server: None, ..ConfigInput::default() };
let (groups, errors) = download_media_server_playlist(&client, &input).await;
assert!(groups.is_empty());
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].kind(), ErrorKind::Download, "{input_type:?}");
assert!(errors[0].message().contains("media_server"), "{}", errors[0].message());
}
}
#[test]
fn media_server_provider_is_named_media_server() {
assert_eq!(MediaServerProvider.name(), "media-server");
}
}