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