refactor: replace get_stream_url with get_source_uri per ADR-0002

Providers now expose a source URI (for FFmpeg) instead of a
viewer-facing stream URL. Playout Service will own transcoding.

- Add SourceUri value object (NetworkUrl | FilePath)
- Remove StreamQuality, StreamingProtocol from domain
- Simplify ProviderCapabilities (drop streaming_protocol, transcode)
- Jellyfin: return static direct stream URL
- Local files: return absolute file path
- Rename use case get_stream_url -> get_source
- Stream endpoint returns JSON SourceUri instead of 307 redirect
This commit is contained in:
2026-07-12 07:34:13 +02:00
parent abcf69ce7e
commit 39f5f99bfd
21 changed files with 82 additions and 256 deletions

View File

@@ -37,20 +37,6 @@ pub(crate) struct JellyfinItem {
pub recursive_item_count: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct JellyfinPlaybackInfoResponse {
#[serde(rename = "MediaSources")]
pub media_sources: Vec<JellyfinMediaSource>,
}
#[derive(Debug, Deserialize)]
pub(crate) struct JellyfinMediaSource {
#[serde(rename = "SupportsDirectStream")]
pub supports_direct_stream: bool,
#[serde(rename = "DirectStreamUrl")]
pub direct_stream_url: Option<String>,
}
pub(crate) fn jellyfin_item_type(ct: &ContentType) -> &'static str {
match ct {
ContentType::Movie => "Movie",

View File

@@ -1,18 +1,11 @@
use async_trait::async_trait;
use domain::ports::{
Collection, IMediaProvider, ProviderCapabilities, SeriesSummary, StreamQuality,
StreamingProtocol,
};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId};
use domain::ports::{Collection, IMediaProvider, ProviderCapabilities, SeriesSummary};
use domain::{ContentType, DomainError, DomainResult, MediaFilter, MediaItem, MediaItemId, SourceUri};
use crate::config::JellyfinConfig;
use crate::mapping::{map_jellyfin_item, TICKS_PER_SEC};
use crate::models::{
jellyfin_item_type, JellyfinItemsResponse, JellyfinPlaybackInfoResponse,
};
const FALLBACK_HLS_BITRATE: u32 = 8_000_000;
use crate::models::{jellyfin_item_type, JellyfinItemsResponse};
pub struct JellyfinMediaProvider {
client: reqwest::Client,
@@ -129,16 +122,6 @@ impl JellyfinMediaProvider {
Ok(items)
}
fn hls_url(&self, item_id: &MediaItemId, bitrate: u32) -> String {
format!(
"{}/Videos/{}/master.m3u8?videoCodec=h264&audioCodec=aac&VideoBitRate={}&mediaSourceId={}&SubtitleMethod=Hls&subtitleCodec=vtt&api_key={}",
self.config.base_url,
item_id.as_ref(),
bitrate,
item_id.as_ref(),
self.config.api_key,
)
}
}
#[async_trait]
@@ -151,9 +134,7 @@ impl IMediaProvider for JellyfinMediaProvider {
tags: true,
decade: true,
search: true,
streaming_protocol: StreamingProtocol::Hls,
rescan: false,
transcode: false,
}
}
@@ -350,52 +331,14 @@ impl IMediaProvider for JellyfinMediaProvider {
Ok(body.items.into_iter().map(|item| item.name).collect())
}
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()
&& src.supports_direct_stream
&& let Some(rel_url) = &src.direct_stream_url
{
return Ok(format!(
"{}{}&api_key={}",
self.config.base_url, rel_url, self.config.api_key
));
}
}
Ok(self.hls_url(item_id, FALLBACK_HLS_BITRATE))
}
StreamQuality::Transcode(bps) => Ok(self.hls_url(item_id, *bps)),
}
async fn get_source_uri(&self, item_id: &MediaItemId) -> DomainResult<SourceUri> {
Ok(SourceUri::NetworkUrl {
url: format!(
"{}/Videos/{}/stream?static=true&api_key={}",
self.config.base_url,
item_id.as_ref(),
self.config.api_key,
),
})
}
}