diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index a78fd857..b9bcd228 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -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> 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> 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> getDownloadsByServerId(ServerId serverId) { return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get(); } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 9166c620..f44c6fa7 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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 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 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 get downloadedAlbums { + final Map 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 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 _getEpisodeDownloads({String? showRatingKey, String? seasonRatingKey}) { + /// Get leaf downloads (episodes or tracks) filtered by grandparent + /// (show/artist) and/or parent (season/album) ratingKey. + List _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 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 queueListDownload( List 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 _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 _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 _queueShowDownload( MediaItem show, @@ -1329,7 +1439,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin Future 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> _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, diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 46bfc65f..9a4a8cb8 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -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 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(); @override - List get tabChipFocusNodes => [_queueTabChipFocusNode, _tvShowsTabChipFocusNode, _moviesTabChipFocusNode]; + List get tabChipFocusNodes => [ + _queueTabChipFocusNode, + _tvShowsTabChipFocusNode, + _moviesTabChipFocusNode, + _musicTabChipFocusNode, + ]; @override void initState() { @@ -52,6 +66,7 @@ class DownloadsScreenState extends State _queueTabChipFocusNode.dispose(); _tvShowsTabChipFocusNode.dispose(); _moviesTabChipFocusNode.dispose(); + _musicTabChipFocusNode.dispose(); disposeTabNavigation(); super.dispose(); } @@ -106,6 +121,8 @@ class DownloadsScreenState extends State _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 _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 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 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 _playAlbumFrom(List 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( + 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()?.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), + ), + ); + }, + ); + }, + ); + } +} diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 563583b9..6ac50f82 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -373,10 +373,12 @@ class DataAggregationService { return (hubs: const [], succeededServerIds: const {}, cancelledServerIds: const {}); } - // 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> _fetchLibraryHubsForClient( MediaServerClient client, { required int limit, Set? hiddenLibraryKeys, required bool includePlaybackHubs, List? libraries, + Set 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; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index b0f81648..2ba590f6 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -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 _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 _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 _deleteTracksInContainer({ + required List 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 _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 _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 _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!); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ed5eb512..ecbd5cf0 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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) { diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 23bdbb2a..cb8f8885 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -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 = []; - 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 _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 collectItemsForList( MediaServerClient client, List 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; } } diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 755581ed..b68354cf 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -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 showDownloadOptionsAndQueue( count: count, syncRuleCreated: keepSynced && !syncRuleUpdated, syncRuleUpdated: syncRuleUpdated, + isMusic: kind.isMusic, ); } diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 30cb5ce1..2dbef406 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -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 downloads; @@ -124,6 +125,7 @@ class _DownloadTreeViewState extends State { /// Build the download tree from flat download list List _buildTree() { final Map>> showGroups = {}; + final Map>> albumGroups = {}; final List movies = []; // Group downloads @@ -139,6 +141,11 @@ class _DownloadTreeViewState extends State { 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 { ); } - // Sort shows and movies by status and title + // Build album nodes (album -> tracks) + final List 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 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 { } 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(); diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 9e6126e2..bb636433 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -450,6 +450,78 @@ void main() { expect(userB.map((e) => e.ratingKey), ['ep-b']); }); + Future 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(); diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 337e33f4..14f81b15 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -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 tracks; + final fetchPlayableDescendantsCalls = []; + + @override + Future> 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(); diff --git a/test/screens/downloads/downloads_screen_focus_test.dart b/test/screens/downloads/downloads_screen_focus_test.dart index 1d1e7ffe..8c9187a6 100644 --- a/test/screens/downloads/downloads_screen_focus_test.dart +++ b/test/screens/downloads/downloads_screen_focus_test.dart @@ -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(); 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); diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 6becf38d..e31aa404 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -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 = []; 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 []}); + } 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 = []; + + 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', + ]); }); }); } diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 0e0b2cd1..6695bf3d 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -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 _insertDownloaded( String? clientScopeId, required String ratingKey, required String videoFilePath, + String type = 'movie', int mediaIndex = 0, String? mediaSourceId, }) async { @@ -500,7 +531,7 @@ Future _insertDownloaded( clientScopeId: Value(clientScopeId), ratingKey: ratingKey, globalKey: '$serverId:$ratingKey', - type: 'movie', + type: type, status: DownloadStatus.completed.index, videoFilePath: Value(videoFilePath), mediaIndex: Value(mediaIndex), diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 53cee27d..6af59f06 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -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 = []; + 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 = []; + 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}) {