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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -33,6 +33,30 @@ class _ThrowingClient implements MediaServerClient {
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
/// Returns canned tracks from [fetchPlayableDescendants] (album/artist
|
||||
/// expansion) and records the requested parent ids.
|
||||
class _MusicExpansionClient implements MediaServerClient {
|
||||
_MusicExpansionClient(this.tracks);
|
||||
|
||||
final List<MediaItem> tracks;
|
||||
final fetchPlayableDescendantsCalls = <String>[];
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
||||
fetchPlayableDescendantsCalls.add(parentId);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('srv');
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _ScopedTestClient implements MediaServerClient, ScopedMediaServerClient {
|
||||
_ScopedTestClient({required this.serverId, required this.scopedServerId});
|
||||
|
||||
@@ -511,6 +535,92 @@ void main() {
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async {
|
||||
final album = MediaItem(
|
||||
id: 'album-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.album,
|
||||
title: 'Album',
|
||||
serverId: ServerId('srv'),
|
||||
);
|
||||
MediaItem track(String id) => MediaItem(
|
||||
id: id,
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.track,
|
||||
title: id,
|
||||
parentId: 'album-1',
|
||||
serverId: ServerId('srv'),
|
||||
);
|
||||
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
// Physical rows already exist (shared/unowned) so each expanded track
|
||||
// takes the claim-existing early path — no manager/network needed.
|
||||
p.debugSeedState(
|
||||
downloads: {
|
||||
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
|
||||
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
|
||||
},
|
||||
metadata: {'srv:t1': track('t1'), 'srv:t2': track('t2')},
|
||||
ownedDownloadKeys: const {},
|
||||
);
|
||||
|
||||
final client = _MusicExpansionClient([track('t1'), track('t2')]);
|
||||
final count = await p.queueDownload(album, client);
|
||||
|
||||
expect(count, 2);
|
||||
expect(client.fetchPlayableDescendantsCalls, ['album-1']);
|
||||
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:t1', 'srv:t2'});
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('album aggregates, downloadedAlbums, and per-album track order come from track downloads', () async {
|
||||
MediaItem track(String id, {required int disc, required int number}) => MediaItem(
|
||||
id: id,
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.track,
|
||||
title: id,
|
||||
parentId: 'album-1',
|
||||
parentTitle: 'Album',
|
||||
grandparentId: 'artist-1',
|
||||
grandparentTitle: 'Artist',
|
||||
parentIndex: disc,
|
||||
index: number,
|
||||
serverId: ServerId('srv'),
|
||||
);
|
||||
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
p.debugSeedState(
|
||||
downloads: {
|
||||
'srv:t1': const DownloadProgress(globalKey: 'srv:t1', status: DownloadStatus.completed),
|
||||
'srv:t2': const DownloadProgress(globalKey: 'srv:t2', status: DownloadStatus.completed),
|
||||
},
|
||||
// Seeded out of disc/track order on purpose.
|
||||
metadata: {
|
||||
'srv:t1': track('t1', disc: 2, number: 1),
|
||||
'srv:t2': track('t2', disc: 1, number: 2),
|
||||
'srv:album-1': MediaItem(
|
||||
id: 'album-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.album,
|
||||
title: 'Album',
|
||||
parentId: 'artist-1',
|
||||
parentTitle: 'Artist',
|
||||
serverId: ServerId('srv'),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
expect(p.getProgress('srv:album-1')?.status, DownloadStatus.completed);
|
||||
expect(p.isDownloaded('srv:album-1'), isTrue);
|
||||
expect(p.downloadedAlbums.map((a) => a.id), ['album-1']);
|
||||
expect(p.getDownloadedTracksForAlbum('album-1').map((item) => item.id), ['t2', 't1']);
|
||||
|
||||
p.dispose();
|
||||
});
|
||||
|
||||
test('queueDownload leaves paused downloads paused instead of re-queueing them', () async {
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
|
||||
@@ -62,7 +62,7 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
testWidgets('right from Movies focuses and opens Sync Rules action', (tester) async {
|
||||
testWidgets('right from the last tab (Music) focuses and opens Sync Rules action', (tester) async {
|
||||
final screenKey = GlobalKey<DownloadsScreenState>();
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -83,8 +83,8 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final state = screenKey.currentState!;
|
||||
state.tabController.index = 2;
|
||||
state.getTabChipFocusNode(2).requestFocus();
|
||||
state.tabController.index = 3;
|
||||
state.getTabChipFocusNode(3).requestFocus();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
|
||||
@@ -726,7 +726,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('per-library home rows include clip libraries and skip music/photo (#1476)', () async {
|
||||
test('per-library home rows include clip and music libraries and skip photo (#1476)', () async {
|
||||
final captured = <Uri>[];
|
||||
|
||||
final client = JellyfinClient.forTesting(
|
||||
@@ -762,9 +762,19 @@ void main() {
|
||||
{'Id': 'vid-1', 'Type': 'Video', 'Name': 'Latest Home Video', 'ParentLibraryId': 'home-vids'},
|
||||
],
|
||||
}),
|
||||
'music' => _json({
|
||||
'Items': [
|
||||
{'Id': 'album-1', 'Type': 'MusicAlbum', 'Name': 'Latest Album', 'ParentLibraryId': 'music'},
|
||||
],
|
||||
}),
|
||||
_ => http.Response('latest should not be requested for $parentId', 500),
|
||||
};
|
||||
}
|
||||
// Music library's played-track rows — empty so only the Latest
|
||||
// Albums hub survives.
|
||||
if (req.url.path == '/Items' && req.url.queryParameters['Filters'] == 'IsPlayed') {
|
||||
return _json({'Items': const <Object>[]});
|
||||
}
|
||||
return http.Response('unexpected request', 500);
|
||||
}),
|
||||
);
|
||||
@@ -775,11 +785,17 @@ void main() {
|
||||
final hubs = result.hubs;
|
||||
|
||||
expect(result.succeededServerIds, {'srv-1'});
|
||||
expect(hubs.map((h) => h.identifier), ['library.movies.recent', 'library.mv.recent', 'library.home-vids.recent']);
|
||||
expect(hubs.map((h) => h.identifier), [
|
||||
'library.movies.recent',
|
||||
'library.mv.recent',
|
||||
'library.home-vids.recent',
|
||||
'library.music.recent',
|
||||
]);
|
||||
expect(hubs[1].items.single.kind, MediaKind.clip);
|
||||
expect(hubs[3].items.single.kind, MediaKind.album);
|
||||
expect(
|
||||
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
|
||||
['movies', 'mv', 'home-vids'],
|
||||
['movies', 'mv', 'home-vids', 'music'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -799,6 +815,15 @@ void main() {
|
||||
promotedHubKey: '/hubs/promoted',
|
||||
httpClient: MockClient((req) async {
|
||||
captured.add(req.url);
|
||||
if (req.url.path == '/library/sections') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Directory': [
|
||||
{'key': '2', 'type': 'show', 'title': 'TV Shows'},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (req.url.path == '/hubs/promoted') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
@@ -840,8 +865,94 @@ void main() {
|
||||
expect(hubs.single.identifier, 'home.television.recent');
|
||||
expect(hubs.single.libraryId, isNull);
|
||||
expect(hubs.single.items, hasLength(7));
|
||||
expect(captured.map((uri) => uri.path), ['/hubs/promoted']);
|
||||
expect(captured.single.queryParameters['count'], defaultHubPreviewLimit.toString());
|
||||
// Library prefetch (music detection) + the promoted hubs — no
|
||||
// per-library hub calls without a music section.
|
||||
expect(captured.map((uri) => uri.path), ['/library/sections', '/hubs/promoted']);
|
||||
expect(
|
||||
captured.singleWhere((uri) => uri.path == '/hubs/promoted').queryParameters['count'],
|
||||
defaultHubPreviewLimit.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
test('Plex home layout appends music library hubs the promoted endpoint excludes', () async {
|
||||
final captured = <Uri>[];
|
||||
|
||||
final client = PlexClient.forTesting(
|
||||
config: PlexConfig(
|
||||
baseUrl: 'https://plex.example.com',
|
||||
token: 'token',
|
||||
clientIdentifier: 'client-id',
|
||||
product: 'Plezy',
|
||||
version: 'test',
|
||||
),
|
||||
serverId: ServerId('plex-1'),
|
||||
serverName: 'Plex',
|
||||
promotedHubKey: '/hubs/promoted',
|
||||
httpClient: MockClient((req) async {
|
||||
captured.add(req.url);
|
||||
if (req.url.path == '/library/sections') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Directory': [
|
||||
{'key': '1', 'type': 'movie', 'title': 'Movies'},
|
||||
{'key': '9', 'type': 'artist', 'title': 'Music'},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (req.url.path == '/hubs/promoted') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'key': '/hubs/home/recentlyAdded?type=1',
|
||||
'title': 'Recently Added Movies',
|
||||
'type': 'movie',
|
||||
'hubIdentifier': 'home.movies.recent',
|
||||
'size': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'movie-1', 'type': 'movie', 'title': 'Movie', 'librarySectionID': 1},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (req.url.path == '/hubs/sections/9') {
|
||||
return _json({
|
||||
'MediaContainer': {
|
||||
'Hub': [
|
||||
{
|
||||
'key': '/library/sections/9/recentlyAdded',
|
||||
'title': 'Recently Added Music',
|
||||
'type': 'album',
|
||||
'hubIdentifier': 'music.recent',
|
||||
'size': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': 'album-1', 'type': 'album', 'title': 'Album', 'librarySectionID': 9},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return http.Response('unexpected request', 500);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
manager.debugRegisterClientForTesting(client);
|
||||
|
||||
final result = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false);
|
||||
final hubs = result.hubs;
|
||||
|
||||
expect(result.succeededServerIds, {'plex-1'});
|
||||
expect(hubs.map((h) => h.identifier), ['home.movies.recent', 'music.recent']);
|
||||
expect(hubs[1].items.single.kind, MediaKind.album);
|
||||
// Only the music section gets a per-library hub call — the movie
|
||||
// library's rows already came from the promoted endpoint.
|
||||
expect(captured.where((uri) => uri.path.startsWith('/hubs/sections/')).map((uri) => uri.path), [
|
||||
'/hubs/sections/9',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -103,6 +103,36 @@ void main() {
|
||||
expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng');
|
||||
});
|
||||
|
||||
test('downloaded track resolves to its local file through the offline path', () async {
|
||||
// Same globalKey shape queueDownload writes (`serverId:ratingKey`) —
|
||||
// the music resolver reaches this via preferOffline=true (original
|
||||
// audio preset), so a downloaded track must play from disk.
|
||||
await _insertDownloaded(
|
||||
db,
|
||||
serverId: ServerId('srv-1'),
|
||||
ratingKey: 'track-1',
|
||||
type: 'track',
|
||||
videoFilePath: 'content://offline/track-1',
|
||||
);
|
||||
final client = _FailingPlaybackClient(serverId: ServerId('srv-1'));
|
||||
|
||||
final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData(
|
||||
metadata: MediaItem(
|
||||
id: 'track-1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.track,
|
||||
serverId: ServerId('srv-1'),
|
||||
),
|
||||
selectedMediaIndex: 0,
|
||||
preferOffline: true,
|
||||
);
|
||||
|
||||
expect(client.playbackInitializationCalls, 0);
|
||||
expect(result.isOffline, isTrue);
|
||||
expect(result.videoUrl, 'content://offline/track-1');
|
||||
expect(result.playMethod, 'DirectPlay');
|
||||
});
|
||||
|
||||
test('preferOffline uses cache without calling live client when local file exists', () async {
|
||||
await _insertDownloaded(
|
||||
db,
|
||||
@@ -489,6 +519,7 @@ Future<void> _insertDownloaded(
|
||||
String? clientScopeId,
|
||||
required String ratingKey,
|
||||
required String videoFilePath,
|
||||
String type = 'movie',
|
||||
int mediaIndex = 0,
|
||||
String? mediaSourceId,
|
||||
}) async {
|
||||
@@ -500,7 +531,7 @@ Future<void> _insertDownloaded(
|
||||
clientScopeId: Value(clientScopeId),
|
||||
ratingKey: ratingKey,
|
||||
globalKey: '$serverId:$ratingKey',
|
||||
type: 'movie',
|
||||
type: type,
|
||||
status: DownloadStatus.completed.index,
|
||||
videoFilePath: Value(videoFilePath),
|
||||
mediaIndex: Value(mediaIndex),
|
||||
|
||||
@@ -396,6 +396,43 @@ void main() {
|
||||
expect(client.collectionPageCalls, [(start: 0, size: 100)]);
|
||||
expect(client.fetchChildrenCalled, isFalse);
|
||||
});
|
||||
|
||||
test('collectItemsForList accepts tracks and expands albums/artists', () async {
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
addTearDown(db.close);
|
||||
final executor = SyncRuleExecutor(database: db);
|
||||
|
||||
final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)];
|
||||
final client = _PlayableDescendantsClient(albumTracks);
|
||||
|
||||
final items = [
|
||||
_track('loose-track'),
|
||||
MediaItem(id: 'album-1', backend: MediaBackend.plex, kind: MediaKind.album, title: 'Album'),
|
||||
MediaItem(id: 'artist-1', backend: MediaBackend.plex, kind: MediaKind.artist, title: 'Artist'),
|
||||
// Still skipped: nested lists / unplayable kinds.
|
||||
MediaItem(id: 'photo-1', backend: MediaBackend.plex, kind: MediaKind.photo, title: 'Photo'),
|
||||
];
|
||||
|
||||
final out = <MediaItem>[];
|
||||
await executor.collectItemsForList(client, items, unwatchedOnly: false, out: out);
|
||||
|
||||
expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']);
|
||||
expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']);
|
||||
|
||||
// unwatchedOnly applies the play-count filter to tracks too.
|
||||
final unwatched = <MediaItem>[];
|
||||
await executor.collectItemsForList(
|
||||
client,
|
||||
[_track('played-track', played: true), items[1]],
|
||||
unwatchedOnly: true,
|
||||
out: unwatched,
|
||||
);
|
||||
expect(unwatched.map((i) => i.id), ['album-track-1']);
|
||||
});
|
||||
}
|
||||
|
||||
MediaItem _track(String id, {bool played = false}) {
|
||||
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.track, title: id, viewCount: played ? 1 : 0);
|
||||
}
|
||||
|
||||
MediaItem _episode(String id, {required int parentIndex, required int index, String? originallyAvailableAt}) {
|
||||
|
||||
Reference in New Issue
Block a user