feat(repository): a route table and resolved server capabilities
Endpoints were 57 inline format! literals with their query strings baked in at
the point of use. That is workable against exactly one server and hostile to
anything else: a second route shape would mean a conditional at every one of
them. They now live in repository/endpoints.rs, one function each, taking
&ServerCapabilities.
Two things fall out of the move:
- A small Endpoint builder replaces the manual ?/& juggling, so a double or
trailing separator is structurally impossible rather than something four
assertions in a deleted test file used to watch for.
- Both user-scoped route shapes (/Users/{uid}/Items and /Items?userId=) are
built and tested, though nothing selects the second yet. The family still
works on 12.0, so migrating is optional; having both means it is a one-line
change if 13.0 removes them, as the newly written removal policy allows.
ServerCapabilities is resolved once per connection from the version the server
already reported at connect. The version-to-flags mapping lives in exactly one
function and nothing else in the crate compares a version number: a `version < N`
at the point of use re-derives a domain fact where it is consumed, is unreadable
by its second occurrence, and cannot express a backport.
An unrecognised version resolves forward to the newest known generation rather
than being refused, because refusing would make every release expire the moment
the server upgrades. Only a version below the floor is refused.
This commit also carries the two fixes that are NOT capability branches, because
they live in the same files:
- Authorization replaces X-Emby-Authorization, and ApiKey replaces the api_key
query parameter. Jellyfin 12.0 disables both legacy spellings by default and
a migration flips them on upgraded servers too, so this is what actually
breaks against 12.0. The header value this app already built was always the
correct MediaBrowser scheme, and both new spellings are ungated on 10.11.x —
so it is a rename, not a branch. The query-parameter spelling is load-bearing
rather than cosmetic: stream URLs go to mpv, ExoPlayer and the webview's
<video>, none of which can send a header.
- A type-filtered listing now states Recursive explicitly. 12.0 defaults it to
true for a library parent with IncludeItemTypes where 10.11 returned
immediate children, so the identical request returned a different result set
with nothing in the response to say which rule applied. The value sent is the
one that shipped, so this is a compatibility fix and not a silent behaviour
change.
A structural test refuses any deprecated auth spelling reaching a request
builder, verified to fail when one is reintroduced. Behaviour is otherwise
preserved: the four previous endpoint builders become test-only shims over the
new table, so the ~20 existing tests encoding DR-116/DR-212/DR-257 now exercise
the production path rather than being deleted.
TRACES: UR-085 | IR-035, DR-279, DR-280, DR-287, DR-288
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+172
-325
@@ -5,6 +5,8 @@ use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use super::capabilities::ServerCapabilities;
|
||||
use super::endpoints;
|
||||
use super::stream_selection::{
|
||||
quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
|
||||
};
|
||||
@@ -188,6 +190,12 @@ pub struct OnlineRepository {
|
||||
/// 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>,
|
||||
/// What this server can do, resolved once from the version it reported at
|
||||
/// connect. Every route and every version-dependent decision reads a named
|
||||
/// flag from here; nothing compares a version number.
|
||||
///
|
||||
/// TRACES: UR-085 | IR-035, DR-280
|
||||
capabilities: ServerCapabilities,
|
||||
}
|
||||
|
||||
impl OnlineRepository {
|
||||
@@ -209,9 +217,34 @@ impl OnlineRepository {
|
||||
user_id,
|
||||
access_token,
|
||||
connectivity: None,
|
||||
// Assumed until the caller supplies what the server reported. The
|
||||
// assumption is the current target, which is what it will be in
|
||||
// nearly every case.
|
||||
capabilities: ServerCapabilities::assumed(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the capabilities resolved from the version the server reported at
|
||||
/// connect. Without this the repository assumes the current target.
|
||||
///
|
||||
/// TRACES: UR-085 | IR-035, DR-280
|
||||
pub fn with_capabilities(mut self, capabilities: ServerCapabilities) -> Self {
|
||||
self.capabilities = capabilities;
|
||||
self
|
||||
}
|
||||
|
||||
/// What the server on the other end can do.
|
||||
///
|
||||
/// Test-only: production reads the flags through the route table and the
|
||||
/// playback paths rather than asking the repository for them, so exposing
|
||||
/// this outside tests would be an accessor nobody calls.
|
||||
///
|
||||
/// TRACES: UR-085 | DR-280
|
||||
#[cfg(test)]
|
||||
pub fn capabilities(&self) -> &ServerCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -261,7 +294,7 @@ impl OnlineRepository {
|
||||
.http_client
|
||||
.client
|
||||
.get(url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to build request: {}", e))?;
|
||||
|
||||
@@ -298,11 +331,7 @@ impl OnlineRepository {
|
||||
item_id: &str,
|
||||
t: f64,
|
||||
) -> Result<Vec<JRayActor>, RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Plugins/JRay/Items/{}/jray?t={}",
|
||||
urlencoding::encode(item_id),
|
||||
t
|
||||
);
|
||||
let endpoint = endpoints::jray_context(&self.capabilities, 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.
|
||||
@@ -339,7 +368,7 @@ impl OnlineRepository {
|
||||
.http_client
|
||||
.client
|
||||
.get(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
@@ -414,7 +443,7 @@ impl OnlineRepository {
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(body)
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
@@ -474,7 +503,7 @@ impl OnlineRepository {
|
||||
.client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.json(body)
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
@@ -538,7 +567,7 @@ impl OnlineRepository {
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.send();
|
||||
|
||||
match request.await {
|
||||
@@ -630,7 +659,7 @@ impl OnlineRepository {
|
||||
// TRACES: UR-004, UR-080 | DR-234
|
||||
let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
|
||||
let mut params = vec![
|
||||
("api_key", self.access_token.clone()),
|
||||
("ApiKey", self.access_token.clone()),
|
||||
("DeviceId", DEVICE_ID.to_string()),
|
||||
("PlaySessionId", play_session_id),
|
||||
("VideoCodec", renderer_video_codecs),
|
||||
@@ -724,7 +753,7 @@ impl OnlineRepository {
|
||||
) -> Result<String, RepoError> {
|
||||
let mut params = vec![
|
||||
("UserId", self.user_id.clone()),
|
||||
("api_key", self.access_token.clone()),
|
||||
("ApiKey", self.access_token.clone()),
|
||||
("DeviceId", DEVICE_ID.to_string()),
|
||||
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
|
||||
("Container", "mp3".to_string()),
|
||||
@@ -783,7 +812,7 @@ impl OnlineRepository {
|
||||
&self,
|
||||
item_id: &str,
|
||||
) -> Result<(NegotiatedSource, String), RepoError> {
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
||||
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
|
||||
|
||||
// What the renderer that will decode this can play. One source, shared
|
||||
// with the transcode URL builder and the client-side audio override, so
|
||||
@@ -1043,7 +1072,7 @@ impl OnlineRepository {
|
||||
// (which is the *video* stream — the index is global across all
|
||||
// streams) only misleads servers that do honour it.
|
||||
let url = format!(
|
||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
|
||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&ApiKey={}&userId={}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
effective_source_id,
|
||||
@@ -1191,119 +1220,26 @@ impl From<JellyfinUserData> for UserData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a folder listing.
|
||||
/// Test-only shim over [`endpoints::get_items`].
|
||||
///
|
||||
/// Extracted from `get_items` so the query it produces — in particular the
|
||||
/// favourites filter — can be asserted without standing up an HTTP server.
|
||||
///
|
||||
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
|
||||
/// The endpoint builders moved to `endpoints.rs` under DR-279. These wrappers
|
||||
/// keep the existing requirement coverage (DR-116, DR-212, DR-257 and friends)
|
||||
/// pointed at the production path rather than deleting it, and pin the *default*
|
||||
/// capability shape — the URLs that shipped before the route table existed.
|
||||
#[cfg(test)]
|
||||
fn build_get_items_endpoint(
|
||||
user_id: &str,
|
||||
parent_id: &str,
|
||||
options: Option<&GetItemsOptions>,
|
||||
) -> String {
|
||||
// Every value below is percent-encoded before it goes into the query
|
||||
// string, the same way `Genres` and `SearchTerm` already are: these are
|
||||
// values, not URL syntax, so a space or an `&` in one must not split it
|
||||
// into another parameter.
|
||||
//
|
||||
// TRACES: UR-007 | DR-212 | UT-206
|
||||
let mut endpoint = format!(
|
||||
"/Users/{}/Items?ParentId={}",
|
||||
user_id,
|
||||
urlencoding::encode(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 {
|
||||
// Encode each type, not the joined string: the comma is the
|
||||
// list separator Jellyfin splits on.
|
||||
let encoded: Vec<String> = types
|
||||
.iter()
|
||||
.map(|t| urlencoding::encode(t).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
|
||||
}
|
||||
// An explicit sort always wins; the container's default only fills the
|
||||
// gap when the caller named none. A caller that names neither gets no
|
||||
// SortBy at all, leaving the server's own order intact.
|
||||
//
|
||||
// TRACES: UR-007 | DR-257 | UT-229
|
||||
let default_sort = default_listing_sort(opts.parent_kind);
|
||||
let sort_by = opts
|
||||
.sort_by
|
||||
.as_deref()
|
||||
.or(default_sort.map(|(field, _)| field));
|
||||
let sort_order = opts
|
||||
.sort_order
|
||||
.as_deref()
|
||||
.or(default_sort.map(|(_, order)| order));
|
||||
|
||||
if let Some(sort_by) = sort_by {
|
||||
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
|
||||
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
|
||||
let encoded: Vec<String> = sort_by
|
||||
.split(',')
|
||||
.map(|field| urlencoding::encode(field).into_owned())
|
||||
.collect();
|
||||
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
|
||||
}
|
||||
if let Some(sort_order) = sort_order {
|
||||
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(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("|")));
|
||||
}
|
||||
}
|
||||
// TRACES: UR-067 | DR-116 | UT-104
|
||||
if opts.favorites_only == Some(true) {
|
||||
endpoint.push_str("&Filters=IsFavorite");
|
||||
}
|
||||
}
|
||||
|
||||
// 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,UserData");
|
||||
endpoint
|
||||
endpoints::get_items(&ServerCapabilities::assumed(), user_id, parent_id, options)
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a "recently added" listing.
|
||||
///
|
||||
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
|
||||
/// `false`, which returns each newly-added *leaf* separately, so importing one
|
||||
/// 14-track album pushed 14 rows into "recently added" and buried everything
|
||||
/// else. With grouping on, the server collapses children into the container
|
||||
/// that was added — an album appears once, while movies (which have no such
|
||||
/// container) are unaffected.
|
||||
///
|
||||
/// Pulled out of `get_latest_items` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-024, UR-034 | IR-024, JA-016
|
||||
/// Test-only shim over [`endpoints::latest_items`]. See
|
||||
/// [`build_get_items_endpoint`].
|
||||
#[cfg(test)]
|
||||
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
|
||||
format!(
|
||||
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
user_id,
|
||||
parent_id,
|
||||
limit.unwrap_or(16)
|
||||
)
|
||||
endpoints::latest_items(&ServerCapabilities::assumed(), user_id, parent_id, limit)
|
||||
}
|
||||
|
||||
/// How many rows to ask the server for, given how many the row will show.
|
||||
@@ -1417,73 +1353,21 @@ fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a Next Up listing.
|
||||
///
|
||||
/// `EnableResumable=false` is the point of this query: the server default is
|
||||
/// `true`, which makes a partially-watched episode its own series' "next up" —
|
||||
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
|
||||
/// end up showing the same cards. Next Up should only ever offer episodes the
|
||||
/// viewer has not started. Servers predating the parameter ignore it, which is
|
||||
/// why the frontend also drops in-progress entries (DR-197).
|
||||
///
|
||||
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
|
||||
/// Test-only shim over [`endpoints::next_up`]. See [`build_get_items_endpoint`].
|
||||
#[cfg(test)]
|
||||
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
user_id,
|
||||
limit.unwrap_or(16)
|
||||
);
|
||||
|
||||
if let Some(sid) = series_id {
|
||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
||||
}
|
||||
|
||||
endpoint
|
||||
endpoints::next_up(&ServerCapabilities::assumed(), user_id, series_id, limit)
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a favourites listing.
|
||||
///
|
||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
||||
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
|
||||
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
|
||||
/// union, which would silently drop every type nobody enumerated (see
|
||||
/// `SearchScope::item_types`).
|
||||
///
|
||||
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
|
||||
/// Test-only shim over [`endpoints::favorites`]. See
|
||||
/// [`build_get_items_endpoint`].
|
||||
#[cfg(test)]
|
||||
fn build_favorites_endpoint(
|
||||
user_id: &str,
|
||||
scope: SearchScope,
|
||||
options: Option<&GetItemsOptions>,
|
||||
) -> String {
|
||||
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
|
||||
|
||||
if let Some(types) = scope.item_types() {
|
||||
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
|
||||
}
|
||||
|
||||
// Jellyfin has no "date favourited", so name order is the only stable sort
|
||||
// available; callers may still override it.
|
||||
let sort_by = options
|
||||
.and_then(|o| o.sort_by.as_deref())
|
||||
.unwrap_or("SortName");
|
||||
let sort_order = options
|
||||
.and_then(|o| o.sort_order.as_deref())
|
||||
.unwrap_or("Ascending");
|
||||
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
|
||||
|
||||
if let Some(limit) = options.and_then(|o| o.limit) {
|
||||
endpoint.push_str(&format!("&Limit={}", limit));
|
||||
}
|
||||
if let Some(start_index) = options.and_then(|o| o.start_index) {
|
||||
endpoint.push_str(&format!("&StartIndex={}", start_index));
|
||||
}
|
||||
|
||||
endpoint
|
||||
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
|
||||
endpoint
|
||||
endpoints::favorites(&ServerCapabilities::assumed(), user_id, scope, options)
|
||||
}
|
||||
|
||||
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
|
||||
@@ -1810,7 +1694,7 @@ impl MediaRepository for OnlineRepository {
|
||||
image_tags: Option<ImageTags>,
|
||||
}
|
||||
|
||||
let endpoint = format!("/Users/{}/Views", self.user_id);
|
||||
let endpoint = endpoints::user_views(&self.capabilities, &self.user_id);
|
||||
let response: LibrariesResponse = self.get_json(&endpoint).await?;
|
||||
|
||||
Ok(response
|
||||
@@ -1832,7 +1716,12 @@ impl MediaRepository for OnlineRepository {
|
||||
parent_id: &str,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
|
||||
let endpoint = endpoints::get_items(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
parent_id,
|
||||
options.as_ref(),
|
||||
);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
|
||||
@@ -1858,7 +1747,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
|
||||
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
|
||||
let endpoint = endpoints::item_detail(&self.capabilities, &self.user_id, item_id);
|
||||
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
let media_item = item.into_media_item(self.user_id.clone());
|
||||
@@ -1879,7 +1768,8 @@ impl MediaRepository for OnlineRepository {
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_val = limit.unwrap_or(16);
|
||||
let endpoint = build_latest_items_endpoint(
|
||||
let endpoint = endpoints::latest_items(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
parent_id,
|
||||
Some(latest_items_fetch_limit(limit_val)),
|
||||
@@ -1909,16 +1799,14 @@ impl MediaRepository for OnlineRepository {
|
||||
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,UserData",
|
||||
self.user_id, limit_str
|
||||
let endpoint = endpoints::resume_items(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
limit.unwrap_or(16),
|
||||
None,
|
||||
parent_id,
|
||||
);
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
||||
}
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(response
|
||||
.items
|
||||
@@ -1936,7 +1824,7 @@ impl MediaRepository for OnlineRepository {
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
|
||||
let endpoint = endpoints::next_up(&self.capabilities, &self.user_id, series_id, limit);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(response
|
||||
@@ -1953,9 +1841,13 @@ impl MediaRepository for OnlineRepository {
|
||||
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,UserData",
|
||||
self.user_id, fetch_limit
|
||||
let endpoint = endpoints::played_items_by_date(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
"Audio",
|
||||
fetch_limit,
|
||||
"Descending",
|
||||
None,
|
||||
);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
@@ -2087,15 +1979,15 @@ impl MediaRepository for OnlineRepository {
|
||||
// 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,UserData",
|
||||
self.user_id, limit_val
|
||||
let endpoint = endpoints::played_items_by_date(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
"MusicAlbum",
|
||||
limit_val,
|
||||
"Ascending",
|
||||
parent_id,
|
||||
);
|
||||
|
||||
if let Some(pid) = parent_id {
|
||||
endpoint.push_str(&format!("&ParentId={}", pid));
|
||||
}
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(response
|
||||
.items
|
||||
@@ -2110,10 +2002,12 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
|
||||
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,UserData",
|
||||
self.user_id, limit_str
|
||||
let endpoint = endpoints::resume_items(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
limit.unwrap_or(16),
|
||||
Some("Movie"),
|
||||
None,
|
||||
);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
@@ -2127,14 +2021,8 @@ impl MediaRepository for OnlineRepository {
|
||||
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));
|
||||
}
|
||||
let endpoint =
|
||||
endpoints::genres(&self.capabilities, &self.user_id, "MusicAlbum", parent_id);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
@@ -2201,28 +2089,12 @@ impl MediaRepository for OnlineRepository {
|
||||
// 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,UserData",
|
||||
let endpoint = endpoints::search(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
query,
|
||||
limit,
|
||||
options.and_then(|o| o.include_item_types).as_deref(),
|
||||
);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
@@ -2311,7 +2183,7 @@ impl MediaRepository for OnlineRepository {
|
||||
// serves the original file untouched, and pinning index 0 (the video
|
||||
// stream) only misleads servers that do honour it.
|
||||
format!(
|
||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
|
||||
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&ApiKey={}&userId={}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
source.id,
|
||||
@@ -2335,7 +2207,7 @@ impl MediaRepository for OnlineRepository {
|
||||
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",
|
||||
"{}/Audio/{}/stream?UserId={}&ApiKey={}&Static=true",
|
||||
self.server_url, item_id, self.user_id, self.access_token
|
||||
);
|
||||
Ok(url)
|
||||
@@ -2360,10 +2232,7 @@ impl MediaRepository for OnlineRepository {
|
||||
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 endpoint = endpoints::live_tv_channels(&self.capabilities, &self.user_id);
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(response
|
||||
.items
|
||||
@@ -2375,7 +2244,7 @@ impl MediaRepository for OnlineRepository {
|
||||
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 endpoint = endpoints::channels(&self.capabilities, &self.user_id);
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
let total = response.total_record_count;
|
||||
let items = response
|
||||
@@ -2426,7 +2295,7 @@ impl MediaRepository for OnlineRepository {
|
||||
live_stream_id: Option<String>,
|
||||
}
|
||||
|
||||
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
|
||||
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
|
||||
let request = OpenLiveStreamRequest {
|
||||
user_id: self.user_id.clone(),
|
||||
auto_open_live_stream: true,
|
||||
@@ -2461,7 +2330,7 @@ impl MediaRepository for OnlineRepository {
|
||||
super::device_profile::without_server_chosen_subtitle(&url)
|
||||
),
|
||||
None => format!(
|
||||
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
|
||||
"{}/Videos/{}/master.m3u8?ApiKey={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
|
||||
self.server_url,
|
||||
item_id,
|
||||
self.access_token,
|
||||
@@ -2503,7 +2372,8 @@ impl MediaRepository for OnlineRepository {
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
self.post_json("/Sessions/Playing", &request).await
|
||||
self.post_json(endpoints::sessions_playing(&self.capabilities), &request)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn report_playback_progress(
|
||||
@@ -2525,7 +2395,11 @@ impl MediaRepository for OnlineRepository {
|
||||
is_paused: false,
|
||||
};
|
||||
|
||||
self.post_json("/Sessions/Playing/Progress", &request).await
|
||||
self.post_json(
|
||||
endpoints::sessions_playing_progress(&self.capabilities),
|
||||
&request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn report_playback_stopped(
|
||||
@@ -2545,7 +2419,11 @@ impl MediaRepository for OnlineRepository {
|
||||
position_ticks,
|
||||
};
|
||||
|
||||
self.post_json("/Sessions/Playing/Stopped", &request).await
|
||||
self.post_json(
|
||||
endpoints::sessions_playing_stopped(&self.capabilities),
|
||||
&request,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn get_image_url(
|
||||
@@ -2561,9 +2439,10 @@ impl MediaRepository for OnlineRepository {
|
||||
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.
|
||||
// Authentication is handled by the `Authorization` header in
|
||||
// download_bytes(). Do NOT add a query-parameter token here — some
|
||||
// Jellyfin servers reject requests carrying one whose format they do not
|
||||
// expect, and this request can already authenticate by header.
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(opts) = options {
|
||||
@@ -2623,7 +2502,7 @@ impl MediaRepository for OnlineRepository {
|
||||
// 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)];
|
||||
let mut params = vec![format!("ApiKey={}", self.access_token)];
|
||||
|
||||
// Map the frontend quality preset to concrete transcode params. For
|
||||
// "original" we request a direct static copy (no transcode) which is
|
||||
@@ -2713,11 +2592,7 @@ impl MediaRepository for OnlineRepository {
|
||||
}
|
||||
|
||||
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Users/{}/FavoriteItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
@@ -2727,7 +2602,8 @@ impl MediaRepository for OnlineRepository {
|
||||
scope: SearchScope,
|
||||
options: Option<GetItemsOptions>,
|
||||
) -> Result<SearchResult, RepoError> {
|
||||
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
|
||||
let endpoint =
|
||||
endpoints::favorites(&self.capabilities, &self.user_id, scope, options.as_ref());
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
|
||||
Ok(SearchResult {
|
||||
@@ -2747,11 +2623,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-017 | JA-018, DR-021
|
||||
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Users/{}/FavoriteItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2759,7 +2631,7 @@ impl MediaRepository for OnlineRepository {
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
@@ -2793,11 +2665,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-064 | DR-106, JA-033
|
||||
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Users/{}/PlayedItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2805,7 +2673,7 @@ impl MediaRepository for OnlineRepository {
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
@@ -2838,11 +2706,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-025 | DR-131 | JA-035
|
||||
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Users/{}/PlayedItems/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(item_id)
|
||||
);
|
||||
let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let result = async {
|
||||
@@ -2850,7 +2714,7 @@ impl MediaRepository for OnlineRepository {
|
||||
.http_client
|
||||
.client
|
||||
.post(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.header("Content-Length", "0")
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
@@ -2887,11 +2751,7 @@ impl MediaRepository for OnlineRepository {
|
||||
///
|
||||
/// TRACES: UR-035, UR-036 | IR-022, JA-030
|
||||
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
|
||||
let endpoint = format!(
|
||||
"/Users/{}/Items/{}",
|
||||
self.user_id,
|
||||
urlencoding::encode(person_id)
|
||||
);
|
||||
let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
|
||||
let item: JellyfinItem = self.get_json(&endpoint).await?;
|
||||
Ok(item.into_media_item(self.user_id.clone()))
|
||||
}
|
||||
@@ -2906,21 +2766,16 @@ impl MediaRepository for OnlineRepository {
|
||||
) -> 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,UserData",
|
||||
self.user_id, person_id, limit
|
||||
let endpoint = endpoints::items_by_person(
|
||||
&self.capabilities,
|
||||
&self.user_id,
|
||||
person_id,
|
||||
limit,
|
||||
options
|
||||
.as_ref()
|
||||
.and_then(|o| o.include_item_types.as_deref()),
|
||||
);
|
||||
|
||||
// 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
|
||||
@@ -2940,10 +2795,8 @@ impl MediaRepository for OnlineRepository {
|
||||
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,UserData",
|
||||
item_id, self.user_id, limit_str
|
||||
);
|
||||
let endpoint =
|
||||
endpoints::similar_items(&self.capabilities, item_id, &self.user_id, limit_str);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(SearchResult {
|
||||
@@ -2974,20 +2827,22 @@ impl MediaRepository for OnlineRepository {
|
||||
"MediaType": "Audio",
|
||||
"UserId": self.user_id,
|
||||
});
|
||||
let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
|
||||
let response: CreatePlaylistResponse = self
|
||||
.post_json_response(endpoints::playlists(&self.capabilities), &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/{}", urlencoding::encode(playlist_id));
|
||||
let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
|
||||
let url = format!("{}{}", self.server_url, endpoint);
|
||||
|
||||
let request = self
|
||||
.http_client
|
||||
.client
|
||||
.delete(&url)
|
||||
.header("X-Emby-Authorization", self.auth_header())
|
||||
.header("Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
@@ -3015,16 +2870,13 @@ impl MediaRepository for OnlineRepository {
|
||||
"[OnlineRepo] Renaming playlist {} to '{}'",
|
||||
playlist_id, name
|
||||
);
|
||||
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
|
||||
let endpoint = endpoints::playlist_as_item(&self.capabilities, 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 endpoint = endpoints::playlist_items(&self.capabilities, playlist_id, &self.user_id);
|
||||
|
||||
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
|
||||
debug!(
|
||||
@@ -3059,11 +2911,7 @@ impl MediaRepository for OnlineRepository {
|
||||
.map(|id| urlencoding::encode(id).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?Ids={}",
|
||||
urlencoding::encode(playlist_id),
|
||||
ids_param
|
||||
);
|
||||
let endpoint = endpoints::playlist_items_add(&self.capabilities, playlist_id, &ids_param);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
|
||||
@@ -3082,18 +2930,15 @@ impl MediaRepository for OnlineRepository {
|
||||
.map(|id| urlencoding::encode(id).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let endpoint = format!(
|
||||
"/Playlists/{}/Items?EntryIds={}",
|
||||
urlencoding::encode(playlist_id),
|
||||
ids_param
|
||||
);
|
||||
let endpoint =
|
||||
endpoints::playlist_items_remove(&self.capabilities, 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())
|
||||
.header("Authorization", self.auth_header())
|
||||
.build()
|
||||
.map_err(|e| RepoError::Network {
|
||||
message: format!("Failed to build request: {}", e),
|
||||
@@ -3126,10 +2971,8 @@ impl MediaRepository for OnlineRepository {
|
||||
"[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
|
||||
);
|
||||
let endpoint =
|
||||
endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
|
||||
self.post_json(&endpoint, &serde_json::json!({})).await
|
||||
}
|
||||
}
|
||||
@@ -3310,7 +3153,7 @@ mod tests {
|
||||
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"
|
||||
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&ApiKey=test-access-token&Static=true"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3765,10 +3608,14 @@ mod tests {
|
||||
// ===== 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.
|
||||
// not a mock. A prior mock 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. That mock lived in
|
||||
// `online_integration_test.rs`, which was never declared as a module and so
|
||||
// never compiled — it was deleted for that reason, and this is the lesson it
|
||||
// left: a mock that reimplements the builder asserts on itself, and passes
|
||||
// just as happily when production is wrong. Assert the real builder targets
|
||||
// the resumable stream endpoint.
|
||||
//
|
||||
// @req-test: DR-013 - Repository pattern for online/offline data access
|
||||
|
||||
@@ -3787,7 +3634,7 @@ mod tests {
|
||||
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}");
|
||||
assert!(url.contains("ApiKey=test-access-token"), "url: {url}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user