feat(metadata): add Jellyfin edit support

This commit is contained in:
edde746
2026-06-01 08:34:38 +02:00
parent 8f9d20a2e4
commit 09f5121732
19 changed files with 2245 additions and 1148 deletions
+3 -3
View File
@@ -31,9 +31,9 @@ import 'server_capabilities.dart';
/// ///
/// ## Naming /// ## Naming
/// ///
/// Read methods use a `fetch*` prefix. Plex-only operations that have no /// Read methods use a `fetch*` prefix. Backend-specific operations that do not
/// Jellyfin equivalent (DVR tuning, metadata edit, match) live on /// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata
/// [PlexClient] directly under their original `get*` / verb names. /// edit adapters) live on concrete clients or feature modules.
/// ///
/// ## Error contract (write methods) /// ## Error contract (write methods)
/// ///
+2 -4
View File
@@ -88,9 +88,7 @@ class ServerCapabilities {
/// uses Plex-shaped session/metadata. /// uses Plex-shaped session/metadata.
final bool discordRpc; final bool discordRpc;
/// Server exposes a metadata edit endpoint (Plex /// Server exposes metadata edit endpoints. Hides edit affordances when false.
/// `/library/metadata/{id}` PUT). Hides the "Manage" affordances when
/// false.
final bool richMetadataEdit; final bool richMetadataEdit;
/// How the alpha-jump bar should behave for this backend's libraries. /// How the alpha-jump bar should behave for this backend's libraries.
@@ -176,7 +174,7 @@ class ServerCapabilities {
endpointFailover: true, endpointFailover: true,
offlineWatchQueue: false, offlineWatchQueue: false,
discordRpc: false, discordRpc: false,
richMetadataEdit: false, richMetadataEdit: true,
alphaBar: AlphaBarMode.nameStartsWithFilter, alphaBar: AlphaBarMode.nameStartsWithFilter,
scrubThumbnails: true, scrubThumbnails: true,
folderGrouping: true, folderGrouping: true,
@@ -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<MetadataEditDraft> load(MediaItem item) async {
final raw = await client.fetchEditableMetadataItem(item.id);
if (raw == null) {
throw StateError('Editable Jellyfin metadata item is unavailable');
}
final values = <String, Object?>{};
_writeCommonValues(values, raw, item);
_writeArtworkValues(values, item);
return MetadataEditDraft(sourceItem: item, currentItem: item, values: values, extras: {'raw': raw});
}
@override
List<MetadataEditSection> 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<bool> save(MetadataEditDraft draft) async {
final raw = draft.extras['raw'];
if (raw is! Map<String, dynamic>) return false;
final dto = Map<String, dynamic>.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<String>('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<String>('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<String>('tagline'));
final existing = _stringList(dto['Taglines']);
dto['Taglines'] = tagline == null ? <String>[] : <String>[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<List<MetadataArtworkOption>> 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<String, dynamic>>()
.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<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
@override
Future<bool> 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<bool> uploadArtwork(
MetadataEditDraft draft,
MetadataEditField field,
List<int> 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<String, Object?> values, Map<String, dynamic> 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<String, Object?> values, MediaItem item) {
values['artwork:Primary'] = item.thumbPath;
values['artwork:Backdrop'] = item.artPath;
values['artwork:Logo'] = item.clearLogoPath;
}
List<MetadataEditField> _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<MetadataEditField> _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<MetadataEditField> _artworkFields(MediaKind kind) {
final fields = <MetadataEditField>[
_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<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) {
if (!draft.fieldChanged(fieldId)) return;
dto[dtoKey] = metadataEmptyToNull(draft.value<String>(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<String> _stringList(Object? value) => metadataStringList(value);
Map<String, String> _stringMap(Object? value) {
if (value is! Map) return <String, String>{};
return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
}
List<Map<String, dynamic>> _mapList(Object? value) {
if (value is! List) return <Map<String, dynamic>>[];
return value.whereType<Map>().map((item) => Map<String, dynamic>.from(item)).toList();
}
List<String> _nameList(Object? value) {
return _mapList(value)
.map((item) => item['Name'] as String?)
.whereType<String>()
.where((name) => name.trim().isNotEmpty)
.map((name) => name.trim())
.toList();
}
List<String> _peopleByType(Object? value, String type) {
return _mapList(value)
.where((person) => (person['Type'] as String?)?.toLowerCase() == type.toLowerCase())
.map((person) => person['Name'] as String?)
.whereType<String>()
.where((name) => name.trim().isNotEmpty)
.map((name) => name.trim())
.toList();
}
List<Map<String, dynamic>> _replacePeopleByType(List<Map<String, dynamic>> people, String type, List<String> names) {
final lowerType = type.toLowerCase();
final existing = people.where((person) => (person['Type'] as String?)?.toLowerCase() == lowerType).toList();
final used = <int>{};
return [
...people.where((person) => (person['Type'] as String?)?.toLowerCase() != lowerType),
...names.map((name) => _preserveNamedMap(existing, used, name, type: type)),
];
}
List<Map<String, dynamic>> _replaceNamePairs(List<Map<String, dynamic>> existing, List<String> names) {
final used = <int>{};
return names.map((name) => _preserveNamedMap(existing, used, name)).toList();
}
Map<String, dynamic> _preserveNamedMap(
List<Map<String, dynamic>> existing,
Set<int> 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 = <String, dynamic>{'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<int> 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';
}
@@ -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;
}
+210
View File
@@ -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<MetadataEditOption> 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<MetadataEditField> 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<String, Object?> values;
final Map<String, Object?> originalValues;
final Map<String, Object?> extras;
List<MetadataEditSection>? cachedSchema;
MetadataEditDraft({
required this.sourceItem,
required this.currentItem,
required this.values,
Map<String, Object?>? originalValues,
Map<String, Object?>? extras,
}) : originalValues = originalValues ?? Map<String, Object?>.from(values),
extras = extras ?? <String, Object?>{};
T? value<T>(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<String, Object?>.from(values));
}
}
abstract class MetadataEditAdapter {
MediaBackend get backend;
MediaServerClient get mediaClient;
bool supportsKind(MediaKind kind);
Future<MetadataEditDraft> load(MediaItem item);
List<MetadataEditSection> buildSchema(MetadataEditDraft draft);
List<MetadataEditSection> 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<bool> save(MetadataEditDraft draft);
Future<bool> 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<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option);
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url);
Future<bool> uploadArtwork(MetadataEditDraft draft, MetadataEditField field, List<int> bytes, {String? fileName});
Future<MediaItem?> 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<String> metadataStringList(Object? value) {
if (value is List) {
return value.whereType<String>().where((v) => v.trim().isNotEmpty).map((v) => v.trim()).toList();
}
if (value is String && value.trim().isNotEmpty) return [value.trim()];
return <String>[];
}
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;
}
@@ -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<MetadataEditDraft> load(MediaItem item) async {
MediaItem fullItem = item;
if (item.summary == null || item.libraryId == null) {
fullItem = await client.fetchItem(item.id) ?? item;
}
final values = <String, Object?>{};
_writeCommonValues(values, fullItem);
_writeArtworkValues(values, fullItem);
_writePrefValues(values, fullItem);
return MetadataEditDraft(sourceItem: item, currentItem: fullItem, values: values);
}
@override
List<MetadataEditSection> 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<bool> save(MetadataEditDraft draft) async {
final sectionId = int.tryParse(draft.currentItem.libraryId ?? draft.sourceItem.libraryId ?? '');
if (sectionId == null) return false;
Map<String, ({List<String> current, List<String> 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<bool> 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<List<MetadataArtworkOption>> 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<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
@override
Future<bool> 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<bool> uploadArtwork(
MetadataEditDraft draft,
MetadataEditField field,
List<int> 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<String, Object?> 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<String>.of(item.genres ?? const []);
values['director'] = List<String>.of(item.directors ?? const []);
values['writer'] = List<String>.of(item.writers ?? const []);
values['producer'] = List<String>.of(item.producers ?? const []);
values['country'] = List<String>.of(item.countries ?? const []);
values['collection'] = List<String>.of(item.collections ?? const []);
values['label'] = List<String>.of(item.labels ?? const []);
values['style'] = List<String>.of(item.styles ?? const []);
values['mood'] = List<String>.of(item.moods ?? const []);
}
void _writeArtworkValues(Map<String, Object?> 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<String, Object?> 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<MetadataEditField> _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<MetadataEditField> _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<MetadataEditField> _artworkFields(MediaKind kind) {
final fields = <MetadataEditField>[
_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<MetadataEditField> _advancedFields(MediaKind kind) {
final fields = <MetadataEditField>[];
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<MetadataEditField> _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<MetadataEditField> _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<MetadataEditOption> 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<MetadataEditOption> _buildLanguageOptions(String defaultLabel, List<String> codes) {
return [
MetadataEditOption(value: '', label: defaultLabel),
...codes.map((code) => MetadataEditOption(value: code, label: LanguageCodes.getDisplayName(code))),
];
}
List<MetadataEditOption> _metadataLanguageOptions(String defaultLabel) =>
_buildLanguageOptions(defaultLabel, _plexLocaleCodes);
List<MetadataEditOption> _audioSubtitleLanguageOptions(String defaultLabel) =>
_buildLanguageOptions(defaultLabel, [..._commonAudioSubtitleCodes, ..._plexLocaleCodes]);
+582
View File
@@ -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<MetadataEditScreen> createState() => _MetadataEditScreenState();
}
class _MetadataEditScreenState extends State<MetadataEditScreen> {
MetadataEditAdapter? _adapter;
MetadataEditDraft? _draft;
bool _isLoading = true;
bool _isSaving = false;
@override
void initState() {
super.initState();
_loadMetadata();
}
Future<void> _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<void> _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<void> _editTextField(MetadataEditField field, {bool multiline = false}) async {
final draft = _draft;
if (draft == null) return;
final currentValue = draft.value<String>(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<void> _editDate(MetadataEditField field) async {
final draft = _draft;
if (draft == null) return;
DateTime initial = DateTime.now();
final current = draft.value<String>(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<void> _editStringList(MetadataEditField field) async {
final draft = _draft;
if (draft == null) return;
final result = await showDialog<List<String>>(
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<void> _editChoice(MetadataEditField field) async {
final adapter = _adapter;
final draft = _draft;
if (adapter == null || draft == null) return;
final current = draft.value<String>(field.id) ?? '';
final result = await showDialog<String>(
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<String>(
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<String>(
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<void> _openArtworkPicker(MetadataEditField field) async {
final adapter = _adapter;
final draft = _draft;
if (adapter == null || draft == null) return;
final result = await showDialog<bool>(
context: context,
builder: (context) => ArtworkPickerDialog(adapter: adapter, draft: draft, field: field),
);
if (result == true && mounted) {
await _reloadArtwork();
}
}
Future<void> _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<String>(field.id),
onTap: () => _editTextField(field),
),
MetadataEditFieldType.multilineText => _buildFieldTile(
label: field.label,
value: draft.value<String>(field.id),
onTap: () => _editTextField(field, multiline: true),
),
MetadataEditFieldType.date => _buildFieldTile(
label: field.label,
value: draft.value<String>(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<String>(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<String>(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<ArtworkPickerDialog> createState() => _ArtworkPickerDialogState();
}
class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
List<MetadataArtworkOption>? _artworkList;
bool _isLoading = true;
bool _isApplying = false;
MetadataArtworkConfig get _config => widget.field.artwork!;
@override
void initState() {
super.initState();
_loadArtwork();
}
Future<void> _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<void> _selectArtwork(MetadataArtworkOption artwork) async {
if (_isApplying) return;
await _runArtworkUpdate(() => widget.adapter.applyArtworkOption(widget.draft, widget.field, artwork));
}
Future<void> _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<void> _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<void> _runArtworkUpdate(Future<bool> 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;
}
-902
View File
@@ -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<PlexMetadataEditScreen> createState() => _PlexMetadataEditScreenState();
}
class _PlexMetadataEditScreenState extends State<PlexMetadataEditScreen> {
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<String, List<String>> _tags = {};
final Map<String, List<String>> _origTags = {};
// Advanced prefs (loaded from metadata JSON)
final Map<String, String> _currentPrefs = {};
static bool _tagsEqual(List<String> a, List<String> 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<void> _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<String>? 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<void> _save() async {
if (!_hasChanges || _isSaving) return;
final sectionId = _librarySectionId;
if (sectionId == null) {
if (mounted) showErrorSnackBar(context, t.metadataEdit.metadataUpdateFailed);
return;
}
setState(() => _isSaving = true);
Map<String, ({List<String> current, List<String> 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<void> _editTextField({
required String title,
required String label,
required String? currentValue,
required ValueChanged<String> 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<void> _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<void> _openArtworkPicker(String element) async {
final result = await showDialog<bool>(
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<void> _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<void> _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<String>(
context: context,
builder: (dialogContext) {
String? selected = currentValue;
return StatefulBuilder(
builder: (context, setDialogState) {
return AlertDialog(
title: Text(title),
content: SizedBox(
width: double.maxFinite,
child: RadioGroup<String>(
groupValue: selected,
onChanged: (val) {
setDialogState(() => selected = val);
Navigator.pop(dialogContext, val);
},
child: ListView(
shrinkWrap: true,
children: options.map((option) {
return FocusableRadioListTile<String>(
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<void> _editTag(String key, String label) async {
final result = await showDialog<List<String>>(
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<Widget> _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<Widget> _buildMovieAdvancedSettings() {
return _buildMetadataLanguageTiles();
}
List<Widget> _buildSeasonAdvancedSettings() {
return _buildAudioSubtitleTiles(t.metadataEdit.seriesDefault);
}
List<Widget> _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<Widget> _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<String> 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]);
+4 -1
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http; 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/file_info.dart';
part 'jellyfin_client/parts/live_tv.dart'; part 'jellyfin_client/parts/live_tv.dart';
part 'jellyfin_client/parts/images_downloads.dart'; part 'jellyfin_client/parts/images_downloads.dart';
part 'jellyfin_client/parts/metadata_edit.dart';
/// [MediaServerClient] over a Jellyfin server. /// [MediaServerClient] over a Jellyfin server.
/// ///
@@ -86,7 +88,8 @@ class JellyfinClient
_JellyfinCollectionMethods, _JellyfinCollectionMethods,
_JellyfinFileInfoMethods, _JellyfinFileInfoMethods,
_JellyfinLiveTvMethods, _JellyfinLiveTvMethods,
_JellyfinImageDownloadMethods _JellyfinImageDownloadMethods,
_JellyfinMetadataEditMethods
implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable { implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable {
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository}) JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository(); : _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
@@ -0,0 +1,86 @@
part of '../../jellyfin_client.dart';
mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
MediaServerHttpClient get _http;
Future<Map<String, dynamic>?> 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<String, dynamic> ? data : null;
}
Future<bool> updateMetadataItem(String itemId, Map<String, dynamic> 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<Map<String, dynamic>> 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<String, dynamic> ? data : const <String, dynamic>{};
}
Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>().toList() : const <Map<String, dynamic>>[];
}
Future<bool> 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<bool> uploadItemImage(
String itemId, {
required String imageType,
required List<int> 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<void> _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);
}
}
}
+34 -10
View File
@@ -2160,7 +2160,7 @@ class PlexClient
String? tagline, String? tagline,
String? summary, String? summary,
Map<String, ({List<String> current, List<String> original})>? tagChanges, Map<String, ({List<String> current, List<String> original})>? tagChanges,
}) { }) async {
final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey}; final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
void addField(String name, String? value) { 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), () => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
'Failed to update metadata', 'Failed to update metadata',
); );
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
} }
/// Search for match candidates for a media item. /// Search for match candidates for a media item.
@@ -2239,7 +2243,7 @@ class PlexClient
'Failed to apply match', 'Failed to apply match',
); );
if (result) { if (result) {
await _cache.deleteForItem(serverId, ratingKey); await _deleteMetadataEditCache(ratingKey);
} }
return result; return result;
} }
@@ -2250,7 +2254,7 @@ class PlexClient
'Failed to unmatch item', 'Failed to unmatch item',
); );
if (result) { if (result) {
await _cache.deleteForItem(serverId, ratingKey); await _deleteMetadataEditCache(ratingKey);
} }
return result; return result;
} }
@@ -2271,18 +2275,22 @@ class PlexClient
} }
/// Set artwork from a URL (can be a Plex internal path or external URL) /// Set artwork from a URL (can be a Plex internal path or external URL)
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) { Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; 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}), () => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
'Failed to set artwork from URL', 'Failed to set artwork from URL',
); );
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
} }
/// Upload artwork from binary data /// Upload artwork from binary data
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) { Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
return _wrapBoolApiCall( final result = await _wrapBoolApiCall(
() => _http.put( () => _http.put(
'/library/metadata/$ratingKey/$setElement', '/library/metadata/$ratingKey/$setElement',
body: bytes, body: bytes,
@@ -2290,14 +2298,30 @@ class PlexClient
), ),
'Failed to upload artwork', 'Failed to upload artwork',
); );
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
} }
/// Update per-media advanced preferences /// Update per-media advanced preferences
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) { Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
return _wrapBoolApiCall( final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs), () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
'Failed to update metadata preferences', 'Failed to update metadata preferences',
); );
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<void> _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. /// Get one page of collections for a library section.
+5 -1
View File
@@ -120,6 +120,7 @@ Future<String?> showTextInputDialog(
TextInputType? keyboardType, TextInputType? keyboardType,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
String? Function(String)? validator, String? Function(String)? validator,
bool allowEmpty = false,
}) { }) {
return showDialog<String>( return showDialog<String>(
context: context, context: context,
@@ -132,6 +133,7 @@ Future<String?> showTextInputDialog(
keyboardType: keyboardType, keyboardType: keyboardType,
inputFormatters: inputFormatters, inputFormatters: inputFormatters,
validator: validator, validator: validator,
allowEmpty: allowEmpty,
), ),
); );
} }
@@ -237,6 +239,7 @@ class _TextInputDialog extends StatefulWidget {
final TextInputType? keyboardType; final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
final String? Function(String)? validator; final String? Function(String)? validator;
final bool allowEmpty;
const _TextInputDialog({ const _TextInputDialog({
required this.title, required this.title,
@@ -247,6 +250,7 @@ class _TextInputDialog extends StatefulWidget {
this.keyboardType, this.keyboardType,
this.inputFormatters, this.inputFormatters,
this.validator, this.validator,
this.allowEmpty = false,
}); });
@override @override
@@ -260,7 +264,7 @@ class _TextInputDialogState extends State<_TextInputDialog>
void _submit() { void _submit() {
final text = _controller.text; final text = _controller.text;
if (text.isEmpty) return; if (text.isEmpty && !widget.allowEmpty) return;
if (widget.validator != null && widget.validator!(text) != null) return; if (widget.validator != null && widget.validator!(text) != null) return;
Navigator.pop(context, text); Navigator.pop(context, text);
} }
+4 -4
View File
@@ -21,8 +21,8 @@ extension ProviderExtensions on BuildContext {
/// Internal: resolve a [PlexClient] from a serverId or fall back to the /// Internal: resolve a [PlexClient] from a serverId or fall back to the
/// first online server. Returns null if neither yields a Plex client. /// first online server. Returns null if neither yields a Plex client.
/// Non-Plex servers (Jellyfin) are skipped — these helpers exist for /// Non-Plex servers (Jellyfin) are skipped — these helpers exist for
/// Plex-only flows that have no neutral equivalent (DVR tuning, metadata /// Plex-only flows that have no neutral equivalent (DVR tuning, match).
/// edit, match). Backend-agnostic flows use the [_resolveMediaClient] /// Backend-agnostic flows use the [_resolveMediaClient]
/// helpers below. /// helpers below.
PlexClient? _resolveClient(String? serverId) { PlexClient? _resolveClient(String? serverId) {
final provider = Provider.of<MultiServerProvider>(this, listen: false); final provider = Provider.of<MultiServerProvider>(this, listen: false);
@@ -65,8 +65,8 @@ extension ProviderExtensions on BuildContext {
// These return [MediaServerClient] regardless of backend kind so callers // These return [MediaServerClient] regardless of backend kind so callers
// that consume only the [MediaServerClient] surface don't need to type- // that consume only the [MediaServerClient] surface don't need to type-
// check the result. Use [getPlexClientForServer] / [getPlexClientForLibrary] // check the result. Use [getPlexClientForServer] / [getPlexClientForLibrary]
// when you specifically need a [PlexClient] (Plex-only flows like Live TV, // when you specifically need a [PlexClient] (Plex-only flows like Live TV or
// metadata editing, etc.). // match/fix-match).
MediaServerClient? _resolveMediaClient(String? serverId) { MediaServerClient? _resolveMediaClient(String? serverId) {
final provider = Provider.of<MultiServerProvider>(this, listen: false); final provider = Provider.of<MultiServerProvider>(this, listen: false);
-206
View File
@@ -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<ArtworkPickerDialog> createState() => _ArtworkPickerDialogState();
}
class _ArtworkPickerDialogState extends State<ArtworkPickerDialog> {
List<Map<String, dynamic>>? _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<void> _loadArtwork() async {
final artwork = await widget.client.getAvailableArtwork(widget.ratingKey, widget.element);
if (!mounted) return;
setState(() {
_artworkList = artwork;
_isLoading = false;
});
}
Future<void> _selectArtwork(Map<String, dynamic> 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<void> _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<void> _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<void> _runArtworkUpdate(Future<bool> 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),
),
),
],
),
),
);
},
);
}
}
+9 -17
View File
@@ -10,6 +10,7 @@ import '../media/media_item.dart';
import '../media/media_kind.dart'; import '../media/media_kind.dart';
import '../media/media_playlist.dart'; import '../media/media_playlist.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../metadata_edit/metadata_edit_adapters.dart';
import '../media/media_version.dart'; import '../media/media_version.dart';
import '../mixins/controller_disposer_mixin.dart'; import '../mixins/controller_disposer_mixin.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
@@ -43,7 +44,7 @@ import '../focus/focusable_text_field.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../screens/plex_match_screen.dart'; import '../screens/plex_match_screen.dart';
import '../screens/media_detail_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/smart_deletion_handler.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
@@ -180,8 +181,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}; };
/// Get the correct PlexClient for this item's server. Throws on /// Get the correct PlexClient for this item's server. Throws on
/// non-Plex backends — Plex-only flows (Add to Collection, metadata /// non-Plex backends — Plex-only flows (Add to Collection, match,
/// edit, etc.) call this directly. Backend-neutral flows must use /// unmatch, etc.) call this directly. Backend-neutral flows must use
/// [_getMediaClientForItem] instead. /// [_getMediaClientForItem] instead.
PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId); PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId);
@@ -203,7 +204,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final isCollection = mediaKind == MediaKind.collection; final isCollection = mediaKind == MediaKind.collection;
// Backend-aware gate: a few menu items remain Plex-only because the // 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 — // No fallback: items without a backend marker show only neutral actions —
// dispatching a Plex-only action against an unknown-backend item could // dispatching a Plex-only action against an unknown-backend item could
// crash or hit the wrong server. // crash or hit the wrong server.
@@ -237,6 +238,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null; final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null;
final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false; final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false;
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false; final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
final menuActions = <_MenuAction>[]; final menuActions = <_MenuAction>[];
@@ -317,15 +319,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate)); menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate));
} }
// Edit Metadata (for movies, shows, seasons, and episodes) — admin only // Edit Metadata — admin-only and backend-capability gated.
// Plex-only: opens PlexMetadataEditScreen which talks to Plex's if (canEditMetadata) {
// `/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)) {
menuActions.add( menuActions.add(
_MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata), _MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata),
); );
@@ -655,10 +650,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
didNavigate = true; didNavigate = true;
if (context.mounted) { if (context.mounted) {
final item = mediaItem!; final item = mediaItem!;
await Navigator.push( await Navigator.push(context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: item)));
context,
MaterialPageRoute(builder: (context) => PlexMetadataEditScreen(metadata: item)),
);
_notifyRefresh(item.id); _notifyRefresh(item.id);
} }
break; break;
@@ -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<StateError>()));
});
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<String, dynamic>;
final people = (body['People'] as List).cast<Map<String, dynamic>>();
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<Map<String, dynamic>>();
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<String, dynamic> _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<String, dynamic> _byName(List<Map<String, dynamic>> values, String name) {
return values.singleWhere((value) => value['Name'] == name);
}
@@ -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<MetadataEditDraft> load(MediaItem item) async => throw UnimplementedError();
@override
List<MetadataEditSection> 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<bool> save(MetadataEditDraft draft) async => true;
@override
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field) async {
throw UnimplementedError();
}
@override
Future<bool> applyArtworkOption(
MetadataEditDraft draft,
MetadataEditField field,
MetadataArtworkOption option,
) async {
throw UnimplementedError();
}
@override
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
throw UnimplementedError();
}
@override
Future<bool> uploadArtwork(
MetadataEditDraft draft,
MetadataEditField field,
List<int> bytes, {
String? fileName,
}) async {
throw UnimplementedError();
}
}
@@ -2873,6 +2873,137 @@ void main() {
expect(uri.queryParameters['api_key'], 'tok-abc'); 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<String, dynamic>;
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 = <Uri>[];
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<String, String>? 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 { test('smart=true returns empty because Jellyfin playlists are normal playlists', () async {
final client = buildClient(); final client = buildClient();
+35
View File
@@ -1,7 +1,13 @@
import 'package:flutter_test/flutter_test.dart'; 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_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/models/plex/plex_home_user.dart';
import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/widgets/media_context_menu.dart'; import 'package:plezy/widgets/media_context_menu.dart';
void main() { 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}) { PlexHomeUser _homeUser({required bool admin}) {
@@ -56,3 +76,18 @@ PlexHomeUser _homeUser({required bool admin}) {
protected: false, 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),
);
}