Background-audio handoff for video + repository/player refactor

Hand video playback off to a native audio-only stream when the app is
backgrounded or locked, with no on-device video decode (UR-040). Adds
player_enter/exit_background_audio commands, an audio-only stream URL
for video items across the repository layer, and the frontend handoff
state machine wired into VideoPlayer. Includes accompanying
repository/offline/player refactoring and regenerates the traceability
matrix.
This commit is contained in:
2026-07-22 21:52:07 +02:00
parent 4e6ab017d4
commit 3fbf6afdbc
72 changed files with 6728 additions and 2338 deletions
+357 -91
View File
@@ -1,15 +1,15 @@
//! TRACES: UR-002, UR-007 | DR-013 | IR-010
use std::sync::Arc;
use async_trait::async_trait;
use log::{debug, error, info};
#[cfg(target_os = "android")]
use log::warn;
use log::{debug, error, info};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use super::{types::*, MediaRepository};
use crate::connectivity::ConnectivityReporter;
use crate::jellyfin::HttpClient;
use super::{MediaRepository, types::*};
/// A single actor returned by the JRay plugin's "context at time t" endpoint.
///
@@ -108,22 +108,34 @@ impl OnlineRepository {
/// Download raw bytes from a URL using the shared authenticated HTTP client.
/// Used by thumbnail cache to download images with proper auth and connection reuse.
pub async fn download_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
let request = self.http_client.client.get(url)
let request = self
.http_client
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
let response = self.http_client.request_with_retry(request).await
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| format!("Download failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let body_preview = if body.len() > 200 { &body[..200] } else { &body };
let body_preview = if body.len() > 200 {
&body[..200]
} else {
&body
};
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
}
response.bytes().await
response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(|e| format!("Failed to read bytes: {}", e))
}
@@ -132,7 +144,11 @@ impl OnlineRepository {
/// the given item. Returns an empty list when the plugin isn't installed or
/// has no truth data for the item (HTTP 404), so callers can treat "no JRay"
/// and "nobody on screen" identically. Other failures propagate.
pub async fn get_jray_actors(&self, item_id: &str, t: f64) -> Result<Vec<JRayActor>, RepoError> {
pub async fn get_jray_actors(
&self,
item_id: &str,
t: f64,
) -> Result<Vec<JRayActor>, RepoError> {
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
match self.get_json::<JRayContext>(&endpoint).await {
Ok(context) => Ok(context.actors),
@@ -160,18 +176,29 @@ impl OnlineRepository {
result
}
async fn get_json_inner<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
async fn get_json_inner<T: for<'de> Deserialize<'de>>(
&self,
endpoint: &str,
) -> Result<T, RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.get(&url)
let request = self
.http_client
.client
.get(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
@@ -197,9 +224,18 @@ impl OnlineRepository {
// Try to deserialize and log the raw JSON on error
serde_json::from_str(&text).map_err(|e| {
error!("[OnlineRepo] Failed to deserialize {} response: {}", endpoint, e);
error!("[OnlineRepo] Response body (first 1000 chars): {}",
if text.len() > 1000 { &text[..1000] } else { &text });
error!(
"[OnlineRepo] Failed to deserialize {} response: {}",
endpoint, e
);
error!(
"[OnlineRepo] Response body (first 1000 chars): {}",
if text.len() > 1000 {
&text[..1000]
} else {
&text
}
);
RepoError::Server {
message: format!("Failed to parse response: {}", e),
}
@@ -213,10 +249,17 @@ impl OnlineRepository {
result
}
async fn post_json_inner<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
async fn post_json_inner<T: Serialize>(
&self,
endpoint: &str,
body: &T,
) -> Result<(), RepoError> {
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.post(&url)
let request = self
.http_client
.client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.auth_header())
.json(body)
@@ -225,8 +268,13 @@ impl OnlineRepository {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
@@ -268,7 +316,10 @@ impl OnlineRepository {
debug!("[HTTP] Request body:\n{}", json);
}
let request = self.http_client.client.post(&url)
let request = self
.http_client
.client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.auth_header())
.json(body)
@@ -277,14 +328,22 @@ impl OnlineRepository {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
let status = response.status();
// Capture response body for error details
let error_body = response.text().await.unwrap_or_else(|_| "Failed to read error body".to_string());
let error_body = response
.text()
.await
.unwrap_or_else(|_| "Failed to read error body".to_string());
error!("[HTTP] Error response ({}): {}", status, error_body);
if status.as_u16() == 401 || status.as_u16() == 403 {
@@ -325,8 +384,7 @@ impl OnlineRepository {
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
// Convert seconds to ticks (10,000,000 ticks per second)
let start_time_ticks = start_time_seconds
.map(|seconds| (seconds * 10_000_000.0) as i64);
let start_time_ticks = start_time_seconds.map(|seconds| (seconds * 10_000_000.0) as i64);
// Use provided audio stream index, or default to 0
let audio_index = audio_stream_index.unwrap_or(0).to_string();
@@ -370,6 +428,69 @@ impl OnlineRepository {
Ok(url)
}
/// Get an **audio-only** stream URL for a *video* item, for the
/// background-audio handoff (UR-040).
///
/// TRACES: UR-040 | JA-032 | UT-059
///
/// This deliberately targets `/Audio/{id}/universal`, NOT the video stream:
/// the server extracts/transcodes only the item's audio track and streams
/// pure audio bytes — no video frames reach the device, so there is no client
/// video decode while backgrounded. Do NOT "optimize" this to reuse the
/// `/Videos/.../master.m3u8` URL: that would keep the device decoding video,
/// defeating the entire point of the feature.
///
/// `AudioStreamIndex` carries the user's currently-selected audio track over
/// from the video player; `StartTimeTicks` resumes at the handoff position.
/// `universal` lets the server pick direct-play vs transcode per codec/device.
///
/// The stream is a **progressive** container (mp3 over plain HTTP), NOT HLS:
/// ExoPlayer plays this natively, whereas an HLS/`ts` transcode on the
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
/// decodable and supports mid-stream `StartTimeTicks`.
pub async fn get_audio_only_stream_url_for_video(
&self,
item_id: &str,
media_source_id: Option<&str>,
start_time_seconds: Option<f64>,
audio_stream_index: Option<i32>,
) -> Result<String, RepoError> {
let audio_index = audio_stream_index.unwrap_or(0).to_string();
let mut params = vec![
("UserId", self.user_id.clone()),
("api_key", self.access_token.clone()),
("DeviceId", "jellytau-tauri".to_string()),
("AudioStreamIndex", audio_index),
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
("Container", "mp3".to_string()),
("AudioCodec", "mp3".to_string()),
("TranscodingContainer", "mp3".to_string()),
("TranscodingProtocol", "http".to_string()),
("MaxStreamingBitrate", "384000".to_string()),
];
if let Some(source_id) = media_source_id {
params.push(("MediaSourceId", source_id.to_string()));
}
if let Some(seconds) = start_time_seconds {
let ticks = (seconds * 10_000_000.0) as i64;
params.push(("StartTimeTicks", ticks.to_string()));
}
let query = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let url = format!("{}/Audio/{}/universal?{}", self.server_url, item_id, query);
Ok(url)
}
}
// Jellyfin API response types (PascalCase from server)
@@ -707,7 +828,10 @@ impl MediaRepository for OnlineRepository {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!("/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags", self.user_id, limit_str);
let mut endpoint = format!(
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
self.user_id, limit_str
);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
@@ -753,14 +877,19 @@ impl MediaRepository for OnlineRepository {
for item in items {
// Use album_id if available, fall back to album_name for grouping
let group_key = item.album_id.clone()
.or_else(|| item.album_name.clone());
let group_key = item.album_id.clone().or_else(|| item.album_name.clone());
if let Some(key) = group_key {
debug!("[get_recently_played_audio] Grouping item '{}' into album '{}'", item.name, key);
debug!(
"[get_recently_played_audio] Grouping item '{}' into album '{}'",
item.name, key
);
album_map.entry(key).or_insert_with(Vec::new).push(item);
} else {
debug!("[get_recently_played_audio] No album_id or album_name for item: '{}'", item.name);
debug!(
"[get_recently_played_audio] No album_id or album_name for item: '{}'",
item.name
);
ungrouped.push(item);
}
}
@@ -770,17 +899,29 @@ impl MediaRepository for OnlineRepository {
.into_iter()
.map(|(album_id, tracks)| {
let first_track = &tracks[0];
let most_recent = tracks.iter()
let most_recent = tracks
.iter()
.max_by(|a, b| {
let date_a = a.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
let date_b = b.user_data.as_ref().and_then(|ud| ud.last_played_date.as_deref()).unwrap_or("");
let date_a = a
.user_data
.as_ref()
.and_then(|ud| ud.last_played_date.as_deref())
.unwrap_or("");
let date_b = b
.user_data
.as_ref()
.and_then(|ud| ud.last_played_date.as_deref())
.unwrap_or("");
date_b.cmp(date_a)
})
.unwrap_or(first_track);
MediaItem {
id: album_id,
name: first_track.album_name.clone().unwrap_or_else(|| "Unknown Album".to_string()),
name: first_track
.album_name
.clone()
.unwrap_or_else(|| "Unknown Album".to_string()),
item_type: "MusicAlbum".to_string(),
is_folder: true,
server_id: first_track.server_id.clone(),
@@ -820,9 +961,15 @@ impl MediaRepository for OnlineRepository {
// Return only the requested limit
let final_result: Vec<MediaItem> = result.into_iter().take(limit_val).collect();
debug!("[get_recently_played_audio] Returning {} items after grouping", final_result.len());
debug!(
"[get_recently_played_audio] Returning {} items after grouping",
final_result.len()
);
for item in &final_result {
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
debug!(
"[get_recently_played_audio] Return: name={}, type={}",
item.name, item.item_type
);
}
Ok(final_result)
}
@@ -1061,8 +1208,8 @@ impl MediaRepository for OnlineRepository {
// Get detected codecs from Android MediaCodecList or use platform defaults
#[cfg(target_os = "android")]
let (video_codecs, audio_codecs) = crate::player::get_detected_codecs()
.unwrap_or_else(|| {
let (video_codecs, audio_codecs) =
crate::player::get_detected_codecs().unwrap_or_else(|| {
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
("h264,hevc".to_string(), "aac,mp3".to_string())
});
@@ -1073,10 +1220,8 @@ impl MediaRepository for OnlineRepository {
// (Audio-only files still direct-play via MPV, but the PlaybackInfo
// profile is shared, so we keep the broadly-supported audio codecs.)
#[cfg(all(not(target_os = "android"), target_os = "linux"))]
let (video_codecs, audio_codecs) = (
"h264".to_string(),
"aac,mp3,opus,vorbis,flac".to_string(),
);
let (video_codecs, audio_codecs) =
("h264".to_string(), "aac,mp3,opus,vorbis,flac".to_string());
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
let (video_codecs, audio_codecs) = (
@@ -1139,25 +1284,31 @@ impl MediaRepository for OnlineRepository {
// POST to PlaybackInfo with device profile containing detected codecs
let request_body = PlaybackInfoRequest {
user_id: self.user_id.clone(),
audio_stream_index: 0, // Request first audio stream
audio_stream_index: 0, // Request first audio stream
subtitle_stream_index: None,
start_time_ticks: 0,
is_playback: true,
auto_open_live_stream: true,
max_streaming_bitrate: 20_000_000, // 20 Mbps
device_profile: Some(device_profile), // Now sending profile with detected codecs
max_streaming_bitrate: 20_000_000, // 20 Mbps
device_profile: Some(device_profile), // Now sending profile with detected codecs
};
let response: PlaybackInfoResponse = self.post_json_response(&endpoint, &request_body).await?;
let response: PlaybackInfoResponse =
self.post_json_response(&endpoint, &request_body).await?;
let source = response.media_sources.first().ok_or(RepoError::NotFound {
message: "No media sources available".to_string(),
})?;
// Log available media streams for debugging
info!("PlaybackInfo MediaSource has {} streams", source.media_streams.len());
info!(
"PlaybackInfo MediaSource has {} streams",
source.media_streams.len()
);
for stream in &source.media_streams {
info!(" Stream type={}, index={}, codec={:?}",
stream.stream_type, stream.index, stream.codec);
info!(
" Stream type={}, index={}, codec={:?}",
stream.stream_type, stream.index, stream.codec
);
}
// Use TranscodingUrl from response if available (Streamyfin pattern)
@@ -1265,12 +1416,15 @@ impl MediaRepository for OnlineRepository {
max_streaming_bitrate: 20_000_000,
};
let response: OpenLiveStreamResponse =
self.post_json_response(&endpoint, &request).await?;
let response: OpenLiveStreamResponse = self.post_json_response(&endpoint, &request).await?;
let source = response.media_sources.into_iter().next().ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
let source = response
.media_sources
.into_iter()
.next()
.ok_or(RepoError::NotFound {
message: "No live media source returned".to_string(),
})?;
// The transcoding URL is server-relative; make it absolute. If the server
// did not provide one (rare for live), fall back to the HLS master endpoint.
@@ -1410,11 +1564,7 @@ impl MediaRepository for OnlineRepository {
) -> String {
format!(
"{}/Videos/{}/{}/Subtitles/{}/{}",
self.server_url,
item_id,
media_source_id,
stream_index,
format
self.server_url, item_id, media_source_id, stream_index, format
)
}
@@ -1484,15 +1634,23 @@ impl MediaRepository for OnlineRepository {
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
let request = self.http_client.client.delete(&url)
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
@@ -1578,15 +1736,18 @@ impl MediaRepository for OnlineRepository {
name: &str,
item_ids: &[String],
) -> Result<PlaylistCreatedResult, RepoError> {
info!("[OnlineRepo] Creating playlist '{}' with {} items", name, item_ids.len());
info!(
"[OnlineRepo] Creating playlist '{}' with {} items",
name,
item_ids.len()
);
let body = serde_json::json!({
"Name": name,
"Ids": item_ids,
"MediaType": "Audio",
"UserId": self.user_id,
});
let response: CreatePlaylistResponse =
self.post_json_response("/Playlists", &body).await?;
let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
Ok(PlaylistCreatedResult { id: response.id })
}
@@ -1595,15 +1756,23 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Items/{}", playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
@@ -1615,15 +1784,16 @@ impl MediaRepository for OnlineRepository {
}
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
info!(
"[OnlineRepo] Renaming playlist {} to '{}'",
playlist_id, name
);
let endpoint = format!("/Items/{}", playlist_id);
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
.await
}
async fn get_playlist_items(
&self,
playlist_id: &str,
) -> Result<Vec<PlaylistEntry>, RepoError> {
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
let endpoint = format!(
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
playlist_id, self.user_id
@@ -1675,15 +1845,23 @@ impl MediaRepository for OnlineRepository {
let endpoint = format!("/Playlists/{}/Items?EntryIds={}", playlist_id, ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self.http_client.client.delete(&url)
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
})?;
let response = self.http_client.request_with_retry(request).await
.map_err(|e| RepoError::Network { message: e.to_string() })?;
let response = self
.http_client
.request_with_retry(request)
.await
.map_err(|e| RepoError::Network {
message: e.to_string(),
})?;
if !response.status().is_success() {
return Err(RepoError::Server {
@@ -1719,9 +1897,8 @@ mod tests {
fn create_test_repository() -> OnlineRepository {
let http_config = crate::jellyfin::HttpConfig::default();
let http_client = Arc::new(
HttpClient::new(http_config).expect("Failed to create HTTP client for test")
);
let http_client =
Arc::new(HttpClient::new(http_config).expect("Failed to create HTTP client for test"));
OnlineRepository::new(
http_client,
"https://test.server.com".to_string(),
@@ -1757,9 +1934,15 @@ mod tests {
// Drive offline first so we can observe "recover to reachable".
for err in [
RepoError::Authentication { message: "401".into() },
RepoError::NotFound { message: "404".into() },
RepoError::Server { message: "500".into() },
RepoError::Authentication {
message: "401".into(),
},
RepoError::NotFound {
message: "404".into(),
},
RepoError::Server {
message: "500".into(),
},
] {
reporter.mark_unreachable_for_test().await;
assert!(!reporter.is_reachable().await, "precondition: offline");
@@ -1789,7 +1972,12 @@ mod tests {
// Force offline, then a Database/Offline error must leave it offline
// (not falsely report reachable).
reporter.mark_unreachable_for_test().await;
for err in [RepoError::Database { message: "cache".into() }, RepoError::Offline] {
for err in [
RepoError::Database {
message: "cache".into(),
},
RepoError::Offline,
] {
let result: Result<(), RepoError> = Err(err);
repo.report_outcome(&result).await;
assert!(
@@ -1825,7 +2013,9 @@ mod tests {
let (repo, reporter) = create_test_repository_with_connectivity();
assert!(reporter.is_reachable().await, "starts online");
let result: Result<(), RepoError> = Err(RepoError::Network { message: "timeout".into() });
let result: Result<(), RepoError> = Err(RepoError::Network {
message: "timeout".into(),
});
repo.report_outcome(&result).await;
assert!(
@@ -1889,6 +2079,64 @@ mod tests {
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_only_stream_url_for_video_carries_track_and_position() {
// TRACES: UR-040 | JA-032 | UT-059
// Background-audio handoff must request an audio-only stream (no video
// decode) that resumes at the current position and keeps the selected
// audio track.
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", Some("source-1"), Some(193.0), Some(2))
.await
.unwrap();
assert!(
url.starts_with("https://test.server.com/Audio/vid-1/universal?"),
"expected audio-only universal endpoint, got: {url}"
);
// Must NOT be a video stream (no client video decode in background).
assert!(
!url.contains("/Videos/"),
"url must not hit the video endpoint: {url}"
);
assert!(
!url.contains("master.m3u8"),
"url must not be a video HLS playlist: {url}"
);
assert!(url.contains("AudioStreamIndex=2"));
assert!(url.contains("MediaSourceId=source-1"));
// 193.0 seconds * 10_000_000 ticks/sec
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
// Progressive mp3 over HTTP — NOT HLS/ts, or ExoPlayer's progressive
// loader fails with ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED.
assert!(url.contains("TranscodingProtocol=http"), "url: {url}");
assert!(url.contains("TranscodingContainer=mp3"), "url: {url}");
assert!(
!url.contains("TranscodingProtocol=hls"),
"url must not be HLS: {url}"
);
assert!(!url.contains("Container=ts"), "url must not be ts: {url}");
}
#[tokio::test]
async fn test_get_audio_only_stream_url_for_video_omits_position_when_absent() {
// TRACES: UR-040 | JA-032 | UT-059
let repo = create_test_repository();
let url = repo
.get_audio_only_stream_url_for_video("vid-1", None, None, None)
.await
.unwrap();
assert!(url.starts_with("https://test.server.com/Audio/vid-1/universal?"));
assert!(!url.contains("StartTimeTicks"));
assert!(!url.contains("MediaSourceId"));
// Defaults to first audio stream.
assert!(url.contains("AudioStreamIndex=0"));
}
#[tokio::test]
async fn test_get_audio_stream_url_with_special_characters() {
let repo = create_test_repository();
@@ -1982,8 +2230,14 @@ mod tests {
// "original" must request a direct static copy (byte-range resumable),
// with no transcode params.
assert!(url.contains("Static=true"), "url: {url}");
assert!(!url.contains("videoBitrate"), "original must not transcode: {url}");
assert!(!url.contains("maxHeight"), "original must not transcode: {url}");
assert!(
!url.contains("videoBitrate"),
"original must not transcode: {url}"
);
assert!(
!url.contains("maxHeight"),
"original must not transcode: {url}"
);
}
#[test]
@@ -1996,14 +2250,20 @@ mod tests {
url.contains("/Videos/item123/stream.mp4"),
"{quality} must use stream.mp4: {url}"
);
assert!(url.contains("videoBitrate="), "{quality} must set bitrate: {url}");
assert!(
url.contains("videoBitrate="),
"{quality} must set bitrate: {url}"
);
assert!(
url.contains(&format!("maxHeight={height}")),
"{quality} must cap height at {height}: {url}"
);
assert!(url.contains("videoCodec=h264"), "{quality}: {url}");
// Transcoded presets must not also ask for a static copy.
assert!(!url.contains("Static=true"), "{quality} must not be Static: {url}");
assert!(
!url.contains("Static=true"),
"{quality} must not be Static: {url}"
);
}
}
@@ -2036,7 +2296,10 @@ mod tests {
assert_eq!(item.name, "Test Album");
assert_eq!(item.item_type, "MusicAlbum");
assert!(item.image_tags.is_some());
assert_eq!(item.image_tags.unwrap().primary(), Some("tag123".to_string()));
assert_eq!(
item.image_tags.unwrap().primary(),
Some("tag123".to_string())
);
}
#[test]
@@ -2083,7 +2346,10 @@ mod tests {
assert_eq!(media_item.id, "album456");
assert_eq!(media_item.name, "Love and Theft");
assert_eq!(media_item.item_type, "MusicAlbum");
assert_eq!(media_item.primary_image_tag, Some("7ebab4f6a80cd09d".to_string()));
assert_eq!(
media_item.primary_image_tag,
Some("7ebab4f6a80cd09d".to_string())
);
assert_eq!(media_item.server_id, "test-server-id");
}