feat(stream): add stream quality selection and update stream URL handling
This commit is contained in:
@@ -47,6 +47,20 @@ pub(super) struct JellyfinItem {
|
||||
pub recursive_item_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct JellyfinPlaybackInfoResponse {
|
||||
#[serde(rename = "MediaSources")]
|
||||
pub media_sources: Vec<JellyfinMediaSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct JellyfinMediaSource {
|
||||
#[serde(rename = "SupportsDirectStream")]
|
||||
pub supports_direct_stream: bool,
|
||||
#[serde(rename = "DirectStreamUrl")]
|
||||
pub direct_stream_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
|
||||
match ct {
|
||||
ContentType::Movie => "Movie",
|
||||
|
||||
@@ -2,12 +2,12 @@ use async_trait::async_trait;
|
||||
|
||||
use domain::{
|
||||
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
||||
MediaItemId, ProviderCapabilities, SeriesSummary, StreamingProtocol,
|
||||
MediaItemId, ProviderCapabilities, SeriesSummary, StreamQuality, StreamingProtocol,
|
||||
};
|
||||
|
||||
use super::config::JellyfinConfig;
|
||||
use super::mapping::{map_jellyfin_item, TICKS_PER_SEC};
|
||||
use super::models::{jellyfin_item_type, JellyfinItemsResponse};
|
||||
use super::models::{jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse};
|
||||
|
||||
pub struct JellyfinMediaProvider {
|
||||
pub(super) client: reqwest::Client,
|
||||
@@ -361,24 +361,45 @@ impl IMediaProvider for JellyfinMediaProvider {
|
||||
Ok(body.items.into_iter().map(|item| item.name).collect())
|
||||
}
|
||||
|
||||
/// Build an HLS stream URL for a Jellyfin item.
|
||||
///
|
||||
/// Returns a `master.m3u8` playlist URL. Jellyfin transcodes to H.264/AAC
|
||||
/// segments on the fly. HLS is preferred over a single MP4 stream because
|
||||
/// `StartTimeTicks` works reliably with HLS — each segment is independent,
|
||||
/// so Jellyfin can begin the playlist at the correct broadcast offset
|
||||
/// without needing to byte-range seek into an in-progress transcode.
|
||||
///
|
||||
/// The API key is embedded so the player needs no separate auth header.
|
||||
/// The caller (stream proxy route) appends `StartTimeTicks` when there is
|
||||
/// a non-zero broadcast offset.
|
||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
||||
Ok(format!(
|
||||
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate=40000000&mediaSourceId={}&api_key={}",
|
||||
self.config.base_url,
|
||||
item_id.as_ref(),
|
||||
item_id.as_ref(),
|
||||
self.config.api_key,
|
||||
))
|
||||
async fn get_stream_url(&self, item_id: &MediaItemId, quality: &StreamQuality) -> DomainResult<String> {
|
||||
match quality {
|
||||
StreamQuality::Direct => {
|
||||
let url = format!("{}/Items/{}/PlaybackInfo", self.config.base_url, item_id.as_ref());
|
||||
let resp = self.client.post(&url)
|
||||
.header("X-Emby-Token", &self.config.api_key)
|
||||
.query(&[("userId", &self.config.user_id), ("mediaSourceId", &item_id.as_ref().to_string())])
|
||||
.json(&serde_json::json!({}))
|
||||
.send().await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("PlaybackInfo failed: {e}")))?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let info: JellyfinPlaybackInfoResponse = resp.json().await
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("PlaybackInfo parse failed: {e}")))?;
|
||||
if let Some(src) = info.media_sources.first() {
|
||||
if src.supports_direct_stream {
|
||||
if let Some(rel_url) = &src.direct_stream_url {
|
||||
return Ok(format!("{}{}&api_key={}", self.config.base_url, rel_url, self.config.api_key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: HLS at 8 Mbps
|
||||
Ok(self.hls_url(item_id, 8_000_000))
|
||||
}
|
||||
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JellyfinMediaProvider {
|
||||
fn hls_url(&self, item_id: &MediaItemId, bitrate: u32) -> String {
|
||||
format!(
|
||||
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate={}&mediaSourceId={}&api_key={}",
|
||||
self.config.base_url,
|
||||
item_id.as_ref(),
|
||||
bitrate,
|
||||
item_id.as_ref(),
|
||||
self.config.api_key,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
Collection, ContentType, DomainError, DomainResult, IMediaProvider, MediaFilter, MediaItem,
|
||||
MediaItemId, ProviderCapabilities, StreamingProtocol,
|
||||
MediaItemId, ProviderCapabilities, StreamQuality, StreamingProtocol,
|
||||
};
|
||||
|
||||
use super::config::LocalFilesConfig;
|
||||
@@ -138,7 +138,7 @@ impl IMediaProvider for LocalFilesProvider {
|
||||
.map(|item| to_media_item(item_id.clone(), &item)))
|
||||
}
|
||||
|
||||
async fn get_stream_url(&self, item_id: &MediaItemId) -> DomainResult<String> {
|
||||
async fn get_stream_url(&self, item_id: &MediaItemId, _quality: &StreamQuality) -> DomainResult<String> {
|
||||
Ok(format!(
|
||||
"{}/api/v1/files/stream/{}",
|
||||
self.base_url,
|
||||
|
||||
Reference in New Issue
Block a user