feat(metadata): add Jellyfin edit support
This commit is contained in:
@@ -31,9 +31,9 @@ import 'server_capabilities.dart';
|
||||
///
|
||||
/// ## Naming
|
||||
///
|
||||
/// Read methods use a `fetch*` prefix. Plex-only operations that have no
|
||||
/// Jellyfin equivalent (DVR tuning, metadata edit, match) live on
|
||||
/// [PlexClient] directly under their original `get*` / verb names.
|
||||
/// Read methods use a `fetch*` prefix. Backend-specific operations that do not
|
||||
/// fit the neutral browsing/playback surface (DVR tuning, match, rich metadata
|
||||
/// edit adapters) live on concrete clients or feature modules.
|
||||
///
|
||||
/// ## Error contract (write methods)
|
||||
///
|
||||
|
||||
@@ -88,9 +88,7 @@ class ServerCapabilities {
|
||||
/// uses Plex-shaped session/metadata.
|
||||
final bool discordRpc;
|
||||
|
||||
/// Server exposes a metadata edit endpoint (Plex
|
||||
/// `/library/metadata/{id}` PUT). Hides the "Manage" affordances when
|
||||
/// false.
|
||||
/// Server exposes metadata edit endpoints. Hides edit affordances when false.
|
||||
final bool richMetadataEdit;
|
||||
|
||||
/// How the alpha-jump bar should behave for this backend's libraries.
|
||||
@@ -176,7 +174,7 @@ class ServerCapabilities {
|
||||
endpointFailover: true,
|
||||
offlineWatchQueue: false,
|
||||
discordRpc: false,
|
||||
richMetadataEdit: false,
|
||||
richMetadataEdit: true,
|
||||
alphaBar: AlphaBarMode.nameStartsWithFilter,
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
|
||||
@@ -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:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -69,6 +70,7 @@ part 'jellyfin_client/parts/collections.dart';
|
||||
part 'jellyfin_client/parts/file_info.dart';
|
||||
part 'jellyfin_client/parts/live_tv.dart';
|
||||
part 'jellyfin_client/parts/images_downloads.dart';
|
||||
part 'jellyfin_client/parts/metadata_edit.dart';
|
||||
|
||||
/// [MediaServerClient] over a Jellyfin server.
|
||||
///
|
||||
@@ -86,7 +88,8 @@ class JellyfinClient
|
||||
_JellyfinCollectionMethods,
|
||||
_JellyfinFileInfoMethods,
|
||||
_JellyfinLiveTvMethods,
|
||||
_JellyfinImageDownloadMethods
|
||||
_JellyfinImageDownloadMethods,
|
||||
_JellyfinMetadataEditMethods
|
||||
implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable {
|
||||
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
|
||||
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
|
||||
|
||||
@@ -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? summary,
|
||||
Map<String, ({List<String> current, List<String> original})>? tagChanges,
|
||||
}) {
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
|
||||
|
||||
void addField(String name, String? value) {
|
||||
@@ -2195,10 +2195,14 @@ class PlexClient
|
||||
}
|
||||
}
|
||||
|
||||
return _wrapBoolApiCall(
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
|
||||
'Failed to update metadata',
|
||||
);
|
||||
if (result) {
|
||||
await _deleteMetadataEditCache(ratingKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Search for match candidates for a media item.
|
||||
@@ -2239,7 +2243,7 @@ class PlexClient
|
||||
'Failed to apply match',
|
||||
);
|
||||
if (result) {
|
||||
await _cache.deleteForItem(serverId, ratingKey);
|
||||
await _deleteMetadataEditCache(ratingKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -2250,7 +2254,7 @@ class PlexClient
|
||||
'Failed to unmatch item',
|
||||
);
|
||||
if (result) {
|
||||
await _cache.deleteForItem(serverId, ratingKey);
|
||||
await _deleteMetadataEditCache(ratingKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -2271,18 +2275,22 @@ class PlexClient
|
||||
}
|
||||
|
||||
/// Set artwork from a URL (can be a Plex internal path or external URL)
|
||||
Future<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;
|
||||
return _wrapBoolApiCall(
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
|
||||
'Failed to set artwork from URL',
|
||||
);
|
||||
if (result) {
|
||||
await _deleteMetadataEditCache(ratingKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Upload artwork from binary data
|
||||
Future<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;
|
||||
return _wrapBoolApiCall(
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _http.put(
|
||||
'/library/metadata/$ratingKey/$setElement',
|
||||
body: bytes,
|
||||
@@ -2290,14 +2298,30 @@ class PlexClient
|
||||
),
|
||||
'Failed to upload artwork',
|
||||
);
|
||||
if (result) {
|
||||
await _deleteMetadataEditCache(ratingKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Update per-media advanced preferences
|
||||
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) {
|
||||
return _wrapBoolApiCall(
|
||||
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
|
||||
final result = await _wrapBoolApiCall(
|
||||
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
|
||||
'Failed to update metadata preferences',
|
||||
);
|
||||
if (result) {
|
||||
await _deleteMetadataEditCache(ratingKey);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<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.
|
||||
|
||||
@@ -120,6 +120,7 @@ Future<String?> showTextInputDialog(
|
||||
TextInputType? keyboardType,
|
||||
List<TextInputFormatter>? inputFormatters,
|
||||
String? Function(String)? validator,
|
||||
bool allowEmpty = false,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
@@ -132,6 +133,7 @@ Future<String?> showTextInputDialog(
|
||||
keyboardType: keyboardType,
|
||||
inputFormatters: inputFormatters,
|
||||
validator: validator,
|
||||
allowEmpty: allowEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -237,6 +239,7 @@ class _TextInputDialog extends StatefulWidget {
|
||||
final TextInputType? keyboardType;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final String? Function(String)? validator;
|
||||
final bool allowEmpty;
|
||||
|
||||
const _TextInputDialog({
|
||||
required this.title,
|
||||
@@ -247,6 +250,7 @@ class _TextInputDialog extends StatefulWidget {
|
||||
this.keyboardType,
|
||||
this.inputFormatters,
|
||||
this.validator,
|
||||
this.allowEmpty = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -260,7 +264,7 @@ class _TextInputDialogState extends State<_TextInputDialog>
|
||||
|
||||
void _submit() {
|
||||
final text = _controller.text;
|
||||
if (text.isEmpty) return;
|
||||
if (text.isEmpty && !widget.allowEmpty) return;
|
||||
if (widget.validator != null && widget.validator!(text) != null) return;
|
||||
Navigator.pop(context, text);
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ extension ProviderExtensions on BuildContext {
|
||||
/// Internal: resolve a [PlexClient] from a serverId or fall back to the
|
||||
/// first online server. Returns null if neither yields a Plex client.
|
||||
/// Non-Plex servers (Jellyfin) are skipped — these helpers exist for
|
||||
/// Plex-only flows that have no neutral equivalent (DVR tuning, metadata
|
||||
/// edit, match). Backend-agnostic flows use the [_resolveMediaClient]
|
||||
/// Plex-only flows that have no neutral equivalent (DVR tuning, match).
|
||||
/// Backend-agnostic flows use the [_resolveMediaClient]
|
||||
/// helpers below.
|
||||
PlexClient? _resolveClient(String? serverId) {
|
||||
final provider = Provider.of<MultiServerProvider>(this, listen: false);
|
||||
@@ -65,8 +65,8 @@ extension ProviderExtensions on BuildContext {
|
||||
// These return [MediaServerClient] regardless of backend kind so callers
|
||||
// that consume only the [MediaServerClient] surface don't need to type-
|
||||
// check the result. Use [getPlexClientForServer] / [getPlexClientForLibrary]
|
||||
// when you specifically need a [PlexClient] (Plex-only flows like Live TV,
|
||||
// metadata editing, etc.).
|
||||
// when you specifically need a [PlexClient] (Plex-only flows like Live TV or
|
||||
// match/fix-match).
|
||||
|
||||
MediaServerClient? _resolveMediaClient(String? serverId) {
|
||||
final provider = Provider.of<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_playlist.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../metadata_edit/metadata_edit_adapters.dart';
|
||||
import '../media/media_version.dart';
|
||||
import '../mixins/controller_disposer_mixin.dart';
|
||||
import '../services/plex_client.dart';
|
||||
@@ -43,7 +44,7 @@ import '../focus/focusable_text_field.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../screens/plex_match_screen.dart';
|
||||
import '../screens/media_detail_screen.dart';
|
||||
import '../screens/plex_metadata_edit_screen.dart';
|
||||
import '../screens/metadata_edit_screen.dart';
|
||||
import '../utils/smart_deletion_handler.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
@@ -180,8 +181,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
};
|
||||
|
||||
/// Get the correct PlexClient for this item's server. Throws on
|
||||
/// non-Plex backends — Plex-only flows (Add to Collection, metadata
|
||||
/// edit, etc.) call this directly. Backend-neutral flows must use
|
||||
/// non-Plex backends — Plex-only flows (Add to Collection, match,
|
||||
/// unmatch, etc.) call this directly. Backend-neutral flows must use
|
||||
/// [_getMediaClientForItem] instead.
|
||||
PlexClient _getClientForItem() => context.getPlexClientWithFallback(_itemServerId);
|
||||
|
||||
@@ -203,7 +204,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final isCollection = mediaKind == MediaKind.collection;
|
||||
|
||||
// Backend-aware gate: a few menu items remain Plex-only because the
|
||||
// server-side feature has no Jellyfin equivalent (metadata edit, match).
|
||||
// server-side feature has no Jellyfin equivalent (match/unmatch).
|
||||
// No fallback: items without a backend marker show only neutral actions —
|
||||
// dispatching a Plex-only action against an unknown-backend item could
|
||||
// crash or hit the wrong server.
|
||||
@@ -237,6 +238,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
final mediaClient = _itemServerId != null ? multiServerProvider.getClientForServer(_itemServerId!) : null;
|
||||
final canTranscode = mediaClient?.capabilities.videoTranscoding ?? false;
|
||||
final canRemoveFromContinueWatching = mediaClient?.capabilities.continueWatchingRemoval ?? false;
|
||||
final canEditMetadata = isAdmin && supportsMetadataEdit(mediaClient, mediaKind);
|
||||
|
||||
final menuActions = <_MenuAction>[];
|
||||
|
||||
@@ -317,15 +319,8 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate));
|
||||
}
|
||||
|
||||
// Edit Metadata (for movies, shows, seasons, and episodes) — admin only
|
||||
// Plex-only: opens PlexMetadataEditScreen which talks to Plex's
|
||||
// `/library/metadata/{id}` PUT API; Jellyfin has no equivalent in v1.
|
||||
if (isPlex &&
|
||||
isAdmin &&
|
||||
(mediaKind == MediaKind.movie ||
|
||||
mediaKind == MediaKind.show ||
|
||||
mediaKind == MediaKind.season ||
|
||||
mediaKind == MediaKind.episode)) {
|
||||
// Edit Metadata — admin-only and backend-capability gated.
|
||||
if (canEditMetadata) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'edit_metadata', icon: Symbols.edit_rounded, label: t.metadataEdit.editMetadata),
|
||||
);
|
||||
@@ -655,10 +650,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
didNavigate = true;
|
||||
if (context.mounted) {
|
||||
final item = mediaItem!;
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => PlexMetadataEditScreen(metadata: item)),
|
||||
);
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => MetadataEditScreen(metadata: item)));
|
||||
_notifyRefresh(item.id);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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 {
|
||||
final client = buildClient();
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/metadata_edit/metadata_edit_adapters.dart';
|
||||
import 'package:plezy/models/plex/plex_home_user.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/services/jellyfin_client.dart';
|
||||
import 'package:plezy/widgets/media_context_menu.dart';
|
||||
|
||||
void main() {
|
||||
@@ -37,6 +43,20 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('supportsMetadataEdit', () {
|
||||
test('allows Jellyfin video metadata edit through capability gate', () {
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _jellyfinConnection(),
|
||||
httpClient: MockClient((_) async => http.Response('', 204)),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
expect(supportsMetadataEdit(client, MediaKind.movie), isTrue);
|
||||
expect(supportsMetadataEdit(client, MediaKind.show), isTrue);
|
||||
expect(supportsMetadataEdit(client, MediaKind.track), isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PlexHomeUser _homeUser({required bool admin}) {
|
||||
@@ -56,3 +76,18 @@ PlexHomeUser _homeUser({required bool admin}) {
|
||||
protected: false,
|
||||
);
|
||||
}
|
||||
|
||||
JellyfinConnection _jellyfinConnection() {
|
||||
return JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
baseUrl: 'https://jf.example.com',
|
||||
serverName: 'Home',
|
||||
serverMachineId: 'srv-1',
|
||||
userId: 'user-1',
|
||||
userName: 'edde',
|
||||
accessToken: 'tok',
|
||||
deviceId: 'dev',
|
||||
isAdministrator: true,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user