refactor(downloads): centralize metadata merging

This commit is contained in:
edde746
2026-07-12 08:42:19 +02:00
parent 0db6eb6f2e
commit dca51a752a
5 changed files with 95 additions and 28 deletions
+14
View File
@@ -0,0 +1,14 @@
import 'ids.dart';
import 'media_item.dart';
/// Merge freshly fetched metadata with identity and library context already
/// known by the caller. The fetched item owns descriptive fields, while
/// existing context wins when the backend omits it.
MediaItem mergeFetchedMediaItem({required MediaItem fetched, required ServerId fallbackServerId, MediaItem? existing}) {
return fetched.copyWith(
serverId: existing?.serverId ?? fetched.serverId ?? fallbackServerId,
serverName: existing?.serverName ?? fetched.serverName,
libraryId: fetched.libraryId ?? existing?.libraryId,
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
);
}
+8 -14
View File
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../i18n/strings.g.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_item_merge.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../media/media_version.dart';
@@ -1128,7 +1129,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
if (!_downloadManager.downloadsSupported) return false;
final ownerProfileId = claimForProfileId ?? _requireActiveProfileId();
final globalKey = metadata.globalKey;
var metadataToStore = metadata.serverId == null ? metadata.copyWith(serverId: client.serverId) : metadata;
final globalKey = metadataToStore.globalKey;
// Don't duplicate the physical download. If another profile already owns
// the shared row, claiming it makes it visible for the owning profile.
@@ -1152,18 +1154,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
// Skip the fetch when offline — it would just fail. The partial metadata
// from whatever hub/grid invoked the queue is good enough to enqueue; the
// actual video URL resolves later when we're back online.
MediaItem metadataToStore = metadata;
if (_offlineSource?.isOffline ?? false) {
appLogger.d('Offline — using partial metadata for ${metadata.id}');
} else {
try {
final fullMetadata = await client.fetchItem(metadata.id);
if (fullMetadata != null) {
metadataToStore = fullMetadata.copyWith(
serverId: metadata.serverId ?? fullMetadata.serverId,
serverName: metadata.serverName ?? fullMetadata.serverName,
libraryId: fullMetadata.libraryId ?? metadata.libraryId,
libraryTitle: fullMetadata.libraryTitle ?? metadata.libraryTitle,
metadataToStore = mergeFetchedMediaItem(
fetched: fullMetadata,
existing: metadataToStore,
fallbackServerId: client.serverId,
);
}
} catch (e) {
@@ -1253,13 +1253,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
try {
final fetched = await client.fetchItem(ratingKey);
if (fetched != null) {
final existing = metadata;
metadata = fetched.copyWith(
serverId: existing?.serverId ?? fetched.serverId ?? serverId,
serverName: existing?.serverName ?? fetched.serverName,
libraryId: fetched.libraryId ?? existing?.libraryId,
libraryTitle: fetched.libraryTitle ?? existing?.libraryTitle,
);
metadata = mergeFetchedMediaItem(fetched: fetched, existing: metadata, fallbackServerId: serverId);
context.hydratedMetadataKeys.add(globalKey);
fetchedFreshMetadata = true;
}
+2 -14
View File
@@ -14,6 +14,7 @@ import '../database/download_operations.dart';
import '../media/download_resolution.dart';
import '../media/media_backend.dart';
import '../media/media_item.dart';
import '../media/media_item_merge.dart';
import '../media/media_item_types.dart';
import '../media/media_kind.dart';
import '../media/media_server_client.dart';
@@ -776,7 +777,7 @@ class DownloadManagerService {
try {
final fetched = await client.fetchItem(ratingKey);
if (fetched != null) {
metadata = _mergeFetchedRepairMetadata(serverId: serverId, cached: cached, fetched: fetched);
metadata = mergeFetchedMediaItem(fallbackServerId: serverId, existing: cached, fetched: fetched);
await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id);
}
} catch (e) {
@@ -792,19 +793,6 @@ class DownloadManagerService {
return metadata.serverId == null ? metadata.copyWith(serverId: serverId) : metadata;
}
MediaItem _mergeFetchedRepairMetadata({
required ServerId serverId,
required MediaItem? cached,
required MediaItem fetched,
}) {
return fetched.copyWith(
serverId: cached?.serverId ?? fetched.serverId ?? serverId,
serverName: cached?.serverName ?? fetched.serverName,
libraryId: fetched.libraryId ?? cached?.libraryId,
libraryTitle: fetched.libraryTitle ?? cached?.libraryTitle,
);
}
Future<void> _backfillArtworkPath(DownloadedMediaItem row, MediaItem metadata) async {
final thumbPath = metadata.thumbPath;
if (thumbPath == null || thumbPath.isEmpty) return;
+49
View File
@@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_item_merge.dart';
import 'package:plezy/media/media_kind.dart';
void main() {
MediaItem item({String? serverId, String? serverName, String? libraryId, String? libraryTitle}) => MediaItem(
id: 'item',
backend: MediaBackend.plex,
kind: MediaKind.movie,
serverId: serverId,
serverName: serverName,
libraryId: libraryId,
libraryTitle: libraryTitle,
);
test('uses the authoritative fallback when both items omit server identity', () {
final merged = mergeFetchedMediaItem(fetched: item(), fallbackServerId: ServerId('fallback'));
expect(merged.serverId, 'fallback');
expect(merged.globalKey, 'fallback:item');
});
test('preserves existing identity while preferring fetched library context', () {
final merged = mergeFetchedMediaItem(
fetched: item(serverId: 'fetched', serverName: 'Fetched', libraryId: 'new-lib', libraryTitle: 'New'),
existing: item(serverId: 'existing', serverName: 'Existing', libraryId: 'old-lib', libraryTitle: 'Old'),
fallbackServerId: ServerId('fallback'),
);
expect(merged.serverId, 'existing');
expect(merged.serverName, 'Existing');
expect(merged.libraryId, 'new-lib');
expect(merged.libraryTitle, 'New');
});
test('fills missing fetched library context from the existing item', () {
final merged = mergeFetchedMediaItem(
fetched: item(),
existing: item(libraryId: 'old-lib', libraryTitle: 'Old'),
fallbackServerId: ServerId('fallback'),
);
expect(merged.libraryId, 'old-lib');
expect(merged.libraryTitle, 'Old');
});
}
@@ -563,6 +563,28 @@ void main() {
p.dispose();
});
test('queueDownload applies the client server id before checking existing downloads', () async {
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
await p.ensureInitialized();
p.debugSeedState(
downloads: {'srv:1': const DownloadProgress(globalKey: 'srv:1', status: DownloadStatus.completed)},
metadata: {'srv:1': movie},
ownedDownloadKeys: const {},
);
final count = await p.queueDownload(
movie.copyWith(serverId: null),
_ScopedTestClient(serverId: ServerId('srv'), scopedServerId: 'srv'),
);
expect(count, 1);
expect(p.downloads.keys, ['srv:1']);
expect(p.downloads, isNot(contains('1')));
expect(await db.getDownloadOwnerKeysForProfile('test-profile'), {'srv:1'});
p.dispose();
});
test('queueDownload expands an album into its tracks via fetchPlayableDescendants', () async {
final album = MediaItem(
id: 'album-1',