fix(downloads,playback): re-encode undecodable audio and carry the media source through

Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: a downloaded video keeps audio the device can actually decode.
  `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD
  track came down untouched and the webview had nothing to play it with.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
This commit is contained in:
2026-08-15 23:54:00 +02:00
parent a5535f2941
commit ac4fccd499
10 changed files with 385 additions and 46 deletions
+108 -7
View File
@@ -828,6 +828,32 @@ impl OfflineRepository {
/// the synced-but-not-downloaded catalog branch deliberately excluded, so it
/// is authoritative regardless of the process-wide catalog-browse flag.
///
/// Whether cached item `i` belongs to library `l`, decided by media kind.
///
/// The cache leaves `library_id`/`parent_id` NULL on every item
/// ([[offline-libraries-never-cached]]), so there is no link to follow: a
/// library's `collection_type` and an item's `item_type` are the only things
/// that can associate them. This is Jellyfin taxonomy and therefore lives in
/// Rust, never in the frontend.
///
/// It is a named constant because it is needed in two places that must agree
/// — which library *appears* in the Downloaded list, and which items appear
/// *inside* it. They disagreed: the listing query used this mapping while the
/// browse query only checked that the requested library existed, so opening
/// any library showed every downloaded top-level item on the server.
///
/// A library of some other (or unknown) type keeps everything, since there is
/// no mapping to narrow it by and hiding its contents would be worse.
///
/// TRACES: UR-055 | DR-082, DR-167
const LIBRARY_HOLDS_ITEM: &'static str = "(
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
OR l.collection_type IS NULL
OR l.collection_type NOT IN ('music', 'movies', 'tvshows')
)";
/// TRACES: UR-055 | DR-082, DR-083
const DOWNLOADED_ITEMS_CTE: &'static str = "
WITH downloaded_items AS (
@@ -908,6 +934,7 @@ impl OfflineRepository {
EXISTS (
SELECT 1 FROM libraries l
WHERE l.id = ? AND l.server_id = i.server_id
AND {membership}
)
-- Top-level only: hide leaves whose container is downloaded.
AND NOT EXISTS (
@@ -922,6 +949,7 @@ impl OfflineRepository {
ORDER BY i.sort_name ASC, i.name ASC
LIMIT {limit} OFFSET {start_index}",
cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
);
let query = Query::with_params(
@@ -966,7 +994,7 @@ impl OfflineRepository {
// We match a library by collection_type ↔ item_type instead: any
// completed download of a given media kind qualifies that library.
let query = Query::with_params(
&format!(
format!(
"{cte}
SELECT l.id, l.name, l.collection_type, l.image_tag
FROM libraries l
@@ -975,15 +1003,11 @@ impl OfflineRepository {
SELECT 1 FROM items i
INNER JOIN downloaded_items di ON i.id = di.id
WHERE i.server_id = l.server_id
AND (
(l.collection_type = 'music' AND i.item_type IN ('MusicAlbum', 'MusicArtist', 'Audio'))
OR (l.collection_type = 'movies' AND i.item_type = 'Movie')
OR (l.collection_type = 'tvshows' AND i.item_type IN ('Series', 'Season', 'Episode'))
OR (l.collection_type NOT IN ('music', 'movies', 'tvshows'))
)
AND {membership}
)
ORDER BY l.sort_order ASC, l.name ASC",
cte = Self::DOWNLOADED_ITEMS_CTE,
membership = Self::LIBRARY_HOLDS_ITEM,
),
vec![QueryParam::String(self.server_id.clone())],
);
@@ -1959,6 +1983,7 @@ impl MediaRepository for OfflineRepository {
_item_id: &str,
_quality: &str,
_media_source_id: Option<&str>,
_source_audio_codec: Option<&str>,
) -> String {
// Cannot download while offline
String::new()
@@ -3720,6 +3745,82 @@ mod tests {
assert_eq!(track_ids, vec!["track-1", "track-2"]);
}
/// Regression: each downloaded library shows **only its own media**.
///
/// Cached items carry no link back to their library (`library_id`/`parent_id`
/// are NULL — [[offline-libraries-never-cached]]), and the library branch of
/// the query only asserted that the requested library *exists*, never that
/// the item belongs to it. So opening any downloaded library listed every
/// downloaded top-level item on the server: films in the music library,
/// albums under TV. The library's `collection_type` decides which item types
/// belong to it, the same mapping `get_downloaded_libraries` already uses.
///
/// TRACES: UR-055 | DR-167 | UT-162
#[tokio::test]
async fn test_get_downloaded_items_library_does_not_mix_media_types() {
let db = create_test_db();
seed_library(&db, "music-lib", "music").await;
seed_library(&db, "movie-lib", "movies").await;
seed_library(&db, "tv-lib", "tvshows").await;
insert_item(&db, "album-1", "MusicAlbum", None, None, None).await;
insert_item(&db, "track-1", "Audio", Some("album-1"), None, None).await;
insert_item(&db, "movie-1", "Movie", None, None, None).await;
insert_item(&db, "series-1", "Series", None, None, None).await;
insert_item(&db, "episode-1", "Episode", None, Some("series-1"), None).await;
seed_completed_download(&db, "track-1", 1000).await;
seed_completed_download(&db, "movie-1", 2000).await;
seed_completed_download(&db, "episode-1", 3000).await;
let repo = make_repo(&db);
let music: Vec<String> = repo
.get_downloaded_items("music-lib", None)
.await
.unwrap()
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(
music,
vec!["album-1"],
"the music library must not list films or series; got {:?}",
music
);
let movies: Vec<String> = repo
.get_downloaded_items("movie-lib", None)
.await
.unwrap()
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(
movies,
vec!["movie-1"],
"the movie library must not list albums or series; got {:?}",
movies
);
let tv: Vec<String> = repo
.get_downloaded_items("tv-lib", None)
.await
.unwrap()
.items
.iter()
.map(|i| i.id.clone())
.collect();
assert_eq!(
tv,
vec!["series-1"],
"the TV library must not list albums or films; got {:?}",
tv
);
}
/// Regression: a downloaded TV library lists the Series, not its Seasons or
/// Episodes — the same "individual songs" bug seen for music, for TV. The
/// season and episode are still reachable by drilling into the series.