589 lines
18 KiB
Dart
589 lines
18 KiB
Dart
|
|
import 'dart:convert';
|
||
|
|
import 'dart:io';
|
||
|
|
|
||
|
|
import 'package:flutter_test/flutter_test.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,
|
||
|
|
}),
|
||
|
|
('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': 'MusicAlbum'},
|
||
|
|
],
|
||
|
|
'TotalRecordCount': 2,
|
||
|
|
}),
|
||
|
|
('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 libraries, leaving out what is no video', () async {
|
||
|
|
final account = await signIn();
|
||
|
|
|
||
|
|
final libraries = await gateway.libraries(account);
|
||
|
|
|
||
|
|
expect(libraries, [
|
||
|
|
const JellyfinItem(
|
||
|
|
id: 'lib-1',
|
||
|
|
name: 'Anime',
|
||
|
|
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 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('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,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|