From 09f5121732fffc50cb00fa9de83561240beeffc7 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:33:12 +0200 Subject: [PATCH] feat(metadata): add Jellyfin edit support --- lib/media/media_server_client.dart | 6 +- lib/media/server_capabilities.dart | 6 +- .../jellyfin_metadata_edit_adapter.dart | 417 ++++++++ lib/metadata_edit/metadata_edit_adapters.dart | 18 + lib/metadata_edit/metadata_edit_models.dart | 210 ++++ .../plex_metadata_edit_adapter.dart | 465 +++++++++ lib/screens/metadata_edit_screen.dart | 582 +++++++++++ lib/screens/plex_metadata_edit_screen.dart | 902 ------------------ lib/services/jellyfin_client.dart | 5 +- .../jellyfin_client/parts/metadata_edit.dart | 86 ++ lib/services/plex_client.dart | 44 +- lib/utils/dialogs.dart | 6 +- lib/utils/provider_extensions.dart | 8 +- lib/widgets/artwork_picker_dialog.dart | 206 ---- lib/widgets/media_context_menu.dart | 26 +- .../jellyfin_metadata_edit_adapter_test.dart | 130 +++ .../metadata_edit_models_test.dart | 110 +++ test/services/jellyfin_client_urls_test.dart | 131 +++ test/widgets/media_context_menu_test.dart | 35 + 19 files changed, 2245 insertions(+), 1148 deletions(-) create mode 100644 lib/metadata_edit/jellyfin_metadata_edit_adapter.dart create mode 100644 lib/metadata_edit/metadata_edit_adapters.dart create mode 100644 lib/metadata_edit/metadata_edit_models.dart create mode 100644 lib/metadata_edit/plex_metadata_edit_adapter.dart create mode 100644 lib/screens/metadata_edit_screen.dart delete mode 100644 lib/screens/plex_metadata_edit_screen.dart create mode 100644 lib/services/jellyfin_client/parts/metadata_edit.dart delete mode 100644 lib/widgets/artwork_picker_dialog.dart create mode 100644 test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart create mode 100644 test/metadata_edit/metadata_edit_models_test.dart diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index d3316178..48e40c0c 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -31,9 +31,9 @@ import 'server_capabilities.dart'; /// /// ## Naming /// -/// Read methods use a `fetch*` prefix. Plex-only operations that have no -/// Jellyfin equivalent (DVR tuning, metadata edit, match) live on -/// [PlexClient] directly under their original `get*` / verb names. +/// Read methods use a `fetch*` prefix. Backend-specific operations that do not +/// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata +/// edit adapters) live on concrete clients or feature modules. /// /// ## Error contract (write methods) /// diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index f4fcfafa..a5b64e27 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -88,9 +88,7 @@ class ServerCapabilities { /// uses Plex-shaped session/metadata. final bool discordRpc; - /// Server exposes a metadata edit endpoint (Plex - /// `/library/metadata/{id}` PUT). Hides the "Manage" affordances when - /// false. + /// Server exposes metadata edit endpoints. Hides edit affordances when false. final bool richMetadataEdit; /// How the alpha-jump bar should behave for this backend's libraries. @@ -176,7 +174,7 @@ class ServerCapabilities { endpointFailover: true, offlineWatchQueue: false, discordRpc: false, - richMetadataEdit: false, + richMetadataEdit: true, alphaBar: AlphaBarMode.nameStartsWithFilter, scrubThumbnails: true, folderGrouping: true, diff --git a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart new file mode 100644 index 00000000..d2124cca --- /dev/null +++ b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart @@ -0,0 +1,417 @@ +import '../i18n/strings.g.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import '../services/jellyfin_client.dart'; +import '../utils/jellyfin_time.dart'; +import 'metadata_edit_models.dart'; + +class JellyfinMetadataEditAdapter extends MetadataEditAdapter { + final JellyfinClient client; + + JellyfinMetadataEditAdapter(this.client); + + @override + MediaBackend get backend => MediaBackend.jellyfin; + + @override + MediaServerClient get mediaClient => client; + + @override + bool supportsKind(MediaKind kind) => + kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season || kind == MediaKind.episode; + + @override + Future load(MediaItem item) async { + final raw = await client.fetchEditableMetadataItem(item.id); + if (raw == null) { + throw StateError('Editable Jellyfin metadata item is unavailable'); + } + final values = {}; + _writeCommonValues(values, raw, item); + _writeArtworkValues(values, item); + return MetadataEditDraft(sourceItem: item, currentItem: item, values: values, extras: {'raw': raw}); + } + + @override + List buildSchema(MetadataEditDraft draft) { + final kind = draft.sourceItem.kind; + return [ + MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), + if (_tagFields(kind).isNotEmpty) + MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), + MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), + ]; + } + + @override + Future save(MetadataEditDraft draft) async { + final raw = draft.extras['raw']; + if (raw is! Map) return false; + final dto = Map.from(raw); + + dto['ProviderIds'] = _stringMap(dto['ProviderIds']); + dto['Tags'] = _stringList(dto['Tags']); + dto['Genres'] = _stringList(dto['Genres']); + dto['People'] = _mapList(dto['People']); + dto['Studios'] = _mapList(dto['Studios']); + dto['LockedFields'] = _stringList(dto['LockedFields']); + dto['LockData'] = dto['LockData'] == true; + dto.remove('Trickplay'); + + if (draft.fieldChanged('title')) dto['Name'] = (draft.value('title') ?? '').trim(); + _setChangedString(dto, draft, 'titleSort', 'ForcedSortName'); + _setChangedString(dto, draft, 'originalTitle', 'OriginalTitle'); + _setChangedString(dto, draft, 'contentRating', 'OfficialRating'); + _setChangedString(dto, draft, 'summary', 'Overview'); + + if (draft.fieldChanged('originallyAvailableAt')) { + final value = draft.value('originallyAvailableAt') ?? ''; + dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']); + } + if (_fieldChanged(draft, 'studio')) { + dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio'])); + } + if (draft.fieldChanged('tagline')) { + final tagline = metadataEmptyToNull(draft.value('tagline')); + final existing = _stringList(dto['Taglines']); + dto['Taglines'] = tagline == null ? [] : [tagline, ...existing.skip(1)]; + } + if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']); + if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']); + if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']); + + var peopleChanged = false; + var people = _mapList(dto['People']); + if (_fieldChanged(draft, 'director')) { + people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director'])); + peopleChanged = true; + } + if (_fieldChanged(draft, 'writer')) { + people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer'])); + peopleChanged = true; + } + if (_fieldChanged(draft, 'producer')) { + people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer'])); + peopleChanged = true; + } + if (peopleChanged) dto['People'] = people; + + final success = await client.updateMetadataItem(draft.sourceItem.id, dto); + if (success) { + draft.extras['raw'] = dto; + draft.acceptChanges(); + } + return success; + } + + @override + Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async { + final imageType = field.artwork?.key; + if (imageType == null) return const []; + final result = await client.getRemoteImages(draft.sourceItem.id, imageType: imageType); + final images = result['Images']; + if (images is! List) return const []; + return images + .whereType>() + .map((image) { + final url = image['Url'] as String? ?? ''; + final thumb = image['ThumbnailUrl'] as String?; + return MetadataArtworkOption( + id: url, + thumbnailPath: (thumb == null || thumb.isEmpty) ? url : thumb, + sourceUrl: url, + provider: image['ProviderName'] as String?, + width: image['Width'] as int?, + height: image['Height'] as int?, + ); + }) + .where((image) => image.sourceUrl.isNotEmpty) + .toList(); + } + + @override + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { + return applyArtworkFromUrl(draft, field, option.sourceUrl); + } + + @override + Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { + final imageType = field.artwork?.key; + if (imageType == null || url.trim().isEmpty) return false; + return client.downloadRemoteImage(draft.sourceItem.id, imageType: imageType, imageUrl: url.trim()); + } + + @override + Future uploadArtwork( + MetadataEditDraft draft, + MetadataEditField field, + List bytes, { + String? fileName, + }) async { + final imageType = field.artwork?.key; + if (imageType == null || bytes.isEmpty) return false; + return client.uploadItemImage( + draft.sourceItem.id, + imageType: imageType, + bytes: bytes, + contentType: _imageContentType(bytes, fileName), + ); + } + + @override + void syncReloadedItem(MetadataEditDraft draft, MediaItem item) { + draft.currentItem = item; + _writeArtworkValues(draft.values, item); + } + + void _writeCommonValues(Map values, Map raw, MediaItem item) { + values['title'] = raw['Name'] as String? ?? item.title ?? ''; + values['titleSort'] = raw['ForcedSortName'] as String? ?? ''; + values['originalTitle'] = raw['OriginalTitle'] as String? ?? item.originalTitle ?? ''; + values['originallyAvailableAt'] = + jellyfinIsoToYmd(raw['PremiereDate'] as String?) ?? item.originallyAvailableAt ?? ''; + values['contentRating'] = raw['OfficialRating'] as String? ?? item.contentRating ?? ''; + final studios = _nameList(raw['Studios']); + values['studio'] = studios.isNotEmpty ? studios : metadataStringList(item.studio); + values['tagline'] = metadataFirstString(raw['Taglines']).isNotEmpty + ? metadataFirstString(raw['Taglines']) + : item.tagline ?? ''; + values['summary'] = raw['Overview'] as String? ?? item.summary ?? ''; + values['genre'] = _stringList(raw['Genres']); + values['director'] = _peopleByType(raw['People'], 'Director'); + values['writer'] = _peopleByType(raw['People'], 'Writer'); + values['producer'] = _peopleByType(raw['People'], 'Producer'); + values['country'] = _stringList(raw['ProductionLocations']); + values['label'] = _stringList(raw['Tags']); + } + + void _writeArtworkValues(Map values, MediaItem item) { + values['artwork:Primary'] = item.thumbPath; + values['artwork:Backdrop'] = item.artPath; + values['artwork:Logo'] = item.clearLogoPath; + } + + List _basicFields(MediaKind kind) { + return [ + MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField( + id: 'originallyAvailableAt', + label: t.metadataEdit.releaseDate, + type: MetadataEditFieldType.date, + ), + if (kind != MediaKind.season) + MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.stringList), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), + MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), + ]; + } + + List _tagFields(MediaKind kind) { + MetadataEditField tag(String id, String label) => + MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); + return switch (kind) { + MediaKind.movie || MediaKind.show => [ + tag('genre', t.metadataEdit.genre), + tag('director', t.metadataEdit.director), + tag('writer', t.metadataEdit.writer), + tag('producer', t.metadataEdit.producer), + tag('country', t.metadataEdit.country), + tag('label', t.metadataEdit.label), + ], + MediaKind.episode => [tag('director', t.metadataEdit.director), tag('writer', t.metadataEdit.writer)], + _ => const [], + }; + } + + List _artworkFields(MediaKind kind) { + final fields = [ + _artworkField('Primary', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), + ]; + if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { + fields.add( + _artworkField('Backdrop', t.metadataEdit.background, t.metadataEdit.selectBackground, 80, 45, 2, 16 / 9), + ); + } + if (kind == MediaKind.movie || kind == MediaKind.show) { + fields.add( + _artworkField( + 'Logo', + t.metadataEdit.logo, + t.metadataEdit.selectLogo, + 80, + 32, + 2, + 2.5, + fit: MetadataArtworkFit.contain, + ), + ); + } + return fields; + } + + MetadataEditField _artworkField( + String key, + String label, + String title, + double width, + double height, + int columns, + double aspectRatio, { + MetadataArtworkFit fit = MetadataArtworkFit.cover, + }) { + return MetadataEditField( + id: 'artwork:$key', + label: label, + type: MetadataEditFieldType.artwork, + saveMode: MetadataEditSaveMode.immediate, + artwork: MetadataArtworkConfig( + key: key, + selectTitle: title, + previewWidth: width, + previewHeight: height, + gridColumns: columns, + gridAspectRatio: aspectRatio, + fit: fit, + ), + ); + } + + void _setChangedString(Map dto, MetadataEditDraft draft, String fieldId, String dtoKey) { + if (!draft.fieldChanged(fieldId)) return; + dto[dtoKey] = metadataEmptyToNull(draft.value(fieldId)); + } + + bool _fieldChanged(MetadataEditDraft draft, String fieldId) { + for (final section in schemaFor(draft)) { + for (final field in section.fields) { + if (field.id == fieldId) return metadataEditFieldChanged(draft, field); + } + } + return draft.fieldChanged(fieldId); + } +} + +List _stringList(Object? value) => metadataStringList(value); + +Map _stringMap(Object? value) { + if (value is! Map) return {}; + return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')); +} + +List> _mapList(Object? value) { + if (value is! List) return >[]; + return value.whereType().map((item) => Map.from(item)).toList(); +} + +List _nameList(Object? value) { + return _mapList(value) + .map((item) => item['Name'] as String?) + .whereType() + .where((name) => name.trim().isNotEmpty) + .map((name) => name.trim()) + .toList(); +} + +List _peopleByType(Object? value, String type) { + return _mapList(value) + .where((person) => (person['Type'] as String?)?.toLowerCase() == type.toLowerCase()) + .map((person) => person['Name'] as String?) + .whereType() + .where((name) => name.trim().isNotEmpty) + .map((name) => name.trim()) + .toList(); +} + +List> _replacePeopleByType(List> people, String type, List names) { + final lowerType = type.toLowerCase(); + final existing = people.where((person) => (person['Type'] as String?)?.toLowerCase() == lowerType).toList(); + final used = {}; + return [ + ...people.where((person) => (person['Type'] as String?)?.toLowerCase() != lowerType), + ...names.map((name) => _preserveNamedMap(existing, used, name, type: type)), + ]; +} + +List> _replaceNamePairs(List> existing, List names) { + final used = {}; + return names.map((name) => _preserveNamedMap(existing, used, name)).toList(); +} + +Map _preserveNamedMap( + List> existing, + Set used, + String name, { + String? type, +}) { + final trimmed = name.trim(); + final lowerName = trimmed.toLowerCase(); + for (var i = 0; i < existing.length; i++) { + if (used.contains(i)) continue; + final existingName = (existing[i]['Name'] as String?)?.trim().toLowerCase(); + if (existingName == lowerName) { + used.add(i); + final preserved = {...existing[i], 'Name': trimmed}; + if (type != null) preserved['Type'] = type; + return preserved; + } + } + final created = {'Name': trimmed}; + if (type != null) created['Type'] = type; + return created; +} + +String? _jellyfinDate(String value, Object? originalIso) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + final original = originalIso as String?; + if (original != null && original.startsWith(trimmed)) { + final tIndex = original.indexOf('T'); + if (tIndex >= 0) return '$trimmed${original.substring(tIndex)}'; + } + return trimmed; +} + +String _imageContentType(List bytes, String? fileName) { + if (bytes.length >= 8 && + bytes[0] == 0x89 && + bytes[1] == 0x50 && + bytes[2] == 0x4e && + bytes[3] == 0x47 && + bytes[4] == 0x0d && + bytes[5] == 0x0a && + bytes[6] == 0x1a && + bytes[7] == 0x0a) { + return 'image/png'; + } + if (bytes.length >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) { + return 'image/jpeg'; + } + if (bytes.length >= 6) { + final header = String.fromCharCodes(bytes.take(6)); + if (header == 'GIF87a' || header == 'GIF89a') return 'image/gif'; + } + if (bytes.length >= 12) { + final riff = String.fromCharCodes(bytes.take(4)); + final webp = String.fromCharCodes(bytes.skip(8).take(4)); + if (riff == 'RIFF' && webp == 'WEBP') return 'image/webp'; + } + if (bytes.length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4d) return 'image/bmp'; + + final lowerName = fileName?.toLowerCase() ?? ''; + if (lowerName.endsWith('.png')) return 'image/png'; + if (lowerName.endsWith('.jpg') || lowerName.endsWith('.jpeg')) return 'image/jpeg'; + if (lowerName.endsWith('.gif')) return 'image/gif'; + if (lowerName.endsWith('.webp')) return 'image/webp'; + if (lowerName.endsWith('.bmp')) return 'image/bmp'; + return 'image/jpeg'; +} diff --git a/lib/metadata_edit/metadata_edit_adapters.dart b/lib/metadata_edit/metadata_edit_adapters.dart new file mode 100644 index 00000000..0eeb15d6 --- /dev/null +++ b/lib/metadata_edit/metadata_edit_adapters.dart @@ -0,0 +1,18 @@ +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import '../services/jellyfin_client.dart'; +import '../services/plex_client.dart'; +import 'jellyfin_metadata_edit_adapter.dart'; +import 'metadata_edit_models.dart'; +import 'plex_metadata_edit_adapter.dart'; + +MetadataEditAdapter? metadataEditAdapterFor(MediaServerClient client) { + if (client is PlexClient) return PlexMetadataEditAdapter(client); + if (client is JellyfinClient) return JellyfinMetadataEditAdapter(client); + return null; +} + +bool supportsMetadataEdit(MediaServerClient? client, MediaKind? kind) { + if (client == null || kind == null || !client.capabilities.richMetadataEdit) return false; + return metadataEditAdapterFor(client)?.supportsKind(kind) ?? false; +} diff --git a/lib/metadata_edit/metadata_edit_models.dart b/lib/metadata_edit/metadata_edit_models.dart new file mode 100644 index 00000000..207bd48f --- /dev/null +++ b/lib/metadata_edit/metadata_edit_models.dart @@ -0,0 +1,210 @@ +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; + +enum MetadataEditFieldType { text, multilineText, date, stringList, choice, artwork } + +enum MetadataEditSaveMode { draft, immediate } + +enum MetadataArtworkFit { cover, contain } + +class MetadataEditOption { + final String value; + final String label; + + const MetadataEditOption({required this.value, required this.label}); +} + +class MetadataArtworkConfig { + final String key; + final String selectTitle; + final double previewWidth; + final double previewHeight; + final int gridColumns; + final double gridAspectRatio; + final MetadataArtworkFit fit; + + const MetadataArtworkConfig({ + required this.key, + required this.selectTitle, + required this.previewWidth, + required this.previewHeight, + required this.gridColumns, + required this.gridAspectRatio, + this.fit = MetadataArtworkFit.cover, + }); +} + +class MetadataEditField { + final String id; + final String label; + final MetadataEditFieldType type; + final MetadataEditSaveMode saveMode; + final List options; + final MetadataArtworkConfig? artwork; + + const MetadataEditField({ + required this.id, + required this.label, + required this.type, + this.saveMode = MetadataEditSaveMode.draft, + this.options = const [], + this.artwork, + }); +} + +class MetadataEditSection { + final String id; + final String title; + final List fields; + + const MetadataEditSection({required this.id, required this.title, required this.fields}); +} + +class MetadataArtworkOption { + final String id; + final String thumbnailPath; + final String sourceUrl; + final bool selected; + final String? provider; + final int? width; + final int? height; + + const MetadataArtworkOption({ + required this.id, + required this.thumbnailPath, + required this.sourceUrl, + this.selected = false, + this.provider, + this.width, + this.height, + }); +} + +class MetadataEditDraft { + final MediaItem sourceItem; + MediaItem currentItem; + final Map values; + final Map originalValues; + final Map extras; + List? cachedSchema; + + MetadataEditDraft({ + required this.sourceItem, + required this.currentItem, + required this.values, + Map? originalValues, + Map? extras, + }) : originalValues = originalValues ?? Map.from(values), + extras = extras ?? {}; + + T? value(String id) => values[id] as T?; + + void setValue(String id, Object? value) { + values[id] = value; + } + + bool fieldChanged(String id) => !metadataEditValueEquals(values[id], originalValues[id]); + + void acceptChanges() { + originalValues + ..clear() + ..addAll(Map.from(values)); + } +} + +abstract class MetadataEditAdapter { + MediaBackend get backend; + MediaServerClient get mediaClient; + + bool supportsKind(MediaKind kind); + + Future load(MediaItem item); + + List buildSchema(MetadataEditDraft draft); + + List schemaFor(MetadataEditDraft draft) => draft.cachedSchema ??= buildSchema(draft); + + bool hasChanges(MetadataEditDraft draft) { + final draftFields = schemaFor( + draft, + ).expand((section) => section.fields).where((field) => field.saveMode == MetadataEditSaveMode.draft); + return draftFields.any((field) => metadataEditFieldChanged(draft, field)); + } + + Future save(MetadataEditDraft draft); + + Future saveImmediateField(MetadataEditDraft draft, MetadataEditField field, Object? value) async { + draft.setValue(field.id, value); + final success = await save(draft); + if (success) draft.acceptChanges(); + return success; + } + + Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); + + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option); + + Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url); + + Future uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List bytes, {String? fileName}); + + Future reloadItem(MetadataEditDraft draft) => mediaClient.fetchItem(draft.sourceItem.id); + + void syncReloadedItem(MetadataEditDraft draft, MediaItem item) { + draft.currentItem = item; + } +} + +bool metadataEditValueEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!metadataEditValueEquals(a[i], b[i])) return false; + } + return true; + } + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final key in a.keys) { + if (!b.containsKey(key) || !metadataEditValueEquals(a[key], b[key])) return false; + } + return true; + } + return a == b; +} + +bool metadataEditFieldChanged(MetadataEditDraft draft, MetadataEditField field) { + final current = draft.values[field.id]; + final original = draft.originalValues[field.id]; + return field.type == MetadataEditFieldType.stringList + ? !metadataEditStringListEquals(current, original) + : !metadataEditValueEquals(current, original); +} + +bool metadataEditStringListEquals(Object? a, Object? b) { + final left = metadataStringList(a).toSet(); + final right = metadataStringList(b).toSet(); + if (left.length != right.length) return false; + return left.every(right.contains); +} + +List metadataStringList(Object? value) { + if (value is List) { + return value.whereType().where((v) => v.trim().isNotEmpty).map((v) => v.trim()).toList(); + } + if (value is String && value.trim().isNotEmpty) return [value.trim()]; + return []; +} + +String metadataFirstString(Object? value) { + final list = metadataStringList(value); + return list.isEmpty ? '' : list.first; +} + +String? metadataEmptyToNull(String? value) { + final trimmed = value?.trim(); + return trimmed == null || trimmed.isEmpty ? null : trimmed; +} diff --git a/lib/metadata_edit/plex_metadata_edit_adapter.dart b/lib/metadata_edit/plex_metadata_edit_adapter.dart new file mode 100644 index 00000000..3052293e --- /dev/null +++ b/lib/metadata_edit/plex_metadata_edit_adapter.dart @@ -0,0 +1,465 @@ +import '../i18n/strings.g.dart'; +import '../media/media_backend.dart'; +import '../media/media_item.dart'; +import '../media/media_kind.dart'; +import '../media/media_server_client.dart'; +import '../services/plex_client.dart'; +import '../utils/language_codes.dart'; +import 'metadata_edit_models.dart'; + +class PlexMetadataEditAdapter extends MetadataEditAdapter { + final PlexClient client; + + PlexMetadataEditAdapter(this.client); + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + MediaServerClient get mediaClient => client; + + @override + bool supportsKind(MediaKind kind) => + kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.season || kind == MediaKind.episode; + + @override + Future load(MediaItem item) async { + MediaItem fullItem = item; + if (item.summary == null || item.libraryId == null) { + fullItem = await client.fetchItem(item.id) ?? item; + } + + final values = {}; + _writeCommonValues(values, fullItem); + _writeArtworkValues(values, fullItem); + _writePrefValues(values, fullItem); + + return MetadataEditDraft(sourceItem: item, currentItem: fullItem, values: values); + } + + @override + List buildSchema(MetadataEditDraft draft) { + final kind = draft.sourceItem.kind; + return [ + MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), + if (_tagFields(kind).isNotEmpty) + MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), + MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), + if (kind != MediaKind.episode) + MetadataEditSection(id: 'advanced', title: t.metadataEdit.advancedSettings, fields: _advancedFields(kind)), + ]; + } + + @override + Future save(MetadataEditDraft draft) async { + final sectionId = int.tryParse(draft.currentItem.libraryId ?? draft.sourceItem.libraryId ?? ''); + if (sectionId == null) return false; + + Map current, List original})>? tagChanges; + for (final field in _tagFields(draft.sourceItem.kind)) { + final current = metadataStringList(draft.values[field.id]); + final original = metadataStringList(draft.originalValues[field.id]); + if (metadataEditFieldChanged(draft, field)) { + tagChanges ??= {}; + tagChanges[field.id] = (current: current, original: original); + } + } + + final success = await client.updateMetadata( + sectionId: sectionId, + ratingKey: draft.sourceItem.id, + typeNumber: _plexTypeNumberForKind(draft.sourceItem.kind), + title: _changedString(draft, 'title'), + titleSort: _changedString(draft, 'titleSort'), + originalTitle: _changedString(draft, 'originalTitle'), + originallyAvailableAt: _changedString(draft, 'originallyAvailableAt'), + contentRating: _changedString(draft, 'contentRating'), + studio: _changedString(draft, 'studio'), + tagline: _changedString(draft, 'tagline'), + summary: _changedString(draft, 'summary'), + tagChanges: tagChanges, + ); + if (success) draft.acceptChanges(); + return success; + } + + @override + Future saveImmediateField(MetadataEditDraft draft, MetadataEditField field, Object? value) async { + final prefKey = _prefKey(field.id); + if (prefKey == null) return super.saveImmediateField(draft, field, value); + final success = await client.updateMetadataPrefs(draft.sourceItem.id, {prefKey: (value as String?) ?? ''}); + if (success) { + draft.originalValues[field.id] = value; + } + return success; + } + + @override + Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async { + final element = field.artwork?.key; + if (element == null) return const []; + final artwork = await client.getAvailableArtwork(draft.sourceItem.id, element); + return artwork + .map((item) { + final source = item['ratingKey'] as String? ?? item['key'] as String? ?? ''; + final thumb = item['thumb'] as String? ?? source; + return MetadataArtworkOption( + id: source, + thumbnailPath: thumb, + sourceUrl: source, + selected: item['selected'] == true, + ); + }) + .where((item) => item.sourceUrl.isNotEmpty) + .toList(); + } + + @override + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { + return applyArtworkFromUrl(draft, field, option.sourceUrl); + } + + @override + Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { + final element = field.artwork?.key; + if (element == null || url.trim().isEmpty) return false; + return client.setArtworkFromUrl(draft.sourceItem.id, element, url.trim()); + } + + @override + Future uploadArtwork( + MetadataEditDraft draft, + MetadataEditField field, + List bytes, { + String? fileName, + }) async { + final element = field.artwork?.key; + if (element == null || bytes.isEmpty) return false; + return client.uploadArtwork(draft.sourceItem.id, element, bytes); + } + + @override + void syncReloadedItem(MetadataEditDraft draft, MediaItem item) { + draft.currentItem = item; + _writeArtworkValues(draft.values, item); + } + + void _writeCommonValues(Map values, MediaItem item) { + values['title'] = item.title ?? ''; + values['titleSort'] = item.titleSort ?? ''; + values['originalTitle'] = item.originalTitle ?? ''; + values['originallyAvailableAt'] = item.originallyAvailableAt ?? ''; + values['contentRating'] = item.contentRating ?? ''; + values['studio'] = item.studio ?? ''; + values['tagline'] = item.tagline ?? ''; + values['summary'] = item.summary ?? ''; + values['genre'] = List.of(item.genres ?? const []); + values['director'] = List.of(item.directors ?? const []); + values['writer'] = List.of(item.writers ?? const []); + values['producer'] = List.of(item.producers ?? const []); + values['country'] = List.of(item.countries ?? const []); + values['collection'] = List.of(item.collections ?? const []); + values['label'] = List.of(item.labels ?? const []); + values['style'] = List.of(item.styles ?? const []); + values['mood'] = List.of(item.moods ?? const []); + } + + void _writeArtworkValues(Map values, MediaItem item) { + values['artwork:posters'] = item.thumbPath; + values['artwork:arts'] = item.artPath; + values['artwork:clearLogos'] = item.clearLogoPath; + values['artwork:squareArts'] = item.backgroundSquarePath; + } + + void _writePrefValues(Map values, MediaItem item) { + values['pref:episodeSort'] = '-1'; + values['pref:autoDeletionItemPolicyUnwatchedLibrary'] = '0'; + values['pref:autoDeletionItemPolicyWatchedLibrary'] = '0'; + values['pref:flattenSeasons'] = '-1'; + values['pref:showOrdering'] = ''; + values['pref:languageOverride'] = ''; + values['pref:useOriginalTitle'] = '-1'; + values['pref:audioLanguage'] = item.audioLanguage ?? ''; + values['pref:subtitleLanguage'] = item is PlexMediaItem ? item.subtitleLanguage ?? '' : ''; + values['pref:subtitleMode'] = item is PlexMediaItem ? (item.subtitleMode?.toString() ?? '-1') : '-1'; + } + + List _basicFields(MediaKind kind) { + return [ + MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField( + id: 'originallyAvailableAt', + label: t.metadataEdit.releaseDate, + type: MetadataEditFieldType.date, + ), + if (kind != MediaKind.season) + MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), + MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), + ]; + } + + List _tagFields(MediaKind kind) { + MetadataEditField tag(String id, String label) => + MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); + return switch (kind) { + MediaKind.movie || MediaKind.show => [ + tag('genre', t.metadataEdit.genre), + tag('director', t.metadataEdit.director), + tag('writer', t.metadataEdit.writer), + tag('producer', t.metadataEdit.producer), + tag('country', t.metadataEdit.country), + tag('collection', t.metadataEdit.collection), + tag('label', t.metadataEdit.label), + ], + MediaKind.episode => [tag('director', t.metadataEdit.director), tag('writer', t.metadataEdit.writer)], + MediaKind.artist => [ + tag('genre', t.metadataEdit.genre), + tag('style', t.metadataEdit.style), + tag('mood', t.metadataEdit.mood), + tag('country', t.metadataEdit.country), + tag('collection', t.metadataEdit.collection), + ], + MediaKind.album => [ + tag('genre', t.metadataEdit.genre), + tag('style', t.metadataEdit.style), + tag('mood', t.metadataEdit.mood), + tag('collection', t.metadataEdit.collection), + ], + _ => const [], + }; + } + + List _artworkFields(MediaKind kind) { + final fields = [ + _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), + ]; + if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { + fields.add(_artworkField('arts', t.metadataEdit.background, t.metadataEdit.selectBackground, 80, 45, 2, 16 / 9)); + } + if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.collection) { + fields.add( + _artworkField( + 'clearLogos', + t.metadataEdit.logo, + t.metadataEdit.selectLogo, + 80, + 32, + 2, + 2.5, + fit: MetadataArtworkFit.contain, + ), + ); + fields.add(_artworkField('squareArts', t.metadataEdit.squareArt, t.metadataEdit.selectSquareArt, 50, 50, 3, 1)); + } + return fields; + } + + MetadataEditField _artworkField( + String key, + String label, + String title, + double width, + double height, + int columns, + double aspectRatio, { + MetadataArtworkFit fit = MetadataArtworkFit.cover, + }) { + return MetadataEditField( + id: 'artwork:$key', + label: label, + type: MetadataEditFieldType.artwork, + saveMode: MetadataEditSaveMode.immediate, + artwork: MetadataArtworkConfig( + key: key, + selectTitle: title, + previewWidth: width, + previewHeight: height, + gridColumns: columns, + gridAspectRatio: aspectRatio, + fit: fit, + ), + ); + } + + List _advancedFields(MediaKind kind) { + final fields = []; + if (kind == MediaKind.show) { + fields.addAll([ + _choice('episodeSort', t.metadataEdit.episodeSorting, [ + MetadataEditOption(value: '-1', label: t.metadataEdit.libraryDefault), + MetadataEditOption(value: '0', label: t.metadataEdit.oldestFirst), + MetadataEditOption(value: '1', label: t.metadataEdit.newestFirst), + ]), + _choice('autoDeletionItemPolicyUnwatchedLibrary', t.metadataEdit.keep, [ + MetadataEditOption(value: '0', label: t.metadataEdit.allEpisodes), + MetadataEditOption( + value: '5', + label: t.metadataEdit.latestEpisodes(count: '5'), + ), + MetadataEditOption( + value: '3', + label: t.metadataEdit.latestEpisodes(count: '3'), + ), + MetadataEditOption(value: '1', label: t.metadataEdit.latestEpisode), + MetadataEditOption( + value: '-3', + label: t.metadataEdit.episodesAddedPastDays(count: '3'), + ), + MetadataEditOption( + value: '-7', + label: t.metadataEdit.episodesAddedPastDays(count: '7'), + ), + MetadataEditOption( + value: '-30', + label: t.metadataEdit.episodesAddedPastDays(count: '30'), + ), + ]), + _choice('autoDeletionItemPolicyWatchedLibrary', t.metadataEdit.deleteAfterPlaying, [ + MetadataEditOption(value: '0', label: t.metadataEdit.never), + MetadataEditOption(value: '1', label: t.metadataEdit.afterADay), + MetadataEditOption(value: '7', label: t.metadataEdit.afterAWeek), + MetadataEditOption(value: '30', label: t.metadataEdit.afterAMonth), + MetadataEditOption(value: '100', label: t.metadataEdit.onNextRefresh), + ]), + _choice('flattenSeasons', t.metadataEdit.seasons, [ + MetadataEditOption(value: '-1', label: t.metadataEdit.libraryDefault), + MetadataEditOption(value: '0', label: t.metadataEdit.show), + MetadataEditOption(value: '1', label: t.metadataEdit.hide), + ]), + _choice('showOrdering', t.metadataEdit.episodeOrdering, [ + MetadataEditOption(value: '', label: t.metadataEdit.libraryDefault), + MetadataEditOption(value: 'tmdbAiring', label: t.metadataEdit.tmdbAiring), + MetadataEditOption(value: 'tvdbAiring', label: t.metadataEdit.tvdbAiring), + MetadataEditOption(value: 'tvdbAbsolute', label: t.metadataEdit.tvdbAbsolute), + ]), + ]); + fields.addAll(_metadataLanguageFields(t.metadataEdit.libraryDefault)); + fields.addAll(_audioSubtitleFields(t.metadataEdit.accountDefault)); + } else if (kind == MediaKind.movie) { + fields.addAll(_metadataLanguageFields(t.metadataEdit.libraryDefault)); + } else if (kind == MediaKind.season) { + fields.addAll(_audioSubtitleFields(t.metadataEdit.seriesDefault)); + } + return fields; + } + + List _metadataLanguageFields(String defaultLabel) => [ + _choice('languageOverride', t.metadataEdit.metadataLanguage, _metadataLanguageOptions(defaultLabel)), + _choice('useOriginalTitle', t.metadataEdit.useOriginalTitle, [ + MetadataEditOption(value: '-1', label: t.metadataEdit.libraryDefault), + MetadataEditOption(value: '0', label: t.common.no), + MetadataEditOption(value: '1', label: t.common.yes), + ]), + ]; + + List _audioSubtitleFields(String defaultLabel) => [ + _choice('audioLanguage', t.metadataEdit.preferredAudioLanguage, _audioSubtitleLanguageOptions(defaultLabel)), + _choice('subtitleLanguage', t.metadataEdit.preferredSubtitleLanguage, _audioSubtitleLanguageOptions(defaultLabel)), + _choice('subtitleMode', t.metadataEdit.subtitleMode, [ + MetadataEditOption(value: '-1', label: defaultLabel), + MetadataEditOption(value: '0', label: t.metadataEdit.manuallySelected), + MetadataEditOption(value: '1', label: t.metadataEdit.shownWithForeignAudio), + MetadataEditOption(value: '2', label: t.metadataEdit.alwaysEnabled), + ]), + ]; + + MetadataEditField _choice(String prefKey, String label, List options) { + return MetadataEditField( + id: 'pref:$prefKey', + label: label, + type: MetadataEditFieldType.choice, + saveMode: MetadataEditSaveMode.immediate, + options: options, + ); + } + + String? _changedString(MetadataEditDraft draft, String id) { + if (!draft.fieldChanged(id)) return null; + return (draft.values[id] as String?) ?? ''; + } + + String? _prefKey(String fieldId) => fieldId.startsWith('pref:') ? fieldId.substring(5) : null; +} + +int _plexTypeNumberForKind(MediaKind kind) => switch (kind) { + MediaKind.movie => 1, + MediaKind.show => 2, + MediaKind.season => 3, + MediaKind.episode => 4, + MediaKind.artist => 8, + MediaKind.album => 9, + MediaKind.track => 10, + _ => 0, +}; + +const _plexLocaleCodes = [ + 'ar-SA', + 'bg-BG', + 'ca-ES', + 'zh-CN', + 'zh-HK', + 'zh-TW', + 'hr-HR', + 'cs-CZ', + 'da-DK', + 'nl-NL', + 'en-US', + 'en-AU', + 'en-CA', + 'en-GB', + 'et-EE', + 'fi-FI', + 'fr-FR', + 'fr-CA', + 'de-DE', + 'el-GR', + 'he-IL', + 'hi-IN', + 'hu-HU', + 'is-IS', + 'id-ID', + 'it-IT', + 'ja-JP', + 'ko-KR', + 'lv-LV', + 'lt-LT', + 'nb-NO', + 'fa-IR', + 'pl-PL', + 'pt-BR', + 'pt-PT', + 'ro-RO', + 'ru-RU', + 'sk-SK', + 'es-ES', + 'es-MX', + 'sv-SE', + 'th-TH', + 'tr-TR', + 'uk-UA', + 'vi-VN', +]; + +const _commonAudioSubtitleCodes = ['en', 'ja', 'fr', 'de', 'it', 'es', 'pt', 'ru', 'ar']; + +List _buildLanguageOptions(String defaultLabel, List codes) { + return [ + MetadataEditOption(value: '', label: defaultLabel), + ...codes.map((code) => MetadataEditOption(value: code, label: LanguageCodes.getDisplayName(code))), + ]; +} + +List _metadataLanguageOptions(String defaultLabel) => + _buildLanguageOptions(defaultLabel, _plexLocaleCodes); + +List _audioSubtitleLanguageOptions(String defaultLabel) => + _buildLanguageOptions(defaultLabel, [..._commonAudioSubtitleCodes, ..._plexLocaleCodes]); diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart new file mode 100644 index 00000000..4a5eff21 --- /dev/null +++ b/lib/screens/metadata_edit_screen.dart @@ -0,0 +1,582 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../focus/focusable_button.dart'; +import '../focus/focusable_wrapper.dart'; +import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; +import '../metadata_edit/metadata_edit_adapters.dart'; +import '../metadata_edit/metadata_edit_models.dart'; +import '../services/file_picker_service.dart'; +import '../utils/app_logger.dart'; +import '../utils/dialogs.dart'; +import '../utils/formatters.dart'; +import '../utils/media_image_helper.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/snackbar_helper.dart'; +import '../widgets/app_icon.dart'; +import '../widgets/dialog_action_button.dart'; +import '../widgets/focusable_list_tile.dart'; +import '../widgets/focused_scroll_scaffold.dart'; +import '../widgets/loading_indicator_box.dart'; +import '../widgets/optimized_media_image.dart'; +import '../widgets/tag_edit_dialog.dart'; + +class MetadataEditScreen extends StatefulWidget { + final MediaItem metadata; + + const MetadataEditScreen({super.key, required this.metadata}); + + @override + State createState() => _MetadataEditScreenState(); +} + +class _MetadataEditScreenState extends State { + MetadataEditAdapter? _adapter; + MetadataEditDraft? _draft; + bool _isLoading = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _loadMetadata(); + } + + Future _loadMetadata() async { + try { + final client = context.getMediaClientWithFallback(widget.metadata.serverId); + final adapter = metadataEditAdapterFor(client); + if (adapter == null || !adapter.supportsKind(widget.metadata.kind)) { + if (!mounted) return; + setState(() { + _adapter = adapter; + _isLoading = false; + }); + return; + } + final draft = await adapter.load(widget.metadata); + if (!mounted) return; + setState(() { + _adapter = adapter; + _draft = draft; + _isLoading = false; + }); + } catch (e, st) { + appLogger.e('Failed to load metadata editor', error: e, stackTrace: st); + if (!mounted) return; + setState(() => _isLoading = false); + showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); + } + } + + bool get _hasChanges { + final adapter = _adapter; + final draft = _draft; + return adapter != null && draft != null && adapter.hasChanges(draft); + } + + Future _save() async { + final adapter = _adapter; + final draft = _draft; + if (adapter == null || draft == null || !_hasChanges || _isSaving) return; + + setState(() => _isSaving = true); + bool success = false; + try { + success = await adapter.save(draft); + } catch (e, st) { + appLogger.e('Failed to update metadata', error: e, stackTrace: st); + } + + if (!mounted) return; + setState(() => _isSaving = false); + + if (success) { + showSuccessSnackBar(context, t.metadataEdit.metadataUpdated); + Navigator.pop(context, true); + } else { + showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); + } + } + + Future _editTextField(MetadataEditField field, {bool multiline = false}) async { + final draft = _draft; + if (draft == null) return; + final currentValue = draft.value(field.id) ?? ''; + final result = multiline + ? await showMultilineTextInputDialog( + context, + title: field.label, + labelText: field.label, + initialValue: currentValue, + ) + : await showTextInputDialog( + context, + title: field.label, + labelText: field.label, + hintText: '', + initialValue: currentValue, + allowEmpty: true, + ); + + if (result != null && mounted) { + setState(() => draft.setValue(field.id, result)); + } + } + + Future _editDate(MetadataEditField field) async { + final draft = _draft; + if (draft == null) return; + DateTime initial = DateTime.now(); + final current = draft.value(field.id); + if (current != null && current.isNotEmpty) { + final parsed = DateTime.tryParse(current); + if (parsed != null) initial = parsed; + } + + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(1800), + lastDate: DateTime(2200), + ); + + if (picked != null && mounted) { + setState(() { + draft.setValue(field.id, '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}'); + }); + } + } + + Future _editStringList(MetadataEditField field) async { + final draft = _draft; + if (draft == null) return; + final result = await showDialog>( + context: context, + builder: (context) => TagEditDialog(title: field.label, initialTags: metadataStringList(draft.values[field.id])), + ); + if (result != null && mounted) { + setState(() => draft.setValue(field.id, result)); + } + } + + Future _editChoice(MetadataEditField field) async { + final adapter = _adapter; + final draft = _draft; + if (adapter == null || draft == null) return; + final current = draft.value(field.id) ?? ''; + final result = await showDialog( + context: context, + builder: (dialogContext) { + String selected = current; + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(field.label), + content: SizedBox( + width: double.maxFinite, + child: RadioGroup( + groupValue: field.options.any((option) => option.value == selected) ? selected : null, + onChanged: (value) { + if (value == null) return; + setDialogState(() => selected = value); + Navigator.pop(dialogContext, value); + }, + child: ListView( + shrinkWrap: true, + children: [ + for (final option in field.options) + FocusableRadioListTile( + key: ValueKey(option.value), + title: Text(option.label), + value: option.value, + ), + ], + ), + ), + ), + actions: [DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel)], + ); + }, + ); + }, + ); + + if (result == null || !mounted) return; + if (field.saveMode == MetadataEditSaveMode.immediate) { + final previous = draft.values[field.id]; + setState(() => draft.setValue(field.id, result)); + bool success = false; + try { + success = await adapter.saveImmediateField(draft, field, result); + } catch (e, st) { + appLogger.e('Failed to update metadata field', error: e, stackTrace: st); + } + if (!mounted) return; + if (!success) { + setState(() => draft.setValue(field.id, previous)); + showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); + } + } else { + setState(() => draft.setValue(field.id, result)); + } + } + + Future _openArtworkPicker(MetadataEditField field) async { + final adapter = _adapter; + final draft = _draft; + if (adapter == null || draft == null) return; + final result = await showDialog( + context: context, + builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field), + ); + + if (result == true && mounted) { + await _reloadArtwork(); + } + } + + Future _reloadArtwork() async { + final adapter = _adapter; + final draft = _draft; + if (adapter == null || draft == null) return; + try { + final item = await adapter.reloadItem(draft); + if (!mounted) return; + if (item != null) { + setState(() => adapter.syncReloadedItem(draft, item)); + } + } catch (_) { + // Artwork was already saved by the picker; display will refresh next time. + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return FocusedScrollScaffold(title: Text(t.metadataEdit.screenTitle), slivers: [LoadingIndicatorBox.sliver]); + } + + final adapter = _adapter; + final draft = _draft; + if (adapter == null || draft == null) { + return FocusedScrollScaffold( + title: Text(t.metadataEdit.screenTitle), + slivers: [SliverFillRemaining(child: Center(child: Text(t.metadataEdit.metadataUpdateFailed)))], + ); + } + + final sections = adapter.schemaFor(draft).where((section) => section.fields.isNotEmpty).toList(); + return FocusedScrollScaffold( + title: Text(t.metadataEdit.screenTitle), + actions: [ + if (_isSaving) + const Padding(padding: EdgeInsets.all(12), child: LoadingIndicatorBox(size: 24)) + else + IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)), + ], + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList.separated( + itemCount: sections.length, + separatorBuilder: (context, index) => const SizedBox(height: 16), + itemBuilder: (context, index) => _buildSectionCard(adapter, draft, sections[index]), + ), + ), + ], + ); + } + + Widget _buildSectionCard(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditSection section) { + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + section.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + ), + for (final field in section.fields) _buildField(adapter, draft, field), + ], + ), + ); + } + + Widget _buildField(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditField field) { + return switch (field.type) { + MetadataEditFieldType.text => _buildFieldTile( + label: field.label, + value: draft.value(field.id), + onTap: () => _editTextField(field), + ), + MetadataEditFieldType.multilineText => _buildFieldTile( + label: field.label, + value: draft.value(field.id), + onTap: () => _editTextField(field, multiline: true), + ), + MetadataEditFieldType.date => _buildFieldTile( + label: field.label, + value: draft.value(field.id), + onTap: () => _editDate(field), + ), + MetadataEditFieldType.stringList => _buildFieldTile( + label: field.label, + value: metadataStringList(draft.values[field.id]).join(', '), + onTap: () => _editStringList(field), + ), + MetadataEditFieldType.choice => _buildFieldTile( + label: field.label, + value: _choiceDisplayValue(field, draft.value(field.id)), + onTap: () => _editChoice(field), + ), + MetadataEditFieldType.artwork => _buildArtworkTile(adapter, draft, field), + }; + } + + Widget _buildFieldTile({required String label, String? value, required VoidCallback onTap}) { + final displayValue = (value == null || value.isEmpty) ? t.metadataEdit.notSet : value; + final isNotSet = value == null || value.isEmpty; + return FocusableListTile( + title: Text(label), + subtitle: Text( + displayValue, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: isNotSet + ? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)) + : null, + ), + trailing: const AppIcon(Symbols.chevron_right_rounded), + onTap: onTap, + dense: false, + visualDensity: VisualDensity.standard, + ); + } + + Widget _buildArtworkTile(MetadataEditAdapter adapter, MetadataEditDraft draft, MetadataEditField field) { + final artwork = field.artwork!; + final imagePath = draft.value(field.id); + return FocusableListTile( + leading: SizedBox( + width: artwork.previewWidth, + height: artwork.previewHeight, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(4)), + child: OptimizedMediaImage( + client: adapter.mediaClient, + imagePath: imagePath, + width: artwork.previewWidth, + height: artwork.previewHeight, + fit: artwork.fit == MetadataArtworkFit.contain ? BoxFit.contain : BoxFit.cover, + imageType: _imageTypeForArtwork(artwork), + ), + ), + ), + title: Text(field.label), + trailing: const AppIcon(Symbols.chevron_right_rounded), + onTap: () => _openArtworkPicker(field), + dense: false, + visualDensity: VisualDensity.standard, + ); + } + + String _choiceDisplayValue(MetadataEditField field, String? value) { + for (final option in field.options) { + if (option.value == value) return option.label; + } + return value == null || value.isEmpty ? t.metadataEdit.notSet : value; + } +} + +class ArtworkPickerDialog extends StatefulWidget { + final MetadataEditAdapter adapter; + final MetadataEditDraft draft; + final MetadataEditField field; + + const ArtworkPickerDialog({super.key, required this.adapter, required this.draft, required this.field}); + + @override + State createState() => _ArtworkPickerDialogState(); +} + +class _ArtworkPickerDialogState extends State { + List? _artworkList; + bool _isLoading = true; + bool _isApplying = false; + + MetadataArtworkConfig get _config => widget.field.artwork!; + + @override + void initState() { + super.initState(); + _loadArtwork(); + } + + Future _loadArtwork() async { + try { + final artwork = await widget.adapter.fetchArtwork(widget.draft, widget.field); + if (!mounted) return; + setState(() { + _artworkList = artwork; + _isLoading = false; + }); + } catch (e, st) { + appLogger.e('Failed to load artwork', error: e, stackTrace: st); + if (!mounted) return; + setState(() { + _artworkList = const []; + _isLoading = false; + }); + } + } + + Future _selectArtwork(MetadataArtworkOption artwork) async { + if (_isApplying) return; + await _runArtworkUpdate(() => widget.adapter.applyArtworkOption(widget.draft, widget.field, artwork)); + } + + Future _addFromUrl() async { + final url = await showTextInputDialog( + context, + title: t.metadataEdit.fromUrl, + labelText: t.metadataEdit.imageUrl, + hintText: t.metadataEdit.enterImageUrl, + ); + + if (url == null || url.isEmpty || !mounted) return; + await _runArtworkUpdate(() => widget.adapter.applyArtworkFromUrl(widget.draft, widget.field, url)); + } + + Future _uploadFile() async { + final result = await FilePickerService.instance.pickFiles(type: FileType.image, withData: true); + if (result == null || result.files.isEmpty || !mounted) return; + final file = result.files.first; + final bytes = file.bytes; + if (bytes == null) return; + await _runArtworkUpdate(() => widget.adapter.uploadArtwork(widget.draft, widget.field, bytes, fileName: file.name)); + } + + Future _runArtworkUpdate(Future Function() action) async { + if (_isApplying) return; + setState(() => _isApplying = true); + bool success = false; + try { + success = await action(); + } catch (e, st) { + appLogger.e('Artwork update failed', error: e, stackTrace: st); + } + if (!mounted) return; + setState(() => _isApplying = false); + if (success) { + showSuccessSnackBar(context, t.metadataEdit.artworkUpdated); + Navigator.pop(context, true); + } else { + showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(_config.selectTitle), + content: SizedBox( + width: 500, + height: 400, + child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), + ), + actions: [ + if (_isApplying) const Padding(padding: EdgeInsets.all(8), child: LoadingIndicatorBox(size: 24)), + FocusableButton( + onPressed: _addFromUrl, + child: TextButton.icon( + onPressed: _addFromUrl, + icon: const AppIcon(Symbols.link_rounded, size: 18), + label: Text(t.metadataEdit.fromUrl), + ), + ), + FocusableButton( + onPressed: _uploadFile, + child: TextButton.icon( + onPressed: _uploadFile, + icon: const AppIcon(Symbols.upload_rounded, size: 18), + label: Text(t.metadataEdit.uploadFile), + ), + ), + FocusableButton( + autofocus: true, + onPressed: () => Navigator.pop(context), + child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), + ), + ], + ); + } + + Widget _buildArtworkContent() { + if (_artworkList == null || _artworkList!.isEmpty) { + return Center(child: Text(t.metadataEdit.noArtworkAvailable)); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: _config.gridColumns, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + childAspectRatio: _config.gridAspectRatio, + ), + itemCount: _artworkList!.length, + itemBuilder: (context, index) { + final artwork = _artworkList![index]; + return FocusableWrapper( + borderRadius: 8, + onSelect: () => _selectArtwork(artwork), + child: GestureDetector( + onTap: () => _selectArtwork(artwork), + child: Stack( + fit: StackFit.expand, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: OptimizedMediaImage( + client: widget.adapter.mediaClient, + imagePath: artwork.thumbnailPath, + fit: _config.fit == MetadataArtworkFit.contain ? BoxFit.contain : BoxFit.cover, + imageType: _imageTypeForArtwork(_config), + ), + ), + ), + if (artwork.selected) + Positioned( + right: 6, + bottom: 6, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle), + child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +ImageType _imageTypeForArtwork(MetadataArtworkConfig artwork) { + final key = artwork.key.toLowerCase(); + if (key == 'arts' || key == 'backdrop') return ImageType.art; + if (key == 'clearlogos' || key == 'logo') return ImageType.logo; + if (key == 'squarearts') return ImageType.avatar; + return ImageType.poster; +} diff --git a/lib/screens/plex_metadata_edit_screen.dart b/lib/screens/plex_metadata_edit_screen.dart deleted file mode 100644 index d2ffe0dc..00000000 --- a/lib/screens/plex_metadata_edit_screen.dart +++ /dev/null @@ -1,902 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import '../widgets/dialog_action_button.dart'; -import '../i18n/strings.g.dart'; -import '../media/media_item.dart'; -import '../media/media_kind.dart'; -import '../services/plex_client.dart'; -import '../utils/app_logger.dart'; -import '../utils/dialogs.dart'; -import '../utils/formatters.dart'; -import '../utils/language_codes.dart'; -import '../utils/provider_extensions.dart'; -import '../utils/snackbar_helper.dart'; -import '../widgets/app_icon.dart'; -import '../widgets/artwork_picker_dialog.dart'; -import '../widgets/focusable_list_tile.dart'; -import '../widgets/focused_scroll_scaffold.dart'; -import '../widgets/optimized_media_image.dart'; -import '../widgets/tag_edit_dialog.dart'; -import '../widgets/loading_indicator_box.dart'; - -/// Plex `type` number used by `/library/sections/{id}/all` PUT — required by -/// [PlexClient.updateMetadata]. Mirrors the legacy `PlexMediaType.typeNumber` -/// helper so the migrated [PlexMetadataEditScreen] (which now operates on -/// [MediaItem]) can still talk to the Plex update endpoint. -int _plexTypeNumberForKind(MediaKind kind) => switch (kind) { - MediaKind.movie => 1, - MediaKind.show => 2, - MediaKind.season => 3, - MediaKind.episode => 4, - MediaKind.artist => 8, - MediaKind.album => 9, - MediaKind.track => 10, - _ => 0, -}; - -/// Plex-only metadata editor. Calls Plex-specific PUT endpoints; the Jellyfin -/// backend has no analogous surface yet. -class PlexMetadataEditScreen extends StatefulWidget { - final MediaItem metadata; - - const PlexMetadataEditScreen({super.key, required this.metadata}); - - @override - State createState() => _PlexMetadataEditScreenState(); -} - -class _PlexMetadataEditScreenState extends State { - late PlexClient _client; - - /// Full neutral metadata reloaded after save / artwork picker. Metadata - /// editing uses Plex-only update endpoints (Jellyfin has no equivalent in - /// the current scope), so the in-memory model is [MediaItem] but the - /// boundary call to [PlexClient.updateMetadata] is Plex-only. - MediaItem? _fullMetadata; - bool _isLoading = true; - bool _isSaving = false; - - // Text field values - String? _title; - String? _titleSort; - String? _originalTitle; - String? _originallyAvailableAt; - String? _contentRating; - String? _studio; - String? _tagline; - String? _summary; - - // Original values for change detection - String? _origTitle; - String? _origTitleSort; - String? _origOriginalTitle; - String? _origOriginallyAvailableAt; - String? _origContentRating; - String? _origStudio; - String? _origTagline; - String? _origSummary; - - // Tag field values - final Map> _tags = {}; - final Map> _origTags = {}; - - // Advanced prefs (loaded from metadata JSON) - final Map _currentPrefs = {}; - - static bool _tagsEqual(List a, List b) => a.length == b.length && a.every((e) => b.contains(e)); - - bool get _hasTagChanges => _tags.keys.any((k) => !_tagsEqual(_tags[k] ?? [], _origTags[k] ?? [])); - - bool get _hasChanges => - _title != _origTitle || - _titleSort != _origTitleSort || - _originalTitle != _origOriginalTitle || - _originallyAvailableAt != _origOriginallyAvailableAt || - _contentRating != _origContentRating || - _studio != _origStudio || - _tagline != _origTagline || - _summary != _origSummary || - _hasTagChanges; - - MediaKind get _mediaType => widget.metadata.kind; - - /// Library section id required by the Plex update endpoint. Plex stores it - /// as an int; [MediaItem.libraryId] preserves it as a string. - int? get _librarySectionId => int.tryParse(_fullMetadata?.libraryId ?? widget.metadata.libraryId ?? ''); - - @override - void initState() { - super.initState(); - _client = context.getPlexClientWithFallback(widget.metadata.serverId); - _loadMetadata(); - } - - Future _loadMetadata() async { - try { - // If the passed metadata already has full fields (e.g., from detail screen), - // use it directly instead of re-fetching. We check both summary and - // libraryId since the edit screen needs both for display and save. - if (widget.metadata.summary != null && widget.metadata.libraryId != null) { - _fullMetadata = widget.metadata; - _initFieldsFromMetadata(widget.metadata); - setState(() => _isLoading = false); - return; - } - - final meta = await _client.fetchItem(widget.metadata.id); - if (!mounted) return; - if (meta != null) { - _fullMetadata = meta; - _initFieldsFromMetadata(meta); - } else { - _initFieldsFromMetadata(widget.metadata); - } - setState(() => _isLoading = false); - } catch (e) { - if (!mounted) return; - _initFieldsFromMetadata(widget.metadata); - setState(() => _isLoading = false); - } - } - - void _initFieldsFromMetadata(MediaItem meta) { - _title = meta.title; - _titleSort = meta.titleSort ?? ''; - _originalTitle = meta.originalTitle ?? ''; - _originallyAvailableAt = meta.originallyAvailableAt ?? ''; - _contentRating = meta.contentRating ?? ''; - _studio = meta.studio ?? ''; - _tagline = meta.tagline ?? ''; - _summary = meta.summary ?? ''; - - _origTitle = _title; - _origTitleSort = _titleSort; - _origOriginalTitle = _originalTitle; - _origOriginallyAvailableAt = _originallyAvailableAt; - _origContentRating = _contentRating; - _origStudio = _studio; - _origTagline = _tagline; - _origSummary = _summary; - - void initTag(String key, List? values) { - _tags[key] = List.of(values ?? []); - _origTags[key] = List.of(values ?? []); - } - - initTag('genre', meta.genres); - initTag('director', meta.directors); - initTag('writer', meta.writers); - initTag('producer', meta.producers); - initTag('country', meta.countries); - initTag('collection', meta.collections); - initTag('label', meta.labels); - initTag('style', meta.styles); - initTag('mood', meta.moods); - } - - Future _save() async { - if (!_hasChanges || _isSaving) return; - - final sectionId = _librarySectionId; - if (sectionId == null) { - if (mounted) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); - return; - } - - setState(() => _isSaving = true); - - Map current, List original})>? tagChanges; - for (final key in _tags.keys) { - final current = _tags[key] ?? []; - final original = _origTags[key] ?? []; - if (!_tagsEqual(current, original)) { - tagChanges ??= {}; - tagChanges[key] = (current: current, original: original); - } - } - - bool success = false; - try { - success = await _client.updateMetadata( - sectionId: sectionId, - ratingKey: widget.metadata.id, - typeNumber: _plexTypeNumberForKind(_mediaType), - title: _title != _origTitle ? _title : null, - titleSort: _titleSort != _origTitleSort ? _titleSort : null, - originalTitle: _originalTitle != _origOriginalTitle ? _originalTitle : null, - originallyAvailableAt: _originallyAvailableAt != _origOriginallyAvailableAt ? _originallyAvailableAt : null, - contentRating: _contentRating != _origContentRating ? _contentRating : null, - studio: _studio != _origStudio ? _studio : null, - tagline: _tagline != _origTagline ? _tagline : null, - summary: _summary != _origSummary ? _summary : null, - tagChanges: tagChanges, - ); - } catch (e, st) { - // [PlexClient._wrapBoolApiCall] rethrows on HTTP/network errors — - // catch here so `_isSaving` doesn't get stuck `true`. - appLogger.e('Failed to update metadata', error: e, stackTrace: st); - } - - if (!mounted) return; - setState(() => _isSaving = false); - - if (success) { - showSuccessSnackBar(context, t.metadataEdit.metadataUpdated); - Navigator.pop(context, true); - } else { - showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); - } - } - - Future _editTextField({ - required String title, - required String label, - required String? currentValue, - required ValueChanged onChanged, - bool multiline = false, - }) async { - final String? result; - if (multiline) { - result = await showMultilineTextInputDialog(context, title: title, labelText: label, initialValue: currentValue); - } else { - result = await showTextInputDialog( - context, - title: title, - labelText: label, - hintText: '', - initialValue: currentValue, - ); - } - - if (result != null && mounted) { - final value = result; - setState(() => onChanged(value)); - } - } - - Future _editDate() async { - DateTime initial = DateTime.now(); - if (_originallyAvailableAt != null && _originallyAvailableAt!.isNotEmpty) { - final parsed = DateTime.tryParse(_originallyAvailableAt!); - if (parsed != null) initial = parsed; - } - - final picked = await showDatePicker( - context: context, - initialDate: initial, - firstDate: DateTime(1800), - lastDate: DateTime(2200), - ); - - if (picked != null && mounted) { - setState(() { - _originallyAvailableAt = '${picked.year}-${padNumber(picked.month, 2)}-${padNumber(picked.day, 2)}'; - }); - } - } - - Future _openArtworkPicker(String element) async { - final result = await showDialog( - context: context, - builder: (context) => ArtworkPickerDialog(client: _client, ratingKey: widget.metadata.id, element: element), - ); - - if (result == true && mounted) { - // Re-fetch metadata to get updated artwork paths without resetting - // any text field edits the user may have made. - await _reloadArtwork(); - } - } - - Future _reloadArtwork() async { - try { - final meta = await _client.fetchItem(widget.metadata.id); - if (!mounted) return; - if (meta != null) { - setState(() => _fullMetadata = meta); - } - } catch (_) { - // Artwork was already saved by the picker; display will refresh next - // time the editor is opened. - } - } - - Future _showAdvancedSettingDialog({ - required String title, - required String prefKey, - required List<({String value, String label})> options, - }) async { - // Determine current value from metadata or default - final currentValue = _currentPrefs[prefKey] ?? _getMetadataPrefValue(prefKey); - - final result = await showDialog( - context: context, - builder: (dialogContext) { - String? selected = currentValue; - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - title: Text(title), - content: SizedBox( - width: double.maxFinite, - child: RadioGroup( - groupValue: selected, - onChanged: (val) { - setDialogState(() => selected = val); - Navigator.pop(dialogContext, val); - }, - child: ListView( - shrinkWrap: true, - children: options.map((option) { - return FocusableRadioListTile( - key: ValueKey(option.value), - title: Text(option.label), - value: option.value, - ); - }).toList(), - ), - ), - ), - actions: [DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel)], - ); - }, - ); - }, - ); - - if (result != null && mounted) { - final previous = _currentPrefs[prefKey]; - setState(() => _currentPrefs[prefKey] = result); - try { - await _client.updateMetadataPrefs(widget.metadata.id, {prefKey: result}); - } catch (e, st) { - // [PlexClient._wrapBoolApiCall] rethrows — revert the optimistic - // UI change and surface a snackbar so the radio doesn't lie. - appLogger.e('Failed to update metadata prefs', error: e, stackTrace: st); - if (!mounted) return; - setState(() { - if (previous == null) { - _currentPrefs.remove(prefKey); - } else { - _currentPrefs[prefKey] = previous; - } - }); - showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed); - } - } - } - - String _getMetadataPrefValue(String key) { - // These prefs appear as keys on the raw metadata JSON when non-default. - // Since we use typed models, we check known fields. Falls back to the - // public [MediaItem] when the Plex-typed cache hasn't loaded yet. - // - // [subtitleLanguage] / [subtitleMode] live on [PlexMediaItem] (Plex-only - // — Jellyfin has no per-item subtitle preference). This screen is - // documented as Plex-only at the class level, so the cast is safe; on - // the off chance a Jellyfin item slips through, fall back to the - // unset/default string. - final fullMeta = _fullMetadata; - final fullPlex = fullMeta is PlexMediaItem ? fullMeta : null; - final widgetMeta = widget.metadata; - final widgetPlex = widgetMeta is PlexMediaItem ? widgetMeta : null; - switch (key) { - case 'audioLanguage': - return fullMeta?.audioLanguage ?? widgetMeta.audioLanguage ?? ''; - case 'subtitleLanguage': - return fullPlex?.subtitleLanguage ?? widgetPlex?.subtitleLanguage ?? ''; - case 'subtitleMode': - return (fullPlex?.subtitleMode ?? widgetPlex?.subtitleMode)?.toString() ?? '-1'; - default: - return ''; - } - } - - String _getDisplayValueForPref(String prefKey, List<({String value, String label})> options) { - final val = _currentPrefs[prefKey] ?? _getMetadataPrefValue(prefKey); - for (final option in options) { - if (option.value == val) return option.label; - } - return options.first.label; - } - - bool get _showSortTitle => _mediaType != MediaKind.season; - bool get _showOriginalTitle => _mediaType == MediaKind.movie || _mediaType == MediaKind.show; - bool get _showReleaseDate => _mediaType != MediaKind.season; - bool get _showContentRating => _mediaType != MediaKind.season; - bool get _showStudio => _mediaType == MediaKind.movie || _mediaType == MediaKind.show; - bool get _showTagline => _mediaType == MediaKind.movie || _mediaType == MediaKind.show; - bool get _showBackground => - _mediaType == MediaKind.movie || _mediaType == MediaKind.show || _mediaType == MediaKind.episode; - bool get _showExtendedArtwork => - _mediaType == MediaKind.movie || _mediaType == MediaKind.show || _mediaType == MediaKind.collection; - bool get _showAdvanced => _mediaType != MediaKind.episode; - - List<({String key, String label})> get _tagFields { - switch (_mediaType) { - case MediaKind.movie: - case MediaKind.show: - return [ - (key: 'genre', label: t.metadataEdit.genre), - (key: 'director', label: t.metadataEdit.director), - (key: 'writer', label: t.metadataEdit.writer), - (key: 'producer', label: t.metadataEdit.producer), - (key: 'country', label: t.metadataEdit.country), - (key: 'collection', label: t.metadataEdit.collection), - (key: 'label', label: t.metadataEdit.label), - ]; - case MediaKind.episode: - return [(key: 'director', label: t.metadataEdit.director), (key: 'writer', label: t.metadataEdit.writer)]; - case MediaKind.artist: - return [ - (key: 'genre', label: t.metadataEdit.genre), - (key: 'style', label: t.metadataEdit.style), - (key: 'mood', label: t.metadataEdit.mood), - (key: 'country', label: t.metadataEdit.country), - (key: 'collection', label: t.metadataEdit.collection), - ]; - case MediaKind.album: - return [ - (key: 'genre', label: t.metadataEdit.genre), - (key: 'style', label: t.metadataEdit.style), - (key: 'mood', label: t.metadataEdit.mood), - (key: 'collection', label: t.metadataEdit.collection), - ]; - default: - return []; - } - } - - Future _editTag(String key, String label) async { - final result = await showDialog>( - context: context, - builder: (context) => TagEditDialog(title: label, initialTags: _tags[key] ?? []), - ); - if (result != null && mounted) { - setState(() => _tags[key] = result); - } - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return FocusedScrollScaffold(title: Text(t.metadataEdit.screenTitle), slivers: [LoadingIndicatorBox.sliver]); - } - - return FocusedScrollScaffold( - title: Text(t.metadataEdit.screenTitle), - actions: [ - if (_isSaving) - const Padding(padding: EdgeInsets.all(12), child: LoadingIndicatorBox(size: 24)) - else - IconButton(onPressed: _hasChanges ? _save : null, icon: const AppIcon(Symbols.check_rounded, fill: 1)), - ], - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildBasicInfoCard(), - if (_tagFields.isNotEmpty) ...[const SizedBox(height: 16), _buildTagsCard()], - const SizedBox(height: 16), - _buildArtworkCard(), - if (_showAdvanced) ...[const SizedBox(height: 16), _buildAdvancedSettingsCard()], - ]), - ), - ), - ], - ); - } - - Widget _buildBasicInfoCard() { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.metadataEdit.basicInfo, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - _buildFieldTile( - label: t.metadataEdit.title, - value: _title, - onTap: () => _editTextField( - title: t.metadataEdit.title, - label: t.metadataEdit.title, - currentValue: _title, - onChanged: (v) => _title = v, - ), - ), - if (_showSortTitle) - _buildFieldTile( - label: t.metadataEdit.sortTitle, - value: _titleSort, - onTap: () => _editTextField( - title: t.metadataEdit.sortTitle, - label: t.metadataEdit.sortTitle, - currentValue: _titleSort, - onChanged: (v) => _titleSort = v, - ), - ), - if (_showOriginalTitle) - _buildFieldTile( - label: t.metadataEdit.originalTitle, - value: _originalTitle, - onTap: () => _editTextField( - title: t.metadataEdit.originalTitle, - label: t.metadataEdit.originalTitle, - currentValue: _originalTitle, - onChanged: (v) => _originalTitle = v, - ), - ), - if (_showReleaseDate) - _buildFieldTile(label: t.metadataEdit.releaseDate, value: _originallyAvailableAt, onTap: _editDate), - if (_showContentRating) - _buildFieldTile( - label: t.metadataEdit.contentRating, - value: _contentRating, - onTap: () => _editTextField( - title: t.metadataEdit.contentRating, - label: t.metadataEdit.contentRating, - currentValue: _contentRating, - onChanged: (v) => _contentRating = v, - ), - ), - if (_showStudio) - _buildFieldTile( - label: t.metadataEdit.studio, - value: _studio, - onTap: () => _editTextField( - title: t.metadataEdit.studio, - label: t.metadataEdit.studio, - currentValue: _studio, - onChanged: (v) => _studio = v, - ), - ), - if (_showTagline) - _buildFieldTile( - label: t.metadataEdit.tagline, - value: _tagline, - onTap: () => _editTextField( - title: t.metadataEdit.tagline, - label: t.metadataEdit.tagline, - currentValue: _tagline, - onChanged: (v) => _tagline = v, - ), - ), - _buildFieldTile( - label: t.metadataEdit.summary, - value: _summary, - onTap: () => _editTextField( - title: t.metadataEdit.summary, - label: t.metadataEdit.summary, - currentValue: _summary, - onChanged: (v) => _summary = v, - multiline: true, - ), - ), - ], - ), - ); - } - - Widget _buildFieldTile({required String label, String? value, required VoidCallback onTap}) { - final displayValue = (value == null || value.isEmpty) ? t.metadataEdit.notSet : value; - final isNotSet = value == null || value.isEmpty; - - return ListTile( - title: Text(label), - subtitle: Text( - displayValue, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: isNotSet - ? TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.5)) - : null, - ), - trailing: const AppIcon(Symbols.chevron_right_rounded), - onTap: onTap, - ); - } - - Widget _buildTagsCard() { - final fields = _tagFields; - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.metadataEdit.tags, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - for (final field in fields) - _buildFieldTile( - label: field.label, - value: (_tags[field.key] ?? []).isEmpty ? null : (_tags[field.key]!).join(', '), - onTap: () => _editTag(field.key, field.label), - ), - ], - ), - ); - } - - Widget _buildArtworkTile({ - required double width, - required double height, - required String? imagePath, - required String label, - required String element, - BoxFit fit = BoxFit.cover, - }) { - return ListTile( - leading: SizedBox( - width: width, - height: height, - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(4)), - child: OptimizedMediaImage(client: _client, imagePath: imagePath, width: width, height: height, fit: fit), - ), - ), - title: Text(label), - trailing: const AppIcon(Symbols.chevron_right_rounded), - onTap: () => _openArtworkPicker(element), - ); - } - - Widget _buildArtworkCard() { - // Prefer the freshly fetched metadata for image paths, falling back to - // the public [MediaItem] before the fetch resolves. - final fullMeta = _fullMetadata; - final thumb = fullMeta?.thumbPath ?? widget.metadata.thumbPath; - final art = fullMeta?.artPath ?? widget.metadata.artPath; - final clearLogo = fullMeta?.clearLogoPath ?? widget.metadata.clearLogoPath; - final backgroundSquare = fullMeta?.backgroundSquarePath ?? widget.metadata.backgroundSquarePath; - - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.metadataEdit.artwork, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - _buildArtworkTile(width: 40, height: 60, imagePath: thumb, label: t.metadataEdit.poster, element: 'posters'), - if (_showBackground) - _buildArtworkTile(width: 80, height: 45, imagePath: art, label: t.metadataEdit.background, element: 'arts'), - if (_showExtendedArtwork) - _buildArtworkTile( - width: 80, - height: 32, - imagePath: clearLogo, - label: t.metadataEdit.logo, - element: 'clearLogos', - fit: BoxFit.contain, - ), - if (_showExtendedArtwork) - _buildArtworkTile( - width: 50, - height: 50, - imagePath: backgroundSquare, - label: t.metadataEdit.squareArt, - element: 'squareArts', - ), - ], - ), - ); - } - - Widget _buildAdvancedSettingsCard() { - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - t.metadataEdit.advancedSettings, - style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), - ), - ), - if (_mediaType == MediaKind.show) ..._buildShowAdvancedSettings(), - if (_mediaType == MediaKind.movie) ..._buildMovieAdvancedSettings(), - if (_mediaType == MediaKind.season) ..._buildSeasonAdvancedSettings(), - ], - ), - ); - } - - List _buildShowAdvancedSettings() { - return [ - _buildAdvancedTile( - title: t.metadataEdit.episodeSorting, - prefKey: 'episodeSort', - options: [ - (value: '-1', label: t.metadataEdit.libraryDefault), - (value: '0', label: t.metadataEdit.oldestFirst), - (value: '1', label: t.metadataEdit.newestFirst), - ], - ), - _buildAdvancedTile( - title: t.metadataEdit.keep, - prefKey: 'autoDeletionItemPolicyUnwatchedLibrary', - options: [ - (value: '0', label: t.metadataEdit.allEpisodes), - (value: '5', label: t.metadataEdit.latestEpisodes(count: '5')), - (value: '3', label: t.metadataEdit.latestEpisodes(count: '3')), - (value: '1', label: t.metadataEdit.latestEpisode), - (value: '-3', label: t.metadataEdit.episodesAddedPastDays(count: '3')), - (value: '-7', label: t.metadataEdit.episodesAddedPastDays(count: '7')), - (value: '-30', label: t.metadataEdit.episodesAddedPastDays(count: '30')), - ], - ), - _buildAdvancedTile( - title: t.metadataEdit.deleteAfterPlaying, - prefKey: 'autoDeletionItemPolicyWatchedLibrary', - options: [ - (value: '0', label: t.metadataEdit.never), - (value: '1', label: t.metadataEdit.afterADay), - (value: '7', label: t.metadataEdit.afterAWeek), - (value: '30', label: t.metadataEdit.afterAMonth), - (value: '100', label: t.metadataEdit.onNextRefresh), - ], - ), - _buildAdvancedTile( - title: t.metadataEdit.seasons, - prefKey: 'flattenSeasons', - options: [ - (value: '-1', label: t.metadataEdit.libraryDefault), - (value: '0', label: t.metadataEdit.show), - (value: '1', label: t.metadataEdit.hide), - ], - ), - _buildAdvancedTile( - title: t.metadataEdit.episodeOrdering, - prefKey: 'showOrdering', - options: [ - (value: '', label: t.metadataEdit.libraryDefault), - (value: 'tmdbAiring', label: t.metadataEdit.tmdbAiring), - (value: 'tvdbAiring', label: t.metadataEdit.tvdbAiring), - (value: 'tvdbAbsolute', label: t.metadataEdit.tvdbAbsolute), - ], - ), - ..._buildMetadataLanguageTiles(), - ..._buildAudioSubtitleTiles(t.metadataEdit.accountDefault), - ]; - } - - List _buildMovieAdvancedSettings() { - return _buildMetadataLanguageTiles(); - } - - List _buildSeasonAdvancedSettings() { - return _buildAudioSubtitleTiles(t.metadataEdit.seriesDefault); - } - - List _buildMetadataLanguageTiles() { - return [ - _buildAdvancedTile( - title: t.metadataEdit.metadataLanguage, - prefKey: 'languageOverride', - options: _metadataLanguageOptions(t.metadataEdit.libraryDefault), - ), - _buildAdvancedTile( - title: t.metadataEdit.useOriginalTitle, - prefKey: 'useOriginalTitle', - options: [ - (value: '-1', label: t.metadataEdit.libraryDefault), - (value: '0', label: t.common.no), - (value: '1', label: t.common.yes), - ], - ), - ]; - } - - List _buildAudioSubtitleTiles(String defaultLabel) { - return [ - _buildAdvancedTile( - title: t.metadataEdit.preferredAudioLanguage, - prefKey: 'audioLanguage', - options: _audioSubtitleLanguageOptions(defaultLabel), - ), - _buildAdvancedTile( - title: t.metadataEdit.preferredSubtitleLanguage, - prefKey: 'subtitleLanguage', - options: _audioSubtitleLanguageOptions(defaultLabel), - ), - _buildAdvancedTile( - title: t.metadataEdit.subtitleMode, - prefKey: 'subtitleMode', - options: [ - (value: '-1', label: defaultLabel), - (value: '0', label: t.metadataEdit.manuallySelected), - (value: '1', label: t.metadataEdit.shownWithForeignAudio), - (value: '2', label: t.metadataEdit.alwaysEnabled), - ], - ), - ]; - } - - Widget _buildAdvancedTile({ - required String title, - required String prefKey, - required List<({String value, String label})> options, - }) { - return ListTile( - title: Text(title), - subtitle: Text(_getDisplayValueForPref(prefKey, options)), - trailing: const AppIcon(Symbols.chevron_right_rounded), - onTap: () => _showAdvancedSettingDialog(title: title, prefKey: prefKey, options: options), - ); - } -} - -// Plex locale codes for metadata agent language. -const _plexLocaleCodes = [ - 'ar-SA', - 'bg-BG', - 'ca-ES', - 'zh-CN', - 'zh-HK', - 'zh-TW', - 'hr-HR', - 'cs-CZ', - 'da-DK', - 'nl-NL', - 'en-US', - 'en-AU', - 'en-CA', - 'en-GB', - 'et-EE', - 'fi-FI', - 'fr-FR', - 'fr-CA', - 'de-DE', - 'el-GR', - 'he-IL', - 'hi-IN', - 'hu-HU', - 'is-IS', - 'id-ID', - 'it-IT', - 'ja-JP', - 'ko-KR', - 'lv-LV', - 'lt-LT', - 'nb-NO', - 'fa-IR', - 'pl-PL', - 'pt-BR', - 'pt-PT', - 'ro-RO', - 'ru-RU', - 'sk-SK', - 'es-ES', - 'es-MX', - 'sv-SE', - 'th-TH', - 'tr-TR', - 'uk-UA', - 'vi-VN', -]; - -// Common 2-letter codes shown at the top of audio/subtitle pickers. -const _commonAudioSubtitleCodes = ['en', 'ja', 'fr', 'de', 'it', 'es', 'pt', 'ru', 'ar']; - -List<({String value, String label})> _buildLanguageOptions(String defaultLabel, List codes) { - return [(value: '', label: defaultLabel), ...codes.map((c) => (value: c, label: LanguageCodes.getDisplayName(c)))]; -} - -List<({String value, String label})> _metadataLanguageOptions(String defaultLabel) => - _buildLanguageOptions(defaultLabel, _plexLocaleCodes); - -List<({String value, String label})> _audioSubtitleLanguageOptions(String defaultLabel) => - _buildLanguageOptions(defaultLabel, [..._commonAudioSubtitleCodes, ..._plexLocaleCodes]); diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index e54afbe1..b91a8db1 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; @@ -69,6 +70,7 @@ part 'jellyfin_client/parts/collections.dart'; part 'jellyfin_client/parts/file_info.dart'; part 'jellyfin_client/parts/live_tv.dart'; part 'jellyfin_client/parts/images_downloads.dart'; +part 'jellyfin_client/parts/metadata_edit.dart'; /// [MediaServerClient] over a Jellyfin server. /// @@ -86,7 +88,8 @@ class JellyfinClient _JellyfinCollectionMethods, _JellyfinFileInfoMethods, _JellyfinLiveTvMethods, - _JellyfinImageDownloadMethods + _JellyfinImageDownloadMethods, + _JellyfinMetadataEditMethods implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable { JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository}) : _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(); diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart new file mode 100644 index 00000000..3d797c2e --- /dev/null +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -0,0 +1,86 @@ +part of '../../jellyfin_client.dart'; + +mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { + JellyfinConnection get connection; + MediaServerHttpClient get _http; + + Future?> fetchEditableMetadataItem(String itemId) async { + if (isOfflineMode) return null; + final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}'); + if (response.statusCode == 404) return null; + throwIfHttpError(response); + final data = response.data; + return data is Map ? data : null; + } + + Future updateMetadataItem(String itemId, Map item) async { + final response = await _http.post('/Items/${_segment(itemId)}', body: item); + throwIfHttpError(response); + await _deleteMetadataEditCache(itemId); + return response.statusCode >= 200 && response.statusCode < 300; + } + + Future> getRemoteImages( + String itemId, { + required String imageType, + int startIndex = 0, + int limit = 60, + String? providerName, + bool includeAllLanguages = false, + }) async { + final response = await _http.get( + '/Items/${_segment(itemId)}/RemoteImages', + queryParameters: { + 'type': imageType, + 'startIndex': startIndex, + 'limit': limit, + if (providerName != null && providerName.isNotEmpty) 'providerName': providerName, + 'includeAllLanguages': includeAllLanguages, + }, + ); + throwIfHttpError(response); + final data = response.data; + return data is Map ? data : const {}; + } + + Future>> getItemImageInfos(String itemId) async { + final response = await _http.get('/Items/${_segment(itemId)}/Images'); + throwIfHttpError(response); + final data = response.data; + return data is List ? data.whereType>().toList() : const >[]; + } + + Future downloadRemoteImage(String itemId, {required String imageType, required String imageUrl}) async { + final response = await _http.post( + '/Items/${_segment(itemId)}/RemoteImages/Download', + queryParameters: {'type': imageType, 'imageUrl': imageUrl}, + ); + throwIfHttpError(response); + await _deleteMetadataEditCache(itemId); + return response.statusCode >= 200 && response.statusCode < 300; + } + + Future uploadItemImage( + String itemId, { + required String imageType, + required List bytes, + required String contentType, + }) async { + final response = await _http.post( + '/Items/${_segment(itemId)}/Images/${_segment(imageType)}', + body: base64Encode(bytes), + headers: {'Content-Type': contentType}, + ); + throwIfHttpError(response); + await _deleteMetadataEditCache(itemId); + return response.statusCode >= 200 && response.statusCode < 300; + } + + Future _deleteMetadataEditCache(String itemId) async { + try { + await cache.deleteForItem(cacheServerId, itemId); + } catch (e, st) { + appLogger.w('Jellyfin metadata edit cache invalidation failed', error: e, stackTrace: st); + } + } +} diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ad8e7f43..4de9fe63 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -2160,7 +2160,7 @@ class PlexClient String? tagline, String? summary, Map current, List original})>? tagChanges, - }) { + }) async { final queryParams = {'type': typeNumber, 'id': ratingKey}; void addField(String name, String? value) { @@ -2195,10 +2195,14 @@ class PlexClient } } - return _wrapBoolApiCall( + final result = await _wrapBoolApiCall( () => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams), 'Failed to update metadata', ); + if (result) { + await _deleteMetadataEditCache(ratingKey); + } + return result; } /// Search for match candidates for a media item. @@ -2239,7 +2243,7 @@ class PlexClient 'Failed to apply match', ); if (result) { - await _cache.deleteForItem(serverId, ratingKey); + await _deleteMetadataEditCache(ratingKey); } return result; } @@ -2250,7 +2254,7 @@ class PlexClient 'Failed to unmatch item', ); if (result) { - await _cache.deleteForItem(serverId, ratingKey); + await _deleteMetadataEditCache(ratingKey); } return result; } @@ -2271,18 +2275,22 @@ class PlexClient } /// Set artwork from a URL (can be a Plex internal path or external URL) - Future setArtworkFromUrl(String ratingKey, String element, String url) { + Future setArtworkFromUrl(String ratingKey, String element, String url) async { final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; - return _wrapBoolApiCall( + final result = await _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}), 'Failed to set artwork from URL', ); + if (result) { + await _deleteMetadataEditCache(ratingKey); + } + return result; } /// Upload artwork from binary data - Future uploadArtwork(String ratingKey, String element, List bytes) { + Future uploadArtwork(String ratingKey, String element, List bytes) async { final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; - return _wrapBoolApiCall( + final result = await _wrapBoolApiCall( () => _http.put( '/library/metadata/$ratingKey/$setElement', body: bytes, @@ -2290,14 +2298,30 @@ class PlexClient ), 'Failed to upload artwork', ); + if (result) { + await _deleteMetadataEditCache(ratingKey); + } + return result; } /// Update per-media advanced preferences - Future updateMetadataPrefs(String ratingKey, Map prefs) { - return _wrapBoolApiCall( + Future updateMetadataPrefs(String ratingKey, Map prefs) async { + final result = await _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs), 'Failed to update metadata preferences', ); + if (result) { + await _deleteMetadataEditCache(ratingKey); + } + return result; + } + + Future _deleteMetadataEditCache(String ratingKey) async { + try { + await _cache.deleteForItem(serverId, ratingKey); + } catch (e, st) { + appLogger.w('Plex metadata edit cache invalidation failed', error: e, stackTrace: st); + } } /// Get one page of collections for a library section. diff --git a/lib/utils/dialogs.dart b/lib/utils/dialogs.dart index 79f02ab2..cd7ea146 100644 --- a/lib/utils/dialogs.dart +++ b/lib/utils/dialogs.dart @@ -120,6 +120,7 @@ Future showTextInputDialog( TextInputType? keyboardType, List? inputFormatters, String? Function(String)? validator, + bool allowEmpty = false, }) { return showDialog( context: context, @@ -132,6 +133,7 @@ Future showTextInputDialog( keyboardType: keyboardType, inputFormatters: inputFormatters, validator: validator, + allowEmpty: allowEmpty, ), ); } @@ -237,6 +239,7 @@ class _TextInputDialog extends StatefulWidget { final TextInputType? keyboardType; final List? inputFormatters; final String? Function(String)? validator; + final bool allowEmpty; const _TextInputDialog({ required this.title, @@ -247,6 +250,7 @@ class _TextInputDialog extends StatefulWidget { this.keyboardType, this.inputFormatters, this.validator, + this.allowEmpty = false, }); @override @@ -260,7 +264,7 @@ class _TextInputDialogState extends State<_TextInputDialog> void _submit() { final text = _controller.text; - if (text.isEmpty) return; + if (text.isEmpty && !widget.allowEmpty) return; if (widget.validator != null && widget.validator!(text) != null) return; Navigator.pop(context, text); } diff --git a/lib/utils/provider_extensions.dart b/lib/utils/provider_extensions.dart index 8735558a..9cee9e01 100644 --- a/lib/utils/provider_extensions.dart +++ b/lib/utils/provider_extensions.dart @@ -21,8 +21,8 @@ extension ProviderExtensions on BuildContext { /// Internal: resolve a [PlexClient] from a serverId or fall back to the /// first online server. Returns null if neither yields a Plex client. /// Non-Plex servers (Jellyfin) are skipped — these helpers exist for - /// Plex-only flows that have no neutral equivalent (DVR tuning, metadata - /// edit, match). Backend-agnostic flows use the [_resolveMediaClient] + /// Plex-only flows that have no neutral equivalent (DVR tuning, match). + /// Backend-agnostic flows use the [_resolveMediaClient] /// helpers below. PlexClient? _resolveClient(String? serverId) { final provider = Provider.of(this, listen: false); @@ -65,8 +65,8 @@ extension ProviderExtensions on BuildContext { // These return [MediaServerClient] regardless of backend kind so callers // that consume only the [MediaServerClient] surface don't need to type- // check the result. Use [getPlexClientForServer] / [getPlexClientForLibrary] - // when you specifically need a [PlexClient] (Plex-only flows like Live TV, - // metadata editing, etc.). + // when you specifically need a [PlexClient] (Plex-only flows like Live TV or + // match/fix-match). MediaServerClient? _resolveMediaClient(String? serverId) { final provider = Provider.of(this, listen: false); diff --git a/lib/widgets/artwork_picker_dialog.dart b/lib/widgets/artwork_picker_dialog.dart deleted file mode 100644 index bbda0cc7..00000000 --- a/lib/widgets/artwork_picker_dialog.dart +++ /dev/null @@ -1,206 +0,0 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import '../focus/focusable_button.dart'; -import '../focus/focusable_wrapper.dart'; -import '../i18n/strings.g.dart'; -import '../services/file_picker_service.dart'; -import '../services/plex_client.dart'; -import '../utils/app_logger.dart'; -import '../utils/dialogs.dart'; -import '../utils/snackbar_helper.dart'; -import '../widgets/app_icon.dart'; -import '../widgets/optimized_media_image.dart'; -import 'loading_indicator_box.dart'; - -class ArtworkPickerDialog extends StatefulWidget { - final PlexClient client; - final String ratingKey; - final String element; // "posters" or "arts" - - const ArtworkPickerDialog({super.key, required this.client, required this.ratingKey, required this.element}); - - @override - State createState() => _ArtworkPickerDialogState(); -} - -class _ArtworkPickerDialogState extends State { - List>? _artworkList; - bool _isLoading = true; - bool _isApplying = false; - - ({int crossAxisCount, double aspectRatio, String title}) get _elementConfig { - return switch (widget.element) { - 'posters' => (crossAxisCount: 3, aspectRatio: 2.0 / 3.0, title: t.metadataEdit.selectPoster), - 'arts' => (crossAxisCount: 2, aspectRatio: 16.0 / 9.0, title: t.metadataEdit.selectBackground), - 'clearLogos' => (crossAxisCount: 2, aspectRatio: 2.5 / 1.0, title: t.metadataEdit.selectLogo), - 'squareArts' => (crossAxisCount: 3, aspectRatio: 1.0, title: t.metadataEdit.selectSquareArt), - _ => (crossAxisCount: 3, aspectRatio: 2.0 / 3.0, title: t.metadataEdit.selectPoster), - }; - } - - @override - void initState() { - super.initState(); - _loadArtwork(); - } - - Future _loadArtwork() async { - final artwork = await widget.client.getAvailableArtwork(widget.ratingKey, widget.element); - if (!mounted) return; - setState(() { - _artworkList = artwork; - _isLoading = false; - }); - } - - Future _selectArtwork(Map artwork) async { - // Use ratingKey (the artwork provider identifier) rather than key (a - // file-serving path that is already percent-encoded). Passing key through - // Dio's query-parameter encoding double-encodes it, causing Plex to - // silently ignore the selection despite returning 200. - final url = artwork['ratingKey'] as String? ?? artwork['key'] as String?; - if (url == null || _isApplying) return; - await _runArtworkUpdate(() => widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url)); - } - - Future _addFromUrl() async { - final url = await showTextInputDialog( - context, - title: t.metadataEdit.fromUrl, - labelText: t.metadataEdit.imageUrl, - hintText: t.metadataEdit.enterImageUrl, - ); - - if (url == null || url.isEmpty || !mounted) return; - await _runArtworkUpdate(() => widget.client.setArtworkFromUrl(widget.ratingKey, widget.element, url)); - } - - Future _uploadFile() async { - final result = await FilePickerService.instance.pickFiles(type: FileType.image, withData: true); - - if (result == null || result.files.isEmpty || !mounted) return; - - final bytes = result.files.first.bytes; - if (bytes == null) return; - await _runArtworkUpdate(() => widget.client.uploadArtwork(widget.ratingKey, widget.element, bytes)); - } - - /// Runs an artwork update API call with shared loading-state and - /// error-handling. The underlying client throws on HTTP errors (see - /// [PlexClient] `_wrapBoolApiCall`), so we must catch here or `_isApplying` - /// gets stuck `true` and the user sees an infinite spinner. - Future _runArtworkUpdate(Future Function() action) async { - if (_isApplying) return; - setState(() => _isApplying = true); - bool success = false; - try { - success = await action(); - } catch (e, st) { - appLogger.e('Artwork update failed', error: e, stackTrace: st); - } - if (!mounted) return; - setState(() => _isApplying = false); - if (success) { - showSuccessSnackBar(context, t.metadataEdit.artworkUpdated); - Navigator.pop(context, true); - } else { - showErrorSnackBar(context, t.metadataEdit.artworkUpdateFailed); - } - } - - @override - Widget build(BuildContext context) { - return AlertDialog( - title: Text(_elementConfig.title), - content: SizedBox( - width: 500, - height: 400, - child: _isLoading ? const Center(child: CircularProgressIndicator()) : _buildArtworkContent(), - ), - actions: [ - if (_isApplying) const Padding(padding: EdgeInsets.all(8), child: LoadingIndicatorBox(size: 24)), - FocusableButton( - onPressed: _addFromUrl, - child: TextButton.icon( - onPressed: _addFromUrl, - icon: const AppIcon(Symbols.link_rounded, size: 18), - label: Text(t.metadataEdit.fromUrl), - ), - ), - FocusableButton( - onPressed: _uploadFile, - child: TextButton.icon( - onPressed: _uploadFile, - icon: const AppIcon(Symbols.upload_rounded, size: 18), - label: Text(t.metadataEdit.uploadFile), - ), - ), - FocusableButton( - autofocus: true, - onPressed: () => Navigator.pop(context), - child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)), - ), - ], - ); - } - - Widget _buildArtworkContent() { - if (_artworkList == null || _artworkList!.isEmpty) { - return Center(child: Text(t.metadataEdit.noArtworkAvailable)); - } - return _buildGrid(); - } - - Widget _buildGrid() { - final config = _elementConfig; - - return GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: config.crossAxisCount, - crossAxisSpacing: 8, - mainAxisSpacing: 8, - childAspectRatio: config.aspectRatio, - ), - itemCount: _artworkList!.length, - itemBuilder: (context, index) { - final artwork = _artworkList![index]; - final thumbUrl = artwork['thumb'] as String?; - final isSelected = artwork['selected'] == true; - - return FocusableWrapper( - borderRadius: 8, - onSelect: () => _selectArtwork(artwork), - child: GestureDetector( - onTap: () => _selectArtwork(artwork), - child: Stack( - fit: StackFit.expand, - children: [ - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: OptimizedMediaImage(client: widget.client, imagePath: thumbUrl, fit: BoxFit.contain), - ), - ), - if (isSelected) - Positioned( - right: 6, - bottom: 6, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration(color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle), - child: Icon(Symbols.check_rounded, size: 16, color: Theme.of(context).colorScheme.onPrimary), - ), - ), - ], - ), - ), - ); - }, - ); - } -} diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 45dbd089..58927a71 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -10,6 +10,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; +import '../metadata_edit/metadata_edit_adapters.dart'; import '../media/media_version.dart'; import '../mixins/controller_disposer_mixin.dart'; import '../services/plex_client.dart'; @@ -43,7 +44,7 @@ import '../focus/focusable_text_field.dart'; import '../focus/dpad_navigator.dart'; import '../screens/plex_match_screen.dart'; import '../screens/media_detail_screen.dart'; -import '../screens/plex_metadata_edit_screen.dart'; +import '../screens/metadata_edit_screen.dart'; import '../utils/smart_deletion_handler.dart'; import '../utils/video_player_navigation.dart'; import '../utils/deletion_notifier.dart'; @@ -180,8 +181,8 @@ class MediaContextMenuState extends State { }; /// Get the correct PlexClient for this item's server. Throws on - /// non-Plex backends — Plex-only flows (Add to Collection, metadata - /// edit, etc.) call this directly. Backend-neutral flows must use + /// non-Plex backends — Plex-only flows (Add to Collection, match, + /// unmatch, etc.) call this directly. Backend-neutral flows must use /// [_getMediaClientForItem] instead. PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId); @@ -203,7 +204,7 @@ class MediaContextMenuState extends State { final isCollection = mediaKind == MediaKind.collection; // Backend-aware gate: a few menu items remain Plex-only because the - // server-side feature has no Jellyfin equivalent (metadata edit, match). + // server-side feature has no Jellyfin equivalent (match/unmatch). // No fallback: items without a backend marker show only neutral actions — // dispatching a Plex-only action against an unknown-backend item could // crash or hit the wrong server. @@ -237,6 +238,7 @@ class MediaContextMenuState extends State { final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null; final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false; final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false; + final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind); final menuActions = <_MenuAction>[]; @@ -317,15 +319,8 @@ class MediaContextMenuState extends State { menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate)); } - // Edit Metadata (for movies, shows, seasons, and episodes) — admin only - // Plex-only: opens PlexMetadataEditScreen which talks to Plex's - // `/library/metadata/{id}` PUT API; Jellyfin has no equivalent in v1. - if (isPlex && - isAdmin && - (mediaKind == MediaKind.movie || - mediaKind == MediaKind.show || - mediaKind == MediaKind.season || - mediaKind == MediaKind.episode)) { + // Edit Metadata — admin-only and backend-capability gated. + if (canEditMetadata) { menuActions.add( _MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata), ); @@ -655,10 +650,7 @@ class MediaContextMenuState extends State { didNavigate = true; if (context.mounted) { final item = mediaItem!; - await Navigator.push( - context, - MaterialPageRoute(builder: (context) => PlexMetadataEditScreen(metadata: item)), - ); + await Navigator.push(context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: item))); _notifyRefresh(item.id); } break; diff --git a/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart b/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart new file mode 100644 index 00000000..d8c8ce0b --- /dev/null +++ b/test/metadata_edit/jellyfin_metadata_edit_adapter_test.dart @@ -0,0 +1,130 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/metadata_edit/jellyfin_metadata_edit_adapter.dart'; +import 'package:plezy/services/jellyfin_client.dart'; + +void main() { + test('load fails when the full editable Jellyfin DTO is unavailable', () async { + final client = JellyfinClient.forTesting( + connection: _connection(), + httpClient: MockClient((request) async => http.Response('', 404)), + ); + addTearDown(client.close); + + final adapter = JellyfinMetadataEditAdapter(client); + final item = MediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + raw: {'Id': 'item-1', 'Name': 'Sparse browse row'}, + ); + + expect(adapter.load(item), throwsA(isA())); + }); + + test('save preserves unchanged Jellyfin people and studio identity data', () async { + String? capturedBody; + final client = JellyfinClient.forTesting( + connection: _connection(), + httpClient: MockClient((request) async { + if (request.url.path == '/Users/user-1/Items/item-1') { + return http.Response(jsonEncode(_editableMovie()), 200, headers: {'content-type': 'application/json'}); + } + if (request.url.path == '/Items/item-1') { + capturedBody = request.body; + return http.Response('', 204); + } + return http.Response('unexpected ${request.url}', 500); + }), + ); + addTearDown(client.close); + + final adapter = JellyfinMetadataEditAdapter(client); + final item = MediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie); + final draft = await adapter.load(item); + + draft.setValue('director', ['Alice', 'Charlie']); + draft.setValue('studio', ['Studio A', 'Studio C']); + draft.setValue('tagline', 'Updated tagline'); + + final success = await adapter.save(draft); + + expect(success, isTrue); + final body = jsonDecode(capturedBody!) as Map; + + final people = (body['People'] as List).cast>(); + expect(people.any((person) => person['Name'] == 'Bob'), isFalse); + expect(_byName(people, 'Alice'), containsPair('Id', 'person-alice')); + expect(_byName(people, 'Alice'), containsPair('PrimaryImageTag', 'alice-tag')); + expect(_byName(people, 'Alice'), containsPair('ProviderIds', {'Imdb': 'nm1'})); + expect(_byName(people, 'Charlie'), {'Name': 'Charlie', 'Type': 'Director'}); + expect(_byName(people, 'Actor One'), containsPair('Id', 'actor-1')); + expect(_byName(people, 'Wendy'), containsPair('Id', 'person-wendy')); + + final studios = (body['Studios'] as List).cast>(); + expect(studios.any((studio) => studio['Name'] == 'Studio B'), isFalse); + expect(_byName(studios, 'Studio A'), containsPair('Id', 'studio-a')); + expect(_byName(studios, 'Studio C'), {'Name': 'Studio C'}); + + expect(body['Taglines'], ['Updated tagline', 'Second tagline']); + }); +} + +JellyfinConnection _connection() { + return JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok', + deviceId: 'dev', + isAdministrator: true, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); +} + +Map _editableMovie() { + return { + 'Id': 'item-1', + 'Name': 'Movie', + 'Type': 'Movie', + 'ProviderIds': {'Tmdb': '123'}, + 'Tags': ['Favorite'], + 'Genres': ['Drama'], + 'Studios': [ + {'Name': 'Studio A', 'Id': 'studio-a'}, + {'Name': 'Studio B', 'Id': 'studio-b'}, + ], + 'People': [ + { + 'Name': 'Alice', + 'Type': 'Director', + 'Id': 'person-alice', + 'PrimaryImageTag': 'alice-tag', + 'ProviderIds': {'Imdb': 'nm1'}, + }, + {'Name': 'Bob', 'Type': 'Director', 'Id': 'person-bob'}, + {'Name': 'Wendy', 'Type': 'Writer', 'Id': 'person-wendy'}, + {'Name': 'Actor One', 'Type': 'Actor', 'Role': 'Hero', 'Id': 'actor-1'}, + ], + 'ProductionLocations': ['US'], + 'Taglines': ['Original tagline', 'Second tagline'], + 'LockedFields': ['Overview'], + 'LockData': true, + 'PremiereDate': '2020-01-01T00:00:00.0000000Z', + 'Trickplay': {'1': {}}, + }; +} + +Map _byName(List> values, String name) { + return values.singleWhere((value) => value['Name'] == name); +} diff --git a/test/metadata_edit/metadata_edit_models_test.dart b/test/metadata_edit/metadata_edit_models_test.dart new file mode 100644 index 00000000..747c3d54 --- /dev/null +++ b/test/metadata_edit/metadata_edit_models_test.dart @@ -0,0 +1,110 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/metadata_edit/metadata_edit_models.dart'; + +void main() { + test('adapter dirty tracking ignores immediate fields', () { + final item = MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie); + final adapter = _TestMetadataEditAdapter(); + final draft = MetadataEditDraft( + sourceItem: item, + currentItem: item, + values: {'title': 'Original', 'artwork:posters': 'old-poster'}, + ); + + draft.setValue('artwork:posters', 'new-poster'); + expect(adapter.hasChanges(draft), isFalse); + + draft.setValue('title', 'Edited'); + expect(adapter.hasChanges(draft), isTrue); + }); + + test('adapter dirty tracking compares string lists as sets', () { + final item = MediaItem(id: '1', backend: MediaBackend.plex, kind: MediaKind.movie); + final adapter = _TestMetadataEditAdapter(); + final draft = MetadataEditDraft( + sourceItem: item, + currentItem: item, + values: { + 'title': 'Original', + 'genre': ['Drama', 'Action'], + 'artwork:posters': 'old-poster', + }, + ); + + draft.setValue('genre', ['Action', 'Drama']); + expect(adapter.hasChanges(draft), isFalse); + + draft.setValue('genre', ['Action', 'Comedy']); + expect(adapter.hasChanges(draft), isTrue); + }); +} + +class _TestMetadataEditAdapter extends MetadataEditAdapter { + @override + MediaBackend get backend => MediaBackend.plex; + + @override + MediaServerClient get mediaClient => throw UnimplementedError(); + + @override + bool supportsKind(MediaKind kind) => true; + + @override + Future load(MediaItem item) async => throw UnimplementedError(); + + @override + List buildSchema(MetadataEditDraft draft) { + return const [ + MetadataEditSection( + id: 'test', + title: 'Test', + fields: [ + MetadataEditField(id: 'title', label: 'Title', type: MetadataEditFieldType.text), + MetadataEditField(id: 'genre', label: 'Genre', type: MetadataEditFieldType.stringList), + MetadataEditField( + id: 'artwork:posters', + label: 'Poster', + type: MetadataEditFieldType.artwork, + saveMode: MetadataEditSaveMode.immediate, + ), + ], + ), + ]; + } + + @override + Future save(MetadataEditDraft draft) async => true; + + @override + Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async { + throw UnimplementedError(); + } + + @override + Future applyArtworkOption( + MetadataEditDraft draft, + MetadataEditField field, + MetadataArtworkOption option, + ) async { + throw UnimplementedError(); + } + + @override + Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { + throw UnimplementedError(); + } + + @override + Future uploadArtwork( + MetadataEditDraft draft, + MetadataEditField field, + List bytes, { + String? fileName, + }) async { + throw UnimplementedError(); + } +} diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 69da2fb1..79d3017a 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -2873,6 +2873,137 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); + test('fetchEditableMetadataItem requests full item dto without limited fields', () async { + Uri? capturedUri; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + return http.Response( + jsonEncode({ + 'Id': 'folder/item #1?x', + 'Name': 'Movie', + 'Type': 'Movie', + 'ProviderIds': {'Tmdb': '1'}, + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }), + ); + addTearDown(client.close); + + final item = await client.fetchEditableMetadataItem('folder/item #1?x'); + + expect(item?['ProviderIds'], {'Tmdb': '1'}); + expect(capturedUri!.path, '/Users/user-1/Items/folder%2Fitem%20%231%3Fx'); + expect(capturedUri!.queryParameters.containsKey('Fields'), isFalse); + }); + + test('updateMetadataItem posts full dto to item update endpoint', () async { + Uri? capturedUri; + String? capturedBody; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + capturedBody = request.body; + return http.Response('', 204); + }), + ); + addTearDown(client.close); + + final success = await client.updateMetadataItem('item-1', { + 'Id': 'item-1', + 'Name': 'Edited', + 'Type': 'Movie', + 'ProviderIds': {'Tmdb': '123'}, + 'Tags': ['Favorite'], + }); + + expect(success, isTrue); + expect(capturedUri!.path, '/Items/item-1'); + final body = jsonDecode(capturedBody!) as Map; + expect(body['Name'], 'Edited'); + expect(body['ProviderIds'], {'Tmdb': '123'}); + expect(body['Tags'], ['Favorite']); + }); + + test('remote image search and apply use Jellyfin image endpoints', () async { + final requests = []; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + requests.add(request.url); + if (request.url.path == '/Items/item-1/RemoteImages') { + return http.Response( + jsonEncode({ + 'TotalRecordCount': 1, + 'Providers': ['TheMovieDb'], + 'Images': [ + {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + } + return http.Response('', 204); + }), + ); + addTearDown(client.close); + + final result = await client.getRemoteImages( + 'item-1', + imageType: 'Primary', + limit: 20, + providerName: 'TheMovieDb', + ); + final success = await client.downloadRemoteImage( + 'item-1', + imageType: 'Primary', + imageUrl: 'https://img.example/poster.jpg', + ); + + expect((result['Images'] as List).single['Url'], 'https://img.example/poster.jpg'); + expect(success, isTrue); + expect(requests[0].path, '/Items/item-1/RemoteImages'); + expect(requests[0].queryParameters['type'], 'Primary'); + expect(requests[0].queryParameters['limit'], '20'); + expect(requests[0].queryParameters['providerName'], 'TheMovieDb'); + expect(requests[1].path, '/Items/item-1/RemoteImages/Download'); + expect(requests[1].queryParameters['type'], 'Primary'); + expect(requests[1].queryParameters['imageUrl'], 'https://img.example/poster.jpg'); + }); + + test('uploadItemImage sends base64 image body and image content type', () async { + Uri? capturedUri; + String? capturedBody; + Map? capturedHeaders; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + capturedBody = request.body; + capturedHeaders = request.headers; + return http.Response('', 204); + }), + ); + addTearDown(client.close); + + final success = await client.uploadItemImage( + 'item-1', + imageType: 'Primary', + bytes: [0xff, 0xd8, 0xff, 0x00], + contentType: 'image/jpeg', + ); + + expect(success, isTrue); + expect(capturedUri!.path, '/Items/item-1/Images/Primary'); + expect(capturedBody, base64Encode([0xff, 0xd8, 0xff, 0x00])); + expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], 'image/jpeg'); + }); + test('smart=true returns empty because Jellyfin playlists are normal playlists', () async { final client = buildClient(); diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index 1904e5c0..9fcdd12d 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -1,7 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/connection/connection.dart'; import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/metadata_edit/metadata_edit_adapters.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/profiles/profile.dart'; +import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/widgets/media_context_menu.dart'; void main() { @@ -37,6 +43,20 @@ void main() { ); }); }); + + group('supportsMetadataEdit', () { + test('allows Jellyfin video metadata edit through capability gate', () { + final client = JellyfinClient.forTesting( + connection: _jellyfinConnection(), + httpClient: MockClient((_) async => http.Response('', 204)), + ); + addTearDown(client.close); + + expect(supportsMetadataEdit(client, MediaKind.movie), isTrue); + expect(supportsMetadataEdit(client, MediaKind.show), isTrue); + expect(supportsMetadataEdit(client, MediaKind.track), isFalse); + }); + }); } PlexHomeUser _homeUser({required bool admin}) { @@ -56,3 +76,18 @@ PlexHomeUser _homeUser({required bool admin}) { protected: false, ); } + +JellyfinConnection _jellyfinConnection() { + return JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok', + deviceId: 'dev', + isAdministrator: true, + createdAt: DateTime.fromMillisecondsSinceEpoch(0), + ); +}