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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user