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
+185 -71
View File
@@ -311,8 +311,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (cached != null) {
_metadata[item.globalKey] = cached;
// For episodes, also load parent (show and season) metadata from the same map
if (cached.isEpisode) {
// For episodes (show/season) and tracks (artist/album), also load
// parent metadata from the same map.
if (cached.isEpisode || cached.kind == MediaKind.track) {
_loadParentMetadataFromMap(
cached,
allMetadata,
@@ -381,10 +382,11 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return downloadedScope == null || downloadedScope.isEmpty ? null : downloadedScope;
}
/// Load parent (show and season) metadata from a pre-loaded map (no DB I/O).
/// Used during bulk initialization to avoid per-item DB queries.
void _loadParentMetadataFromMap(MediaItem episode, Map<String, MediaItem> allMetadata, {String? clientScopeId}) {
final serverId = episode.serverId;
/// Load parent metadata (show + season for episodes, artist + album for
/// tracks) from a pre-loaded map (no DB I/O). Used during bulk
/// initialization to avoid per-item DB queries.
void _loadParentMetadataFromMap(MediaItem leaf, Map<String, MediaItem> allMetadata, {String? clientScopeId}) {
final serverId = leaf.serverId;
if (serverId == null) return;
MediaItem? lookupParent(String ratingKey) {
@@ -395,35 +397,20 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return allMetadata[buildGlobalKey(ServerId(serverId), ratingKey)];
}
// Load show metadata
final showRatingKey = episode.grandparentId;
if (showRatingKey != null) {
final showGlobalKey = buildGlobalKey(ServerId(serverId), showRatingKey);
if (!_metadata.containsKey(showGlobalKey)) {
final showMetadata = lookupParent(showRatingKey);
if (showMetadata != null) {
_metadata[showGlobalKey] = showMetadata;
if (showMetadata.thumbPath != null) {
_artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumbPath);
}
}
void loadParent(String? ratingKey) {
if (ratingKey == null) return;
final parentGlobalKey = buildGlobalKey(ServerId(serverId), ratingKey);
if (_metadata.containsKey(parentGlobalKey)) return;
final parentMetadata = lookupParent(ratingKey);
if (parentMetadata == null) return;
_metadata[parentGlobalKey] = parentMetadata;
if (parentMetadata.thumbPath != null) {
_artworkPaths[parentGlobalKey] = DownloadedArtwork(thumbPath: parentMetadata.thumbPath);
}
}
// Load season metadata
final seasonRatingKey = episode.parentId;
if (seasonRatingKey != null) {
final seasonGlobalKey = buildGlobalKey(ServerId(serverId), seasonRatingKey);
if (!_metadata.containsKey(seasonGlobalKey)) {
final seasonMetadata = lookupParent(seasonRatingKey);
if (seasonMetadata != null) {
_metadata[seasonGlobalKey] = seasonMetadata;
if (seasonMetadata.thumbPath != null) {
_artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumbPath);
}
}
}
}
loadParent(leaf.grandparentId); // show / artist
loadParent(leaf.parentId); // season / album
}
void _onProgressUpdate(DownloadProgress progress) {
@@ -567,6 +554,70 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
.toList();
}
/// Unique albums that have completed downloaded tracks, sorted by artist
/// then album title. Uses stored album metadata (persisted alongside each
/// track download) and falls back to synthesizing from track fields.
List<MediaItem> get downloadedAlbums {
final Map<String, MediaItem> albums = {};
for (final entry in _metadata.entries) {
final globalKey = entry.key;
if (!_ownsDownloadKey(globalKey)) continue;
final meta = entry.value;
if (meta.kind != MediaKind.track) continue;
if (_downloads[globalKey]?.status != DownloadStatus.completed) continue;
final albumRatingKey = meta.parentId;
if (albumRatingKey == null || albums.containsKey(albumRatingKey)) continue;
final albumGlobalKey = buildGlobalKey(ServerId(meta.serverId!), albumRatingKey);
final storedAlbum = _metadata[albumGlobalKey];
if (storedAlbum != null && storedAlbum.kind == MediaKind.album) {
albums[albumRatingKey] = storedAlbum;
} else {
albums[albumRatingKey] = MediaItem(
id: albumRatingKey,
backend: meta.backend,
kind: MediaKind.album,
title: meta.albumTitle ?? t.common.unknown,
parentId: meta.grandparentId,
parentTitle: meta.grandparentTitle,
thumbPath: meta.parentThumbPath ?? meta.thumbPath,
serverId: meta.serverId,
);
}
}
final list = albums.values.toList();
list.sort((a, b) {
final byArtist = (a.albumArtistTitle ?? '').compareTo(b.albumArtistTitle ?? '');
if (byArtist != 0) return byArtist;
return (a.title ?? '').compareTo(b.title ?? '');
});
return list;
}
/// Completed downloaded tracks of an album, sorted by disc then track
/// number — the offline playback queue for that album.
List<MediaItem> getDownloadedTracksForAlbum(String albumRatingKey) {
final tracks = _metadata.entries
.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final meta = entry.value;
return meta.kind == MediaKind.track &&
meta.parentId == albumRatingKey &&
_downloads[entry.key]?.status == DownloadStatus.completed;
})
.map((entry) => entry.value)
.toList();
tracks.sort((a, b) {
final byDisc = (a.discNumber ?? 1).compareTo(b.discNumber ?? 1);
if (byDisc != 0) return byDisc;
return (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0);
});
return tracks;
}
/// Get metadata for a specific download
MediaItem? getMetadata(String globalKey) => _metadata[globalKey];
@@ -593,15 +644,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
.toList();
}
/// Get episode downloads filtered by show and/or season ratingKey.
List<DownloadProgress> _getEpisodeDownloads({String? showRatingKey, String? seasonRatingKey}) {
/// Get leaf downloads (episodes or tracks) filtered by grandparent
/// (show/artist) and/or parent (season/album) ratingKey.
List<DownloadProgress> _getLeafDownloads({String? grandparentRatingKey, String? parentRatingKey}) {
return _downloads.entries
.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final meta = _metadata[entry.key];
if (meta == null || !meta.isEpisode) return false;
if (showRatingKey != null && meta.grandparentId != showRatingKey) return false;
if (seasonRatingKey != null && meta.parentId != seasonRatingKey) return false;
if (meta == null || !(meta.isEpisode || meta.kind == MediaKind.track)) return false;
if (grandparentRatingKey != null && meta.grandparentId != grandparentRatingKey) return false;
if (parentRatingKey != null && meta.parentId != parentRatingKey) return false;
return true;
})
.map((entry) => entry.value)
@@ -614,7 +666,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: showRatingKey,
episodes: _getEpisodeDownloads(showRatingKey: showRatingKey),
episodes: _getLeafDownloads(grandparentRatingKey: showRatingKey),
entityType: 'show',
);
}
@@ -625,11 +677,31 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: seasonRatingKey,
episodes: _getEpisodeDownloads(seasonRatingKey: seasonRatingKey),
episodes: _getLeafDownloads(parentRatingKey: seasonRatingKey),
entityType: 'season',
);
}
/// Aggregate progress for an album (parent of its tracks).
DownloadProgress? getAggregateProgressForAlbum(ServerId serverId, String albumRatingKey) {
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: albumRatingKey,
episodes: _getLeafDownloads(parentRatingKey: albumRatingKey),
entityType: 'album',
);
}
/// Aggregate progress for an artist (grandparent of its tracks).
DownloadProgress? getAggregateProgressForArtist(ServerId serverId, String artistRatingKey) {
return _calculateAggregateProgress(
serverId: serverId,
ratingKey: artistRatingKey,
episodes: _getLeafDownloads(grandparentRatingKey: artistRatingKey),
entityType: 'artist',
);
}
/// Shared helper to calculate aggregate download progress for shows/seasons
DownloadProgress? _calculateAggregateProgress({
required ServerId serverId,
@@ -704,13 +776,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
'$queuedCount queued of $totalEpisodes total) - Status: $overallStatus',
);
final leafNoun = entityType == 'album' || entityType == 'artist' ? 'tracks' : 'episodes';
return DownloadProgress(
globalKey: globalKey,
status: overallStatus,
progress: overallProgress,
downloadedBytes: 0,
totalBytes: 0,
currentFile: '$completedCount/$totalEpisodes episodes',
currentFile: '$completedCount/$totalEpisodes $leafNoun',
);
}
@@ -736,15 +809,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// Try to get metadata to determine type
final meta = _metadata[globalKey];
if (meta == null) {
// No metadata stored yet, might be a show/season being queued
// Check if any episodes exist for this as a parent
final episodesAsShow = _getEpisodeDownloads(showRatingKey: ratingKey);
if (episodesAsShow.isNotEmpty) {
// No metadata stored yet, might be a container (show/season/artist/
// album) being queued. Check if any leaves exist for this as a parent
// the aggregate helpers are kind-agnostic over grandparent/parent keys.
final leavesAsGrandparent = _getLeafDownloads(grandparentRatingKey: ratingKey);
if (leavesAsGrandparent.isNotEmpty) {
return getAggregateProgressForShow(serverId, ratingKey);
}
final episodesAsSeason = _getEpisodeDownloads(seasonRatingKey: ratingKey);
if (episodesAsSeason.isNotEmpty) {
final leavesAsParent = _getLeafDownloads(parentRatingKey: ratingKey);
if (leavesAsParent.isNotEmpty) {
return getAggregateProgressForSeason(serverId, ratingKey);
}
@@ -752,13 +826,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
// We have metadata, check kind
if (meta.kind == MediaKind.show) {
return getAggregateProgressForShow(serverId, ratingKey);
} else if (meta.kind == MediaKind.season) {
return getAggregateProgressForSeason(serverId, ratingKey);
}
return null;
return switch (meta.kind) {
MediaKind.show => getAggregateProgressForShow(serverId, ratingKey),
MediaKind.season => getAggregateProgressForSeason(serverId, ratingKey),
MediaKind.album => getAggregateProgressForAlbum(serverId, ratingKey),
MediaKind.artist => getAggregateProgressForArtist(serverId, ratingKey),
_ => null,
};
}
/// Check if an item is downloaded
@@ -855,8 +929,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
/// Queue a download for a media item.
/// For movies and episodes, queues directly.
/// For movies, episodes, and tracks, queues directly.
/// For shows and seasons, fetches all child episodes and queues them.
/// For albums and artists, fetches all child tracks and queues them.
/// Returns the number of items queued.
Future<int> queueDownload(
MediaItem metadata,
@@ -881,9 +956,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_queueing.add(globalKey);
safeNotifyListeners();
if (metadata.isMovie || metadata.isEpisode) {
if (metadata.isMovie || metadata.isEpisode || metadata.kind == MediaKind.track) {
final queued = await _queueSingleDownload(metadata, client, mediaIndex: config.mediaIndex);
return queued ? 1 : 0;
} else if (metadata.kind == MediaKind.album || metadata.kind == MediaKind.artist) {
final hadMetadata = _metadata.containsKey(globalKey);
_metadata[globalKey] = metadata;
try {
return await _queueMusicContainerDownload(metadata, client);
} catch (_) {
if (!hadMetadata) _metadata.remove(globalKey);
rethrow;
}
} else if (metadata.isShow) {
// Stash metadata pre-queue so the UI can render the queueing state;
// roll back if expansion throws so the orphan doesn't linger.
@@ -929,9 +1013,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Queue every playable item from a collection/playlist for download.
///
/// Movies and episodes are queued directly. Shows and seasons are expanded
/// into their episodes (when [expandShows] is true). Music items, nested
/// collections/playlists, and unknown types are skipped.
/// Movies, episodes, and tracks are queued directly. Shows and seasons are
/// expanded into their episodes and albums/artists into their tracks (when
/// [expandShows] is true). Nested collections/playlists and unknown types
/// are skipped.
Future<int> queueListDownload(
List<MediaItem> items,
MediaServerClient client, {
@@ -955,7 +1040,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
for (final item in items) {
if (item.isMovie || item.isEpisode) {
if (item.isMovie || item.isEpisode || item.kind == MediaKind.track) {
await queueItem(item);
} else if (item.isShow || item.isSeason) {
if (!expandShows) continue;
@@ -971,8 +1056,15 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
for (final ep in episodes) {
await queueItem(ep);
}
} else if (item.kind == MediaKind.album || item.kind == MediaKind.artist) {
if (!expandShows) continue;
// Same one-shot expansion for music containers (album/artist →
// tracks) via the shared recursive-leaves call.
for (final track in await client.fetchPlayableDescendants(item.id)) {
await queueItem(_ensureServerId(track, item.serverId));
}
} else {
// Skip music, clips, nested collections/playlists, unknown types.
// Skip clips, nested collections/playlists, unknown types.
continue;
}
}
@@ -1052,8 +1144,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
}
// For episodes, also fetch and store show and season metadata for offline display
if (metadataToStore.isEpisode) {
// For episodes (show + season) and tracks (artist + album), also fetch
// and store parent metadata for offline display.
if (metadataToStore.isEpisode || metadataToStore.kind == MediaKind.track) {
await _fetchAndStoreParentMetadata(
metadataToStore,
client,
@@ -1075,25 +1168,26 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return true;
}
/// Fetch and store show and season metadata for an episode
/// Also downloads artwork for show and season
/// Fetch and store parent metadata for a leaf item — show + season for an
/// episode, artist + album for a track (same grandparent/parent fields).
/// Also downloads the parents' artwork.
Future<void> _fetchAndStoreParentMetadata(
MediaItem episode,
MediaItem leaf,
MediaServerClient client, {
required _RelatedMetadataDownloadContext context,
}) async {
final serverId = episode.serverId;
final serverId = leaf.serverId;
if (serverId == null) return;
await _fetchAndStoreRelatedMetadata(
serverId: ServerId(serverId),
ratingKey: episode.grandparentId,
ratingKey: leaf.grandparentId,
client: client,
context: context,
);
await _fetchAndStoreRelatedMetadata(
serverId: ServerId(serverId),
ratingKey: episode.parentId,
ratingKey: leaf.parentId,
client: client,
context: context,
);
@@ -1142,6 +1236,22 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
_artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath);
}
/// Queue every track under an album/artist. Expansion is one
/// recursive-leaves call ([MediaServerClient.fetchPlayableDescendants]) on
/// both backends — Plex branches album→/children, Jellyfin retries
/// tag-only artists by album-artist credit.
Future<int> _queueMusicContainerDownload(MediaItem container, MediaServerClient client) async {
final tracks = await client.fetchPlayableDescendants(container.id);
final relatedContext = _RelatedMetadataDownloadContext();
int count = 0;
for (final track in tracks) {
final trackWithServer = _ensureServerId(track, container.serverId);
final queued = await _queueSingleDownload(trackWithServer, client, relatedContext: relatedContext);
if (queued) count++;
}
return count;
}
/// Queue all episodes from a TV show for download
Future<int> _queueShowDownload(
MediaItem show,
@@ -1329,7 +1439,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Future<void> deleteDownload(String globalKey) async {
try {
final meta = _metadata[globalKey];
if (meta != null && (meta.isShow || meta.isSeason)) {
if (meta != null &&
(meta.isShow || meta.isSeason || meta.kind == MediaKind.album || meta.kind == MediaKind.artist)) {
await _deleteOwnedContainerDownloads(globalKey, meta);
return;
}
@@ -1379,11 +1490,14 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}
Iterable<MapEntry<String, MediaItem>> _ownedDescendantEntries(MediaItem container) {
// Shows and artists are grandparents of their leaves; seasons and albums
// are direct parents.
final matchesGrandparent = container.isShow || container.kind == MediaKind.artist;
return _metadata.entries.where((entry) {
if (!_ownsDownloadKey(entry.key)) return false;
final meta = entry.value;
if (meta.serverId != container.serverId) return false;
return container.isShow
return matchesGrandparent
? (meta.grandparentId == container.id || meta.parentId == container.id)
: meta.parentId == container.id;
});
@@ -1471,7 +1585,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (cached != null) {
_metadata[globalKey] = cached;
if (cached.isEpisode) {
if (cached.isEpisode || cached.kind == MediaKind.track) {
_loadParentMetadataFromMap(
cached,
allMetadata,