An instance now names a Jellyfin server for its people to learn from (instance.info().jellyfinUrl): its operator's JELLYFIN_URL (`none`: none), else https://jellyfin. on the domain registered for its public host, as learn.halkyon.eu suggests jellyfin.halkyon.eu, once that answers as a Jellyfin server. The registered domain comes from Mozilla's Public Suffix List, kept in the server's data folder, so an instance at example.co.uk never names someone else's jellyfin.co.uk. The instance looks as it starts and again hourly when asked; the app never reaches for a guessed address. In the app, Add material offers From Jellyfin as soon as the instance suggests a server, before anyone signed in to one. The sign-in fills the server in and finds it by itself, and lists the people the server shows on its login screen: one without a password, as a demo's, signs in with a tap; one with a password is named in the form, which asks for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
709 lines
22 KiB
Dart
709 lines
22 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: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, one without a
|
|
// password (a demo's), in either case of field names.
|
|
('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',
|
|
'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',
|
|
'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,
|
|
),
|
|
('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, and which '
|
|
'need a password', () async {
|
|
final server = await gateway.server(
|
|
Uri.parse('http://jellyfin.local:8096'),
|
|
);
|
|
|
|
expect(await gateway.publicUsers(server), const [
|
|
JellyfinPublicUser(id: 'u-2', name: 'Demo', hasPassword: false),
|
|
JellyfinPublicUser(id: 'u-1', name: 'mika', hasPassword: true),
|
|
]);
|
|
});
|
|
|
|
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('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);
|
|
});
|
|
});
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|