feat(music): audio downloads and music home rows

Album/artist downloads expand to tracks with pinned parent metadata,
aggregate progress, container deletes with reference-counted album
covers, and a Music tab on the downloads screen with fully offline
album playback. Home rows include music libraries (fixes plex hub
items being filtered to video types) and audio playlists join
download/sync rules.
This commit is contained in:
edde746
2026-07-05 21:51:37 +02:00
parent 7d6a747aa7
commit 764345f021
15 changed files with 1037 additions and 112 deletions
@@ -450,6 +450,78 @@ void main() {
expect(userB.map((e) => e.ratingKey), ['ep-b']);
});
Future<void> seedMusic() async {
await db.insertDownload(
serverId: ServerId('srvA'),
ratingKey: 'track1',
globalKey: 'srvA:track1',
type: 'track',
parentRatingKey: 'album1',
grandparentRatingKey: 'artist1',
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: ServerId('srvA'),
ratingKey: 'track2',
globalKey: 'srvA:track2',
type: 'track',
parentRatingKey: 'album1',
grandparentRatingKey: 'artist1',
status: DownloadStatus.completed.index,
);
await db.insertDownload(
serverId: ServerId('srvA'),
ratingKey: 'track3',
globalKey: 'srvA:track3',
type: 'track',
parentRatingKey: 'album2',
grandparentRatingKey: 'artist1',
status: DownloadStatus.completed.index,
);
// Episode sharing the album's parent key must not leak into track
// queries (type filter).
await db.insertDownload(
serverId: ServerId('srvA'),
ratingKey: 'ep-collide',
globalKey: 'srvA:ep-collide',
type: 'episode',
parentRatingKey: 'album1',
grandparentRatingKey: 'artist1',
status: DownloadStatus.completed.index,
);
// Same album key on another server.
await db.insertDownload(
serverId: ServerId('srvB'),
ratingKey: 'track-b',
globalKey: 'srvB:track-b',
type: 'track',
parentRatingKey: 'album1',
grandparentRatingKey: 'artist1',
status: DownloadStatus.completed.index,
);
}
test('getTracksByAlbum filters by parentRatingKey and type', () async {
await seedMusic();
final album1 = await db.getTracksByAlbum('album1');
expect(album1.map((e) => e.globalKey).toSet(), {'srvA:track1', 'srvA:track2', 'srvB:track-b'});
final album1SrvA = await db.getTracksByAlbum('album1', serverId: ServerId('srvA'));
expect(album1SrvA.map((e) => e.ratingKey).toSet(), {'track1', 'track2'});
expect(await db.getTracksByAlbum('albumZ'), isEmpty);
});
test('getTracksByArtist filters by grandparentRatingKey and type', () async {
await seedMusic();
final artist = await db.getTracksByArtist('artist1', serverId: ServerId('srvA'));
expect(artist.map((e) => e.ratingKey).toSet(), {'track1', 'track2', 'track3'});
expect(await db.getTracksByArtist('artist-missing'), isEmpty);
});
test('getDownloadsByServerId filters by serverId', () async {
await seedTree();
+110
View File
@@ -33,6 +33,30 @@ class _ThrowingClient implements MediaServerClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// Returns canned tracks from [fetchPlayableDescendants] (album/artist
/// expansion) and records the requested parent ids.
class _MusicExpansionClient implements MediaServerClient {
_MusicExpansionClient(this.tracks);
final List<MediaItem> tracks;
final fetchPlayableDescendantsCalls = <String>[];
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
fetchPlayableDescendantsCalls.add(parentId);
return tracks;
}
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerId get serverId => ServerId('srv');
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient {
_ScopedTestClient({required this.serverId, required this.scopedServerId});
@@ -511,6 +535,92 @@ void main() {
p.dispose();
});
test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async {
final album = MediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
serverId: ServerId('srv'),
);
MediaItem track(String id) => MediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: id,
parentId: 'album-1',
serverId: ServerId('srv'),
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
// Physical rows already exist (shared/unowned) so each expanded track
// takes the claim-existing early path — no manager/network needed.
p.debugSeedState(
downloads: {
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
},
metadata: {'srv:t1': track('t1'), 'srv:t2': track('t2')},
ownedDownloadKeys: const {},
);
final client = _MusicExpansionClient([track('t1'), track('t2')]);
final count = await p.queueDownload(album, client);
expect(count, 2);
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:t1', 'srv:t2'});
p.dispose();
});
test('album aggregates, downloadedAlbums, and per-album track order come from track downloads', () async {
MediaItem track(String id, {required int disc, required int number}) => MediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.track,
title: id,
parentId: 'album-1',
parentTitle: 'Album',
grandparentId: 'artist-1',
grandparentTitle: 'Artist',
parentIndex: disc,
index: number,
serverId: ServerId('srv'),
);
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
},
// Seeded out of disc/track order on purpose.
metadata: {
'srv:t1': track('t1', disc: 2, number: 1),
'srv:t2': track('t2', disc: 1, number: 2),
'srv:album-1': MediaItem(
id: 'album-1',
backend: MediaBackend.plex,
kind: MediaKind.album,
title: 'Album',
parentId: 'artist-1',
parentTitle: 'Artist',
serverId: ServerId('srv'),
),
},
);
expect(p.getProgress('srv:album-1')?.status, DownloadStatus.completed);
expect(p.isDownloaded('srv:album-1'), isTrue);
expect(p.downloadedAlbums.map((a) => a.id), ['album-1']);
expect(p.getDownloadedTracksForAlbum('album-1').map((item) => item.id), ['t2', 't1']);
p.dispose();
});
test('queueDownload leaves paused downloads paused instead of re-queueing them', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
@@ -62,7 +62,7 @@ void main() {
await db.close();
});
testWidgets('right from Movies focuses and opens Sync Rules action', (tester) async {
testWidgets('right from the last tab (Music) focuses and opens Sync Rules action', (tester) async {
final screenKey = GlobalKey<DownloadsScreenState>();
await tester.pumpWidget(
@@ -83,8 +83,8 @@ void main() {
await tester.pumpAndSettle();
final state = screenKey.currentState!;
state.tabController.index = 2;
state.getTabChipFocusNode(2).requestFocus();
state.tabController.index = 3;
state.getTabChipFocusNode(3).requestFocus();
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+116 -5
View File
@@ -726,7 +726,7 @@ void main() {
);
});
test('per-library home rows include clip libraries and skip music/photo (#1476)', () async {
test('per-library home rows include clip and music libraries and skip photo (#1476)', () async {
final captured = <Uri>[];
final client = JellyfinClient.forTesting(
@@ -762,9 +762,19 @@ void main() {
{'Id': 'vid-1', 'Type': 'Video', 'Name': 'Latest Home Video', 'ParentLibraryId': 'home-vids'},
],
}),
'music' => _json({
'Items': [
{'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Latest Album', 'ParentLibraryId': 'music'},
],
}),
_ => http.Response('latest should not be requested for $parentId', 500),
};
}
// Music library's played-track rows — empty so only the Latest
// Albums hub survives.
if (req.url.path == '/Items' && req.url.queryParameters['Filters'] == 'IsPlayed') {
return _json({'Items': const <Object>[]});
}
return http.Response('unexpected request', 500);
}),
);
@@ -775,11 +785,17 @@ void main() {
final hubs = result.hubs;
expect(result.succeededServerIds, {'srv-1'});
expect(hubs.map((h) => h.identifier), ['library.movies.recent', 'library.mv.recent', 'library.home-vids.recent']);
expect(hubs.map((h) => h.identifier), [
'library.movies.recent',
'library.mv.recent',
'library.home-vids.recent',
'library.music.recent',
]);
expect(hubs[1].items.single.kind, MediaKind.clip);
expect(hubs[3].items.single.kind, MediaKind.album);
expect(
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
['movies', 'mv', 'home-vids'],
['movies', 'mv', 'home-vids', 'music'],
);
});
@@ -799,6 +815,15 @@ void main() {
promotedHubKey: '/hubs/promoted',
httpClient: MockClient((req) async {
captured.add(req.url);
if (req.url.path == '/library/sections') {
return _json({
'MediaContainer': {
'Directory': [
{'key': '2', 'type': 'show', 'title': 'TV Shows'},
],
},
});
}
if (req.url.path == '/hubs/promoted') {
return _json({
'MediaContainer': {
@@ -840,8 +865,94 @@ void main() {
expect(hubs.single.identifier, 'home.television.recent');
expect(hubs.single.libraryId, isNull);
expect(hubs.single.items, hasLength(7));
expect(captured.map((uri) => uri.path), ['/hubs/promoted']);
expect(captured.single.queryParameters['count'], defaultHubPreviewLimit.toString());
// Library prefetch (music detection) + the promoted hubs — no
// per-library hub calls without a music section.
expect(captured.map((uri) => uri.path), ['/library/sections', '/hubs/promoted']);
expect(
captured.singleWhere((uri) => uri.path == '/hubs/promoted').queryParameters['count'],
defaultHubPreviewLimit.toString(),
);
});
test('Plex home layout appends music library hubs the promoted endpoint excludes', () async {
final captured = <Uri>[];
final client = PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'token',
clientIdentifier: 'client-id',
product: 'Plezy',
version: 'test',
),
serverId: ServerId('plex-1'),
serverName: 'Plex',
promotedHubKey: '/hubs/promoted',
httpClient: MockClient((req) async {
captured.add(req.url);
if (req.url.path == '/library/sections') {
return _json({
'MediaContainer': {
'Directory': [
{'key': '1', 'type': 'movie', 'title': 'Movies'},
{'key': '9', 'type': 'artist', 'title': 'Music'},
],
},
});
}
if (req.url.path == '/hubs/promoted') {
return _json({
'MediaContainer': {
'Hub': [
{
'key': '/hubs/home/recentlyAdded?type=1',
'title': 'Recently Added Movies',
'type': 'movie',
'hubIdentifier': 'home.movies.recent',
'size': 1,
'Metadata': [
{'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie', 'librarySectionID': 1},
],
},
],
},
});
}
if (req.url.path == '/hubs/sections/9') {
return _json({
'MediaContainer': {
'Hub': [
{
'key': '/library/sections/9/recentlyAdded',
'title': 'Recently Added Music',
'type': 'album',
'hubIdentifier': 'music.recent',
'size': 1,
'Metadata': [
{'ratingKey': 'album-1', 'type': 'album', 'title': 'Album', 'librarySectionID': 9},
],
},
],
},
});
}
return http.Response('unexpected request', 500);
}),
);
addTearDown(client.close);
manager.debugRegisterClientForTesting(client);
final result = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false);
final hubs = result.hubs;
expect(result.succeededServerIds, {'plex-1'});
expect(hubs.map((h) => h.identifier), ['home.movies.recent', 'music.recent']);
expect(hubs[1].items.single.kind, MediaKind.album);
// Only the music section gets a per-library hub call — the movie
// library's rows already came from the promoted endpoint.
expect(captured.where((uri) => uri.path.startsWith('/hubs/sections/')).map((uri) => uri.path), [
'/hubs/sections/9',
]);
});
});
}
@@ -103,6 +103,36 @@ void main() {
expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng');
});
test('downloaded track resolves to its local file through the offline path', () async {
// Same globalKey shape queueDownload writes (`serverId:ratingKey`) —
// the music resolver reaches this via preferOffline=true (original
// audio preset), so a downloaded track must play from disk.
await _insertDownloaded(
db,
serverId: ServerId('srv-1'),
ratingKey: 'track-1',
type: 'track',
videoFilePath: 'content://offline/track-1',
);
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
metadata: MediaItem(
id: 'track-1',
backend: MediaBackend.plex,
kind: MediaKind.track,
serverId: ServerId('srv-1'),
),
selectedMediaIndex: 0,
preferOffline: true,
);
expect(client.playbackInitializationCalls, 0);
expect(result.isOffline, isTrue);
expect(result.videoUrl, 'content://offline/track-1');
expect(result.playMethod, 'DirectPlay');
});
test('preferOffline uses cache without calling live client when local file exists', () async {
await _insertDownloaded(
db,
@@ -489,6 +519,7 @@ Future<void> _insertDownloaded(
String? clientScopeId,
required String ratingKey,
required String videoFilePath,
String type = 'movie',
int mediaIndex = 0,
String? mediaSourceId,
}) async {
@@ -500,7 +531,7 @@ Future<void> _insertDownloaded(
clientScopeId: Value(clientScopeId),
ratingKey: ratingKey,
globalKey: '$serverId:$ratingKey',
type: 'movie',
type: type,
status: DownloadStatus.completed.index,
videoFilePath: Value(videoFilePath),
mediaIndex: Value(mediaIndex),
@@ -396,6 +396,43 @@ void main() {
expect(client.collectionPageCalls, [(start: 0, size: 100)]);
expect(client.fetchChildrenCalled, isFalse);
});
test('collectItemsForList accepts tracks and expands albums/artists', () async {
final db = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(db.close);
final executor = SyncRuleExecutor(database: db);
final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)];
final client = _PlayableDescendantsClient(albumTracks);
final items = [
_track('loose-track'),
MediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'),
MediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'),
// Still skipped: nested lists / unplayable kinds.
MediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'),
];
final out = <MediaItem>[];
await executor.collectItemsForList(client, items, unwatchedOnly: false, out: out);
expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']);
expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']);
// unwatchedOnly applies the play-count filter to tracks too.
final unwatched = <MediaItem>[];
await executor.collectItemsForList(
client,
[_track('played-track', played: true), items[1]],
unwatchedOnly: true,
out: unwatched,
);
expect(unwatched.map((i) => i.id), ['album-track-1']);
});
}
MediaItem _track(String id, {bool played = false}) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0);
}
MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) {