fix(trackers): preserve Plex library context

This commit is contained in:
edde746
2026-05-12 22:46:38 +02:00
parent 3a01fa25a7
commit de129fbb0a
30 changed files with 845 additions and 119 deletions
+2
View File
@@ -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
+18 -4
View File
@@ -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;
+15 -5
View File
@@ -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,
+17 -3
View File
@@ -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) {
+65 -23
View File
@@ -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;
+60 -7
View File
@@ -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);
+26
View File
@@ -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;
}
+16 -2
View File
@@ -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
View File
@@ -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();
}
+6 -3
View File
@@ -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;
}
+28 -18
View File
@@ -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;
+2 -2
View File
@@ -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);
+10 -2
View File
@@ -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;
+5
View File
@@ -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(),
+12 -2
View File
@@ -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),
+16 -3
View File
@@ -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,
);
}
+2
View File
@@ -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,
);
+6 -2
View File
@@ -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) {
+27
View File
@@ -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();
}
+2 -1
View File
@@ -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
+8
View File
@@ -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,
);
+6 -4
View File
@@ -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(
+36 -1
View File
@@ -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
+2
View File
@@ -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() => {
+16
View File
@@ -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);
});
});
}
+8 -1
View File
@@ -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(
+25
View File
@@ -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');
});
}