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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
Reference in New Issue
Block a user