fix(trackers): preserve Plex library context
This commit is contained in:
@@ -307,6 +307,8 @@ abstract class MediaServerClient {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
});
|
||||
|
||||
/// Create a new collection in [libraryId] seeded with [items]. Returns the
|
||||
|
||||
@@ -965,9 +965,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// was the same pattern as collectEpisodes*, just inlined.
|
||||
final episodes = <MediaItem>[];
|
||||
if (item.isShow) {
|
||||
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes);
|
||||
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
|
||||
} else {
|
||||
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes);
|
||||
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: episodes, fallback: item);
|
||||
}
|
||||
for (final ep in episodes) {
|
||||
await queueItem(ep);
|
||||
@@ -1022,6 +1022,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
metadataToStore = fullMetadata.copyWith(
|
||||
serverId: metadata.serverId ?? fullMetadata.serverId,
|
||||
serverName: metadata.serverName ?? fullMetadata.serverName,
|
||||
libraryId: fullMetadata.libraryId ?? metadata.libraryId,
|
||||
libraryTitle: fullMetadata.libraryTitle ?? metadata.libraryTitle,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1191,9 +1193,21 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
final unwatchedOnly = filter == DownloadFilter.unwatched;
|
||||
final episodes = <MediaItem>[];
|
||||
if (container.kind == MediaKind.show) {
|
||||
await collectEpisodesForShow(client, container.id, unwatchedOnly: unwatchedOnly, out: episodes);
|
||||
await collectEpisodesForShow(
|
||||
client,
|
||||
container.id,
|
||||
unwatchedOnly: unwatchedOnly,
|
||||
out: episodes,
|
||||
fallback: container,
|
||||
);
|
||||
} else {
|
||||
await collectEpisodesForSeason(client, container.id, unwatchedOnly: unwatchedOnly, out: episodes);
|
||||
await collectEpisodesForSeason(
|
||||
client,
|
||||
container.id,
|
||||
unwatchedOnly: unwatchedOnly,
|
||||
out: episodes,
|
||||
fallback: container,
|
||||
);
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../i18n/strings.g.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../services/playlist_items_loader.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
class CollectionDetailScreen extends StatefulWidget {
|
||||
@@ -59,7 +60,14 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
||||
return mediaClient.fetchCollectionPage(widget.collection.id, start: start, size: size, abort: abort);
|
||||
return mediaClient.fetchCollectionPage(
|
||||
widget.collection.id,
|
||||
start: start,
|
||||
size: size,
|
||||
abort: abort,
|
||||
libraryId: widget.collection.libraryId,
|
||||
libraryTitle: widget.collection.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -147,10 +155,12 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
try {
|
||||
// [fetchChildren] is the neutral equivalent of the Plex-only
|
||||
// `fetchAllCollectionItemsAsMediaItems` — both backends return the
|
||||
// collection's full contents.
|
||||
final allItems = await mediaClient.fetchChildren(widget.collection.id);
|
||||
final allItems = await fetchAllCollectionItemsPaged(
|
||||
mediaClient,
|
||||
widget.collection.id,
|
||||
libraryId: widget.collection.libraryId,
|
||||
libraryTitle: widget.collection.libraryTitle,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await showCollectionDownloadOptionsAndQueue(
|
||||
context,
|
||||
|
||||
@@ -116,7 +116,11 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
|
||||
// Items are automatically tagged with server info by PlexClient.
|
||||
final children = await client.fetchFolderChildren(folderKey);
|
||||
final children = await client.fetchFolderChildren(
|
||||
folderKey,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -162,7 +166,12 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
if (folderKey == null) return;
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(folderKey: folderKey, shuffle: false);
|
||||
await launcher.launchFromFolder(
|
||||
folderKey: folderKey,
|
||||
shuffle: false,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleFolderShuffle(MediaItem folder) async {
|
||||
@@ -170,7 +179,12 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
if (folderKey == null) return;
|
||||
final client = context.getPlexClientForServer(widget.serverId!);
|
||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(folderKey: folderKey, shuffle: true);
|
||||
await launcher.launchFromFolder(
|
||||
folderKey: folderKey,
|
||||
shuffle: true,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isFolder(MediaItem item) {
|
||||
|
||||
@@ -117,6 +117,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final Map<String, List<MediaItem>> _episodeCache = {};
|
||||
bool _isLoadingSeasonEpisodes = false;
|
||||
List<FocusNode> _seasonTabFocusNodes = [];
|
||||
|
||||
MediaItem _withFallbackLibrary(MediaItem item, MediaItem fallback) {
|
||||
return item.copyWith(
|
||||
libraryId: item.libraryId ?? fallback.libraryId,
|
||||
libraryTitle: item.libraryTitle ?? fallback.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
final Map<int, GlobalKey<MediaContextMenuState>> _seasonContextMenuKeys = {};
|
||||
final ScrollController _seasonTabsScrollController = ScrollController();
|
||||
final FocusNode _firstEpisodeFocusNode = FocusNode(debugLabel: 'first_episode');
|
||||
@@ -400,15 +408,23 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final metadata = result.item;
|
||||
final onDeckEpisode = result.onDeckEpisode;
|
||||
if (metadata != null) {
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = _applyLocalProgress(
|
||||
final refreshedMetadata = _applyLocalProgress(
|
||||
_withFallbackLibrary(
|
||||
metadata.copyWith(serverId: serverId, serverName: serverName ?? metadata.serverName),
|
||||
);
|
||||
_onDeckEpisode = onDeckEpisode == null
|
||||
? null
|
||||
: _applyLocalProgress(
|
||||
_metadata,
|
||||
),
|
||||
);
|
||||
final refreshedOnDeck = onDeckEpisode == null
|
||||
? null
|
||||
: _applyLocalProgress(
|
||||
_withFallbackLibrary(
|
||||
onDeckEpisode.copyWith(serverId: serverId, serverName: serverName ?? onDeckEpisode.serverName),
|
||||
);
|
||||
refreshedMetadata,
|
||||
),
|
||||
);
|
||||
setStateIfMounted(() {
|
||||
_fullMetadata = refreshedMetadata;
|
||||
_onDeckEpisode = refreshedOnDeck;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -417,7 +433,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_episodeCache.clear();
|
||||
setStateIfMounted(() {
|
||||
_seasons = seasons
|
||||
.map((s) => s.copyWith(serverId: serverId, serverName: serverName ?? s.serverName))
|
||||
.map(
|
||||
(s) => _withFallbackLibrary(
|
||||
s.copyWith(serverId: serverId, serverName: serverName ?? s.serverName),
|
||||
_metadata,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
if (_showEpisodesDirectly) {
|
||||
@@ -1002,14 +1023,20 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final serverName = _metadata.serverName;
|
||||
final source = metadata ?? _metadata;
|
||||
final base = _applyLocalProgress(
|
||||
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
|
||||
_withFallbackLibrary(
|
||||
source.copyWith(serverId: serverId ?? source.serverId, serverName: serverName ?? source.serverName),
|
||||
_metadata,
|
||||
),
|
||||
);
|
||||
final onDeckWithServerId = onDeckEpisode == null
|
||||
? null
|
||||
: _applyLocalProgress(
|
||||
onDeckEpisode.copyWith(
|
||||
serverId: serverId ?? onDeckEpisode.serverId,
|
||||
serverName: serverName ?? onDeckEpisode.serverName,
|
||||
_withFallbackLibrary(
|
||||
onDeckEpisode.copyWith(
|
||||
serverId: serverId ?? onDeckEpisode.serverId,
|
||||
serverName: serverName ?? onDeckEpisode.serverName,
|
||||
),
|
||||
base,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1081,7 +1108,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
|
||||
// Preserve serverId for each season.
|
||||
final seasonsWithServerId = seasons
|
||||
.map((season) => season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName))
|
||||
.map(
|
||||
(season) => _withFallbackLibrary(
|
||||
season.copyWith(serverId: serverId, serverName: _metadata.serverName ?? season.serverName),
|
||||
_metadata,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Plex can override the library season mode per show; Jellyfin falls
|
||||
@@ -1157,6 +1189,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
leafCount: entry.value.length,
|
||||
thumbPath: firstEp.parentThumbPath,
|
||||
parentId: firstEp.grandparentId,
|
||||
libraryId: firstEp.libraryId ?? _metadata.libraryId,
|
||||
libraryTitle: firstEp.libraryTitle ?? _metadata.libraryTitle,
|
||||
serverId: _metadata.serverId,
|
||||
serverName: _metadata.serverName,
|
||||
);
|
||||
@@ -1270,11 +1304,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final episodes = await mediaClient.fetchChildren(season.id);
|
||||
final episodesWithServerId = episodes
|
||||
.map(
|
||||
(e) => e.copyWith(
|
||||
serverId: _metadata.serverId ?? e.serverId,
|
||||
serverName: _metadata.serverName ?? e.serverName,
|
||||
grandparentId: _metadata.id,
|
||||
grandparentTitle: _metadata.title ?? e.grandparentTitle,
|
||||
(e) => _withFallbackLibrary(
|
||||
e.copyWith(
|
||||
serverId: _metadata.serverId ?? e.serverId,
|
||||
serverName: _metadata.serverName ?? e.serverName,
|
||||
grandparentId: _metadata.id,
|
||||
grandparentTitle: _metadata.title ?? e.grandparentTitle,
|
||||
),
|
||||
season.libraryId != null ? season : _metadata,
|
||||
),
|
||||
)
|
||||
.map(_applyLocalProgress)
|
||||
@@ -2002,11 +2039,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
: _metadata.title;
|
||||
final enriched = episodes
|
||||
.map(
|
||||
(e) => e.copyWith(
|
||||
serverId: serverId,
|
||||
serverName: _metadata.serverName ?? e.serverName,
|
||||
grandparentId: e.grandparentId ?? fallbackGrandparentId,
|
||||
grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle,
|
||||
(e) => _withFallbackLibrary(
|
||||
e.copyWith(
|
||||
serverId: serverId,
|
||||
serverName: _metadata.serverName ?? e.serverName,
|
||||
grandparentId: e.grandparentId ?? fallbackGrandparentId,
|
||||
grandparentTitle: e.grandparentTitle ?? fallbackGrandparentTitle,
|
||||
),
|
||||
_metadata,
|
||||
),
|
||||
)
|
||||
.map(_applyLocalProgress)
|
||||
@@ -2092,6 +2132,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
final episodeWithServerId = firstEpisode.copyWith(
|
||||
serverId: _metadata.serverId ?? firstEpisode.serverId,
|
||||
serverName: _metadata.serverName ?? firstEpisode.serverName,
|
||||
libraryId: firstEpisode.libraryId ?? _metadata.libraryId,
|
||||
libraryTitle: firstEpisode.libraryTitle ?? _metadata.libraryTitle,
|
||||
);
|
||||
if (mounted) {
|
||||
appLogger.d('Playing first episode: ${episodeWithServerId.title}');
|
||||
|
||||
@@ -53,11 +53,21 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
|
||||
showRatingKey: showRatingKey,
|
||||
shuffle: 0,
|
||||
startingEpisodeKey: _currentMetadata.id,
|
||||
librarySectionID: _currentMetadata.libraryId,
|
||||
librarySectionTitle: _currentMetadata.libraryTitle,
|
||||
);
|
||||
|
||||
if (playQueue != null && playQueue.items != null && playQueue.items!.isNotEmpty) {
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, showRatingKey);
|
||||
playbackState.setPlayQueueWindowFetcher(client.getPlayQueue);
|
||||
playbackState.setPlayQueueWindowFetcher(
|
||||
(id, {center, window = 50}) => client.getPlayQueue(
|
||||
id,
|
||||
center: center,
|
||||
window: window,
|
||||
librarySectionID: _currentMetadata.libraryId,
|
||||
librarySectionTitle: _currentMetadata.libraryTitle,
|
||||
),
|
||||
);
|
||||
|
||||
appLogger.d('Sequential play queue created with ${playQueue.items!.length} items');
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
}) async {
|
||||
final cached = _collectionItemsCache[collectionId] ?? await _loadAndCacheCollectionItems(collectionId);
|
||||
final s = start ?? 0;
|
||||
|
||||
@@ -106,6 +106,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
actionLabel: t.common.shuffle,
|
||||
execute: (dismissLoading) async {
|
||||
PlayQueueResponse? playQueue;
|
||||
final sourceLibraryId = facts.isCollection && item is MediaItem ? item.libraryId : null;
|
||||
final sourceLibraryTitle = facts.isCollection && item is MediaItem ? item.libraryTitle : null;
|
||||
// Plex's `key` param positions the queue's selected item — passed
|
||||
// through when the caller wants playback to start at a specific
|
||||
// entry. Ignored on shuffle (the server picks a random head).
|
||||
@@ -124,6 +126,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
type: 'video',
|
||||
shuffle: shuffle ? 1 : 0,
|
||||
key: selectedKey,
|
||||
librarySectionID: sourceLibraryId,
|
||||
librarySectionTitle: sourceLibraryTitle,
|
||||
);
|
||||
} else {
|
||||
// For playlists, use playlistID parameter
|
||||
@@ -137,7 +141,11 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
|
||||
// If the queue is empty, try fetching it again with getPlayQueue
|
||||
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) {
|
||||
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
|
||||
final fetchedQueue = await client.getPlayQueue(
|
||||
playQueue.playQueueID,
|
||||
librarySectionID: sourceLibraryId,
|
||||
librarySectionTitle: sourceLibraryTitle,
|
||||
);
|
||||
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
|
||||
playQueue = fetchedQueue;
|
||||
}
|
||||
@@ -151,6 +159,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
ratingKey: ratingKey,
|
||||
serverId: itemServerId,
|
||||
serverName: itemServerName,
|
||||
libraryId: sourceLibraryId,
|
||||
libraryTitle: sourceLibraryTitle,
|
||||
selectedItem: selectedKey != null ? _resolveSelectedMediaItem(playQueue) : null,
|
||||
);
|
||||
},
|
||||
@@ -217,7 +227,12 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
showRatingKey = metadata.parentId!;
|
||||
}
|
||||
|
||||
final playQueue = await client.createShowPlayQueue(showRatingKey: showRatingKey, shuffle: 1);
|
||||
final playQueue = await client.createShowPlayQueue(
|
||||
showRatingKey: showRatingKey,
|
||||
shuffle: 1,
|
||||
librarySectionID: metadata.libraryId,
|
||||
librarySectionTitle: metadata.libraryTitle,
|
||||
);
|
||||
|
||||
// Close loading dialog before navigating to the player
|
||||
await dismissLoading();
|
||||
@@ -227,6 +242,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
ratingKey: showRatingKey,
|
||||
serverId: metadata.serverId ?? serverId,
|
||||
serverName: metadata.serverName ?? serverName,
|
||||
libraryId: metadata.libraryId,
|
||||
libraryTitle: metadata.libraryTitle,
|
||||
copyServerInfo: true,
|
||||
);
|
||||
},
|
||||
@@ -237,6 +254,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
Future<PlayQueueResult> launchFromFolder({
|
||||
required String folderKey,
|
||||
required bool shuffle,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
bool showLoadingIndicator = true,
|
||||
}) async {
|
||||
return executeWithLoading(
|
||||
@@ -246,10 +265,20 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
execute: (dismissLoading) async {
|
||||
final folderUri = await client.buildFolderUri(folderKey);
|
||||
|
||||
var playQueue = await client.createPlayQueue(uri: folderUri, type: 'video', shuffle: shuffle ? 1 : 0);
|
||||
var playQueue = await client.createPlayQueue(
|
||||
uri: folderUri,
|
||||
type: 'video',
|
||||
shuffle: shuffle ? 1 : 0,
|
||||
librarySectionID: libraryId,
|
||||
librarySectionTitle: libraryTitle,
|
||||
);
|
||||
|
||||
if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) {
|
||||
final fetchedQueue = await client.getPlayQueue(playQueue.playQueueID);
|
||||
final fetchedQueue = await client.getPlayQueue(
|
||||
playQueue.playQueueID,
|
||||
librarySectionID: libraryId,
|
||||
librarySectionTitle: libraryTitle,
|
||||
);
|
||||
if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) {
|
||||
playQueue = fetchedQueue;
|
||||
}
|
||||
@@ -257,7 +286,14 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
|
||||
await dismissLoading();
|
||||
|
||||
return _launchFromQueue(playQueue: playQueue, ratingKey: folderKey, serverId: serverId, serverName: serverName);
|
||||
return _launchFromQueue(
|
||||
playQueue: playQueue,
|
||||
ratingKey: folderKey,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
libraryId: libraryId,
|
||||
libraryTitle: libraryTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -268,6 +304,8 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
required String ratingKey,
|
||||
String? serverId,
|
||||
String? serverName,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
MediaItem? selectedItem,
|
||||
bool copyServerInfo = false,
|
||||
}) async {
|
||||
@@ -278,7 +316,17 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
if (!context.mounted) return const PlayQueueError('Context not mounted');
|
||||
|
||||
final playbackState = context.read<PlaybackStateProvider>();
|
||||
playbackState.setPlayQueueWindowFetcher(client.getPlayQueue);
|
||||
playbackState.setPlayQueueWindowFetcher(
|
||||
libraryId == null
|
||||
? (id, {center, window = 50}) => client.getPlayQueue(id, center: center, window: window)
|
||||
: (id, {center, window = 50}) => client.getPlayQueue(
|
||||
id,
|
||||
center: center,
|
||||
window: window,
|
||||
librarySectionID: libraryId,
|
||||
librarySectionTitle: libraryTitle,
|
||||
),
|
||||
);
|
||||
await playbackState.setPlaybackFromPlayQueue(playQueue, ratingKey);
|
||||
|
||||
if (!context.mounted) return const PlayQueueError('Context not mounted');
|
||||
@@ -286,7 +334,12 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher {
|
||||
var itemToPlay = selectedItem ?? playQueue.items!.first;
|
||||
|
||||
if (copyServerInfo && serverId != null) {
|
||||
itemToPlay = itemToPlay.copyWith(serverId: serverId, serverName: serverName ?? itemToPlay.serverName);
|
||||
itemToPlay = itemToPlay.copyWith(
|
||||
serverId: serverId,
|
||||
serverName: serverName ?? itemToPlay.serverName,
|
||||
libraryId: itemToPlay.libraryId ?? libraryId,
|
||||
libraryTitle: itemToPlay.libraryTitle ?? libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
await navigateToVideoPlayer(context, metadata: itemToPlay);
|
||||
|
||||
@@ -14,3 +14,29 @@ Future<List<MediaItem>> fetchAllPlaylistItems(MediaServerClient client, String p
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/// Page through every item in a collection via the backend-neutral client API.
|
||||
Future<List<MediaItem>> fetchAllCollectionItemsPaged(
|
||||
MediaServerClient client,
|
||||
String collectionId, {
|
||||
int pageSize = 100,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
}) async {
|
||||
final all = <MediaItem>[];
|
||||
var offset = 0;
|
||||
while (true) {
|
||||
final page = await client.fetchCollectionPage(
|
||||
collectionId,
|
||||
start: offset,
|
||||
size: pageSize,
|
||||
libraryId: libraryId,
|
||||
libraryTitle: libraryTitle,
|
||||
);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
if (all.length >= page.totalCount || page.items.length < pageSize) break;
|
||||
offset += page.items.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import '../media/media_item.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/isolate_helper.dart';
|
||||
import '../utils/plex_cache_parser.dart';
|
||||
import '../utils/plex_library_section_utils.dart';
|
||||
import 'api_cache.dart';
|
||||
import 'plex_mappers.dart';
|
||||
|
||||
@@ -78,9 +79,21 @@ class PlexApiCache extends ApiCache {
|
||||
@override
|
||||
Future<MediaItem?> getMetadata(String serverId, String ratingKey) async {
|
||||
final cached = await get(serverId, '/library/metadata/$ratingKey');
|
||||
final container = PlexCacheParser.extractMediaContainer(cached);
|
||||
final json = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (json == null) return null;
|
||||
return PlexMappers.mediaItemFromCacheJson(json, serverId: serverId);
|
||||
return PlexMappers.mediaItemFromCacheJson(_withContainerLibrary(json, container), serverId: serverId);
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _withContainerLibrary(Map<String, dynamic> json, Map<String, dynamic>? container) {
|
||||
final sectionId = plexLibrarySectionIdFromJson(json) ?? plexLibrarySectionIdFromJson(container);
|
||||
final sectionTitle = plexLibrarySectionTitleFromJson(json) ?? plexLibrarySectionTitleFromJson(container);
|
||||
if (sectionId == null && sectionTitle == null) return json;
|
||||
|
||||
final enriched = Map<String, dynamic>.from(json);
|
||||
if (sectionId != null) enriched['librarySectionID'] ??= sectionId;
|
||||
if (sectionTitle != null) enriched['librarySectionTitle'] ??= sectionTitle;
|
||||
return enriched;
|
||||
}
|
||||
|
||||
/// Persist a watched/unwatched flip into the cached metadata JSON. Mirrors
|
||||
@@ -135,10 +148,11 @@ class PlexApiCache extends ApiCache {
|
||||
for (final entry in entries) {
|
||||
try {
|
||||
final data = jsonDecode(entry.data) as Map<String, dynamic>;
|
||||
final container = PlexCacheParser.extractMediaContainer(data);
|
||||
final json = PlexCacheParser.extractFirstMetadata(data);
|
||||
if (json == null) continue;
|
||||
result[buildGlobalKey(entry.serverId, entry.id)] = PlexMappers.mediaItemFromCacheJson(
|
||||
json,
|
||||
_withContainerLibrary(json, container),
|
||||
serverId: entry.serverId,
|
||||
);
|
||||
} catch (_) {
|
||||
|
||||
+287
-35
@@ -55,6 +55,7 @@ import '../utils/media_server_retry.dart';
|
||||
import '../utils/media_server_timeouts.dart';
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/plex_cache_parser.dart';
|
||||
import '../utils/plex_library_section_utils.dart';
|
||||
import '../utils/plex_url_helper.dart';
|
||||
import '../utils/session_identifier.dart' as session_id;
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
@@ -81,16 +82,27 @@ List<PlexHubDto> _processHubResponse(
|
||||
Map<String, dynamic> decoded,
|
||||
String serverId,
|
||||
String? serverName, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
bool Function(PlexMetadataDto)? filter,
|
||||
}) {
|
||||
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
|
||||
if (container == null || container['Hub'] == null) return [];
|
||||
|
||||
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
|
||||
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
|
||||
final itemFilter = filter ?? (PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase());
|
||||
final hubs = <PlexHubDto>[];
|
||||
for (final hubJson in container['Hub'] as List) {
|
||||
try {
|
||||
final hub = PlexHubDto.fromJson(hubJson as Map<String, dynamic>, serverId: serverId, serverName: serverName);
|
||||
final hubMap = hubJson as Map<String, dynamic>;
|
||||
final hubSectionID = _librarySectionIdFromJson(hubMap) ?? containerSectionID;
|
||||
final hubSectionTitle = _librarySectionTitleFromJson(hubMap) ?? containerSectionTitle;
|
||||
final hub = _plexHubWithLibrarySection(
|
||||
PlexHubDto.fromJson(hubMap, serverId: serverId, serverName: serverName),
|
||||
librarySectionID: hubSectionID,
|
||||
librarySectionTitle: hubSectionTitle,
|
||||
);
|
||||
if (hub.items.isEmpty) continue;
|
||||
|
||||
final filteredItems = hub.items.where(itemFilter).toList();
|
||||
@@ -117,6 +129,48 @@ List<PlexHubDto> _processHubResponse(
|
||||
return hubs;
|
||||
}
|
||||
|
||||
int? _librarySectionIdFromJson(Map<String, dynamic>? json) => plexLibrarySectionIdFromJson(json);
|
||||
|
||||
int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId);
|
||||
|
||||
String? _librarySectionTitleFromJson(Map<String, dynamic>? json) => plexLibrarySectionTitleFromJson(json);
|
||||
|
||||
PlexMetadataDto _plexMetadataWithLibrarySection(
|
||||
PlexMetadataDto metadata, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) {
|
||||
final nextSectionID = metadata.librarySectionID ?? librarySectionID;
|
||||
final nextSectionTitle = metadata.librarySectionTitle ?? librarySectionTitle;
|
||||
if (nextSectionID == metadata.librarySectionID && nextSectionTitle == metadata.librarySectionTitle) {
|
||||
return metadata;
|
||||
}
|
||||
return metadata.copyWith(librarySectionID: nextSectionID, librarySectionTitle: nextSectionTitle);
|
||||
}
|
||||
|
||||
PlexHubDto _plexHubWithLibrarySection(PlexHubDto hub, {int? librarySectionID, String? librarySectionTitle}) {
|
||||
if (librarySectionID == null && librarySectionTitle == null) return hub;
|
||||
return PlexHubDto(
|
||||
hubKey: hub.hubKey,
|
||||
title: hub.title,
|
||||
type: hub.type,
|
||||
hubIdentifier: hub.hubIdentifier,
|
||||
size: hub.size,
|
||||
more: hub.more,
|
||||
items: hub.items
|
||||
.map(
|
||||
(item) => _plexMetadataWithLibrarySection(
|
||||
item,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
serverId: hub.serverId,
|
||||
serverName: hub.serverName,
|
||||
);
|
||||
}
|
||||
|
||||
// PlexStreamType moved to plex_constants.dart to break a would-be circular
|
||||
// import once plex_mappers.dart started referencing the same names.
|
||||
|
||||
@@ -614,14 +668,54 @@ class PlexClient
|
||||
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
|
||||
metadata.copyWith(serverId: serverId, serverName: serverName);
|
||||
|
||||
PlexMetadataDto _tagMetadataWithLibrary(
|
||||
PlexMetadataDto metadata, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) {
|
||||
return _plexMetadataWithLibrarySection(
|
||||
_tagMetadata(metadata),
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
|
||||
|
||||
PlexMetadataDto _createTaggedMetadataWithLibrary(
|
||||
Map<String, dynamic> json, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) {
|
||||
return _tagMetadataWithLibrary(
|
||||
PlexMetadataDto.fromJson(json),
|
||||
librarySectionID: _librarySectionIdFromJson(json) ?? librarySectionID,
|
||||
librarySectionTitle: _librarySectionTitleFromJson(json) ?? librarySectionTitle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) {
|
||||
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) => _extractMetadataListWithLibrary(response);
|
||||
|
||||
List<PlexMetadataDto> _extractMetadataListWithLibrary(
|
||||
MediaServerResponse response, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) {
|
||||
final container = _getMediaContainer(response);
|
||||
if (container != null && container['Metadata'] != null) {
|
||||
return (container['Metadata'] as List).map((json) => _createTaggedMetadata(json)).toList();
|
||||
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
|
||||
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
|
||||
return (container['Metadata'] as List)
|
||||
.map(
|
||||
(json) => _createTaggedMetadataWithLibrary(
|
||||
json as Map<String, dynamic>,
|
||||
librarySectionID: containerSectionID,
|
||||
librarySectionTitle: containerSectionTitle,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -745,7 +839,7 @@ class PlexClient
|
||||
if (filters != null) queryParams.addAll(filters);
|
||||
final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
|
||||
final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
|
||||
return _extractLibraryContentResult(response);
|
||||
return _extractLibraryContentResult(response, librarySectionID: _librarySectionIdFromString(sectionId));
|
||||
}
|
||||
|
||||
Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
|
||||
@@ -755,8 +849,16 @@ class PlexClient
|
||||
return params;
|
||||
}
|
||||
|
||||
_LibraryContentResult _extractLibraryContentResult(MediaServerResponse response) {
|
||||
final items = _extractMetadataList(response);
|
||||
_LibraryContentResult _extractLibraryContentResult(
|
||||
MediaServerResponse response, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) {
|
||||
final items = _extractMetadataListWithLibrary(
|
||||
response,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
final container = _getMediaContainer(response);
|
||||
final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length;
|
||||
return _LibraryContentResult(items: items, totalSize: totalSize);
|
||||
@@ -767,16 +869,35 @@ class PlexClient
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) async {
|
||||
final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort);
|
||||
return _extractLibraryContentResult(response);
|
||||
return _extractLibraryContentResult(
|
||||
response,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse list of PlexMetadataDto from a cached response
|
||||
List<PlexMetadataDto> _parseMetadataListFromCachedResponse(Map<String, dynamic> cached) {
|
||||
final container = cached['MediaContainer'] is Map<String, dynamic>
|
||||
? cached['MediaContainer'] as Map<String, dynamic>
|
||||
: null;
|
||||
final containerSectionID = _librarySectionIdFromJson(container);
|
||||
final containerSectionTitle = _librarySectionTitleFromJson(container);
|
||||
final metadataList = PlexCacheParser.extractMetadataList(cached);
|
||||
if (metadataList != null) {
|
||||
return metadataList.map((json) => _createTaggedMetadata(json)).toList();
|
||||
return metadataList
|
||||
.map(
|
||||
(json) => _createTaggedMetadataWithLibrary(
|
||||
json as Map<String, dynamic>,
|
||||
librarySectionID: containerSectionID,
|
||||
librarySectionTitle: containerSectionTitle,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -840,10 +961,17 @@ class PlexClient
|
||||
PlexMetadataDto? metadata;
|
||||
PlexMetadataDto? onDeckEpisode;
|
||||
|
||||
final container = _getMediaContainer(response);
|
||||
final containerSectionID = _librarySectionIdFromJson(container);
|
||||
final containerSectionTitle = _librarySectionTitleFromJson(container);
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
|
||||
if (metadataJson != null) {
|
||||
metadata = _tagMetadata(PlexMetadataDto.fromJsonWithImages(metadataJson));
|
||||
metadata = _tagMetadataWithLibrary(
|
||||
PlexMetadataDto.fromJsonWithImages(metadataJson),
|
||||
librarySectionID: _librarySectionIdFromJson(metadataJson) ?? containerSectionID,
|
||||
librarySectionTitle: _librarySectionTitleFromJson(metadataJson) ?? containerSectionTitle,
|
||||
);
|
||||
|
||||
// Check if OnDeck is nested inside Metadata
|
||||
if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) {
|
||||
@@ -853,7 +981,11 @@ class PlexClient
|
||||
if (onDeckData is Map && onDeckData.containsKey('Metadata')) {
|
||||
final onDeckMetadata = onDeckData['Metadata'];
|
||||
if (onDeckMetadata != null) {
|
||||
onDeckEpisode = _createTaggedMetadata(onDeckMetadata);
|
||||
onDeckEpisode = _createTaggedMetadataWithLibrary(
|
||||
onDeckMetadata as Map<String, dynamic>,
|
||||
librarySectionID: metadata.librarySectionID ?? containerSectionID,
|
||||
librarySectionTitle: metadata.librarySectionTitle ?? containerSectionTitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -878,17 +1010,32 @@ class PlexClient
|
||||
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
|
||||
parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData),
|
||||
parseResponse: (response) {
|
||||
final container = _getMediaContainer(response);
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
return metadataJson != null ? _tagMetadata(PlexMetadataDto.fromJsonWithImages(metadataJson)) : null;
|
||||
return metadataJson != null
|
||||
? _tagMetadataWithLibrary(
|
||||
PlexMetadataDto.fromJsonWithImages(metadataJson),
|
||||
librarySectionID: _librarySectionIdFromJson(metadataJson) ?? _librarySectionIdFromJson(container),
|
||||
librarySectionTitle:
|
||||
_librarySectionTitleFromJson(metadataJson) ?? _librarySectionTitleFromJson(container),
|
||||
)
|
||||
: null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse PlexMetadataDto with images from a cached response
|
||||
PlexMetadataDto? _parseMetadataWithImagesFromCachedResponse(Map<String, dynamic> cached) {
|
||||
final container = cached['MediaContainer'] is Map<String, dynamic>
|
||||
? cached['MediaContainer'] as Map<String, dynamic>
|
||||
: null;
|
||||
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
|
||||
if (firstMetadata != null) {
|
||||
return _tagMetadata(PlexMetadataDto.fromJsonWithImages(firstMetadata));
|
||||
return _tagMetadataWithLibrary(
|
||||
PlexMetadataDto.fromJsonWithImages(firstMetadata),
|
||||
librarySectionID: _librarySectionIdFromJson(firstMetadata) ?? _librarySectionIdFromJson(container),
|
||||
librarySectionTitle: _librarySectionTitleFromJson(firstMetadata) ?? _librarySectionTitleFromJson(container),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1574,7 +1721,7 @@ class PlexClient
|
||||
|
||||
/// Get library hubs (recommendations for a specific library section)
|
||||
/// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc.
|
||||
Future<List<PlexHubDto>> _getLibraryHubs(String sectionId, {int limit = 10}) async {
|
||||
Future<List<PlexHubDto>> _getLibraryHubs(String sectionId, {int limit = 10, String? libraryName}) async {
|
||||
try {
|
||||
final response = await retryTransientMediaServerCall(
|
||||
operation: 'Plex library hubs',
|
||||
@@ -1590,7 +1737,15 @@ class PlexClient
|
||||
final sid = serverId;
|
||||
final sname = serverName;
|
||||
final data = response.data as Map<String, dynamic>;
|
||||
return await tryIsolateRun(() => _processHubResponse(data, sid, sname));
|
||||
return await tryIsolateRun(
|
||||
() => _processHubResponse(
|
||||
data,
|
||||
sid,
|
||||
sname,
|
||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
||||
librarySectionTitle: libraryName,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get library hubs: $e');
|
||||
}
|
||||
@@ -1655,8 +1810,9 @@ class PlexClient
|
||||
/// Get full content from a hub using its hub key
|
||||
/// Returns the complete list of metadata items in the hub
|
||||
Future<List<PlexMetadataDto>> _getHubContent(String hubKey) async {
|
||||
final hubSectionID = _librarySectionIdFromString(hubKey);
|
||||
return _wrapListApiCall<PlexMetadataDto>(() => _http.get(hubKey), (response) {
|
||||
final allItems = _extractMetadataList(response);
|
||||
final allItems = _extractMetadataListWithLibrary(response, librarySectionID: hubSectionID);
|
||||
// Filter to only video content (movies, shows, seasons, episodes)
|
||||
return allItems.where((item) {
|
||||
return ContentTypes.videoTypes.contains(item.type?.toLowerCase());
|
||||
@@ -1996,7 +2152,10 @@ class PlexClient
|
||||
queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize},
|
||||
),
|
||||
(response) {
|
||||
final allItems = _extractMetadataList(response);
|
||||
final allItems = _extractMetadataListWithLibrary(
|
||||
response,
|
||||
librarySectionID: _librarySectionIdFromString(sectionId),
|
||||
);
|
||||
// Collections should have type="collection"
|
||||
return allItems.where((item) {
|
||||
return item.type?.toLowerCase() == ContentTypes.collection;
|
||||
@@ -2012,11 +2171,32 @@ class PlexClient
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) => _fetchPaginatedList('/library/collections/$collectionId/children', start: start, size: size, abort: abort);
|
||||
String? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) => _fetchPaginatedList(
|
||||
'/library/collections/$collectionId/children',
|
||||
start: start,
|
||||
size: size,
|
||||
abort: abort,
|
||||
librarySectionID: _librarySectionIdFromString(librarySectionID),
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
|
||||
/// Fetch every item in a collection (downloads, sync rules, context-menu shuffle).
|
||||
Future<List<PlexMetadataDto>> _fetchAllCollectionItemsDto(String collectionId) =>
|
||||
_fetchAllPages((start, size, abort) => _getCollectionItems(collectionId, start: start, size: size, abort: abort));
|
||||
Future<List<PlexMetadataDto>> _fetchAllCollectionItemsDto(
|
||||
String collectionId, {
|
||||
String? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) => _fetchAllPages(
|
||||
(start, size, abort) => _getCollectionItems(
|
||||
collectionId,
|
||||
start: start,
|
||||
size: size,
|
||||
abort: abort,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
),
|
||||
);
|
||||
|
||||
/// Get media featuring a specific person (actor/director), paginated.
|
||||
Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
|
||||
@@ -2141,16 +2321,25 @@ class PlexClient
|
||||
|
||||
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
|
||||
/// MediaItem-typed entries.
|
||||
PlayQueueResponse _parsePlayQueueResponse(dynamic data) {
|
||||
PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
|
||||
final container = data is Map && data['MediaContainer'] is Map
|
||||
? data['MediaContainer'] as Map<String, dynamic>
|
||||
: data as Map<String, dynamic>;
|
||||
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
|
||||
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
|
||||
final metadata = container['Metadata'];
|
||||
List<MediaItem>? items;
|
||||
if (metadata is List) {
|
||||
items = [
|
||||
for (final e in metadata)
|
||||
if (e is Map<String, dynamic>) PlexMappers.mediaItem(_createTaggedMetadata(e)),
|
||||
if (e is Map<String, dynamic>)
|
||||
PlexMappers.mediaItem(
|
||||
_createTaggedMetadataWithLibrary(
|
||||
e,
|
||||
librarySectionID: containerSectionID,
|
||||
librarySectionTitle: containerSectionTitle,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return PlayQueueResponse(
|
||||
@@ -2177,6 +2366,8 @@ class PlexClient
|
||||
int shuffle = 0,
|
||||
int repeat = 0,
|
||||
int continuous = 0,
|
||||
String? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
@@ -2198,7 +2389,11 @@ class PlexClient
|
||||
|
||||
final response = await _http.post('/playQueues', queryParameters: queryParams);
|
||||
|
||||
return _parsePlayQueueResponse(response.data);
|
||||
return _parsePlayQueueResponse(
|
||||
response.data,
|
||||
librarySectionID: _librarySectionIdFromString(librarySectionID),
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create play queue', error: e);
|
||||
return null;
|
||||
@@ -2213,6 +2408,8 @@ class PlexClient
|
||||
int window = 50,
|
||||
int includeBefore = 1,
|
||||
int includeAfter = 1,
|
||||
String? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
@@ -2227,7 +2424,11 @@ class PlexClient
|
||||
|
||||
final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams);
|
||||
|
||||
return _parsePlayQueueResponse(response.data);
|
||||
return _parsePlayQueueResponse(
|
||||
response.data,
|
||||
librarySectionID: _librarySectionIdFromString(librarySectionID),
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get play queue: $e');
|
||||
return null;
|
||||
@@ -2249,6 +2450,8 @@ class PlexClient
|
||||
required String showRatingKey,
|
||||
int shuffle = 0,
|
||||
String? startingEpisodeKey,
|
||||
String? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) async {
|
||||
try {
|
||||
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
||||
@@ -2263,6 +2466,8 @@ class PlexClient
|
||||
shuffle: shuffle,
|
||||
key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
|
||||
continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
|
||||
librarySectionID: librarySectionID,
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to create show play queue', error: e);
|
||||
@@ -2273,17 +2478,29 @@ class PlexClient
|
||||
/// Extract both Metadata and Directory entries from response
|
||||
/// Folders can come back as either type
|
||||
/// Automatically tags all items with this client's serverId and serverName
|
||||
List<PlexMetadataDto> _extractMetadataAndDirectories(MediaServerResponse response) {
|
||||
List<PlexMetadataDto> _extractMetadataAndDirectories(
|
||||
MediaServerResponse response, {
|
||||
int? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) {
|
||||
final List<PlexMetadataDto> items = [];
|
||||
final container = _getMediaContainer(response);
|
||||
|
||||
if (container != null) {
|
||||
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
|
||||
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
|
||||
// Extract Metadata entries - try full parsing first
|
||||
if (container['Metadata'] != null) {
|
||||
for (final json in container['Metadata'] as List) {
|
||||
try {
|
||||
// Try to parse with full PlexMetadataDto.fromJson first
|
||||
items.add(_createTaggedMetadata(json));
|
||||
items.add(
|
||||
_createTaggedMetadataWithLibrary(
|
||||
json as Map<String, dynamic>,
|
||||
librarySectionID: containerSectionID,
|
||||
librarySectionTitle: containerSectionTitle,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// If full parsing fails, use minimal safe parsing
|
||||
appLogger.d('Using minimal parsing for metadata item: $e');
|
||||
@@ -2297,6 +2514,8 @@ class PlexClient
|
||||
thumb: json['thumb'],
|
||||
art: json['art'],
|
||||
year: json['year'],
|
||||
librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
|
||||
librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
),
|
||||
@@ -2313,7 +2532,13 @@ class PlexClient
|
||||
for (final json in container['Directory'] as List) {
|
||||
try {
|
||||
// Try to parse as PlexMetadataDto first
|
||||
items.add(_createTaggedMetadata(json));
|
||||
items.add(
|
||||
_createTaggedMetadataWithLibrary(
|
||||
json as Map<String, dynamic>,
|
||||
librarySectionID: containerSectionID,
|
||||
librarySectionTitle: containerSectionTitle,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// If that fails, use minimal folder representation
|
||||
try {
|
||||
@@ -2325,6 +2550,8 @@ class PlexClient
|
||||
title: json['title'] ?? 'Untitled',
|
||||
thumb: json['thumb'],
|
||||
art: json['art'],
|
||||
librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
|
||||
librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
|
||||
serverId: serverId,
|
||||
serverName: serverName,
|
||||
),
|
||||
@@ -2348,7 +2575,7 @@ class PlexClient
|
||||
'/library/sections/$sectionId/folder',
|
||||
queryParameters: {'includeCollections': 0},
|
||||
);
|
||||
return _extractMetadataAndDirectories(response);
|
||||
return _extractMetadataAndDirectories(response, librarySectionID: _librarySectionIdFromString(sectionId));
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get library folders: $e');
|
||||
return [];
|
||||
@@ -2357,10 +2584,18 @@ class PlexClient
|
||||
|
||||
/// Get children of a specific folder
|
||||
/// Returns files and subfolders within the given folder
|
||||
Future<List<PlexMetadataDto>> _getFolderChildren(String folderKey) async {
|
||||
Future<List<PlexMetadataDto>> _getFolderChildren(
|
||||
String folderKey, {
|
||||
String? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _getWithFailover(folderKey);
|
||||
return _extractMetadataAndDirectories(response);
|
||||
return _extractMetadataAndDirectories(
|
||||
response,
|
||||
librarySectionID: _librarySectionIdFromString(folderKey) ?? _librarySectionIdFromString(librarySectionID),
|
||||
librarySectionTitle: librarySectionTitle,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to get folder children: $e');
|
||||
return [];
|
||||
@@ -3091,7 +3326,7 @@ class PlexClient
|
||||
}) async {
|
||||
// libraryName is unused: Plex's /hubs/sections/{id} returns hubs already
|
||||
// titled per-library (e.g. "Recently Added in Movies").
|
||||
final hubs = await _getLibraryHubs(libraryId, limit: limit);
|
||||
final hubs = await _getLibraryHubs(libraryId, limit: limit, libraryName: libraryName);
|
||||
return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
|
||||
}
|
||||
|
||||
@@ -3168,8 +3403,17 @@ class PlexClient
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
}) async {
|
||||
final result = await _getCollectionItems(collectionId, start: start, size: size, abort: abort);
|
||||
final result = await _getCollectionItems(
|
||||
collectionId,
|
||||
start: start,
|
||||
size: size,
|
||||
abort: abort,
|
||||
librarySectionID: libraryId,
|
||||
librarySectionTitle: libraryTitle,
|
||||
);
|
||||
return LibraryPage<MediaItem>(
|
||||
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
||||
totalCount: result.totalSize,
|
||||
@@ -3178,8 +3422,16 @@ class PlexClient
|
||||
}
|
||||
|
||||
/// Plex-specific: full collection contents across pages.
|
||||
Future<List<MediaItem>> fetchAllCollectionItemsAsMediaItems(String collectionId) async {
|
||||
final raw = await _fetchAllCollectionItemsDto(collectionId);
|
||||
Future<List<MediaItem>> fetchAllCollectionItemsAsMediaItems(
|
||||
String collectionId, {
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
}) async {
|
||||
final raw = await _fetchAllCollectionItemsDto(
|
||||
collectionId,
|
||||
librarySectionID: libraryId,
|
||||
librarySectionTitle: libraryTitle,
|
||||
);
|
||||
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
@@ -3225,8 +3477,8 @@ class PlexClient
|
||||
}
|
||||
|
||||
/// Plex-specific: contents of a folder (files and subfolders).
|
||||
Future<List<MediaItem>> fetchFolderChildren(String folderKey) async {
|
||||
final raw = await _getFolderChildren(folderKey);
|
||||
Future<List<MediaItem>> fetchFolderChildren(String folderKey, {String? libraryId, String? libraryTitle}) async {
|
||||
final raw = await _getFolderChildren(folderKey, librarySectionID: libraryId, librarySectionTitle: libraryTitle);
|
||||
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -446,11 +446,14 @@ class SettingsService extends BaseSharedPreferencesService {
|
||||
static Map<String, String> defaultKeyboardShortcuts() => _defaultKeyboardShortcuts();
|
||||
static Map<String, HotKey> defaultKeyboardHotkeys() => _defaultKeyboardHotkeys();
|
||||
|
||||
/// Null keys bypass the filter so we err on the side of syncing.
|
||||
/// Unknown libraries are allowed only when no filter is configured.
|
||||
bool isLibraryAllowedForTracker(TrackerService service, String? libraryGlobalKey) {
|
||||
if (libraryGlobalKey == null) return true;
|
||||
final inList = read(trackerFilterIdsPref(service)).contains(libraryGlobalKey);
|
||||
final filterIds = read(trackerFilterIdsPref(service));
|
||||
final mode = read(trackerFilterModePref(service));
|
||||
if (libraryGlobalKey == null) {
|
||||
return mode == TrackerLibraryFilterMode.blacklist && filterIds.isEmpty;
|
||||
}
|
||||
final inList = filterIds.contains(libraryGlobalKey);
|
||||
return mode == TrackerLibraryFilterMode.blacklist ? !inList : inList;
|
||||
}
|
||||
|
||||
|
||||
@@ -259,10 +259,23 @@ class SyncRuleExecutor {
|
||||
required Future<bool> Function(MediaItem episode, MediaServerClient client, {int mediaIndex}) queueSingleDownload,
|
||||
}) async {
|
||||
final fromServer = <MediaItem>[];
|
||||
final sourceMetadata = metadata[rule.globalKey];
|
||||
if (rule.targetType == ContentTypes.show) {
|
||||
await collectEpisodesForShow(client, rule.ratingKey, unwatchedOnly: true, out: fromServer);
|
||||
await collectEpisodesForShow(
|
||||
client,
|
||||
rule.ratingKey,
|
||||
unwatchedOnly: true,
|
||||
out: fromServer,
|
||||
fallback: sourceMetadata,
|
||||
);
|
||||
} else {
|
||||
await collectEpisodesForSeason(client, rule.ratingKey, unwatchedOnly: true, out: fromServer);
|
||||
await collectEpisodesForSeason(
|
||||
client,
|
||||
rule.ratingKey,
|
||||
unwatchedOnly: true,
|
||||
out: fromServer,
|
||||
fallback: sourceMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
final unwatchedEpisodes = await _excludeLocallyWatched(
|
||||
@@ -334,7 +347,7 @@ class SyncRuleExecutor {
|
||||
// default limit. Plex collections use a distinct collections endpoint;
|
||||
// Jellyfin's collection page implementation maps to its children API.
|
||||
if (rule.targetType == ContentTypes.collection) {
|
||||
rootItems = await _fetchAllCollectionItems(client, rule.ratingKey);
|
||||
rootItems = await _fetchAllCollectionItems(client, rule.ratingKey, source: metadata[rule.globalKey]);
|
||||
} else {
|
||||
rootItems = await _fetchAllPlaylistItems(client, rule.ratingKey);
|
||||
}
|
||||
@@ -399,19 +412,16 @@ class SyncRuleExecutor {
|
||||
/// Page through every item in a collection. Plex requires
|
||||
/// [MediaServerClient.fetchCollectionPage] because collection children live
|
||||
/// under `/library/collections/{id}/children`, not metadata children.
|
||||
Future<List<MediaItem>> _fetchAllCollectionItems(MediaServerClient client, String collectionId) async {
|
||||
final all = <MediaItem>[];
|
||||
const pageSize = 100;
|
||||
var offset = 0;
|
||||
while (true) {
|
||||
final page = await client.fetchCollectionPage(collectionId, start: offset, size: pageSize);
|
||||
if (page.items.isEmpty) break;
|
||||
all.addAll(page.items);
|
||||
if (all.length >= page.totalCount || page.items.length < pageSize) break;
|
||||
offset += page.items.length;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
Future<List<MediaItem>> _fetchAllCollectionItems(
|
||||
MediaServerClient client,
|
||||
String collectionId, {
|
||||
MediaItem? source,
|
||||
}) => fetchAllCollectionItemsPaged(
|
||||
client,
|
||||
collectionId,
|
||||
libraryId: source?.libraryId,
|
||||
libraryTitle: source?.libraryTitle,
|
||||
);
|
||||
|
||||
/// Walks [items] and collects playable movie/episode entries into [out].
|
||||
/// Shows and seasons are expanded into their episodes; music and nested
|
||||
@@ -429,9 +439,9 @@ class SyncRuleExecutor {
|
||||
if (unwatchedOnly && item.isWatched && !item.hasActiveProgress) break;
|
||||
out.add(item);
|
||||
case MediaKind.show:
|
||||
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out);
|
||||
await collectEpisodesForShow(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
|
||||
case MediaKind.season:
|
||||
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out);
|
||||
await collectEpisodesForSeason(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item);
|
||||
default:
|
||||
// Skip music, clips, nested collections/playlists, unknown types.
|
||||
break;
|
||||
|
||||
@@ -25,8 +25,8 @@ abstract class Tracker {
|
||||
|
||||
/// Whether an item in the given library should be scrobbled. Applies the
|
||||
/// per-tracker whitelist/blacklist — callers pass the Plex library
|
||||
/// `serverId:sectionId` globalKey (null when unknown, in which case the
|
||||
/// filter is bypassed so we err on the side of syncing).
|
||||
/// `serverId:sectionId` globalKey. Null is allowed only when no filter is
|
||||
/// configured for this tracker.
|
||||
bool shouldScrobbleForLibrary(String? libraryGlobalKey);
|
||||
|
||||
Future<void> markWatched(TrackerContext ctx);
|
||||
|
||||
@@ -27,6 +27,7 @@ class TrackerCoordinator {
|
||||
/// Resolver persists across episode swaps so back-to-back episodes of the
|
||||
/// same show reuse the cached IDs. Cleared only on profile switch.
|
||||
TrackerIdResolver? _resolver;
|
||||
String? _activeLibraryGlobalKey;
|
||||
|
||||
TrackerContext? _ctx;
|
||||
Duration _duration = Duration.zero;
|
||||
@@ -41,8 +42,13 @@ class TrackerCoordinator {
|
||||
if (isLive) return;
|
||||
final mediaType = metadata.kind;
|
||||
if (mediaType != MediaKind.movie && mediaType != MediaKind.episode) return;
|
||||
if (!_trackers.any((t) => t.canScrobble)) return;
|
||||
final libraryGlobalKey = metadata.libraryGlobalKey;
|
||||
if (!_trackers.any((t) => t.canScrobble && t.shouldScrobbleForLibrary(libraryGlobalKey))) {
|
||||
_reset();
|
||||
return;
|
||||
}
|
||||
|
||||
_activeLibraryGlobalKey = libraryGlobalKey;
|
||||
_resolver ??= TrackerIdResolver(client, needsFribb: _anyTrackerNeedsFribb);
|
||||
final ctx = await _buildContext(metadata);
|
||||
if (ctx == null) {
|
||||
@@ -54,7 +60,8 @@ class TrackerCoordinator {
|
||||
_ctx = ctx;
|
||||
}
|
||||
|
||||
bool _anyTrackerNeedsFribb() => _trackers.any((t) => t.canScrobble && t.needsFribb);
|
||||
bool _anyTrackerNeedsFribb() =>
|
||||
_trackers.any((t) => t.canScrobble && t.needsFribb && t.shouldScrobbleForLibrary(_activeLibraryGlobalKey));
|
||||
|
||||
Future<void> stopPlayback() async {
|
||||
final ctx = _ctx;
|
||||
@@ -98,6 +105,7 @@ class TrackerCoordinator {
|
||||
|
||||
void _reset() {
|
||||
_ctx = null;
|
||||
_activeLibraryGlobalKey = null;
|
||||
_duration = Duration.zero;
|
||||
_lastPosition = Duration.zero;
|
||||
_thresholdCrossed = false;
|
||||
|
||||
@@ -11,6 +11,7 @@ class TraktSyncQueueItem {
|
||||
final TraktSyncOp op;
|
||||
final String ratingKey;
|
||||
final String serverId;
|
||||
final String? libraryGlobalKey;
|
||||
final TraktMediaKind kind;
|
||||
final TraktIds ids;
|
||||
|
||||
@@ -28,6 +29,7 @@ class TraktSyncQueueItem {
|
||||
required this.kind,
|
||||
required this.ids,
|
||||
required this.watchedAtIso,
|
||||
this.libraryGlobalKey,
|
||||
this.season,
|
||||
this.number,
|
||||
this.attempts = 0,
|
||||
@@ -37,6 +39,7 @@ class TraktSyncQueueItem {
|
||||
op: op,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
libraryGlobalKey: libraryGlobalKey,
|
||||
kind: kind,
|
||||
ids: ids,
|
||||
watchedAtIso: watchedAtIso,
|
||||
@@ -49,6 +52,7 @@ class TraktSyncQueueItem {
|
||||
'op': op.name,
|
||||
'ratingKey': ratingKey,
|
||||
'serverId': serverId,
|
||||
if (libraryGlobalKey != null) 'libraryGlobalKey': libraryGlobalKey,
|
||||
'kind': kind.name,
|
||||
'ids': ids.toJson(),
|
||||
if (season != null) 'season': season,
|
||||
@@ -61,6 +65,7 @@ class TraktSyncQueueItem {
|
||||
op: TraktSyncOp.fromName(json['op'] as String),
|
||||
ratingKey: json['ratingKey'] as String,
|
||||
serverId: json['serverId'] as String,
|
||||
libraryGlobalKey: json['libraryGlobalKey'] as String?,
|
||||
kind: TraktMediaKind.fromName(json['kind'] as String),
|
||||
ids: TraktIds.fromJson(json['ids'] as Map<String, dynamic>),
|
||||
season: (json['season'] as num?)?.toInt(),
|
||||
|
||||
@@ -114,8 +114,7 @@ class TraktSyncService {
|
||||
final kind = TraktMediaKind.tryFromMediaKindId(event.mediaType);
|
||||
if (kind == null) return;
|
||||
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && !settings.isLibraryAllowedForTracker(TrackerService.trakt, event.librarySectionGlobalKey)) {
|
||||
if (!_isLibraryAllowed(event.librarySectionGlobalKey)) {
|
||||
appLogger.d('Trakt sync: library filtered out for ${event.itemId}');
|
||||
return;
|
||||
}
|
||||
@@ -125,6 +124,7 @@ class TraktSyncService {
|
||||
op: op,
|
||||
ratingKey: event.itemId,
|
||||
serverId: event.serverId,
|
||||
libraryGlobalKey: event.librarySectionGlobalKey,
|
||||
kind: kind,
|
||||
watchedAtIso: DateTime.now().toUtc().toIso8601String(),
|
||||
);
|
||||
@@ -134,6 +134,7 @@ class TraktSyncService {
|
||||
required TraktSyncOp op,
|
||||
required String ratingKey,
|
||||
required String serverId,
|
||||
required String? libraryGlobalKey,
|
||||
required TraktMediaKind kind,
|
||||
required String watchedAtIso,
|
||||
}) async {
|
||||
@@ -178,6 +179,7 @@ class TraktSyncService {
|
||||
op: op,
|
||||
ratingKey: ratingKey,
|
||||
serverId: serverId,
|
||||
libraryGlobalKey: libraryGlobalKey,
|
||||
kind: kind,
|
||||
ids: ids,
|
||||
season: season,
|
||||
@@ -241,6 +243,10 @@ class TraktSyncService {
|
||||
await _recoverInMemoryFallback();
|
||||
|
||||
await _queue.drainWith(_activeUserUuid, (item) async {
|
||||
if (!_isLibraryAllowed(item.libraryGlobalKey)) {
|
||||
appLogger.d('Trakt sync: queued library filtered out for ${item.ratingKey}');
|
||||
return null;
|
||||
}
|
||||
if (item.attempts >= TraktSyncQueue.maxAttempts) {
|
||||
appLogger.w('Trakt sync: dropping ${item.op.name} ${item.ratingKey} after ${item.attempts} attempts');
|
||||
return null;
|
||||
@@ -273,6 +279,10 @@ class TraktSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isLibraryAllowed(String? libraryGlobalKey) {
|
||||
return SettingsService.instanceOrNull?.isLibraryAllowedForTracker(TrackerService.trakt, libraryGlobalKey) ?? true;
|
||||
}
|
||||
|
||||
TraktScrobbleRequest _bodyFor(TraktSyncQueueItem item) {
|
||||
return switch (item.kind) {
|
||||
TraktMediaKind.movie => TraktScrobbleRequest.movie(ids: item.ids),
|
||||
|
||||
@@ -18,8 +18,9 @@ Future<void> collectEpisodesForShow(
|
||||
String showRatingKey, {
|
||||
required bool unwatchedOnly,
|
||||
required List<MediaItem> out,
|
||||
MediaItem? fallback,
|
||||
}) {
|
||||
return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out);
|
||||
return _collectPlayable(client, showRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback);
|
||||
}
|
||||
|
||||
/// Collect every episode of a single season into [out] via the same
|
||||
@@ -30,8 +31,9 @@ Future<void> collectEpisodesForSeason(
|
||||
String seasonRatingKey, {
|
||||
required bool unwatchedOnly,
|
||||
required List<MediaItem> out,
|
||||
MediaItem? fallback,
|
||||
}) {
|
||||
return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out);
|
||||
return _collectPlayable(client, seasonRatingKey, unwatchedOnly: unwatchedOnly, out: out, fallback: fallback);
|
||||
}
|
||||
|
||||
Future<void> _collectPlayable(
|
||||
@@ -39,11 +41,22 @@ Future<void> _collectPlayable(
|
||||
String parentId, {
|
||||
required bool unwatchedOnly,
|
||||
required List<MediaItem> out,
|
||||
MediaItem? fallback,
|
||||
}) async {
|
||||
final leaves = await client.fetchPlayableDescendants(parentId);
|
||||
for (final ep in leaves) {
|
||||
if (ep.kind != MediaKind.episode) continue;
|
||||
if (unwatchedOnly && ep.isWatched && !ep.hasActiveProgress) continue;
|
||||
out.add(ep);
|
||||
out.add(_withFallbackLibrary(ep, fallback));
|
||||
}
|
||||
}
|
||||
|
||||
MediaItem _withFallbackLibrary(MediaItem item, MediaItem? fallback) {
|
||||
if (fallback == null) return item;
|
||||
return item.copyWith(
|
||||
serverId: item.serverId ?? fallback.serverId,
|
||||
serverName: item.serverName ?? fallback.serverName,
|
||||
libraryId: item.libraryId ?? fallback.libraryId,
|
||||
libraryTitle: item.libraryTitle ?? fallback.libraryTitle,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
||||
title: mi.grandparentTitle ?? mi.parentTitle ?? mi.displayTitle,
|
||||
thumbPath: mi.grandparentThumbPath ?? mi.parentThumbPath,
|
||||
artPath: mi.grandparentArtPath,
|
||||
libraryId: mi.libraryId,
|
||||
libraryTitle: mi.libraryTitle,
|
||||
serverId: mi.serverId,
|
||||
serverName: mi.serverName,
|
||||
);
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
class PlexCacheParser {
|
||||
PlexCacheParser._();
|
||||
|
||||
static Map<String, dynamic>? extractMediaContainer(Map<String, dynamic>? cached) {
|
||||
final container = cached?['MediaContainer'];
|
||||
return container is Map<String, dynamic> ? container : null;
|
||||
}
|
||||
|
||||
static List<dynamic>? extractMetadataList(Map<String, dynamic>? cached) {
|
||||
if (cached == null) return null;
|
||||
return cached['MediaContainer']?['Metadata'] as List?;
|
||||
return extractMediaContainer(cached)?['Metadata'] as List?;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? extractFirstMetadata(Map<String, dynamic>? cached) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'json_utils.dart';
|
||||
|
||||
final RegExp plexLibrarySectionPathPattern = RegExp(r'/(?:library|hubs)/sections/(\d+)');
|
||||
|
||||
int? plexLibrarySectionIdFromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
final direct = flexibleInt(json['librarySectionID']) ?? flexibleInt(json['targetLibrarySectionID']);
|
||||
if (direct != null) return direct;
|
||||
|
||||
for (final key in const ['librarySectionKey', 'key', 'hubKey']) {
|
||||
final parsed = plexLibrarySectionIdFromString(json[key]?.toString());
|
||||
if (parsed != null) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int? plexLibrarySectionIdFromString(String? value) {
|
||||
if (value == null || value == 'shared') return null;
|
||||
final direct = int.tryParse(value);
|
||||
if (direct != null) return direct;
|
||||
final match = plexLibrarySectionPathPattern.firstMatch(value);
|
||||
return match == null ? null : int.tryParse(match.group(1)!);
|
||||
}
|
||||
|
||||
String? plexLibrarySectionTitleFromJson(Map<String, dynamic>? json) {
|
||||
return json?['librarySectionTitle']?.toString();
|
||||
}
|
||||
@@ -63,7 +63,8 @@ class WatchStateEvent with HierarchicalEventMixin {
|
||||
}) : globalKey = buildGlobalKey(serverId, itemId);
|
||||
|
||||
/// `serverId:librarySectionID`, matching [MediaLibrary.globalKey]. Null when
|
||||
/// the library section is unknown.
|
||||
/// the library section is unknown; tracker filters treat unknown as allowed
|
||||
/// only when no filter is configured.
|
||||
String? get librarySectionGlobalKey => librarySectionID != null ? buildGlobalKey(serverId, librarySectionID!) : null;
|
||||
|
||||
@override
|
||||
|
||||
@@ -909,6 +909,8 @@ void _navigateToSeason(BuildContext context, MediaItem episode, {bool isOffline
|
||||
title: episode.grandparentTitle ?? episode.displayTitle,
|
||||
thumbPath: episode.grandparentThumbPath,
|
||||
artPath: episode.grandparentArtPath,
|
||||
libraryId: episode.libraryId,
|
||||
libraryTitle: episode.libraryTitle,
|
||||
serverId: episode.serverId,
|
||||
serverName: episode.serverName,
|
||||
);
|
||||
@@ -929,6 +931,8 @@ void _navigateToSeason(BuildContext context, MediaItem episode, {bool isOffline
|
||||
index: episode.parentIndex,
|
||||
parentId: episode.grandparentId,
|
||||
thumbPath: episode.parentThumbPath,
|
||||
libraryId: episode.libraryId,
|
||||
libraryTitle: episode.libraryTitle,
|
||||
serverId: episode.serverId,
|
||||
serverName: episode.serverName,
|
||||
);
|
||||
@@ -956,6 +960,8 @@ void _navigateToDetail(BuildContext context, MediaItem mi, {bool isOffline = fal
|
||||
title: mi.grandparentTitle ?? mi.displayTitle,
|
||||
thumbPath: mi.grandparentThumbPath,
|
||||
artPath: mi.grandparentArtPath,
|
||||
libraryId: mi.libraryId,
|
||||
libraryTitle: mi.libraryTitle,
|
||||
serverId: mi.serverId,
|
||||
serverName: mi.serverName,
|
||||
);
|
||||
@@ -968,6 +974,8 @@ void _navigateToDetail(BuildContext context, MediaItem mi, {bool isOffline = fal
|
||||
title: mi.grandparentTitle ?? mi.parentTitle ?? mi.displayTitle,
|
||||
thumbPath: mi.grandparentThumbPath ?? mi.parentThumbPath,
|
||||
artPath: mi.grandparentArtPath,
|
||||
libraryId: mi.libraryId,
|
||||
libraryTitle: mi.libraryTitle,
|
||||
serverId: mi.serverId,
|
||||
serverName: mi.serverName,
|
||||
);
|
||||
|
||||
@@ -1283,10 +1283,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final client = _getMediaClientForItem();
|
||||
|
||||
try {
|
||||
// [fetchChildren] is the neutral equivalent of the previous Plex-only
|
||||
// `fetchAllCollectionItemsAsMediaItems` — both backends return the
|
||||
// collection's contents.
|
||||
final items = await client.fetchChildren(collection.id);
|
||||
final items = await fetchAllCollectionItemsPaged(
|
||||
client,
|
||||
collection.id,
|
||||
libraryId: collection.libraryId,
|
||||
libraryTitle: collection.libraryTitle,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final result = await showCollectionDownloadOptionsAndQueue(
|
||||
|
||||
@@ -23,8 +23,15 @@ void main() {
|
||||
});
|
||||
|
||||
// Helper: minimal Plex MediaContainer payload that PlexCacheParser can parse.
|
||||
Map<String, dynamic> mediaContainer({String ratingKey = '42', String title = 'Item'}) => {
|
||||
Map<String, dynamic> mediaContainer({
|
||||
String ratingKey = '42',
|
||||
String title = 'Item',
|
||||
Object? librarySectionID,
|
||||
String? librarySectionTitle,
|
||||
}) => {
|
||||
'MediaContainer': {
|
||||
'librarySectionID': ?librarySectionID,
|
||||
'librarySectionTitle': ?librarySectionTitle,
|
||||
'Metadata': [
|
||||
{'ratingKey': ratingKey, 'title': title, 'type': 'movie'},
|
||||
],
|
||||
@@ -260,6 +267,20 @@ void main() {
|
||||
expect(meta.serverId, 'srv');
|
||||
});
|
||||
|
||||
test('getMetadata preserves hoisted MediaContainer library fields', () async {
|
||||
await cache.put(
|
||||
'srv',
|
||||
'/library/metadata/42',
|
||||
mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: '7', librarySectionTitle: 'Movies'),
|
||||
);
|
||||
|
||||
final meta = await cache.getMetadata('srv', '42');
|
||||
|
||||
expect(meta, isNotNull);
|
||||
expect(meta!.libraryId, '7');
|
||||
expect(meta.libraryTitle, 'Movies');
|
||||
});
|
||||
|
||||
test('getAllPinnedMetadata returns an empty map when nothing is pinned', () async {
|
||||
await cache.put('srv', '/library/metadata/1', mediaContainer(ratingKey: '1'));
|
||||
// No pin yet.
|
||||
@@ -285,6 +306,20 @@ void main() {
|
||||
expect(result['srv-b:9']!.serverId, 'srv-b');
|
||||
});
|
||||
|
||||
test('getAllPinnedMetadata preserves hoisted MediaContainer library fields', () async {
|
||||
await cache.put(
|
||||
'srv',
|
||||
'/library/metadata/42',
|
||||
mediaContainer(ratingKey: '42', title: 'Hello', librarySectionID: 7, librarySectionTitle: 'Movies'),
|
||||
);
|
||||
await cache.pinForOffline('srv', '42');
|
||||
|
||||
final result = await cache.getAllPinnedMetadata();
|
||||
|
||||
expect(result['srv:42']!.libraryId, '7');
|
||||
expect(result['srv:42']!.libraryTitle, 'Movies');
|
||||
});
|
||||
|
||||
test('getAllPinnedMetadata skips rows whose key is not a metadata endpoint', () async {
|
||||
// Insert a pinned row at a non-metadata endpoint via raw insert.
|
||||
await db
|
||||
|
||||
@@ -156,6 +156,8 @@ void main() {
|
||||
final hubs = await client.fetchLibraryHubs('4', libraryName: 'Movies', limit: 12);
|
||||
|
||||
expect(hubs, hasLength(1));
|
||||
expect(hubs.single.items.single.libraryId, '4');
|
||||
expect(hubs.single.items.single.libraryTitle, 'Movies');
|
||||
expect(client.config.baseUrl, primary);
|
||||
expect(httpClient.requests, hasLength(2));
|
||||
expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary));
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/models/plex/plex_config.dart';
|
||||
import 'package:plezy/services/plex_api_cache.dart';
|
||||
import 'package:plezy/services/plex_client.dart';
|
||||
@@ -71,6 +72,114 @@ void main() {
|
||||
'random',
|
||||
]);
|
||||
});
|
||||
|
||||
test('library content stamps known section when Plex omits librarySectionID on rows', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/sections/7/all') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': '42', 'type': 'movie', 'title': 'Library Movie'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchLibraryContent('7', const LibraryQuery(limit: 1));
|
||||
|
||||
expect(page.items.single.id, '42');
|
||||
expect(page.items.single.libraryId, '7');
|
||||
});
|
||||
|
||||
test('child metadata inherits hoisted MediaContainer library section', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/metadata/show-1/children') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'librarySectionID': '9',
|
||||
'librarySectionTitle': 'TV Shows',
|
||||
'Metadata': [
|
||||
{'ratingKey': 'season-1', 'type': 'season', 'title': 'Season 1'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final children = await client.fetchChildren('show-1');
|
||||
|
||||
expect(children.single.libraryId, '9');
|
||||
expect(children.single.libraryTitle, 'TV Shows');
|
||||
});
|
||||
|
||||
test('hub content infers library section from /hubs/sections key', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/hubs/sections/7/recentlyAdded/items') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': '42', 'type': 'movie', 'title': 'Hub Movie'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final items = await client.fetchHubContent('/hubs/sections/7/recentlyAdded/items');
|
||||
|
||||
expect(items.single.id, '42');
|
||||
expect(items.single.libraryId, '7');
|
||||
});
|
||||
|
||||
test('collection page can inherit source collection library section', () async {
|
||||
final client = makeClient((request) async {
|
||||
if (request.url.path == '/library/collections/99/children') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaContainer': {
|
||||
'size': 1,
|
||||
'totalSize': 1,
|
||||
'Metadata': [
|
||||
{'ratingKey': '42', 'type': 'movie', 'title': 'Collection Movie'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('not found', 404);
|
||||
});
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchCollectionPage('99', libraryId: '7', libraryTitle: 'Movies');
|
||||
|
||||
expect(page.items.single.id, '42');
|
||||
expect(page.items.single.libraryId, '7');
|
||||
expect(page.items.single.libraryTitle, 'Movies');
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _filtersPayload() => {
|
||||
|
||||
@@ -94,5 +94,21 @@ void main() {
|
||||
expect(mode.value, TrackerLibraryFilterMode.blacklist);
|
||||
expect(ids.value, isEmpty);
|
||||
});
|
||||
|
||||
test('tracker library filter only allows unknown libraries when no filter is configured', () async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
final modePref = SettingsService.trackerFilterModePref(TrackerService.trakt);
|
||||
final idsPref = SettingsService.trackerFilterIdsPref(TrackerService.trakt);
|
||||
|
||||
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isTrue);
|
||||
|
||||
await settings.write(idsPref, ['server:blocked']);
|
||||
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse);
|
||||
|
||||
await settings.write(modePref, TrackerLibraryFilterMode.whitelist);
|
||||
await settings.write(idsPref, ['server:allowed']);
|
||||
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, null), isFalse);
|
||||
expect(settings.isLibraryAllowedForTracker(TrackerService.trakt, 'server:allowed'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,7 +377,14 @@ class _CollectionPagingClient implements MediaServerClient {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(String collectionId, {int? start, int? size, abort}) async {
|
||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||
String collectionId, {
|
||||
int? start,
|
||||
int? size,
|
||||
abort,
|
||||
String? libraryId,
|
||||
String? libraryTitle,
|
||||
}) async {
|
||||
collectionPageCalls.add((start: start, size: size));
|
||||
expect(collectionId, 'collection-1');
|
||||
return LibraryPage(
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/models/trakt/trakt_ids.dart';
|
||||
import 'package:plezy/services/trakt/trakt_constants.dart';
|
||||
import 'package:plezy/services/trakt/trakt_sync_queue.dart';
|
||||
|
||||
void main() {
|
||||
test('TraktSyncQueueItem preserves library context in JSON', () {
|
||||
const item = TraktSyncQueueItem(
|
||||
op: TraktSyncOp.add,
|
||||
ratingKey: 'episode-1',
|
||||
serverId: 'server-1',
|
||||
libraryGlobalKey: 'server-1:7',
|
||||
kind: TraktMediaKind.episode,
|
||||
ids: TraktIds(tvdb: 123),
|
||||
watchedAtIso: '2026-05-12T00:00:00.000Z',
|
||||
season: 1,
|
||||
number: 2,
|
||||
);
|
||||
|
||||
final decoded = TraktSyncQueueItem.fromJson(item.toJson());
|
||||
|
||||
expect(decoded.libraryGlobalKey, 'server-1:7');
|
||||
expect(decoded.incrementAttempts().libraryGlobalKey, 'server-1:7');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user