2160 lines
80 KiB
Rust
2160 lines
80 KiB
Rust
//! 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 serde::{Deserialize, Serialize};
|
|
|
|
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.
|
|
///
|
|
/// Mirrors the `actors[]` objects from `GET /Plugins/JRay/Items/{id}/jray?t=`.
|
|
/// `jellyfin_id` (a Jellyfin Person item GUID) is preferred for navigation;
|
|
/// the IMDb/TMDb ids are informational fallbacks. Unknown ids are `""`.
|
|
#[derive(specta::Type, Debug, Clone, Serialize, Deserialize)]
|
|
pub struct JRayActor {
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub imdb_id: String,
|
|
#[serde(default)]
|
|
pub tmdb_id: String,
|
|
#[serde(default)]
|
|
pub jellyfin_id: String,
|
|
}
|
|
|
|
/// Envelope returned by the JRay `jray?t=` endpoint. Extra keys (future
|
|
/// `locations`, `trivia`, …) are ignored so the client tolerates schema growth.
|
|
#[derive(Debug, Clone, Deserialize)]
|
|
struct JRayContext {
|
|
#[serde(default)]
|
|
actors: Vec<JRayActor>,
|
|
}
|
|
|
|
/// Online repository - fetches data from Jellyfin server via HTTP
|
|
pub struct OnlineRepository {
|
|
http_client: Arc<HttpClient>,
|
|
server_url: String,
|
|
user_id: String,
|
|
access_token: String,
|
|
/// Reports the outcome of every server request to the connectivity monitor.
|
|
/// This is the source of truth for the offline/online banner. `None` in
|
|
/// tests / contexts where connectivity tracking isn't wired up.
|
|
connectivity: Option<ConnectivityReporter>,
|
|
}
|
|
|
|
impl OnlineRepository {
|
|
pub fn new(
|
|
http_client: Arc<HttpClient>,
|
|
server_url: String,
|
|
user_id: String,
|
|
access_token: String,
|
|
) -> Self {
|
|
Self {
|
|
http_client,
|
|
server_url,
|
|
user_id,
|
|
access_token,
|
|
connectivity: None,
|
|
}
|
|
}
|
|
|
|
/// Attach a connectivity reporter so server outcomes drive the reachability
|
|
/// state observed by the UI. See `report_outcome`.
|
|
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
|
|
self.connectivity = Some(reporter);
|
|
self
|
|
}
|
|
|
|
/// Feed a request outcome into the connectivity monitor.
|
|
///
|
|
/// Classification (matches docs/architecture/07-connectivity.md):
|
|
/// - `Ok` / `Authentication` / `NotFound` / `Server` → the server answered,
|
|
/// so it is reachable → `report_success` (instant recovery).
|
|
/// - `Network` → connection-level failure → `report_network_failure`
|
|
/// (subject to the time-window debounce before going offline).
|
|
/// - `Database` → not a server signal → ignored.
|
|
async fn report_outcome<T>(&self, result: &Result<T, RepoError>) {
|
|
let Some(reporter) = &self.connectivity else {
|
|
return;
|
|
};
|
|
|
|
match result {
|
|
Ok(_)
|
|
| Err(RepoError::Authentication { .. })
|
|
| Err(RepoError::NotFound { .. })
|
|
| Err(RepoError::Server { .. }) => {
|
|
reporter.report_success().await;
|
|
}
|
|
Err(RepoError::Network { message }) => {
|
|
reporter.report_network_failure(Some(message.clone())).await;
|
|
}
|
|
Err(RepoError::Database { .. }) | Err(RepoError::Offline) => {
|
|
// Local-side errors (cache failure / already-offline) — not a
|
|
// statement about the server's reachability, so ignore them.
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build authorization header
|
|
fn auth_header(&self) -> String {
|
|
HttpClient::build_auth_header(Some(&self.access_token), "jellytau-device")
|
|
}
|
|
|
|
/// 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)
|
|
.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
|
|
.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 };
|
|
return Err(format!("HTTP {} ({})", status, body_preview.trim()));
|
|
}
|
|
|
|
response.bytes().await
|
|
.map(|b| b.to_vec())
|
|
.map_err(|e| format!("Failed to read bytes: {}", e))
|
|
}
|
|
|
|
/// Query the JRay plugin for the actors on screen at time `t` (seconds) in
|
|
/// 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> {
|
|
let endpoint = format!("/Plugins/JRay/Items/{}/jray?t={}", item_id, t);
|
|
match self.get_json::<JRayContext>(&endpoint).await {
|
|
Ok(context) => Ok(context.actors),
|
|
// No plugin / no truth data for this item — not an error to the user.
|
|
Err(RepoError::NotFound { .. }) => Ok(Vec::new()),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Make authenticated GET request
|
|
async fn get_json<T: for<'de> Deserialize<'de>>(&self, endpoint: &str) -> Result<T, RepoError> {
|
|
// Fast-fail when connectivity is known-offline. Without this every request
|
|
// still runs the full HTTP retry/backoff cycle (~7s) before giving up,
|
|
// which stalls cache-miss paths and makes offline browsing feel janky.
|
|
// The offline recovery probe (connectivity monitor) flips us back to
|
|
// reachable the moment the server returns, so this never sticks.
|
|
if let Some(reporter) = &self.connectivity {
|
|
if !reporter.is_reachable().await {
|
|
return Err(RepoError::Offline);
|
|
}
|
|
}
|
|
|
|
let result = self.get_json_inner(endpoint).await;
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
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)
|
|
.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() })?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
|
return Err(RepoError::Authentication {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
} else if status.as_u16() == 404 {
|
|
return Err(RepoError::NotFound {
|
|
message: "Resource not found".to_string(),
|
|
});
|
|
} else {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get the response text first for better error reporting
|
|
let text = response.text().await.map_err(|e| RepoError::Server {
|
|
message: format!("Failed to read response: {}", e),
|
|
})?;
|
|
|
|
// 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 });
|
|
RepoError::Server {
|
|
message: format!("Failed to parse response: {}", e),
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Make authenticated POST request
|
|
async fn post_json<T: Serialize>(&self, endpoint: &str, body: &T) -> Result<(), RepoError> {
|
|
let result = self.post_json_inner(endpoint, body).await;
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
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)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.json(body)
|
|
.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() })?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
|
return Err(RepoError::Authentication {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
} else {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", status),
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Make authenticated POST request and return response
|
|
async fn post_json_response<T: Serialize, R: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
endpoint: &str,
|
|
body: &T,
|
|
) -> Result<R, RepoError> {
|
|
let result = self.post_json_response_inner(endpoint, body).await;
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
async fn post_json_response_inner<T: Serialize, R: for<'de> Deserialize<'de>>(
|
|
&self,
|
|
endpoint: &str,
|
|
body: &T,
|
|
) -> Result<R, RepoError> {
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
// Log request body for debugging
|
|
if let Ok(json) = serde_json::to_string_pretty(body) {
|
|
debug!("[HTTP] POST {}", endpoint);
|
|
debug!("[HTTP] Request body:\n{}", json);
|
|
}
|
|
|
|
let request = self.http_client.client.post(&url)
|
|
.header("Content-Type", "application/json")
|
|
.header("X-Emby-Authorization", self.auth_header())
|
|
.json(body)
|
|
.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() })?;
|
|
|
|
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());
|
|
error!("[HTTP] Error response ({}): {}", status, error_body);
|
|
|
|
if status.as_u16() == 401 || status.as_u16() == 403 {
|
|
return Err(RepoError::Authentication {
|
|
message: format!("HTTP {}: {}", status, error_body),
|
|
});
|
|
} else if status.as_u16() == 404 {
|
|
return Err(RepoError::NotFound {
|
|
message: format!("Resource not found: {}", error_body),
|
|
});
|
|
} else {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}: {}", status, error_body),
|
|
});
|
|
}
|
|
}
|
|
|
|
response.json().await.map_err(|e| RepoError::Server {
|
|
message: format!("Failed to parse response: {}", e),
|
|
})
|
|
}
|
|
|
|
/// Get a video stream URL for playback at an arbitrary position (resume,
|
|
/// transcoded seeking, audio-track switching).
|
|
///
|
|
/// Returns an HLS master playlist (`/Videos/{id}/master.m3u8`) transcoded to
|
|
/// h264/aac. HLS is used rather than a progressive `stream.mp4` because the
|
|
/// HTML5 `<video>` element (via HLS.js) starts playing within seconds and can
|
|
/// seek within the stream, whereas a progressive MP4 transcode of HEVC source
|
|
/// forces the server to transcode the whole file before playback can begin —
|
|
/// which manifests as playback never starting. `StartTimeTicks` makes the
|
|
/// server begin the transcode at the requested position.
|
|
pub async fn get_video_stream_url(
|
|
&self,
|
|
item_id: &str,
|
|
media_source_id: Option<&str>,
|
|
start_time_seconds: Option<f64>,
|
|
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);
|
|
|
|
// Use provided audio stream index, or default to 0
|
|
let audio_index = audio_stream_index.unwrap_or(0).to_string();
|
|
|
|
// Build an HLS transcode URL. VideoCodec lists h264 first so the server
|
|
// transcodes HEVC/10-bit/unsupported sources to h264 the WebView can decode.
|
|
let mut params = vec![
|
|
("api_key", self.access_token.clone()),
|
|
("DeviceId", "jellytau-tauri".to_string()),
|
|
("VideoCodec", "h264".to_string()),
|
|
("AudioCodec", "aac".to_string()),
|
|
("AudioStreamIndex", audio_index),
|
|
("MaxStreamingBitrate", "20000000".to_string()),
|
|
("VideoBitrate", "18000000".to_string()),
|
|
("AudioBitrate", "384000".to_string()),
|
|
("TranscodingMaxAudioChannels", "2".to_string()),
|
|
("SegmentContainer", "ts".to_string()),
|
|
("TranscodingContainer", "ts".to_string()),
|
|
("TranscodingProtocol", "hls".to_string()),
|
|
];
|
|
|
|
if let Some(source_id) = media_source_id {
|
|
params.push(("MediaSourceId", source_id.to_string()));
|
|
}
|
|
|
|
if let Some(ticks) = start_time_ticks {
|
|
params.push(("StartTimeTicks", ticks.to_string()));
|
|
}
|
|
|
|
// Build query string (values are already safe, no encoding needed)
|
|
let query = params
|
|
.iter()
|
|
.map(|(k, v)| format!("{}={}", k, v))
|
|
.collect::<Vec<_>>()
|
|
.join("&");
|
|
|
|
let url = format!(
|
|
"{}/Videos/{}/master.m3u8?{}",
|
|
self.server_url, item_id, query
|
|
);
|
|
|
|
Ok(url)
|
|
}
|
|
}
|
|
|
|
// Jellyfin API response types (PascalCase from server)
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct ItemsResponse {
|
|
items: Vec<JellyfinItem>,
|
|
total_record_count: usize,
|
|
}
|
|
|
|
/// Jellyfin playlist creation response
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct CreatePlaylistResponse {
|
|
id: String,
|
|
}
|
|
|
|
/// Jellyfin playlist items response — items include PlaylistItemId
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
#[allow(dead_code)]
|
|
struct PlaylistItemsResponse {
|
|
items: Vec<JellyfinPlaylistItem>,
|
|
total_record_count: usize,
|
|
}
|
|
|
|
/// A playlist item from Jellyfin — wraps a regular item with an entry-scoped ID
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinPlaylistItem {
|
|
playlist_item_id: String,
|
|
#[serde(flatten)]
|
|
item: JellyfinItem,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinItem {
|
|
id: String,
|
|
name: String,
|
|
#[serde(rename = "Type")]
|
|
item_type: String,
|
|
#[serde(default)]
|
|
is_folder: bool,
|
|
parent_id: Option<String>,
|
|
overview: Option<String>,
|
|
genres: Option<Vec<String>>,
|
|
production_year: Option<i32>,
|
|
premiere_date: Option<String>,
|
|
community_rating: Option<f64>,
|
|
official_rating: Option<String>,
|
|
run_time_ticks: Option<i64>,
|
|
image_tags: Option<ImageTags>,
|
|
backdrop_image_tags: Option<Vec<String>>,
|
|
parent_backdrop_image_tags: Option<Vec<String>>,
|
|
album_id: Option<String>,
|
|
album: Option<String>,
|
|
album_artist: Option<String>,
|
|
artists: Option<Vec<String>>,
|
|
artist_items: Option<Vec<crate::repository::types::ArtistItem>>,
|
|
index_number: Option<i32>,
|
|
parent_index_number: Option<i32>,
|
|
series_id: Option<String>,
|
|
series_name: Option<String>,
|
|
season_id: Option<String>,
|
|
season_name: Option<String>,
|
|
media_streams: Option<Vec<JellyfinMediaStream>>,
|
|
media_sources: Option<Vec<JellyfinMediaSource>>,
|
|
people: Option<Vec<crate::repository::types::Person>>,
|
|
}
|
|
|
|
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
|
|
// We use a wrapper to extract just the Primary tag we need
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(untagged)]
|
|
enum ImageTags {
|
|
// Modern format: HashMap
|
|
Map(std::collections::HashMap<String, String>),
|
|
// Legacy/alternative format: structured
|
|
Structured {
|
|
#[serde(rename = "Primary")]
|
|
primary: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl ImageTags {
|
|
fn primary(&self) -> Option<String> {
|
|
match self {
|
|
ImageTags::Map(map) => map.get("Primary").cloned(),
|
|
ImageTags::Structured { primary } => primary.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinMediaStream {
|
|
#[serde(rename = "Type")]
|
|
stream_type: String,
|
|
codec: Option<String>,
|
|
language: Option<String>,
|
|
display_title: Option<String>,
|
|
index: i32,
|
|
is_default: bool,
|
|
#[serde(default)]
|
|
is_forced: bool,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinMediaSource {
|
|
id: String,
|
|
name: String,
|
|
container: Option<String>,
|
|
size: Option<i64>,
|
|
bitrate: Option<i32>,
|
|
supports_direct_play: bool,
|
|
supports_direct_stream: bool,
|
|
supports_transcoding: bool,
|
|
direct_stream_url: Option<String>,
|
|
}
|
|
|
|
impl JellyfinItem {
|
|
fn to_media_item(self, server_id: String) -> MediaItem {
|
|
// Extract image tags before consuming self
|
|
let primary_tag = self.image_tags.as_ref().and_then(|tags| tags.primary());
|
|
let backdrop_tags = self.backdrop_image_tags;
|
|
|
|
MediaItem {
|
|
id: self.id,
|
|
name: self.name,
|
|
item_type: self.item_type,
|
|
is_folder: self.is_folder,
|
|
server_id,
|
|
parent_id: self.parent_id,
|
|
library_id: None, // Not provided by Jellyfin API directly
|
|
overview: self.overview,
|
|
genres: self.genres,
|
|
production_year: self.production_year,
|
|
premiere_date: self.premiere_date,
|
|
community_rating: self.community_rating,
|
|
official_rating: self.official_rating,
|
|
runtime_ticks: self.run_time_ticks,
|
|
primary_image_tag: primary_tag,
|
|
backdrop_image_tags: backdrop_tags,
|
|
parent_backdrop_image_tags: self.parent_backdrop_image_tags,
|
|
album_id: self.album_id,
|
|
album_name: self.album,
|
|
album_artist: self.album_artist,
|
|
artists: self.artists,
|
|
artist_items: self.artist_items,
|
|
index_number: self.index_number,
|
|
parent_index_number: self.parent_index_number,
|
|
series_id: self.series_id,
|
|
series_name: self.series_name,
|
|
season_id: self.season_id,
|
|
season_name: self.season_name,
|
|
user_data: None, // User data not included in basic item responses
|
|
media_streams: self.media_streams.map(|streams| {
|
|
streams
|
|
.into_iter()
|
|
.map(|s| crate::repository::types::MediaStream {
|
|
stream_type: s.stream_type,
|
|
codec: s.codec,
|
|
language: s.language,
|
|
display_title: s.display_title,
|
|
index: s.index,
|
|
is_default: s.is_default,
|
|
is_forced: s.is_forced,
|
|
})
|
|
.collect()
|
|
}),
|
|
media_sources: self.media_sources.map(|sources| {
|
|
sources
|
|
.into_iter()
|
|
.map(|s| crate::repository::types::MediaSource {
|
|
id: s.id,
|
|
name: s.name,
|
|
container: s.container,
|
|
size: s.size,
|
|
bitrate: s.bitrate,
|
|
supports_direct_play: s.supports_direct_play,
|
|
supports_direct_stream: s.supports_direct_stream,
|
|
supports_transcoding: s.supports_transcoding,
|
|
direct_stream_url: s.direct_stream_url,
|
|
})
|
|
.collect()
|
|
}),
|
|
people: self.people,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MediaRepository for OnlineRepository {
|
|
async fn get_libraries(&self) -> Result<Vec<Library>, RepoError> {
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct LibrariesResponse {
|
|
items: Vec<JellyfinLibrary>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinLibrary {
|
|
id: String,
|
|
name: String,
|
|
collection_type: Option<String>,
|
|
image_tags: Option<ImageTags>,
|
|
}
|
|
|
|
let endpoint = format!("/Users/{}/Views", self.user_id);
|
|
let response: LibrariesResponse = self.get_json(&endpoint).await?;
|
|
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|lib| Library {
|
|
id: lib.id,
|
|
name: lib.name,
|
|
collection_type: lib.collection_type.unwrap_or_else(|| "unknown".to_string()),
|
|
image_tag: lib.image_tags.and_then(|tags| tags.primary()),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn get_items(
|
|
&self,
|
|
parent_id: &str,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError> {
|
|
let mut endpoint = format!("/Users/{}/Items?ParentId={}", self.user_id, parent_id);
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(limit) = opts.limit {
|
|
endpoint.push_str(&format!("&Limit={}", limit));
|
|
}
|
|
if let Some(start_index) = opts.start_index {
|
|
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
|
}
|
|
if let Some(types) = opts.include_item_types {
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
|
}
|
|
if let Some(sort_by) = opts.sort_by {
|
|
endpoint.push_str(&format!("&SortBy={}", sort_by));
|
|
}
|
|
if let Some(sort_order) = opts.sort_order {
|
|
endpoint.push_str(&format!("&SortOrder={}", sort_order));
|
|
}
|
|
if let Some(recursive) = opts.recursive {
|
|
endpoint.push_str(&format!("&Recursive={}", recursive));
|
|
}
|
|
if let Some(genres) = opts.genres {
|
|
if !genres.is_empty() {
|
|
// Genre names may contain spaces/ampersands, so percent-encode each.
|
|
let encoded: Vec<String> = genres
|
|
.iter()
|
|
.map(|g| urlencoding::encode(g).into_owned())
|
|
.collect();
|
|
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Request image fields for list views (People only needed in get_item
|
|
// detail view). Genres is needed so cached items carry their genres,
|
|
// which lets the offline store derive genre lists + per-genre counts.
|
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect(),
|
|
total_record_count: response.total_record_count,
|
|
})
|
|
}
|
|
|
|
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
|
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate", self.user_id, item_id);
|
|
|
|
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
|
let media_item = item.to_media_item(self.user_id.clone());
|
|
|
|
Ok(media_item)
|
|
}
|
|
|
|
async fn get_latest_items(
|
|
&self,
|
|
parent_id: &str,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_str = limit.unwrap_or(16);
|
|
let endpoint = format!(
|
|
"/Users/{}/Items/Latest?ParentId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
self.user_id, parent_id, limit_str
|
|
);
|
|
|
|
let items: Vec<JellyfinItem> = self.get_json(&endpoint).await?;
|
|
Ok(items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect())
|
|
}
|
|
|
|
async fn get_resume_items(
|
|
&self,
|
|
parent_id: Option<&str>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_str = limit.unwrap_or(16);
|
|
let mut endpoint = format!(
|
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
self.user_id, limit_str
|
|
);
|
|
|
|
if let Some(pid) = parent_id {
|
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect())
|
|
}
|
|
|
|
async fn get_next_up_episodes(
|
|
&self,
|
|
series_id: Option<&str>,
|
|
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);
|
|
|
|
if let Some(sid) = series_id {
|
|
endpoint.push_str(&format!("&SeriesId={}", sid));
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect())
|
|
}
|
|
|
|
async fn get_recently_played_audio(
|
|
&self,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_val = limit.unwrap_or(12);
|
|
// Fetch more items to account for grouping reducing the count
|
|
let fetch_limit = limit_val * 3;
|
|
let endpoint = format!(
|
|
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
self.user_id, fetch_limit
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
let items: Vec<MediaItem> = response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect();
|
|
|
|
debug!("[get_recently_played_audio] Fetched {} items", items.len());
|
|
for item in &items {
|
|
debug!("[get_recently_played_audio] Item: name={}, type={}, album_id={:?}, album_name={:?}",
|
|
item.name, item.item_type, item.album_id, item.album_name);
|
|
}
|
|
|
|
// Group by album - create pseudo-album entries for tracks with same albumId
|
|
use std::collections::BTreeMap;
|
|
let mut album_map: BTreeMap<String, Vec<MediaItem>> = BTreeMap::new();
|
|
let mut ungrouped = Vec::new();
|
|
|
|
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());
|
|
|
|
if let Some(key) = group_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);
|
|
ungrouped.push(item);
|
|
}
|
|
}
|
|
|
|
// Create album entries from grouped tracks
|
|
let mut result: Vec<MediaItem> = album_map
|
|
.into_iter()
|
|
.map(|(album_id, tracks)| {
|
|
let first_track = &tracks[0];
|
|
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("");
|
|
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()),
|
|
item_type: "MusicAlbum".to_string(),
|
|
is_folder: true,
|
|
server_id: first_track.server_id.clone(),
|
|
parent_id: None,
|
|
library_id: None,
|
|
overview: None,
|
|
genres: None,
|
|
production_year: None,
|
|
premiere_date: None,
|
|
community_rating: None,
|
|
official_rating: None,
|
|
runtime_ticks: None,
|
|
primary_image_tag: first_track.primary_image_tag.clone(),
|
|
backdrop_image_tags: None,
|
|
parent_backdrop_image_tags: None,
|
|
album_id: None,
|
|
album_name: None,
|
|
album_artist: None,
|
|
artists: first_track.artists.clone(),
|
|
artist_items: first_track.artist_items.clone(),
|
|
index_number: None,
|
|
parent_index_number: None,
|
|
series_id: None,
|
|
series_name: None,
|
|
season_id: None,
|
|
season_name: None,
|
|
user_data: most_recent.user_data.clone(),
|
|
media_streams: None,
|
|
media_sources: None,
|
|
people: None,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Append ungrouped tracks
|
|
result.extend(ungrouped);
|
|
|
|
// 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());
|
|
for item in &final_result {
|
|
debug!("[get_recently_played_audio] Return: name={}, type={}", item.name, item.item_type);
|
|
}
|
|
Ok(final_result)
|
|
}
|
|
|
|
async fn get_rediscover_albums(
|
|
&self,
|
|
parent_id: Option<&str>,
|
|
limit: Option<usize>,
|
|
) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_val = limit.unwrap_or(12);
|
|
// Ask Jellyfin for played albums sorted by least-recently played first.
|
|
// Filters=IsPlayed keeps only albums the user has actually listened to,
|
|
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
|
|
let mut endpoint = format!(
|
|
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
self.user_id, limit_val
|
|
);
|
|
|
|
if let Some(pid) = parent_id {
|
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect())
|
|
}
|
|
|
|
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
|
|
let limit_str = limit.unwrap_or(16);
|
|
let endpoint = format!(
|
|
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
self.user_id, limit_str
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect())
|
|
}
|
|
|
|
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
|
|
// Ask Jellyfin to scope counts to albums and include them, so the
|
|
// frontend can rank genres by popularity without probing each one.
|
|
let mut endpoint = format!(
|
|
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
|
|
self.user_id
|
|
);
|
|
|
|
if let Some(pid) = parent_id {
|
|
endpoint.push_str(&format!("&ParentId={}", pid));
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct GenresResponse {
|
|
items: Vec<JellyfinGenre>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct JellyfinGenre {
|
|
id: String,
|
|
name: String,
|
|
// Which count field Jellyfin populates for a genre under
|
|
// Fields=ItemCounts varies by server/version: scoped queries may
|
|
// fill AlbumCount, others only ChildCount. Read whichever is
|
|
// present so ranking still works. Absent on servers that ignore
|
|
// Fields=ItemCounts entirely, so all stay optional.
|
|
album_count: Option<u32>,
|
|
child_count: Option<u32>,
|
|
}
|
|
|
|
let response: GenresResponse = self.get_json(&endpoint).await?;
|
|
let genres: Vec<Genre> = response
|
|
.items
|
|
.into_iter()
|
|
.map(|g| Genre {
|
|
id: g.id,
|
|
name: g.name,
|
|
album_count: g.album_count.or(g.child_count),
|
|
})
|
|
.collect();
|
|
|
|
let with_counts = genres.iter().filter(|g| g.album_count.is_some()).count();
|
|
// TEMP DIAGNOSTIC: dump the first few genres with their counts so we can
|
|
// see whether the server populates any count field. Remove once known.
|
|
log::warn!(
|
|
"get_genres: {} genres, {} carry counts. sample: {:?}",
|
|
genres.len(),
|
|
with_counts,
|
|
genres
|
|
.iter()
|
|
.take(8)
|
|
.map(|g| (g.name.as_str(), g.album_count))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
|
|
Ok(genres)
|
|
}
|
|
|
|
async fn search(
|
|
&self,
|
|
query: &str,
|
|
options: Option<SearchOptions>,
|
|
) -> Result<SearchResult, RepoError> {
|
|
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(50);
|
|
// SearchTerm is arbitrary user input and must be percent-encoded so that
|
|
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
|
|
// search like "Star Wars" would otherwise produce a malformed URL).
|
|
let mut endpoint = format!(
|
|
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
|
|
self.user_id,
|
|
urlencoding::encode(query),
|
|
limit
|
|
);
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(types) = opts.include_item_types {
|
|
let encoded_types = types
|
|
.iter()
|
|
.map(|t| urlencoding::encode(t).into_owned())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
|
|
}
|
|
}
|
|
|
|
// Request image fields for list views (plus Genres so cached items
|
|
// carry genres for offline genre lists/counts).
|
|
endpoint.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate");
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect(),
|
|
total_record_count: response.total_record_count,
|
|
})
|
|
}
|
|
|
|
async fn get_playback_info(&self, item_id: &str) -> Result<PlaybackInfo, RepoError> {
|
|
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackInfoRequest {
|
|
user_id: String,
|
|
audio_stream_index: i32,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
subtitle_stream_index: Option<i32>,
|
|
start_time_ticks: i64,
|
|
is_playback: bool,
|
|
auto_open_live_stream: bool,
|
|
max_streaming_bitrate: i64,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
device_profile: Option<DeviceProfile>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct DeviceProfile {
|
|
name: String,
|
|
max_streaming_bitrate: i64,
|
|
max_static_bitrate: i64,
|
|
direct_play_profiles: Vec<DirectPlayProfile>,
|
|
transcoding_profiles: Vec<TranscodingProfile>,
|
|
subtitle_profiles: Vec<SubtitleProfile>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct DirectPlayProfile {
|
|
#[serde(rename = "Type")]
|
|
profile_type: String,
|
|
container: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
video_codec: Option<String>,
|
|
audio_codec: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct TranscodingProfile {
|
|
#[serde(rename = "Type")]
|
|
profile_type: String,
|
|
context: String,
|
|
protocol: String,
|
|
container: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
video_codec: Option<String>,
|
|
audio_codec: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct SubtitleProfile {
|
|
format: String,
|
|
method: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackInfoResponse {
|
|
media_sources: Vec<MediaSource>,
|
|
play_session_id: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct MediaSource {
|
|
id: String,
|
|
supports_direct_play: bool,
|
|
supports_transcoding: bool,
|
|
transcoding_url: Option<String>,
|
|
#[serde(default)]
|
|
media_streams: Vec<MediaStream>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct MediaStream {
|
|
#[serde(rename = "Type")]
|
|
stream_type: String,
|
|
#[serde(default)]
|
|
index: i32,
|
|
#[serde(default)]
|
|
codec: Option<String>,
|
|
}
|
|
|
|
// 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(|| {
|
|
warn!("[DeviceProfile] Codec detection not complete, using conservative defaults");
|
|
("h264,hevc".to_string(), "aac,mp3".to_string())
|
|
});
|
|
|
|
// Linux desktop plays video through the WebKitGTK HTML5 <video> element,
|
|
// which cannot reliably decode HEVC/AV1/VP9. Advertise only codecs the
|
|
// WebView can decode so Jellyfin transcodes anything else to h264 HLS.
|
|
// (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(),
|
|
);
|
|
|
|
#[cfg(all(not(target_os = "android"), not(target_os = "linux")))]
|
|
let (video_codecs, audio_codecs) = (
|
|
"h264,hevc,vp8,vp9,av1,mpeg4".to_string(),
|
|
"aac,mp3,opus,vorbis,flac".to_string(),
|
|
);
|
|
|
|
info!("[DeviceProfile] Using video codecs: {}", video_codecs);
|
|
info!("[DeviceProfile] Using audio codecs: {}", audio_codecs);
|
|
|
|
// Create device profile with detected hardware capabilities
|
|
let device_profile = DeviceProfile {
|
|
name: "JellyTau Native Player".to_string(),
|
|
max_streaming_bitrate: 999_999_999,
|
|
max_static_bitrate: 999_999_999,
|
|
direct_play_profiles: vec![
|
|
DirectPlayProfile {
|
|
profile_type: "Video".to_string(),
|
|
container: "mp4,mkv,avi,mov,flv,ts,m2ts,webm,ogv,3gp".to_string(),
|
|
video_codec: Some(video_codecs.clone()),
|
|
audio_codec: audio_codecs.clone(),
|
|
},
|
|
DirectPlayProfile {
|
|
profile_type: "Audio".to_string(),
|
|
container: "mp3,aac,flac,alac,wav,ogg,wma,opus".to_string(),
|
|
video_codec: None,
|
|
audio_codec: audio_codecs.clone(),
|
|
},
|
|
],
|
|
transcoding_profiles: vec![
|
|
TranscodingProfile {
|
|
profile_type: "Video".to_string(),
|
|
context: "Streaming".to_string(),
|
|
protocol: "hls".to_string(),
|
|
container: "ts".to_string(),
|
|
video_codec: Some("h264,hevc".to_string()),
|
|
audio_codec: "aac,mp3".to_string(),
|
|
},
|
|
TranscodingProfile {
|
|
profile_type: "Audio".to_string(),
|
|
context: "Streaming".to_string(),
|
|
protocol: "http".to_string(),
|
|
container: "mp3".to_string(),
|
|
video_codec: None,
|
|
audio_codec: "mp3".to_string(),
|
|
},
|
|
],
|
|
subtitle_profiles: vec![
|
|
SubtitleProfile {
|
|
format: "srt".to_string(),
|
|
method: "External".to_string(),
|
|
},
|
|
SubtitleProfile {
|
|
format: "vtt".to_string(),
|
|
method: "External".to_string(),
|
|
},
|
|
],
|
|
};
|
|
|
|
// 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
|
|
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
|
|
};
|
|
|
|
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());
|
|
for stream in &source.media_streams {
|
|
info!(" Stream type={}, index={}, codec={:?}",
|
|
stream.stream_type, stream.index, stream.codec);
|
|
}
|
|
|
|
// Use TranscodingUrl from response if available (Streamyfin pattern)
|
|
let stream_url = if let Some(transcoding_url) = &source.transcoding_url {
|
|
format!("{}{}", self.server_url, transcoding_url)
|
|
} else {
|
|
// Fall back to direct stream URL
|
|
format!(
|
|
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&audioStreamIndex=0&userId={}",
|
|
self.server_url,
|
|
item_id,
|
|
source.id,
|
|
self.access_token,
|
|
self.user_id
|
|
)
|
|
};
|
|
|
|
info!("Final stream URL: {}", stream_url);
|
|
|
|
Ok(PlaybackInfo {
|
|
media_source_id: source.id.clone(),
|
|
play_session_id: response.play_session_id,
|
|
stream_url,
|
|
direct_play: source.supports_direct_play,
|
|
needs_transcoding: !source.supports_direct_play && source.supports_transcoding,
|
|
})
|
|
}
|
|
|
|
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
|
|
// Construct direct audio stream URL
|
|
let url = format!(
|
|
"{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
|
|
self.server_url, item_id, self.user_id, self.access_token
|
|
);
|
|
Ok(url)
|
|
}
|
|
|
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
|
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
|
// type "TvChannel" — playable via open_live_stream.
|
|
let endpoint = format!(
|
|
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
|
|
self.user_id
|
|
);
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.server_url.clone()))
|
|
.collect())
|
|
}
|
|
|
|
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
|
|
// Root list of plugin "Channels". Drill-down into a channel folder reuses
|
|
// get_items(channel_id, ...).
|
|
let endpoint = format!("/Channels?UserId={}", self.user_id);
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
let total = response.total_record_count;
|
|
let items = response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.server_url.clone()))
|
|
.collect();
|
|
Ok(SearchResult {
|
|
items,
|
|
total_record_count: total,
|
|
})
|
|
}
|
|
|
|
async fn open_live_stream(&self, item_id: &str) -> Result<LiveStreamInfo, RepoError> {
|
|
// Live channels require a PlaybackInfo call with AutoOpenLiveStream so the
|
|
// server opens the live stream and returns a ready-to-play transcoding URL.
|
|
// We send a minimal request; the server applies its own defaults for live.
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct OpenLiveStreamRequest {
|
|
user_id: String,
|
|
#[serde(rename = "AutoOpenLiveStream")]
|
|
auto_open_live_stream: bool,
|
|
is_playback: bool,
|
|
max_streaming_bitrate: u64,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct OpenLiveStreamResponse {
|
|
#[serde(default)]
|
|
media_sources: Vec<LiveMediaSource>,
|
|
play_session_id: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct LiveMediaSource {
|
|
id: String,
|
|
transcoding_url: Option<String>,
|
|
live_stream_id: Option<String>,
|
|
}
|
|
|
|
let endpoint = format!("/Items/{}/PlaybackInfo", item_id);
|
|
let request = OpenLiveStreamRequest {
|
|
user_id: self.user_id.clone(),
|
|
auto_open_live_stream: true,
|
|
is_playback: true,
|
|
max_streaming_bitrate: 20_000_000,
|
|
};
|
|
|
|
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(),
|
|
})?;
|
|
|
|
// 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.
|
|
let stream_url = match source.transcoding_url {
|
|
Some(url) => format!("{}{}", self.server_url, url),
|
|
None => format!(
|
|
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts",
|
|
self.server_url,
|
|
item_id,
|
|
self.access_token,
|
|
source.id,
|
|
source.live_stream_id.clone().unwrap_or_default(),
|
|
),
|
|
};
|
|
|
|
Ok(LiveStreamInfo {
|
|
stream_url,
|
|
play_session_id: response.play_session_id,
|
|
live_stream_id: source.live_stream_id,
|
|
media_source_id: Some(source.id),
|
|
})
|
|
}
|
|
|
|
async fn report_playback_start(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError> {
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackStartRequest {
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
play_command: String,
|
|
is_paused: bool,
|
|
}
|
|
|
|
let request = PlaybackStartRequest {
|
|
item_id: item_id.to_string(),
|
|
position_ticks,
|
|
play_command: "PlayNow".to_string(),
|
|
is_paused: false,
|
|
};
|
|
|
|
self.post_json("/Sessions/Playing", &request).await
|
|
}
|
|
|
|
async fn report_playback_progress(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError> {
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackProgressRequest {
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
is_paused: bool,
|
|
}
|
|
|
|
let request = PlaybackProgressRequest {
|
|
item_id: item_id.to_string(),
|
|
position_ticks,
|
|
is_paused: false,
|
|
};
|
|
|
|
self.post_json("/Sessions/Playing/Progress", &request).await
|
|
}
|
|
|
|
async fn report_playback_stopped(
|
|
&self,
|
|
item_id: &str,
|
|
position_ticks: i64,
|
|
) -> Result<(), RepoError> {
|
|
#[derive(Serialize)]
|
|
#[serde(rename_all = "PascalCase")]
|
|
struct PlaybackStoppedRequest {
|
|
item_id: String,
|
|
position_ticks: i64,
|
|
}
|
|
|
|
let request = PlaybackStoppedRequest {
|
|
item_id: item_id.to_string(),
|
|
position_ticks,
|
|
};
|
|
|
|
self.post_json("/Sessions/Playing/Stopped", &request).await
|
|
}
|
|
|
|
fn get_image_url(
|
|
&self,
|
|
item_id: &str,
|
|
image_type: ImageType,
|
|
options: Option<ImageOptions>,
|
|
) -> String {
|
|
let mut url = format!(
|
|
"{}/Items/{}/Images/{}",
|
|
self.server_url,
|
|
item_id,
|
|
image_type.as_str()
|
|
);
|
|
|
|
// Authentication is handled by X-Emby-Authorization header in download_bytes()
|
|
// Do NOT include api_key here — some Jellyfin servers reject requests when
|
|
// api_key is present but the token doesn't match the expected format.
|
|
let mut params: Vec<String> = Vec::new();
|
|
|
|
if let Some(opts) = options {
|
|
if let Some(width) = opts.max_width {
|
|
params.push(format!("maxWidth={}", width));
|
|
}
|
|
if let Some(height) = opts.max_height {
|
|
params.push(format!("maxHeight={}", height));
|
|
}
|
|
if let Some(quality) = opts.quality {
|
|
params.push(format!("quality={}", quality));
|
|
}
|
|
if let Some(tag) = opts.tag {
|
|
params.push(format!("tag={}", tag));
|
|
}
|
|
}
|
|
|
|
if !params.is_empty() {
|
|
url.push('?');
|
|
url.push_str(¶ms.join("&"));
|
|
}
|
|
|
|
url
|
|
}
|
|
|
|
fn get_subtitle_url(
|
|
&self,
|
|
item_id: &str,
|
|
media_source_id: &str,
|
|
stream_index: i32,
|
|
format: &str,
|
|
) -> String {
|
|
format!(
|
|
"{}/Videos/{}/{}/Subtitles/{}/{}",
|
|
self.server_url,
|
|
item_id,
|
|
media_source_id,
|
|
stream_index,
|
|
format
|
|
)
|
|
}
|
|
|
|
fn get_video_download_url(
|
|
&self,
|
|
item_id: &str,
|
|
quality: &str,
|
|
media_source_id: Option<&str>,
|
|
) -> String {
|
|
// NOTE: Jellyfin's `/Videos/{id}/download` endpoint is not universally
|
|
// available (returns 404 on many server configs), which silently broke
|
|
// every movie/TV download. Use the progressive `stream.mp4` endpoint
|
|
// instead — it is always present and supports HTTP Range, which the
|
|
// download worker relies on for resume.
|
|
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
|
|
let mut params = vec![format!("api_key={}", self.access_token)];
|
|
|
|
// Map the frontend quality preset to concrete transcode params. For
|
|
// "original" we request a direct static copy (no transcode) which is
|
|
// byte-range resumable; other presets ask the server to transcode.
|
|
match quality {
|
|
"high" => {
|
|
params.push("videoBitrate=8000000".to_string());
|
|
params.push("maxHeight=1080".to_string());
|
|
params.push("audioBitrate=384000".to_string());
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
}
|
|
"medium" => {
|
|
params.push("videoBitrate=4000000".to_string());
|
|
params.push("maxHeight=720".to_string());
|
|
params.push("audioBitrate=256000".to_string());
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
}
|
|
"low" => {
|
|
params.push("videoBitrate=1500000".to_string());
|
|
params.push("maxHeight=480".to_string());
|
|
params.push("audioBitrate=128000".to_string());
|
|
params.push("videoCodec=h264".to_string());
|
|
params.push("audioCodec=aac".to_string());
|
|
}
|
|
// "original" (and any unknown value) → direct, resumable copy.
|
|
_ => {
|
|
params.push("Static=true".to_string());
|
|
}
|
|
}
|
|
|
|
// Add media source ID if provided
|
|
if let Some(source_id) = media_source_id {
|
|
params.push(format!("mediaSourceId={}", source_id));
|
|
}
|
|
|
|
url.push('?');
|
|
url.push_str(¶ms.join("&"));
|
|
|
|
url
|
|
}
|
|
|
|
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
|
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
|
}
|
|
|
|
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
|
let endpoint = format!("/Users/{}/FavoriteItems/{}", self.user_id, item_id);
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
let result = async {
|
|
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() })?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
self.report_outcome(&result).await;
|
|
result
|
|
}
|
|
|
|
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
|
let endpoint = format!("/Users/{}/Items/{}", self.user_id, person_id);
|
|
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
|
Ok(item.to_media_item(self.user_id.clone()))
|
|
}
|
|
|
|
async fn get_items_by_person(
|
|
&self,
|
|
person_id: &str,
|
|
options: Option<GetItemsOptions>,
|
|
) -> Result<SearchResult, RepoError> {
|
|
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
|
|
|
|
let mut endpoint = format!(
|
|
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
self.user_id, person_id, limit
|
|
);
|
|
|
|
// Add item type filtering if specified in options
|
|
if let Some(ref opts) = options {
|
|
if let Some(ref include_types) = opts.include_item_types {
|
|
if !include_types.is_empty() {
|
|
let types_param = include_types.join(",");
|
|
endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
|
|
}
|
|
}
|
|
}
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect(),
|
|
total_record_count: response.total_record_count,
|
|
})
|
|
}
|
|
|
|
async fn get_similar_items(
|
|
&self,
|
|
item_id: &str,
|
|
limit: Option<usize>,
|
|
) -> Result<SearchResult, RepoError> {
|
|
let limit_str = limit.unwrap_or(20);
|
|
|
|
// Try the /Similar endpoint which works for most items
|
|
let endpoint = format!(
|
|
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags",
|
|
item_id, self.user_id, limit_str
|
|
);
|
|
|
|
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
|
Ok(SearchResult {
|
|
items: response
|
|
.items
|
|
.into_iter()
|
|
.map(|item| item.to_media_item(self.user_id.clone()))
|
|
.collect(),
|
|
total_record_count: response.total_record_count,
|
|
})
|
|
}
|
|
|
|
// ===== Playlist Methods =====
|
|
|
|
async fn create_playlist(
|
|
&self,
|
|
name: &str,
|
|
item_ids: &[String],
|
|
) -> Result<PlaylistCreatedResult, RepoError> {
|
|
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?;
|
|
Ok(PlaylistCreatedResult { id: response.id })
|
|
}
|
|
|
|
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
|
|
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
|
|
let endpoint = format!("/Items/{}", playlist_id);
|
|
let url = format!("{}{}", self.server_url, endpoint);
|
|
|
|
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() })?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn rename_playlist(&self, playlist_id: &str, name: &str) -> Result<(), RepoError> {
|
|
info!("[OnlineRepo] Renaming playlist {} to '{}'", playlist_id, name);
|
|
let endpoint = format!("/Items/{}", playlist_id);
|
|
self.post_json(&endpoint, &serde_json::json!({ "Name": name })).await
|
|
}
|
|
|
|
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
|
|
);
|
|
|
|
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
|
debug!(
|
|
"[OnlineRepo] Got {} playlist items for {}",
|
|
response.items.len(),
|
|
playlist_id
|
|
);
|
|
|
|
Ok(response
|
|
.items
|
|
.into_iter()
|
|
.map(|pi| PlaylistEntry {
|
|
playlist_item_id: pi.playlist_item_id,
|
|
item: pi.item.to_media_item(self.user_id.clone()),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
async fn add_to_playlist(
|
|
&self,
|
|
playlist_id: &str,
|
|
item_ids: &[String],
|
|
) -> Result<(), RepoError> {
|
|
info!(
|
|
"[OnlineRepo] Adding {} items to playlist {}",
|
|
item_ids.len(),
|
|
playlist_id
|
|
);
|
|
let ids_param = item_ids.join(",");
|
|
let endpoint = format!("/Playlists/{}/Items?Ids={}", playlist_id, ids_param);
|
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
|
}
|
|
|
|
async fn remove_from_playlist(
|
|
&self,
|
|
playlist_id: &str,
|
|
entry_ids: &[String],
|
|
) -> Result<(), RepoError> {
|
|
info!(
|
|
"[OnlineRepo] Removing {} entries from playlist {}",
|
|
entry_ids.len(),
|
|
playlist_id
|
|
);
|
|
let ids_param = entry_ids.join(",");
|
|
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)
|
|
.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() })?;
|
|
|
|
if !response.status().is_success() {
|
|
return Err(RepoError::Server {
|
|
message: format!("HTTP {}", response.status()),
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn move_playlist_item(
|
|
&self,
|
|
playlist_id: &str,
|
|
item_id: &str,
|
|
new_index: u32,
|
|
) -> Result<(), RepoError> {
|
|
info!(
|
|
"[OnlineRepo] Moving item {} in playlist {} to index {}",
|
|
item_id, playlist_id, new_index
|
|
);
|
|
let endpoint = format!(
|
|
"/Playlists/{}/Items/{}/Move/{}",
|
|
playlist_id, item_id, new_index
|
|
);
|
|
self.post_json(&endpoint, &serde_json::json!({})).await
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::Arc;
|
|
|
|
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")
|
|
);
|
|
OnlineRepository::new(
|
|
http_client,
|
|
"https://test.server.com".to_string(),
|
|
"test-user-id".to_string(),
|
|
"test-access-token".to_string(),
|
|
)
|
|
}
|
|
|
|
/// Build a repository wired to a real ConnectivityReporter so we can assert
|
|
/// how `report_outcome` classifies each `RepoError` into reachability.
|
|
/// (No app handle → event emission is a harmless no-op.)
|
|
fn create_test_repository_with_connectivity(
|
|
) -> (OnlineRepository, crate::connectivity::ConnectivityReporter) {
|
|
let monitor_http = HttpClient::new(crate::jellyfin::HttpConfig::default())
|
|
.expect("Failed to create HTTP client for monitor");
|
|
let monitor = crate::connectivity::ConnectivityMonitor::new(monitor_http);
|
|
let reporter = monitor.reporter();
|
|
let repo = create_test_repository().with_connectivity(reporter.clone());
|
|
(repo, reporter)
|
|
}
|
|
|
|
/// `report_outcome` is the seam between repository traffic and the
|
|
/// connectivity monitor. Verify each `RepoError` variant routes correctly:
|
|
/// - the server answering at all (Ok / 401 / 404 / 5xx) ⇒ reachable
|
|
/// - a network-level failure ⇒ marked unreachable (debounce reduced for test)
|
|
/// - local-side errors (Database / Offline) ⇒ no effect on reachability
|
|
///
|
|
/// @req-test: UR-002 - Access media when online or offline
|
|
/// @req-test: DR-013 - Repository pattern for online/offline data access
|
|
#[tokio::test]
|
|
async fn test_report_outcome_classifies_server_answered_as_reachable() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
|
|
// 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() },
|
|
] {
|
|
reporter.mark_unreachable_for_test().await;
|
|
assert!(!reporter.is_reachable().await, "precondition: offline");
|
|
|
|
let result: Result<(), RepoError> = Err(err);
|
|
repo.report_outcome(&result).await;
|
|
|
|
assert!(
|
|
reporter.is_reachable().await,
|
|
"a server that answers should be reported reachable"
|
|
);
|
|
}
|
|
|
|
// Ok should also report reachable.
|
|
reporter.mark_unreachable_for_test().await;
|
|
let ok: Result<(), RepoError> = Ok(());
|
|
repo.report_outcome(&ok).await;
|
|
assert!(reporter.is_reachable().await, "Ok ⇒ reachable");
|
|
}
|
|
|
|
/// Local-side errors must NOT flip reachability — they say nothing about the
|
|
/// server.
|
|
#[tokio::test]
|
|
async fn test_report_outcome_ignores_local_errors() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
|
|
// 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] {
|
|
let result: Result<(), RepoError> = Err(err);
|
|
repo.report_outcome(&result).await;
|
|
assert!(
|
|
!reporter.is_reachable().await,
|
|
"local-side error must not change reachability"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// When connectivity is known-offline, `get_json` must fast-fail with
|
|
/// `RepoError::Offline` instead of running the full HTTP retry cycle (~7s).
|
|
/// This is what keeps offline browsing snappy. `test.server.com` is
|
|
/// unroutable, so if the guard were absent this would hang on retries; the
|
|
/// assertion returning promptly with `Offline` proves the short-circuit.
|
|
#[tokio::test]
|
|
async fn test_get_json_fast_fails_when_offline() {
|
|
let (repo, reporter) = create_test_repository_with_connectivity();
|
|
reporter.mark_unreachable_for_test().await;
|
|
assert!(!reporter.is_reachable().await, "precondition: offline");
|
|
|
|
let result: Result<serde_json::Value, RepoError> = repo.get_json("/System/Info").await;
|
|
assert!(
|
|
matches!(result, Err(RepoError::Offline)),
|
|
"known-offline get_json should return Offline immediately, got {:?}",
|
|
result
|
|
);
|
|
}
|
|
|
|
/// A network error routes through the debounced path. A single failure stays
|
|
/// online (debounce window not yet elapsed).
|
|
#[tokio::test]
|
|
async fn test_report_outcome_network_error_is_debounced() {
|
|
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() });
|
|
repo.report_outcome(&result).await;
|
|
|
|
assert!(
|
|
reporter.is_reachable().await,
|
|
"a single network failure stays online (debounced)"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_audio_stream_url_formats_correctly() {
|
|
let repo = create_test_repository();
|
|
let item_id = "test-track-123";
|
|
|
|
let result = repo.get_audio_stream_url(item_id).await;
|
|
|
|
assert!(result.is_ok());
|
|
let url = result.unwrap();
|
|
assert_eq!(
|
|
url,
|
|
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_video_stream_url_returns_hls_with_position() {
|
|
// Transcoded video resume/seek must produce an HLS master playlist with
|
|
// StartTimeTicks, not a progressive stream.mp4 (which never starts playing
|
|
// for HEVC sources). See get_video_stream_url docs.
|
|
let repo = create_test_repository();
|
|
|
|
let url = repo
|
|
.get_video_stream_url("vid-1", Some("source-1"), Some(193.0), Some(1))
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(
|
|
url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"),
|
|
"expected HLS master playlist, got: {url}"
|
|
);
|
|
assert!(url.contains("VideoCodec=h264"));
|
|
assert!(url.contains("MediaSourceId=source-1"));
|
|
assert!(url.contains("AudioStreamIndex=1"));
|
|
// 193.0 seconds * 10_000_000 ticks/sec
|
|
assert!(url.contains("StartTimeTicks=1930000000"), "url: {url}");
|
|
assert!(!url.contains("stream.mp4"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_video_stream_url_omits_position_when_absent() {
|
|
let repo = create_test_repository();
|
|
|
|
let url = repo
|
|
.get_video_stream_url("vid-1", None, None, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(url.starts_with("https://test.server.com/Videos/vid-1/master.m3u8?"));
|
|
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();
|
|
let item_id = "track-with-special-chars-!@#";
|
|
|
|
let result = repo.get_audio_stream_url(item_id).await;
|
|
|
|
assert!(result.is_ok());
|
|
let url = result.unwrap();
|
|
assert!(url.contains("track-with-special-chars-!@#"));
|
|
assert!(url.starts_with("https://test.server.com/Audio/"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_image_tags_deserialize_hashmap_format() {
|
|
// Test modern HashMap format with Primary tag
|
|
let json = r#"{"Primary":"abc123","Banner":"def456","Backdrop":"ghi789"}"#;
|
|
let result: Result<ImageTags, _> = serde_json::from_str(json);
|
|
|
|
assert!(result.is_ok());
|
|
let tags = result.unwrap();
|
|
assert_eq!(tags.primary(), Some("abc123".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_image_tags_deserialize_structured_format() {
|
|
// Test legacy structured format with Primary field
|
|
let json = r#"{"Primary":"xyz789"}"#;
|
|
let result: Result<ImageTags, _> = serde_json::from_str(json);
|
|
|
|
assert!(result.is_ok());
|
|
let tags = result.unwrap();
|
|
assert_eq!(tags.primary(), Some("xyz789".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_image_tags_deserialize_missing_primary() {
|
|
// Test HashMap without Primary tag
|
|
let json = r#"{"Banner":"def456","Backdrop":"ghi789"}"#;
|
|
let result: Result<ImageTags, _> = serde_json::from_str(json);
|
|
|
|
assert!(result.is_ok());
|
|
let tags = result.unwrap();
|
|
assert_eq!(tags.primary(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_image_tags_deserialize_empty_map() {
|
|
// Test empty HashMap
|
|
let json = r#"{}"#;
|
|
let result: Result<ImageTags, _> = serde_json::from_str(json);
|
|
|
|
assert!(result.is_ok());
|
|
let tags = result.unwrap();
|
|
assert_eq!(tags.primary(), None);
|
|
}
|
|
|
|
// ===== Video download URL (real impl) =====
|
|
//
|
|
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
|
|
// not a mock. A prior mock in online_integration_test.rs used the correct
|
|
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
|
|
// which returns 404 on real servers and silently broke every movie/TV
|
|
// download. Assert the real builder targets the resumable stream endpoint.
|
|
//
|
|
// @req-test: DR-013 - Repository pattern for online/offline data access
|
|
|
|
#[test]
|
|
fn test_video_download_url_uses_stream_not_download_endpoint() {
|
|
let repo = create_test_repository();
|
|
let url = repo.get_video_download_url("item123", "original", None);
|
|
|
|
// Must NOT use the /download endpoint (404 on real servers).
|
|
assert!(
|
|
!url.contains("/download"),
|
|
"download URL must not use the broken /Videos/{{id}}/download endpoint: {url}"
|
|
);
|
|
// Must use the progressive, range-resumable stream endpoint.
|
|
assert!(
|
|
url.contains("/Videos/item123/stream.mp4"),
|
|
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
|
|
);
|
|
assert!(url.contains("api_key=test-access-token"), "url: {url}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_download_url_original_is_static_direct_copy() {
|
|
let repo = create_test_repository();
|
|
let url = repo.get_video_download_url("item123", "original", None);
|
|
|
|
// "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}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_download_url_quality_presets_transcode() {
|
|
let repo = create_test_repository();
|
|
|
|
for (quality, height) in [("high", "1080"), ("medium", "720"), ("low", "480")] {
|
|
let url = repo.get_video_download_url("item123", quality, None);
|
|
assert!(
|
|
url.contains("/Videos/item123/stream.mp4"),
|
|
"{quality} must use stream.mp4: {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}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_video_download_url_passes_media_source_id() {
|
|
let repo = create_test_repository();
|
|
let url = repo.get_video_download_url("item123", "original", Some("src-42"));
|
|
assert!(url.contains("mediaSourceId=src-42"), "url: {url}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_jellyfin_item_deserialize_with_image_tags() {
|
|
// Test full JellyfinItem deserialization with ImageTags
|
|
let json = r#"{
|
|
"Id": "album123",
|
|
"Name": "Test Album",
|
|
"Type": "MusicAlbum",
|
|
"ImageTags": {"Primary": "tag123"},
|
|
"ArtistItems": [
|
|
{"Id": "artist1", "Name": "Artist One"},
|
|
{"Id": "artist2", "Name": "Artist Two"}
|
|
]
|
|
}"#;
|
|
|
|
let result: Result<JellyfinItem, _> = serde_json::from_str(json);
|
|
assert!(result.is_ok());
|
|
|
|
let item = result.unwrap();
|
|
assert_eq!(item.id, "album123");
|
|
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()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_jellyfin_item_deserialize_with_artist_items() {
|
|
// Test that ArtistItems with PascalCase fields deserialize correctly
|
|
let json = r#"{
|
|
"Id": "track123",
|
|
"Name": "Test Track",
|
|
"Type": "Audio",
|
|
"ArtistItems": [
|
|
{"Id": "artist1", "Name": "Bob Dylan"},
|
|
{"Id": "artist2", "Name": "Johnny Cash"}
|
|
]
|
|
}"#;
|
|
|
|
let result: Result<JellyfinItem, _> = serde_json::from_str(json);
|
|
assert!(result.is_ok());
|
|
|
|
let item = result.unwrap();
|
|
let artist_items = item.artist_items.expect("Expected artist items");
|
|
assert_eq!(artist_items.len(), 2);
|
|
assert_eq!(artist_items[0].id, "artist1");
|
|
assert_eq!(artist_items[0].name, "Bob Dylan");
|
|
assert_eq!(artist_items[1].id, "artist2");
|
|
assert_eq!(artist_items[1].name, "Johnny Cash");
|
|
}
|
|
|
|
#[test]
|
|
fn test_jellyfin_item_to_media_item_conversion() {
|
|
// Test conversion from JellyfinItem to MediaItem preserves image tags
|
|
let json = r#"{
|
|
"Id": "album456",
|
|
"Name": "Love and Theft",
|
|
"Type": "MusicAlbum",
|
|
"ImageTags": {"Primary": "7ebab4f6a80cd09d"},
|
|
"Artists": ["Bob Dylan"],
|
|
"ArtistItems": [{"Id": "0b2a6e969a27f22aba97f9f0e69fa849", "Name": "Bob Dylan"}],
|
|
"RunTimeTicks": 33900137190
|
|
}"#;
|
|
|
|
let jellyfin_item: JellyfinItem = serde_json::from_str(json).expect("Failed to parse");
|
|
let media_item = jellyfin_item.to_media_item("test-server-id".to_string());
|
|
|
|
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.server_id, "test-server-id");
|
|
}
|
|
|
|
#[test]
|
|
fn test_items_response_deserialize() {
|
|
// Test full ItemsResponse with multiple items
|
|
let json = r#"{
|
|
"Items": [
|
|
{
|
|
"Id": "item1",
|
|
"Name": "Item One",
|
|
"Type": "MusicAlbum",
|
|
"ImageTags": {"Primary": "tag1"}
|
|
},
|
|
{
|
|
"Id": "item2",
|
|
"Name": "Item Two",
|
|
"Type": "Audio",
|
|
"ImageTags": {"Primary": "tag2"}
|
|
}
|
|
],
|
|
"TotalRecordCount": 2
|
|
}"#;
|
|
|
|
let result: Result<ItemsResponse, _> = serde_json::from_str(json);
|
|
assert!(result.is_ok());
|
|
|
|
let response = result.unwrap();
|
|
assert_eq!(response.total_record_count, 2);
|
|
assert_eq!(response.items.len(), 2);
|
|
assert_eq!(response.items[0].id, "item1");
|
|
assert_eq!(response.items[1].id, "item2");
|
|
}
|
|
|
|
#[test]
|
|
fn test_search_term_is_url_encoded() {
|
|
// A multi-word query (and one with a reserved character) must be
|
|
// percent-encoded before being placed in the SearchTerm query param,
|
|
// otherwise the request URL is malformed and search returns nothing.
|
|
assert_eq!(urlencoding::encode("Star Wars"), "Star%20Wars");
|
|
assert_eq!(urlencoding::encode("Tom & Jerry"), "Tom%20%26%20Jerry");
|
|
}
|
|
|
|
#[test]
|
|
fn test_jray_context_deserializes_actors() {
|
|
// The jray?t= envelope as documented in the JRay truth file format.
|
|
let json = r#"{
|
|
"actors": [
|
|
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
|
|
]
|
|
}"#;
|
|
let ctx: JRayContext = serde_json::from_str(json).expect("should parse");
|
|
assert_eq!(ctx.actors.len(), 1);
|
|
assert_eq!(ctx.actors[0].name, "Tom Hanks");
|
|
assert_eq!(ctx.actors[0].jellyfin_id, "abc123-guid");
|
|
}
|
|
|
|
#[test]
|
|
fn test_jray_context_ignores_unknown_keys_and_missing_ids() {
|
|
// Future fields (locations/trivia) must be ignored, and absent id keys
|
|
// must default to "" rather than failing to parse.
|
|
let json = r#"{
|
|
"actors": [ { "name": "Extra" } ],
|
|
"locations": ["Beach"],
|
|
"trivia": "filmed in 1994"
|
|
}"#;
|
|
let ctx: JRayContext = serde_json::from_str(json).expect("should tolerate extra keys");
|
|
assert_eq!(ctx.actors.len(), 1);
|
|
assert_eq!(ctx.actors[0].name, "Extra");
|
|
assert_eq!(ctx.actors[0].imdb_id, "");
|
|
assert_eq!(ctx.actors[0].jellyfin_id, "");
|
|
}
|
|
}
|