feat(metadata): add Jellyfin edit support

This commit is contained in:
edde746
2026-06-01 08:34:38 +02:00
parent 8f9d20a2e4
commit 09f5121732
19 changed files with 2245 additions and 1148 deletions
@@ -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();
}
}