feat(metadata): add Jellyfin edit support
This commit is contained in:
@@ -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)
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]);
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user