Merge branch 'master' into worktree-mosaic-library

Renumbers the mosaic's requirement IDs out of the way of the download work
that landed on master in parallel: it had already claimed DR-163/DR-164 and
UT-162, so the mosaic layout is now DR-172, the library favourites scope
DR-173, and its composition test UT-167.

Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166,
none of which are defined in requirements.md — that branch defined DR-167..171
instead. Those references are orphaned and want a look; nothing here touches
them.
This commit is contained in:
2026-08-16 00:04:26 +02:00
26 changed files with 2495 additions and 1554 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())],
);
@@ -1957,6 +1981,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()
@@ -3713,6 +3738,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.