Animelon's way of learning from video, from the person's own Jellyfin server: connected per device (Settings, or a studyset's Add material › Watch from Jellyfin) with a name and password or a Quick Connect code, over HTTPS only (plain HTTP only to a server on the same machine); the token stays in the system's keyring. Shows, films and videos tied to a studyset follow the person in their settings as the server's and the item's ids alone (settings schema 9), named by the server when shown. The Watch screen plays the video (video_player on Android, media_kit's libmpv on Linux and Windows) with the line in the studyset's front language cut into the dictionary's words with furigana, and the back language's line beneath; a word tapped pauses the video and becomes a card with the line and its translation as its first hint, tagged with the show. The video's words are listed, most frequent first. The sound is the track in the studyset's language, whichever the file marks default, and the menu switches it; the subtitles read are a whole track, never a forced one (which only covers what the sound doesn't say), and captions for the hard of hearing only without plain ones. The line and its translation take the video's width in a box of a fixed height, so the video keeps its size; in the dark theme the video's colours glow around it, as YouTube's ambient mode, and the words' panel takes the colour of the episode's picture under the pointer. A line is cut into words with one request: DictionaryEndpoint.withKeys looks up every stretch of it that may be a word, as it stands and as every word it may be a conjugation of, in the new dictionary_key table (one row per key, indexed; migration dictionary-keys adds the table and its index, nothing else), which the loader fills beside the entries. The instance gets the text of each line looked up and keeps none of it, as the Watch screen says. Linux builds need libmpv-dev, and the app links libmpv: the snap stages Ubuntu's libmpv2, makes the update-alternatives links for BLAS and LAPACK that ffmpeg's speech recognition needs (staging never runs install scripts), and removes what the GNOME runtime and core24 already hold, as older copies of cairo, pango and librsvg hid the runtime's from its GTK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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,
|
|
);
|
|
}
|
|
}
|