halkyon/halkyon_learn_flutter/test/feature/jellyfin/jellyfin_data_test.dart
Tizian 「ティツィアーン」 238a1a93ae
feat(jellyfin): a show's tags follow it whatever it is called
A word met on Jellyfin is tagged with the show and the episode by the
names Jellyfin gives them, so a server switching its metadata language,
another server, or a rename in Manage tags made a second set of tags for
the same show. Such a tag now also keeps what it stands for: the ids the
show (or the artist, or the film) has with the metadata providers and on
the server, and the episode's number or the song's own id. New words
find their tag by those first, keeping its name; a Jellyfin tag from
before, found by its name, takes them, and a rename keeps them.

They stay in tag schema 2, a deliberate exception to ADR 0002's rule 3:
schema 3 would hide every new Jellyfin tag from v0.17, while an app
that rewrites the tag only drops the ids, and the tag is found by its
name again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 19:46:33 +02:00

841 lines
26 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/video/subtitles.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/data/jellyfin_store.dart';
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/model/tag.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import '../../support/fake_jellyfin.dart';
import '../../support/sample_episode.dart';
/// A server answering as Jellyfin v12.2.0 does (its answers recorded),
/// noting each request.
MockClient jellyfin12(List<http.Request> requests) => MockClient((
request,
) async {
requests.add(request);
http.Response json(Object? body, [int status = 200]) => http.Response.bytes(
utf8.encode(jsonEncode(body)),
status,
headers: {
'content-type': 'application/json; charset=utf-8',
},
);
final token = request.headers['Authorization'] ?? '';
final signedIn = token.contains('Token="t-1"');
return switch ((request.method, request.url.path)) {
// v12 writes this one in camelCase, the rest in PascalCase.
('GET', '/System/Info/Public') => json({
'localAddress': 'http://172.17.0.2:8096',
'serverName': 'Living room',
'version': '12.2.0',
'productName': 'Jellyfin Server',
'id': 'e39b17f2',
'startupWizardCompleted': true,
}),
// The people the server shows on its login screen, in either case of
// field names; whether one has a password, which servers before v10.11
// said, counts for nothing.
('GET', '/Users/Public') => json([
{'Name': 'Demo', 'Id': 'u-2', 'HasPassword': false},
{'name': 'mika', 'id': 'u-1', 'hasPassword': true},
]),
('POST', '/Users/AuthenticateByName') =>
(jsonDecode(request.body) as Map<String, Object?>)['Pw'] == 'secret'
? json({
'User': {'Name': 'mika', 'Id': 'u-1', 'ServerId': 'e39b17f2'},
'AccessToken': 't-1',
'ServerId': 'e39b17f2',
})
: json({'error': 'Invalid user or password'}, 401),
_ when !signedIn => json(null, 401),
('GET', '/UserViews') => json({
'Items': [
{
'Name': 'Anime',
'Id': 'lib-1',
'Type': 'CollectionFolder',
'CollectionType': 'tvshows',
'ImageTags': <String, String>{},
},
{
'Name': 'Music',
'Id': 'lib-2',
'Type': 'CollectionFolder',
'CollectionType': 'music',
},
{'Name': 'A book', 'Id': 'book-1', 'Type': 'Book'},
],
'TotalRecordCount': 2,
}),
('GET', '/Items/song-1') => json({
'Name': '幽霊船戦',
'Id': 'song-1',
'Type': 'Audio',
'AlbumArtists': [
{'Name': '宝鐘マリン', 'Id': 'artist-1'},
],
'ProviderIds': {'MusicBrainzTrack': 'mb-track-1'},
'AlbumArtist': '宝鐘マリン',
'Album': 'Ahoy!!',
'HasLyrics': true,
'IndexNumber': 3,
'ParentIndexNumber': 1,
'RunTimeTicks': 2000000000,
'ImageTags': {'Primary': 'a1'},
}),
('GET', '/Audio/song-1/Lyrics') => json({
'Metadata': <String, Object?>{},
'Lyrics': [
{'Text': '海賊なら Yo-Ho!', 'Start': 68200000, 'Cues': <Object?>[]},
{'Text': 'さぁ今旗のもとへ', 'Start': 95800000, 'Cues': <Object?>[]},
],
}),
('GET', '/Items/ep-1') => json({
'Name': 'Sample Journey S01E01',
'Id': 'ep-1',
'Type': 'Episode',
'SeriesName': 'Sample Journey',
'SeriesId': 'series-1',
'ProviderIds': {'Tmdb': '9001', 'Tvdb': ''},
'IndexNumber': 1,
'ParentIndexNumber': 1,
'RunTimeTicks': 520230000,
'ImageTags': {'Primary': '898f'},
'UserData': {'PlaybackPositionTicks': 260115000, 'Played': false},
}),
('POST', '/Items/ep-1/PlaybackInfo') => json({
'MediaSources': [
{
'Id': 'ep-1',
'Container': 'mkv',
'SupportsDirectPlay': true,
'RunTimeTicks': 520230000,
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
{
'Index': 1,
'Type': 'Audio',
'Language': 'ger',
'Codec': 'aac',
'IsDefault': true,
'DisplayTitle': 'German - AAC - Stereo - Default',
},
{
'index': 4,
'type': 'Audio',
'language': 'jpn',
'codec': 'aac',
'isDefault': true,
'title': 'Japanese [Original]',
},
{
'Index': 2,
'Type': 'Subtitle',
'Language': 'jpn',
'Codec': 'ass',
'IsTextSubtitleStream': true,
'IsDefault': true,
'DisplayTitle': '日本語 - Japanese - Default - ASS',
},
{
'Index': 3,
'Type': 'Subtitle',
'Language': 'eng',
'Codec': 'PGSSUB',
'IsTextSubtitleStream': false,
'DisplayTitle': 'English - PGSSUB',
},
{
'Index': 5,
'Type': 'Subtitle',
'Language': 'jpn',
'Codec': 'subrip',
'IsTextSubtitleStream': true,
'IsForced': true,
'DisplayTitle': '日本語 - Japanese - Forced - SUBRIP',
},
{
'Index': 6,
'Type': 'Subtitle',
'Language': 'jpn',
'Codec': 'subrip',
'IsTextSubtitleStream': true,
'Title': 'closedcaptions',
'DisplayTitle': 'closedcaptions - 日本語 - SUBRIP',
},
{
'Index': 7,
'Type': 'Subtitle',
'Language': 'eng',
'Codec': 'subrip',
'IsTextSubtitleStream': true,
'IsHearingImpaired': true,
'DisplayTitle': 'English - SDH - SUBRIP',
},
],
},
],
'PlaySessionId': 'ps-1',
}),
('GET', '/Videos/ep-1/ep-1/Subtitles/2/0/Stream.vtt') =>
http.Response.bytes(
utf8.encode('WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は\n'),
200,
),
('GET', '/Items/series-1') => json({
'Name': 'Sample Journey',
'Id': 'series-1',
'Type': 'Series',
'providerIds': {'Tmdb': '219109', 'Tvdb': '445566'},
}),
('POST', '/Sessions/Playing/Stopped') => json(null, 204),
_ => json(null, 404),
};
});
void main() {
group('HttpJellyfinGateway', () {
late List<http.Request> requests;
late HttpJellyfinGateway gateway;
setUp(() {
requests = [];
gateway = HttpJellyfinGateway(
jellyfin12(requests),
device: (id: 'device-1', name: 'Linux', version: '0.13.0'),
);
});
Future<JellyfinAccount> signIn() async => await gateway.signIn(
await gateway.server(Uri.parse('http://jellyfin.local:8096')),
'mika',
'secret',
);
test(
'finds a server and signs in, naming the app and the device',
() async {
final server = await gateway.server(
Uri.parse('http://jellyfin.local:8096'),
);
expect(server.name, 'Living room');
expect(server.version, '12.2.0');
final account = await gateway.signIn(server, 'mika', 'secret');
expect(account.userId, 'u-1');
expect(account.token, 't-1');
expect(
requests.last.headers['Authorization'],
'MediaBrowser Client="Halkyon Learn", Device="Linux", '
'DeviceId="device-1", Version="0.13.0"',
);
await expectLater(
gateway.signIn(server, 'mika', 'wrong'),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.wrongCredentials,
),
),
);
},
);
test('lists the people a server shows on its login screen', () async {
final server = await gateway.server(
Uri.parse('http://jellyfin.local:8096'),
);
expect(await gateway.publicUsers(server), const [
JellyfinPublicUser(id: 'u-2', name: 'Demo'),
JellyfinPublicUser(id: 'u-1', name: 'mika'),
]);
});
test('lists libraries of videos and of music, leaving out what is '
'neither', () async {
final account = await signIn();
final libraries = await gateway.libraries(account);
expect(libraries, [
const JellyfinItem(
id: 'lib-1',
name: 'Anime',
kind: JellyfinItemKind.library,
),
const JellyfinItem(
id: 'lib-2',
name: 'Music',
kind: JellyfinItemKind.library,
),
]);
expect(requests.last.headers['Authorization'], contains('Token="t-1"'));
expect(requests.last.url.queryParameters['userId'], 'u-1');
});
test('a playback has its subtitle and audio tracks, which are forced '
'and which for the hard of hearing, and where to resume; the stream '
'is the file as stored; subtitles come as WebVTT', () async {
final account = await signIn();
final playback = await gateway.playback(account, 'ep-1');
expect(playback.playSessionId, 'ps-1');
expect(playback.resumeAt.inSeconds, 26);
expect(playback.subtitles, [
const JellyfinSubtitleTrack(
index: 2,
title: '日本語 - Japanese - Default - ASS',
language: 'jpn',
isDefault: true,
),
const JellyfinSubtitleTrack(
index: 3,
title: 'English - PGSSUB',
language: 'eng',
isText: false,
),
const JellyfinSubtitleTrack(
index: 5,
title: '日本語 - Japanese - Forced - SUBRIP',
language: 'jpn',
isForced: true,
),
// Captions for the hard of hearing are often marked by their name
// alone.
const JellyfinSubtitleTrack(
index: 6,
title: 'closedcaptions - 日本語 - SUBRIP',
language: 'jpn',
isHearingImpaired: true,
),
const JellyfinSubtitleTrack(
index: 7,
title: 'English - SDH - SUBRIP',
language: 'eng',
isHearingImpaired: true,
),
]);
expect(playback.audio, [
const JellyfinAudioTrack(
index: 1,
title: 'German - AAC - Stereo - Default',
language: 'ger',
isDefault: true,
),
const JellyfinAudioTrack(
index: 4,
title: 'Japanese [Original]',
language: 'jpn',
isDefault: true,
),
]);
final stream = gateway.streamOf(account, playback);
expect(stream.path, '/Videos/ep-1/stream');
expect(stream.queryParameters, containsPair('static', 'true'));
expect(
await gateway.subtitles(account, playback, playback.subtitles.first),
contains('今日は'),
);
});
test('an item says what the metadata providers call it, and an episode '
'its show, a song its artist', () async {
final account = await signIn();
final episode = await gateway.item(account, 'ep-1');
expect(episode.providerIds, {'tmdb': '9001'});
expect(episode.seriesId, 'series-1');
expect((await gateway.item(account, 'series-1')).providerIds, {
'tmdb': '219109',
'tvdb': '445566',
});
final song = await gateway.item(account, 'song-1');
expect(song.artistId, 'artist-1');
expect(song.providerIds, {'musicbrainztrack': 'mb-track-1'});
});
test('a song is one, with its artist and whether it has lyrics, its '
'lyrics timed line by line, none for a song without; a song streams '
'as sound', () async {
final account = await signIn();
final song = await gateway.item(account, 'song-1');
expect(song.kind, JellyfinItemKind.audio);
expect(song.artist, '宝鐘マリン');
expect(song.hasLyrics, isTrue);
expect(song.seasonNumber, isNull);
expect(await gateway.lyrics(account, 'song-1'), [
(text: '海賊なら Yo-Ho!', start: const Duration(milliseconds: 6820)),
(text: 'さぁ今旗のもとへ', start: const Duration(milliseconds: 9580)),
]);
expect(await gateway.lyrics(account, 'song-2'), isEmpty);
expect(
gateway
.streamOf(
account,
const JellyfinPlayback(
itemId: 'song-1',
mediaSourceId: 'song-1',
playSessionId: 'ps-2',
subtitles: [],
audioOnly: true,
),
)
.path,
'/Audio/song-1/stream',
);
});
test('a token the server no longer takes is signed out; nothing '
'answering is unreachable', () async {
final server = await gateway.server(
Uri.parse('http://jellyfin.local:8096'),
);
final stale = JellyfinAccount(
server: server,
userId: 'u-1',
userName: 'mika',
token: 'revoked',
);
await expectLater(
gateway.libraries(stale),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.signedOut,
),
),
);
final offline = HttpJellyfinGateway(
MockClient((_) => throw const SocketException('down')),
device: (id: 'd', name: 'n', version: 'v'),
);
await expectLater(
offline.server(Uri.parse('http://jellyfin.local:8096')),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.unreachable,
),
),
);
});
test('reports where a video stopped, in ticks', () async {
final account = await signIn();
final playback = await gateway.playback(account, 'ep-1');
await gateway.report(
account,
playback,
const Duration(seconds: 21),
stopped: true,
);
final body = jsonDecode(requests.last.body) as Map<String, Object?>;
expect(requests.last.url.path, '/Sessions/Playing/Stopped');
expect(body['PositionTicks'], 210000000);
expect(body['PlaySessionId'], 'ps-1');
});
});
group('JellyfinRepository', () {
test("a song's lyrics are lines, each until the next, the last until "
'the song ends; empty ones left out; untimed lyrics are text '
'alone', () async {
JellyfinRepository with_(List<({String text, Duration? start})> lines) =>
JellyfinRepository(
(_) => FakeJellyfinGateway(lyricsOf: {'song-1': lines}),
signedInJellyfin(),
deviceName: 'Test',
appVersion: '1',
);
final timed = await with_(const [
(text: '一', start: Duration(seconds: 1)),
(text: ' ', start: Duration(seconds: 4)),
(text: '二', start: Duration(seconds: 6)),
]).lyrics('song-1', runTime: const Duration(minutes: 3));
expect(timed.synced, isTrue);
expect(timed.lines, const [
SubtitleCue(
start: Duration(seconds: 1),
end: Duration(seconds: 6),
text: '一',
),
SubtitleCue(
start: Duration(seconds: 6),
end: Duration(minutes: 3),
text: '二',
),
]);
final untimed = await with_(const [
(text: '一', start: null),
(text: '二', start: null),
]).lyrics('song-1');
expect(untimed.synced, isFalse);
expect([for (final line in untimed.lines) line.text], ['一', '二']);
});
test('an address is reached over HTTPS only, plain HTTP refused '
'before anything is sent', () async {
final asked = <Uri>[];
final repository = JellyfinRepository(
(_) => _Asking(asked),
MemoryJellyfinStore(),
deviceName: 'Test',
appVersion: '1',
);
final server = await repository.find('jellyfin.example:8920');
expect(server.address, Uri.parse('https://jellyfin.example:8920'));
expect(asked, [Uri.parse('https://jellyfin.example:8920')]);
asked.clear();
await expectLater(
repository.find('http://jellyfin.local:8096'),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.notEncrypted,
),
),
);
await expectLater(
repository.find('httpsonly.local:8096'),
throwsA(
isA<JellyfinException>().having(
(error) => error.problem,
'problem',
JellyfinProblem.unreachable,
),
),
);
expect(asked, [Uri.parse('https://httpsonly.local:8096')]);
});
test('a server on this machine may answer over plain HTTP: nothing '
'leaves it', () async {
final asked = <Uri>[];
final repository = JellyfinRepository(
(_) => _Asking(asked),
MemoryJellyfinStore(),
deviceName: 'Test',
appVersion: '1',
);
expect(
(await repository.find('localhost:8096')).address,
Uri.parse('http://localhost:8096'),
);
expect(asked, [
Uri.parse('https://localhost:8096'),
Uri.parse('http://localhost:8096'),
]);
expect(
(await repository.find('http://127.0.0.1:8096')).address,
Uri.parse('http://127.0.0.1:8096'),
);
expect(
(await repository.find('http://[::1]:8096')).address.host,
'::1',
);
});
test('signing in is kept on the device; signing out forgets it and '
'ends the session on the server', () async {
final store = MemoryJellyfinStore();
final gateway = FakeJellyfinGateway();
final repository = JellyfinRepository(
(_) => gateway,
store,
deviceName: 'Test',
appVersion: '1',
);
await repository.signIn(fakeJellyfinServer, 'mika', 'secret');
expect(store.kept?.account?.token, 'token-1');
await repository.signOut();
expect(store.kept?.account, isNull);
expect(gateway.signedOut, ['token-1']);
});
test('Quick Connect signs in once the code is approved', () async {
final gateway = FakeJellyfinGateway();
final repository = JellyfinRepository(
(_) => gateway,
MemoryJellyfinStore(),
deviceName: 'Test',
appVersion: '1',
);
final code = await repository.startQuickConnect(fakeJellyfinServer);
expect(
await repository.finishQuickConnect(fakeJellyfinServer, code.secret),
isFalse,
);
gateway.approved = true;
expect(
await repository.finishQuickConnect(fakeJellyfinServer, code.secret),
isTrue,
);
expect(repository.account?.token, 'token-2');
});
test('a token the server refuses signs the device out', () async {
final gateway = sampleJellyfin()..fail = JellyfinProblem.signedOut;
final repository = JellyfinRepository(
(_) => gateway,
signedInJellyfin(),
deviceName: 'Test',
appVersion: '1',
);
await expectLater(
repository.libraries(),
throwsA(isA<JellyfinException>()),
);
expect(repository.account, isNull);
});
});
test('what the device keeps comes back as it was kept', () {
final state = JellyfinState(
deviceId: 'device-1',
account: JellyfinAccount(
server: fakeJellyfinServer,
userId: 'user-1',
userName: 'mika',
token: 'token-1',
),
);
expect(
jellyfinStateFromJson(jsonDecode(jsonEncode(jellyfinStateToJson(state)))),
state,
);
expect(jellyfinStateFromJson('not a state').deviceId, hasLength(32));
});
test('a line of two is one: Japanese joined as it is, others with a '
'space', () {
expect(oneLine('昨日、駅で\n友達に会いました。'), '昨日、駅で友達に会いました。');
expect(oneLine('Yesterday I met\na friend.'), 'Yesterday I met a friend.');
});
group('the tracks in a language', () {
const forced = JellyfinSubtitleTrack(
index: 1,
title: 'Japanese (forced)',
language: 'jpn',
isDefault: true,
isForced: true,
);
const captions = JellyfinSubtitleTrack(
index: 2,
title: 'Japanese (SDH)',
language: 'jpn',
isHearingImpaired: true,
);
const full = JellyfinSubtitleTrack(
index: 3,
title: 'Japanese',
language: 'jpn',
);
const english = JellyfinSubtitleTrack(
index: 4,
title: 'English',
language: 'eng',
isDefault: true,
);
test('the subtitles read are whole: never a forced track, which only '
"covers what the sound doesn't say; captions for the hard of "
'hearing only without plain ones', () {
expect(subtitleTrackIn([forced, captions, full, english], 'ja'), full);
expect(subtitleTrackIn([forced, captions, english], 'ja'), captions);
expect(subtitleTrackIn([forced, english], 'ja'), isNull);
expect(subtitleTrackIn([forced, captions, full, english], 'en'), english);
expect(subtitleTrackIn([full], null), isNull);
});
test('the sound is in the language, whichever track the file marks '
'default; a commentary or a description only without another', () {
const german = JellyfinAudioTrack(
index: 1,
title: 'German',
language: 'ger',
isDefault: true,
);
const commentary = JellyfinAudioTrack(
index: 2,
title: "Director's commentary",
language: 'jpn',
isDefault: true,
);
const japanese = JellyfinAudioTrack(
index: 3,
title: 'Japanese [Original]',
language: 'jpn',
);
expect(audioTrackIn([german, commentary, japanese], 'ja'), japanese);
expect(audioTrackIn([german, commentary], 'ja'), commentary);
expect(audioTrackIn([german], 'ja'), isNull);
expect(audioTrackIn([german, japanese], null), isNull);
});
});
group('the tags a word met on Jellyfin gets', () {
const series = JellyfinItem(
id: 'series-1',
name: 'あかね噺',
kind: JellyfinItemKind.series,
providerIds: {'tmdb': '219109'},
);
const episode = JellyfinItem(
id: 'ep-1',
name: '第1話',
kind: JellyfinItemKind.episode,
seriesName: 'あかね噺',
seriesId: 'series-1',
seasonNumber: 1,
episodeNumber: 1,
providerIds: {'tmdb': '9001'},
);
const show = JellyfinTagId(
ids: {'tmdb': '219109', 'jellyfin:server-1': 'series-1'},
);
test("an episode: its show, by the show's ids, and itself, by them and "
'its number', () {
expect(jellyfinTagsOf(episode, serverId: 'server-1', whole: series), (
whole: (name: 'あかね噺', id: show),
part: (
name: 'あかね噺 S1 E1',
id: JellyfinTagId(ids: show.ids, part: 'S1E1'),
),
));
});
test('without its show read, the show is known by its id on the '
'server', () {
expect(
jellyfinTagsOf(episode, serverId: 'server-1').whole.id,
const JellyfinTagId(ids: {'jellyfin:server-1': 'series-1'}),
);
});
test('a film is one tag, by its own ids', () {
expect(
jellyfinTagsOf(
const JellyfinItem(
id: 'film-1',
name: 'CODA',
kind: JellyfinItemKind.movie,
providerIds: {'tmdb': '776503', 'imdb': 'tt10366460'},
),
serverId: 'server-1',
),
(
whole: (
name: 'CODA',
id: const JellyfinTagId(
ids: {
'tmdb': '776503',
'imdb': 'tt10366460',
'jellyfin:server-1': 'film-1',
},
),
),
part: null,
),
);
});
test("a song: its artist, by the artist's ids, and itself, by its "
'own', () {
const song = JellyfinItem(
id: 'song-1',
name: 'Ahoy!!',
kind: JellyfinItemKind.audio,
artist: '宝鐘マリン',
artistId: 'artist-1',
providerIds: {'musicbrainztrack': 'mb-track-1'},
);
const marine = JellyfinItem(
id: 'artist-1',
name: '宝鐘マリン',
kind: JellyfinItemKind.artist,
providerIds: {'musicbrainzartist': 'mb-artist-1'},
);
const artist = JellyfinTagId(
ids: {
'musicbrainzartist': 'mb-artist-1',
'jellyfin:server-1': 'artist-1',
},
);
expect(jellyfinTagsOf(song, serverId: 'server-1', whole: marine), (
whole: (name: '宝鐘マリン', id: artist),
part: (
name: '宝鐘マリン · Ahoy!!',
id: JellyfinTagId(
ids: artist.ids,
part: 'musicbrainztrack:mb-track-1',
),
),
));
});
});
test("a studyset's language finds Jellyfin's", () {
expect(jellyfinLanguagesOf('ja'), {'jpn'});
expect(jellyfinLanguagesOf('de-AT'), {'ger', 'deu'});
expect(jellyfinLanguagesOf('tlh'), {'tlh'});
});
test("Jellyfin's language finds every name a player may give it", () {
expect(languageNamesOf('jpn'), {'jpn', 'ja'});
expect(languageNamesOf('ger'), {'ger', 'deu', 'de'});
expect(languageNamesOf('tlh'), {'tlh'});
expect(languageNamesOf(null), isEmpty);
});
}
/// A gateway noting the addresses tried, finding a server at
/// `jellyfin.example` over HTTPS and on this machine over plain HTTP.
class _Asking(final List<Uri> _asked) extends FakeJellyfinGateway {
@override
Future<JellyfinServer> server(Uri address) async {
_asked.add(address);
final found = address.scheme == 'https'
? address.host == 'jellyfin.example'
: const {'localhost', '127.0.0.1', '::1'}.contains(address.host);
if (!found) {
throw JellyfinException(problem: JellyfinProblem.unreachable);
}
return JellyfinServer(
address: address,
id: fakeJellyfinServer.id,
name: fakeJellyfinServer.name,
version: fakeJellyfinServer.version,
);
}
}