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 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)['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': {}, }, {'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 requests; late HttpJellyfinGateway gateway; setUp(() { requests = []; gateway = HttpJellyfinGateway( jellyfin12(requests), device: (id: 'device-1', name: 'Linux', version: '0.13.0'), ); }); Future 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().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().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().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; 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 = []; 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().having( (error) => error.problem, 'problem', JellyfinProblem.notEncrypted, ), ), ); await expectLater( repository.find('httpsonly.local:8096'), throwsA( isA().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 = []; 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()), ); 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 _asked) extends FakeJellyfinGateway { @override Future 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, ); } }