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
+37
View File
@@ -259,6 +259,43 @@ extension DownloadDatabaseOperations on AppDatabase {
.get();
}
/// Downloaded tracks belonging to an album (parentRatingKey). Mirrors
/// [getEpisodesBySeason] but filters on type so an id collision with a
/// season key can never mix media kinds.
Future<List<DownloadedMediaItem>> getTracksByAlbum(
String albumKey, {
ServerId? serverId,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(downloadedMedia)..where(
(t) =>
t.type.equals('track') &
t.parentRatingKey.equals(albumKey) &
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
))
.get();
}
/// Downloaded tracks belonging to an artist (grandparentRatingKey). Mirrors
/// [getEpisodesByShow] with the same type filter as [getTracksByAlbum].
Future<List<DownloadedMediaItem>> getTracksByArtist(
String artistKey, {
ServerId? serverId,
String? clientScopeId,
bool filterClientScope = false,
}) {
return (select(downloadedMedia)..where(
(t) =>
t.type.equals('track') &
t.grandparentRatingKey.equals(artistKey) &
_optionalServerPredicate(t.serverId, serverIdOrNull(serverId)) &
_optionalClientScopePredicate(t.clientScopeId, clientScopeId, filterClientScope: filterClientScope),
))
.get();
}
Future<List<DownloadedMediaItem>> getDownloadsByServerId(ServerId serverId) {
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
}
+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,
+200 -1
View File
@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../../media/ids.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -6,6 +8,12 @@ import '../../focus/focusable_action_bar.dart';
import '../../media/media_item.dart';
import '../../providers/download_provider.dart';
import '../../providers/multi_server_provider.dart';
import '../../services/music/music_playback_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/music_navigation.dart';
import '../../widgets/app_icon.dart';
import '../../widgets/music/mini_player.dart';
import '../../widgets/music/track_row.dart';
import '../../services/settings_service.dart';
import '../../widgets/settings_builder.dart';
import '../../utils/global_key_utils.dart';
@@ -35,10 +43,16 @@ class DownloadsScreenState extends State<DownloadsScreen>
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
final _musicTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_music');
final _actionBarKey = GlobalKey<FocusableActionBarState>();
@override
List<FocusNode> get tabChipFocusNodes => [_queueTabChipFocusNode, _tvShowsTabChipFocusNode, _moviesTabChipFocusNode];
List<FocusNode> get tabChipFocusNodes => [
_queueTabChipFocusNode,
_tvShowsTabChipFocusNode,
_moviesTabChipFocusNode,
_musicTabChipFocusNode,
];
@override
void initState() {
@@ -52,6 +66,7 @@ class DownloadsScreenState extends State<DownloadsScreen>
_queueTabChipFocusNode.dispose();
_tvShowsTabChipFocusNode.dispose();
_moviesTabChipFocusNode.dispose();
_musicTabChipFocusNode.dispose();
disposeTabNavigation();
super.dispose();
}
@@ -106,6 +121,8 @@ class DownloadsScreenState extends State<DownloadsScreen>
_buildTabChip(t.downloads.tvShows, 1),
const SizedBox(width: 8),
_buildTabChip(t.downloads.movies, 2),
const SizedBox(width: 8),
_buildTabChip(t.downloads.music, 3),
],
);
}
@@ -161,6 +178,8 @@ class DownloadsScreenState extends State<DownloadsScreen>
_buildTabChip(t.downloads.tvShows, 1),
const SizedBox(width: 8),
_buildTabChip(t.downloads.movies, 2),
const SizedBox(width: 8),
_buildTabChip(t.downloads.music, 3),
],
),
),
@@ -216,6 +235,7 @@ class DownloadsScreenState extends State<DownloadsScreen>
suppressAutoFocus: suppressAutoFocus,
onBack: focusTabBar,
),
_DownloadedMusicContent(suppressAutoFocus: suppressAutoFocus, onBack: focusTabBar),
],
),
),
@@ -345,3 +365,182 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
);
}
}
/// A row of the downloaded-music list: an album header ([album] non-null) or
/// a track at [trackIndex] within [albumTracks].
class _MusicListEntry {
final MediaItem? album;
final List<MediaItem> albumTracks;
final int trackIndex;
final bool isFirst;
final bool isLast;
const _MusicListEntry.header(MediaItem this.album)
: albumTracks = const [],
trackIndex = -1,
isFirst = false,
isLast = false;
const _MusicListEntry.track(this.albumTracks, this.trackIndex, {required this.isFirst, required this.isLast})
: album = null;
}
/// Music tab: downloaded tracks grouped under their album (square cover +
/// artist header, [TrackRow] entries). Tapping a track plays the album's
/// downloaded tracks in disc/track order — fully offline through the shared
/// music playback path.
class _DownloadedMusicContent extends StatefulWidget {
final bool suppressAutoFocus;
final VoidCallback? onBack;
const _DownloadedMusicContent({required this.suppressAutoFocus, this.onBack});
@override
State<_DownloadedMusicContent> createState() => _DownloadedMusicContentState();
}
class _DownloadedMusicContentState extends State<_DownloadedMusicContent> {
final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'DownloadsMusic_firstItem');
@override
void dispose() {
_firstItemFocusNode.dispose();
super.dispose();
}
@override
void didUpdateWidget(_DownloadedMusicContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _firstItemFocusNode.canRequestFocus) {
_firstItemFocusNode.requestFocus();
}
});
}
}
Future<void> _playAlbumFrom(List<MediaItem> albumTracks, MediaItem track) async {
final album = track.parentId;
await playTracks(
context,
tracks: albumTracks,
startTrack: track,
playContext: MusicPlayContext(id: album, title: track.albumTitle ?? '', kind: MusicPlayContextKind.album),
);
}
Widget _buildAlbumHeader(BuildContext context, DownloadProvider provider, MediaItem album) {
final tk = tokens(context);
final textTheme = Theme.of(context).textTheme;
final artist = album.albumArtistTitle;
final serverId = album.serverId;
final localArt = serverId == null ? null : provider.getArtworkLocalPath(ServerId(serverId), album.thumbPath);
Widget fallbackCover() => Container(
width: 48,
height: 48,
color: tk.surface,
child: AppIcon(Symbols.album_rounded, fill: 1, size: 24, color: tk.textMuted),
);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 16, 4, 8),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(tk.radiusSm),
child: localArt != null
? Image.file(
File(localArt),
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => fallbackCover(),
)
: fallbackCover(),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(album.displayTitle, style: textTheme.titleSmall, maxLines: 1, overflow: .ellipsis),
if (artist != null && artist.isNotEmpty)
Text(
artist,
style: textTheme.bodySmall?.copyWith(color: tk.textMuted),
maxLines: 1,
overflow: .ellipsis,
),
],
),
),
],
),
);
}
List<_MusicListEntry> _rowModels(DownloadProvider provider) {
final rows = <_MusicListEntry>[];
for (final album in provider.downloadedAlbums) {
final tracks = provider.getDownloadedTracksForAlbum(album.id);
if (tracks.isEmpty) continue;
rows.add(_MusicListEntry.header(album));
for (var i = 0; i < tracks.length; i++) {
rows.add(_MusicListEntry.track(tracks, i, isFirst: i == 0, isLast: i == tracks.length - 1));
}
}
return rows;
}
@override
Widget build(BuildContext context) {
return Consumer<DownloadProvider>(
builder: (context, downloadProvider, _) {
final rows = _rowModels(downloadProvider);
if (rows.isEmpty) {
return EmptyStateWidget(
message: t.downloads.noDownloads,
subtitle: t.downloads.noDownloadsDescription,
icon: Symbols.music_note_rounded,
iconSize: 80,
);
}
// Keep the last rows reachable above the floating mini-player.
final bottomInset = context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0;
return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottomInset),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
final album = row.album;
if (album != null) {
return _buildAlbumHeader(context, downloadProvider, album);
}
final item = row.albumTracks[row.trackIndex];
// Row 0 is always the first album's header, so the first track
// row sits at index 1.
final isFirstTrackRow = index == 1;
return Padding(
padding: EdgeInsets.only(top: row.isFirst ? 0 : tokens(context).groupGap),
child: TrackRow(
key: ValueKey(item.globalKey),
item: item,
isFirst: row.isFirst,
isLast: row.isLast,
showArtist: true,
focusNode: isFirstTrackRow ? _firstItemFocusNode : null,
onBack: widget.onBack,
onTap: () => _playAlbumFrom(row.albumTracks, item),
),
);
},
);
},
);
}
}
+28 -10
View File
@@ -373,10 +373,12 @@ class DataAggregationService {
return (hubs: const <MediaHub>[], succeededServerIds: const <String>{}, cancelledServerIds: const <String>{});
}
// Only fallback clients need a library prefetch when home layout is on;
// rich-hub backends return the intended home rows directly.
final needsLibraryPrefetch = useGlobalHubs && clients.values.any((client) => !client.capabilities.richHubs);
final libraries = needsLibraryPrefetch
// Home layout needs the library list for every client: fallback backends
// build all their rows from per-library hubs, and rich-hub backends
// (Plex) need it to detect visible music libraries, whose hubs the
// global-hub endpoint excludes. One `fetchLibraries` per server, served
// from the per-backend API cache when warm.
final libraries = useGlobalHubs
? _groupLibrariesByServer((await getMediaLibrariesFromAllServers(serverIds: serverIds)).libraries)
: null;
@@ -389,7 +391,21 @@ class DataAggregationService {
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
final hubItemLimit = limit ?? defaultHubPreviewLimit;
final hubs = shouldUseGlobalHubs
? await client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs)
? [
...await client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs),
// Plex's promoted/global hub endpoint never includes music
// libraries — append their per-library hubs so music rows
// reach home. No-op (zero extra calls) without a visible
// music library.
...await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: serverLibraries ?? const [],
kinds: const {MediaKind.artist},
),
]
: await _fetchLibraryHubsForClient(
client,
limit: hubItemLimit,
@@ -421,20 +437,22 @@ class DataAggregationService {
return (hubs: hubs, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds);
}
/// Per-library hub fetch for a single client. Filters to visible
/// movie/show/clip libraries (Plex hides music libraries from this surface;
/// clip covers Jellyfin musicvideos/homevideos, #1476) and concatenates the
/// results.
/// Per-library hub fetch for a single client. Filters to visible libraries
/// of [kinds] (movie/show/clip/artist by default — clip covers Jellyfin
/// musicvideos/homevideos, #1476; artist brings music rows to home) and
/// concatenates the results. The rich-hub music append passes
/// `{MediaKind.artist}` to fetch only what the global endpoint misses.
Future<List<MediaHub>> _fetchLibraryHubsForClient(
MediaServerClient client, {
required int limit,
Set<String>? hiddenLibraryKeys,
required bool includePlaybackHubs,
List<MediaLibrary>? libraries,
Set<MediaKind> kinds = const {MediaKind.movie, MediaKind.show, MediaKind.clip, MediaKind.artist},
}) async {
final libs = libraries ?? await client.fetchLibraries();
final visible = libs.where((l) {
if (l.kind != MediaKind.movie && l.kind != MediaKind.show && l.kind != MediaKind.clip) return false;
if (!kinds.contains(l.kind)) return false;
if (l.hidden) return false;
if (hiddenLibraryKeys != null && hiddenLibraryKeys.contains(l.globalKey)) return false;
return true;
+102 -4
View File
@@ -1280,9 +1280,13 @@ class DownloadManagerService {
? await _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: existing.clientScopeId)
: null;
// Build display name for notifications
// Build display name for notifications. Episodes lead with the show,
// tracks with the artist — same "container - leaf" pattern.
final trackArtist = metadata.trackArtistTitle;
final displayName = metadata.isEpisode
? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}'
: metadata.kind == MediaKind.track && trackArtist != null && trackArtist.isNotEmpty
? '$trackArtist - ${metadata.displayTitle}'
: metadata.displayTitle;
// Get WiFi-only setting for native enforcement
@@ -2232,6 +2236,12 @@ class DownloadManagerService {
case MediaKind.show:
final episodes = await _database.getEpisodesByShow(metadata.id, serverId: serverId);
return episodes.length;
case MediaKind.album:
final tracks = await _database.getTracksByAlbum(metadata.id, serverId: serverId);
return tracks.length;
case MediaKind.artist:
final tracks = await _database.getTracksByArtist(metadata.id, serverId: serverId);
return tracks.length;
default:
return 1;
}
@@ -2276,6 +2286,30 @@ class DownloadManagerService {
? await _deleteMovieFilesSaf(metadata, serverId, clientScopeId: scopeId)
: await _deleteMovieFiles(metadata, serverId, clientScopeId: scopeId);
break;
// Tracks live in the generic downloads/{serverId}/{ratingKey}/ layout
// (both file and SAF mode), so deletion is DB-record-driven rather
// than storage-template-driven like movies/episodes.
case MediaKind.track:
if (downloadRecord != null) await _deleteTrackByRecord(downloadRecord);
break;
case MediaKind.album:
await _deleteTracksInContainer(
tracks: await _database.getTracksByAlbum(metadata.id, serverId: serverId),
serverId: serverId,
clientScopeId: scopeId,
containerKey: metadata.id,
containerTitle: metadata.displayTitle,
);
break;
case MediaKind.artist:
await _deleteTracksInContainer(
tracks: await _database.getTracksByArtist(metadata.id, serverId: serverId),
serverId: serverId,
clientScopeId: scopeId,
containerKey: metadata.id,
containerTitle: metadata.displayTitle,
);
break;
default:
appLogger.w('Unknown type for deletion: ${metadata.kind.id}');
}
@@ -2492,6 +2526,66 @@ class DownloadManagerService {
}
}
/// Delete a single downloaded track. File deletion runs off the DB record
/// (video + .part + empty-parent cleanup via [_deleteByFilePath], which also
/// handles SAF URIs); the album-cover thumb is reference-counted because
/// every track of an album shares the same artwork blob.
Future<void> _deleteTrackByRecord(DownloadedMediaItem record) async {
final parsed = parseGlobalKey(record.globalKey);
final keepThumb =
parsed != null &&
record.thumbPath != null &&
await _isThumbPathInUseByOthers(parsed.serverId, record.thumbPath!, excludingGlobalKey: record.globalKey);
await _deleteByFilePath(record, deleteThumb: !keepThumb);
}
/// Whether any other download row on [serverId] references [thumbPath].
/// Mirrors the chapter-thumbnail in-use check: shared artwork survives
/// until the last referencing download is deleted.
Future<bool> _isThumbPathInUseByOthers(
ServerId serverId,
String thumbPath, {
required String excludingGlobalKey,
}) async {
final rows = await _database.getDownloadsByServerId(serverId);
return rows.any((row) => row.globalKey != excludingGlobalKey && row.thumbPath == thumbPath);
}
/// Delete every downloaded track of an album/artist container, mirroring
/// [_deleteEpisodesInCollection]: per-track deletion progress, file cleanup,
/// per-item server-side residue, then the DB rows.
Future<void> _deleteTracksInContainer({
required List<DownloadedMediaItem> tracks,
required ServerId serverId,
String? clientScopeId,
required String containerKey,
required String containerTitle,
}) async {
appLogger.d('Deleting ${tracks.length} tracks in container $containerKey');
for (int i = 0; i < tracks.length; i++) {
final track = tracks[i];
final trackGlobalKey = buildGlobalKey(ServerId(serverId), track.ratingKey);
_emitDeletionProgress(
DeletionProgress(
globalKey: buildGlobalKey(ServerId(serverId), containerKey),
itemTitle: containerTitle,
currentItem: i + 1,
totalItems: tracks.length,
currentOperation: 'Deleting track ${i + 1} of ${tracks.length}',
),
);
await _deleteTrackByRecord(track);
await _deleteForItemByServer(
ServerId(serverId),
track.ratingKey,
clientScopeId: track.clientScopeId ?? clientScopeId,
);
await _database.deleteDownload(trackGlobalKey);
}
}
Future<void> _deleteShowFiles(MediaItem show, ServerId serverId, {String? clientScopeId}) async {
try {
final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId);
@@ -2816,8 +2910,12 @@ class DownloadManagerService {
}
}
/// Fallback deletion using file paths from database
Future<void> _deleteByFilePath(DownloadedMediaItem record) async {
/// Fallback deletion using file paths from database.
///
/// [deleteThumb] lets callers preserve a shared artwork blob — album-cover
/// thumbs are deduped by path hash across every track of the album, so a
/// single-track delete must keep the file while sibling rows reference it.
Future<void> _deleteByFilePath(DownloadedMediaItem record, {bool deleteThumb = true}) async {
try {
if (record.videoFilePath != null && _storageService.isSafUri(record.videoFilePath!)) {
// Metadata is gone by the time this fallback runs, so parent-dir cleanup
@@ -2845,7 +2943,7 @@ class DownloadManagerService {
// thumbPath is a server-side API path (Plex /library/metadata/.../thumb,
// Jellyfin /Items/.../Images/Primary), not a local file path —
// resolve it via getArtworkPathFromThumb
if (record.thumbPath != null) {
if (deleteThumb && record.thumbPath != null) {
final parsed = parseGlobalKey(record.globalKey);
if (parsed != null) {
final thumbPath = await _storageService.getArtworkPathFromThumb(parsed.serverId, record.thumbPath!);
+6
View File
@@ -1927,6 +1927,12 @@ class PlexClient
sname,
librarySectionID: _librarySectionIdFromString(sectionId),
librarySectionTitle: libraryName,
// Music-section hubs carry artist/album/track items — the default
// video-only filter would empty them out.
filter: (item) {
final type = item.type?.toLowerCase();
return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type);
},
),
);
} catch (e) {
+20 -6
View File
@@ -1,4 +1,5 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart';
import '../database/app_database.dart';
@@ -363,7 +364,7 @@ class SyncRuleExecutor {
final unwatchedOnly = rule.downloadFilter == SyncRuleFilter.unwatched;
final collected = <MediaItem>[];
await _collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected);
await collectItemsForList(client, rootItems, unwatchedOnly: unwatchedOnly, out: collected);
final candidates = unwatchedOnly
? await _excludeLocallyWatched(
@@ -420,10 +421,14 @@ class SyncRuleExecutor {
libraryTitle: source?.libraryTitle,
);
/// Walks [items] and collects playable movie/episode entries into [out].
/// Shows and seasons are expanded into their episodes; music and nested
/// collections/playlists are skipped.
Future<void> _collectItemsForList(
/// Walks [items] and collects playable movie/episode/track entries into
/// [out]. Shows and seasons are expanded into their episodes; albums and
/// artists are expanded into their tracks (audio playlists/collections in
/// sync rules). Clips, nested collections/playlists, and unknown types are
/// skipped. [unwatchedOnly] applies the same played-state filter to every
/// kind — for tracks that means Plex/Jellyfin play counts.
@visibleForTesting
Future<void> collectItemsForList(
MediaServerClient client,
List<MediaItem> items, {
required bool unwatchedOnly,
@@ -433,14 +438,23 @@ class SyncRuleExecutor {
switch (item.kind) {
case MediaKind.movie:
case MediaKind.episode:
case MediaKind.track:
if (unwatchedOnly && !item.isUnwatchedOrInProgress) break;
out.add(item);
case MediaKind.show:
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
case MediaKind.season:
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
case MediaKind.album:
case MediaKind.artist:
// One recursive-leaves call per container on both backends
// (Jellyfin retries tag-only artists by album-artist credit).
for (final track in await client.fetchPlayableDescendants(item.id)) {
if (unwatchedOnly && !track.isUnwatchedOrInProgress) continue;
out.add(track);
}
default:
// Skip music, clips, nested collections/playlists, unknown types.
// Skip clips, nested collections/playlists, unknown types.
break;
}
}
+7 -1
View File
@@ -31,11 +31,16 @@ class DownloadResult {
/// "created" snackbar wording (no "unwatched episodes" suffix).
final bool isListRule;
/// `true` when the queued leaves are tracks (album/artist/track download)
/// — picks "tracks queued" over "episodes queued" wording.
final bool isMusic;
const DownloadResult({
required this.count,
this.syncRuleCreated = false,
this.syncRuleUpdated = false,
this.isListRule = false,
this.isMusic = false,
});
String toSnackBarMessage() {
@@ -43,7 +48,7 @@ class DownloadResult {
if (syncRuleCreated) {
return isListRule ? t.downloads.syncRuleListCreated : t.downloads.syncRuleCreated(count: count.toString());
}
if (count > 1) return t.downloads.episodesQueued(count: count);
if (count > 1) return isMusic ? t.downloads.tracksQueued(count: count) : t.downloads.episodesQueued(count: count);
return t.downloads.downloadQueued;
}
}
@@ -185,6 +190,7 @@ Future<DownloadResult?> showDownloadOptionsAndQueue(
count: count,
syncRuleCreated: keepSynced && !syncRuleUpdated,
syncRuleUpdated: syncRuleUpdated,
isMusic: kind.isMusic,
);
}
+82 -10
View File
@@ -6,6 +6,7 @@ import '../focus/focusable_wrapper.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../models/download_models.dart';
import '../utils/dialogs.dart';
import '../utils/global_key_utils.dart';
@@ -44,10 +45,10 @@ class DownloadTreeNode {
}
/// Type of node in the download tree
enum DownloadNodeType { show, season, episode, movie }
enum DownloadNodeType { show, season, episode, movie, album, track }
/// Hierarchical tree view for downloads
/// Groups TV shows by show -> season -> episode
/// Groups TV shows by show -> season -> episode and music by album -> track
/// Movies appear at top level
class DownloadTreeView extends StatefulWidget {
final Map<String, DownloadProgress> downloads;
@@ -124,6 +125,7 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
/// Build the download tree from flat download list
List<DownloadTreeNode> _buildTree() {
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
final List<DownloadTreeNode> movies = [];
// Group downloads
@@ -139,6 +141,11 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
final showKey = meta.grandparentId ?? 'unknown';
showGroups.putIfAbsent(showKey, () => []);
showGroups[showKey]!.add(entry);
} else if (meta.kind == MediaKind.track) {
// Group tracks by album (single level — no per-disc tier)
final albumKey = meta.parentId ?? 'unknown';
albumGroups.putIfAbsent(albumKey, () => []);
albumGroups[albumKey]!.add(entry);
} else if (meta.isMovie) {
// Movies go at top level
movies.add(
@@ -274,12 +281,69 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
);
}
// Sort shows and movies by status and title
// Build album nodes (album -> tracks)
final List<DownloadTreeNode> albums = [];
for (final albumEntry in albumGroups.entries) {
final albumKey = albumEntry.key;
final tracks = albumEntry.value;
if (tracks.isEmpty) continue;
// Album/artist names from any track's parent fields
final firstTrack = widget.metadata[tracks.first.key];
final albumTitle = firstTrack?.albumTitle ?? 'Unknown Album';
final artistTitle = firstTrack?.albumArtistTitle;
final albumNodeTitle = artistTitle != null && artistTitle.isNotEmpty ? '$artistTitle - $albumTitle' : albumTitle;
final List<DownloadTreeNode> trackNodes = [];
for (final trackEntry in tracks) {
final globalKey = trackEntry.key;
final download = trackEntry.value;
final meta = widget.metadata[globalKey];
if (meta == null) continue;
trackNodes.add(
DownloadTreeNode(
key: globalKey,
title: meta.title ?? globalKey,
type: DownloadNodeType.track,
progress: download.progressPercent,
status: download.status,
metadata: meta,
downloadProgress: download,
),
);
}
if (trackNodes.isEmpty) continue;
// Sort tracks by disc then track number
trackNodes.sort((a, b) {
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
if (byDisc != 0) return byDisc;
return (a.metadata?.trackNumber ?? 0).compareTo(b.metadata?.trackNumber ?? 0);
});
final albumProgress = trackNodes.map((e) => e.progress).reduce((a, b) => a + b) / trackNodes.length;
final albumStatus = _determineAggregateStatus(trackNodes.map((e) => e.status).toList());
albums.add(
DownloadTreeNode(
key: albumKey,
title: albumNodeTitle,
type: DownloadNodeType.album,
progress: albumProgress,
status: albumStatus,
children: trackNodes,
),
);
}
// Sort shows, albums, and movies by status and title
_sortNodesByStatusAndTitle(shows);
_sortNodesByStatusAndTitle(albums);
_sortNodesByStatusAndTitle(movies);
// Combine movies and shows
return [...movies, ...shows];
// Combine movies, shows, and albums
return [...movies, ...shows, ...albums];
}
/// Determine aggregate status from child statuses
@@ -461,11 +525,13 @@ String? resolveDownloadContainerGlobalKey(DownloadTreeNode node, Map<String, Med
if (showRatingKey == null) return null;
return buildGlobalKey(ServerId(serverId), showRatingKey);
case DownloadNodeType.season:
final seasonRatingKey = firstLeafMeta!.parentId;
if (seasonRatingKey == null) return null;
return buildGlobalKey(ServerId(serverId), seasonRatingKey);
case DownloadNodeType.album:
final parentRatingKey = firstLeafMeta!.parentId;
if (parentRatingKey == null) return null;
return buildGlobalKey(ServerId(serverId), parentRatingKey);
case DownloadNodeType.episode:
case DownloadNodeType.movie:
case DownloadNodeType.track:
return null;
}
}
@@ -596,7 +662,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
}
int _getActionCount() {
final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season;
final isContainer =
widget.node.type == DownloadNodeType.show ||
widget.node.type == DownloadNodeType.season ||
widget.node.type == DownloadNodeType.album;
if (isContainer) {
return _getContainerActionCount();
}
@@ -768,7 +837,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
}
Widget _buildActions() {
final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season;
final isContainer =
widget.node.type == DownloadNodeType.show ||
widget.node.type == DownloadNodeType.season ||
widget.node.type == DownloadNodeType.album;
final actions = isContainer ? _buildContainerActions() : _buildItemActions();
@@ -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}) {