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 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)['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': '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': {}, 'Lyrics': [ {'Text': '海賊なら Yo-Ho!', 'Start': 68200000, 'Cues': []}, {'Text': 'さぁ今旗のもとへ', 'Start': 95800000, 'Cues': []}, ], }), ('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 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 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().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("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 = []; 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); }); }); 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 _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, ); } }