- L1 (core, offline): release names with kinds (episode, movie, extra: OP/ED/Opening/Ending/NC/PV/OVA…), folder hints (season and name from parentheticals, ranges), one show across folders, gaps and next with skipped numbers, junk ignored, stable keys. - L2 (app, device-owned cache): AniList match with synonyms and sequel walking, episode mapping across cours (AnimeFlow runs when signed in), art and episode titles, "Wrong show?" pins per show or folder, episode offsets; lazy, spaced requests, works offline. - Files tab as a library: poster cards, show pages with banner, gap prompt, extras, movies, Wrong show? and Episode numbers. - media.ref produced for local files, Jellyfin (provider ids) and links, carried in swaps and Up next; receivers name media from it. - Hosting relays each Jellyfin item through its own server's login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
559 lines
27 KiB
Dart
559 lines
27 KiB
Dart
// restructure: rewrite in step 6 (What's next + Watch tracking): the
|
|
// "progress" and "episodes" groups (reporting, auto-advance swapper).
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:sync_relay_core/core.dart';
|
|
import 'package:starsync/src/jellyfin/accounts.dart';
|
|
import 'package:starsync/src/jellyfin/browse_scope.dart';
|
|
import 'package:starsync/src/jellyfin/browser.dart';
|
|
import 'package:starsync/src/jellyfin/progress.dart';
|
|
import 'package:starsync/src/jellyfin/session_media.dart';
|
|
import 'package:starsync/src/jellyfin/track_prefs.dart';
|
|
import 'package:starsync/src/screens/home/media_picker.dart';
|
|
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
|
|
import 'package:starsync/src/session/delivery.dart';
|
|
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
|
|
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
|
|
|
|
import 'jellyfin_accounts_test.dart';
|
|
import 'jellyfin_browser_test.dart' show app, setSize, settle;
|
|
|
|
import 'test_graph.dart';
|
|
|
|
/// This file's composition root (see test_graph.dart).
|
|
final _graph = testGraph();
|
|
|
|
const episodeId = 'b18826be994070a79f9b52bc0f66f052'; // Black Summoner S1E6
|
|
const nextEpisodeId = '74c50bdbf83e8fef231dd7710058e197'; // S1E7
|
|
const seriesId = 'e9aec9c14a1fcab775111844112b4cf7';
|
|
const seasonId = 'fb0b77cfda15445f9319ed9d753dba23';
|
|
const playSessionId = 'fc428dbbeb9c46a7885fa053bd46f7e7';
|
|
|
|
const episodeRef = JellyfinRef(
|
|
serverId: fixtureServerId, itemId: episodeId, title: 'Rice and Thieves and Heroes');
|
|
|
|
/// The fake plus playback: PlaybackInfo, the Black Summoner episodes and
|
|
/// the three Sessions reports (recorded with their bodies).
|
|
FakeJellyfin playbackFake() {
|
|
final fake = FakeJellyfin();
|
|
fake.fixture('POST', '/Items/$episodeId/PlaybackInfo', 'playback_info');
|
|
fake.on('GET', '/Shows/$seriesId/Episodes',
|
|
(_, _) => JellyfinHttpResponse(200, jellyfinFixture('bs_episodes')));
|
|
for (final path in ['/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped']) {
|
|
fake.on('POST', path, (_, body) {
|
|
reports.add((path: path, body: jsonDecode(body!) as Map<String, dynamic>));
|
|
return const JellyfinHttpResponse(204, '');
|
|
});
|
|
}
|
|
fake.on('POST', '/UserPlayedItems/$episodeId', (_, _) {
|
|
reports.add((path: '/UserPlayedItems', body: const {}));
|
|
return const JellyfinHttpResponse(200, '{"Played":true}');
|
|
});
|
|
fake.on('POST', '/UserItems/$episodeId/UserData', (_, body) {
|
|
reports.add((path: '/UserData', body: jsonDecode(body!) as Map<String, dynamic>));
|
|
return const JellyfinHttpResponse(200, '{}');
|
|
});
|
|
return fake;
|
|
}
|
|
|
|
final reports = <({String path, Map<String, dynamic> body})>[];
|
|
|
|
JellyfinMediaSource fixtureSource() => JellyfinPlaybackInfo.fromJson(
|
|
jsonDecode(jellyfinFixture('playback_info')) as Map<String, dynamic>)
|
|
.primarySource!;
|
|
|
|
MemberView member(String id, String name, {Map<String, bool> jf = const {}, bool gone = false}) =>
|
|
MemberView(
|
|
id: id,
|
|
name: name,
|
|
ready: true,
|
|
inSet: true,
|
|
buffered: 10,
|
|
presence: gone ? Presence.gone : Presence.watching,
|
|
jf: jf);
|
|
|
|
void main() {
|
|
setUp(reports.clear);
|
|
|
|
group('resolve a ref', () {
|
|
test('own account: PlaybackInfo with its own user, static stream, token in the header',
|
|
() async {
|
|
final (accounts, fake) = await linkedAccounts(http: playbackFake());
|
|
final plan = await resolveJellyfinRef(accounts, episodeRef,
|
|
relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId');
|
|
expect(plan, isA<OwnPlay>());
|
|
plan as OwnPlay;
|
|
expect(plan.jfAccess, isTrue);
|
|
expect(plan.info.playSessionId, playSessionId);
|
|
expect(plan.source.id, episodeId);
|
|
expect(plan.item?.seriesId, seriesId);
|
|
final info = fake.requests.firstWhere((r) => r.uri.path.endsWith('/PlaybackInfo'));
|
|
expect(info.uri.queryParameters['userId'], fixtureUserId);
|
|
expect(info.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"'));
|
|
final url = Uri.parse(plan.url);
|
|
expect(url.path, '/Videos/$episodeId/stream');
|
|
expect(url.queryParameters['static'], 'true');
|
|
expect(url.queryParameters.containsKey('api_key'), isFalse, reason: 'token in a header only');
|
|
expect(plan.headers['Authorization'], accounts.clientInfo.authorizationHeader('TEST_ACCESS_TOKEN'));
|
|
});
|
|
|
|
test('no account: the relay URL when the server relays', () async {
|
|
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new);
|
|
final plan = await resolveJellyfinRef(accounts, episodeRef,
|
|
relay: true, relayUrl: 'http://h:1/j/default/t/$episodeId');
|
|
expect(plan, isA<RelayPlay>());
|
|
expect((plan as RelayPlay).url, 'http://h:1/j/default/t/$episodeId');
|
|
expect(plan.jfAccess, isFalse);
|
|
});
|
|
|
|
test('neither: blocked with a clear message, never a silent stall', () async {
|
|
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: FakeJellyfin.new);
|
|
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
|
expect(plan, isA<BlockedPlay>());
|
|
expect((plan as BlockedPlay).message,
|
|
'No Jellyfin account here for that Jellyfin server, and nobody is passing it on');
|
|
expect(plan.jfAccess, isNull);
|
|
});
|
|
|
|
test('a 401 marks the account expired and falls back to the relay', () async {
|
|
final fake = playbackFake()
|
|
..on('POST', '/Items/$episodeId/PlaybackInfo', (_, _) => const JellyfinHttpResponse(401, ''));
|
|
final (accounts, _) = await linkedAccounts(http: fake);
|
|
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: true, relayUrl: 'http://r');
|
|
expect(plan, isA<RelayPlay>());
|
|
expect(accounts.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
|
|
final blocked = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null);
|
|
expect((blocked as BlockedPlay).message, contains('for Example Server'));
|
|
expect(blocked.message, contains('sign-in expired'));
|
|
});
|
|
|
|
test('relay URL is built like the /m one', () {
|
|
expect(relayUrlFor('10.0.0.2', 8080, 'tok', episodeId),
|
|
'http://10.0.0.2:8080/j/default/tok/$episodeId');
|
|
});
|
|
});
|
|
|
|
group('wire media', () {
|
|
test('a pick goes up as a ref without URL or token', () async {
|
|
final (accounts, _) = await linkedAccounts();
|
|
const pick = JellyfinMedia(
|
|
serverId: fixtureServerId,
|
|
baseUrl: 'https://example.invalid',
|
|
itemId: episodeId,
|
|
title: 'Rice and Thieves and Heroes',
|
|
subtitle: 'Black Summoner · S1E6',
|
|
runtime: Duration(minutes: 23));
|
|
final media = putUpJellyfinMedia(accounts, pick);
|
|
expect(media.url, isNull);
|
|
expect(media.jellyfin!.itemId, episodeId);
|
|
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E6');
|
|
expect(media.jellyfin!.runtimeSecs, 23 * 60);
|
|
expect(jsonEncode(SwapIntent(media: media).toJson()), isNot(contains('TEST_ACCESS_TOKEN')));
|
|
expect(() => putUpJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
|
|
throwsFormatException);
|
|
accounts.markExpired(fixtureServerId);
|
|
expect(() => putUpJellyfinMedia(accounts, pick), throwsFormatException);
|
|
});
|
|
|
|
test('a pasted Jellyfin link becomes a ref (ServerId from /System/Info/Public, no api_key)',
|
|
() async {
|
|
final (accounts, fake) = await linkedAccounts();
|
|
final media = await mediaForLink(accounts,
|
|
'https://example.invalid/Videos/$episodeId/stream?static=true&api_key=SOMEONES_TOKEN');
|
|
expect(media.url, isNull);
|
|
expect(media.jellyfin!.serverId, fixtureServerId);
|
|
expect(media.jellyfin!.itemId, episodeId);
|
|
expect(media.jellyfin!.title, 'Rice and Thieves and Heroes', reason: 'title via own account');
|
|
expect(media.name, 'Rice and Thieves and Heroes');
|
|
expect(jsonEncode(media.toJson()), isNot(contains('SOMEONES_TOKEN')));
|
|
expect(fake.count('GET', '/System/Info/Public'), 1);
|
|
// Never sent with the pasted token.
|
|
expect(fake.requests.every((r) => !'${r.uri}${r.headers}'.contains('SOMEONES_TOKEN')), isTrue);
|
|
|
|
// A web UI link carries the ServerId: no lookup.
|
|
final web = await mediaForLink(accounts,
|
|
'https://example.invalid/web/#/details?id=$episodeId&serverId=$fixtureServerId');
|
|
expect(web.jellyfin!.serverId, fixtureServerId);
|
|
expect(fake.count('GET', '/System/Info/Public'), 1);
|
|
});
|
|
|
|
test('other links, or an unreachable Jellyfin, stay plain links', () async {
|
|
final fake = FakeJellyfin()..offline = true;
|
|
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake);
|
|
const url = 'https://jelly.example/Items/$episodeId/Download?api_key=x';
|
|
final media = await mediaForLink(accounts, url);
|
|
expect(media.jellyfin, isNull);
|
|
expect(media.url, url);
|
|
expect(media.name, 'Download');
|
|
final plain = await mediaForLink(accounts, 'https://cdn.example/movie.mkv');
|
|
expect(plain.url, 'https://cdn.example/movie.mkv');
|
|
expect(plain.name, 'movie.mkv');
|
|
});
|
|
|
|
test('relay credentials: each account token with exactly its own device fields', () async {
|
|
final (accounts, _) = await linkedAccounts();
|
|
final creds = relayCredentialsFor(accounts);
|
|
final cred = creds.single;
|
|
final info = accounts.clientInfo;
|
|
expect(cred.token, 'TEST_ACCESS_TOKEN');
|
|
expect(cred.serverId, fixtureServerId);
|
|
expect(cred.baseUrl, 'https://example.invalid');
|
|
expect([cred.client, cred.device, cred.deviceId, cred.version],
|
|
[info.client, info.device, info.deviceId, info.version]);
|
|
expect(cred.device, 'Test PC');
|
|
accounts.markExpired(fixtureServerId);
|
|
expect(relayCredentialsFor(accounts), isEmpty);
|
|
final none = JellyfinAccounts(store: MemoryJellyfinStore());
|
|
await none.ensureLoaded(validate: false);
|
|
expect(relayCredentialsFor(none), isEmpty);
|
|
});
|
|
});
|
|
|
|
group('progress', () {
|
|
late JellyfinAccounts accounts;
|
|
late DateTime now;
|
|
late JellyfinProgressReporter reporter;
|
|
final errors = <Object>[];
|
|
var unauthorized = 0;
|
|
|
|
setUp(() async {
|
|
(accounts, _) = await linkedAccounts(http: playbackFake());
|
|
now = DateTime(2026, 1, 1, 20);
|
|
errors.clear();
|
|
unauthorized = 0;
|
|
reporter = JellyfinProgressReporter(
|
|
client: accounts.clientFor(fixtureServerId)!,
|
|
itemId: episodeId,
|
|
source: fixtureSource(),
|
|
playSessionId: playSessionId,
|
|
clock: () => now,
|
|
onError: errors.add,
|
|
onUnauthorized: () => unauthorized++,
|
|
);
|
|
});
|
|
|
|
Future<void> idle() => reporter.stop(const PlayerSnapshot(position: 0, paused: true))
|
|
.then((_) {});
|
|
|
|
test('Playing, Progress every 10 s and on pause / seek / track change, Stopped', () async {
|
|
// English audio (mpv aid 2 = Jellyfin index 2), English full subs (sid 1 = 3).
|
|
reporter.start(const PlayerSnapshot(position: 0, paused: true, aid: 2, sid: 1));
|
|
void at(int secs, double pos, {bool paused = false, int? aid = 2, int? sid = 1}) {
|
|
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: secs));
|
|
reporter.update(PlayerSnapshot(position: pos, paused: paused, aid: aid, sid: sid));
|
|
}
|
|
|
|
at(1, 0); // resume -> Progress
|
|
at(2, 1);
|
|
at(5, 4); // nothing due
|
|
at(11, 10); // 10 s since the last one
|
|
at(12, 11, paused: true); // pause
|
|
at(13, 11, paused: true);
|
|
at(14, 300, paused: true); // seek
|
|
at(15, 300, paused: true, sid: null); // subtitles off
|
|
await reporter.stop(const PlayerSnapshot(position: 300.5, paused: true, aid: 2));
|
|
|
|
expect(reports.map((r) => r.path.split('/').last), [
|
|
'Playing',
|
|
'Progress', // resumed
|
|
'Progress', // 10 s
|
|
'Progress', // paused
|
|
'Progress', // seek
|
|
'Progress', // track change
|
|
'Stopped',
|
|
]);
|
|
final playing = reports.first.body;
|
|
expect(playing['ItemId'], episodeId);
|
|
expect(playing['MediaSourceId'], episodeId);
|
|
expect(playing['PlaySessionId'], playSessionId);
|
|
expect(playing['PlayMethod'], 'DirectPlay');
|
|
expect(playing['IsPaused'], isTrue);
|
|
expect(playing['AudioStreamIndex'], 2);
|
|
expect(playing['SubtitleStreamIndex'], 3);
|
|
expect(reports[2].body['PositionTicks'], 10 * jellyfinTicksPerSecond);
|
|
expect(reports[3].body['IsPaused'], isTrue);
|
|
expect(reports[4].body['PositionTicks'], 300 * jellyfinTicksPerSecond);
|
|
expect(reports[5].body['SubtitleStreamIndex'], -1);
|
|
expect(reports.last.body, {
|
|
'ItemId': episodeId,
|
|
'MediaSourceId': episodeId,
|
|
'PlaySessionId': playSessionId,
|
|
'PositionTicks': 0,
|
|
}, reason: '11 s watched is a peek: the resume point from before (none) stays');
|
|
expect(errors, isEmpty);
|
|
});
|
|
|
|
test('watched to the end: Stopped without a position, then marked played', () async {
|
|
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
|
for (var i = 1; i <= 1420; i++) {
|
|
now = DateTime(2026, 1, 1, 20).add(Duration(seconds: i));
|
|
reporter.update(PlayerSnapshot(position: i.toDouble(), paused: false));
|
|
}
|
|
await reporter.stop(const PlayerSnapshot(position: 1420, paused: true));
|
|
expect(reports.map((r) => r.path.split('/').last).toList().sublist(reports.length - 3),
|
|
['Stopped', 'UserPlayedItems', 'UserData']);
|
|
expect(reports[reports.length - 3].body.containsKey('PositionTicks'), isFalse);
|
|
expect(reports.last.body, {'PlaybackPositionTicks': 0}, reason: 'the resume point Progress left is cleared');
|
|
final nearEnd = [for (final r in reports) if (r.path.endsWith('Progress')) r.body]
|
|
.where((b) => b.containsKey('PositionTicks') && b['PositionTicks'] >= 1207 * jellyfinTicksPerSecond);
|
|
expect(nearEnd, isEmpty, reason: 'near the end Progress carries no position: Jellyfin must not decide');
|
|
});
|
|
|
|
test('played before: a peek never unmarks it', () async {
|
|
final r = JellyfinProgressReporter(
|
|
client: accounts.clientFor(fixtureServerId)!,
|
|
itemId: episodeId,
|
|
source: fixtureSource(),
|
|
playSessionId: playSessionId,
|
|
playedBefore: true,
|
|
clock: () => now,
|
|
);
|
|
r.start(const PlayerSnapshot(position: 700, paused: false));
|
|
now = now.add(const Duration(seconds: 1));
|
|
r.update(const PlayerSnapshot(position: 701, paused: true));
|
|
await r.stop(const PlayerSnapshot(position: 701, paused: true));
|
|
expect(reports.map((r) => r.path.split('/').last),
|
|
['Playing', 'UserPlayedItems', 'Progress', 'Stopped', 'UserPlayedItems', 'UserData']);
|
|
expect(reports[2].body.containsKey('PositionTicks'), isFalse);
|
|
expect(reports[3].body.containsKey('PositionTicks'), isFalse);
|
|
});
|
|
|
|
test('played to the end, then seeked back: a fresh Playing', () async {
|
|
reporter.start(const PlayerSnapshot(position: 0, paused: false));
|
|
await reporter.stop(const PlayerSnapshot(position: 1440, paused: true));
|
|
expect(reporter.active, isFalse);
|
|
reporter.update(const PlayerSnapshot(position: 1440, paused: true));
|
|
reporter.start(const PlayerSnapshot(position: 600, paused: false));
|
|
await idle();
|
|
expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped', 'Playing', 'Stopped']);
|
|
});
|
|
|
|
test('failures are handed on and never throw; a 401 ends reporting', () async {
|
|
final fake = playbackFake()
|
|
..on('POST', '/Sessions/Playing/Progress', (_, _) => const JellyfinHttpResponse(500, ''));
|
|
(accounts, _) = await linkedAccounts(http: fake);
|
|
final r = JellyfinProgressReporter(
|
|
client: accounts.clientFor(fixtureServerId)!,
|
|
itemId: episodeId,
|
|
source: fixtureSource(),
|
|
playSessionId: playSessionId,
|
|
clock: () => now,
|
|
onError: errors.add,
|
|
onUnauthorized: () => unauthorized++,
|
|
);
|
|
r.start(const PlayerSnapshot(position: 0, paused: false));
|
|
r.update(const PlayerSnapshot(position: 0, paused: true));
|
|
await r.stop(const PlayerSnapshot(position: 1, paused: true));
|
|
expect(errors, hasLength(1));
|
|
expect(unauthorized, 0);
|
|
expect(reports.map((r) => r.path.split('/').last), ['Playing', 'Stopped']);
|
|
|
|
fake.on('POST', '/Sessions/Playing', (_, _) => const JellyfinHttpResponse(401, ''));
|
|
reports.clear();
|
|
r.start(const PlayerSnapshot(position: 0, paused: false));
|
|
r.update(const PlayerSnapshot(position: 0, paused: true));
|
|
await r.stop(const PlayerSnapshot(position: 1, paused: true));
|
|
expect(unauthorized, 1);
|
|
expect(reports, isEmpty, reason: 'nothing after the 401');
|
|
expect(r.active, isFalse);
|
|
});
|
|
});
|
|
|
|
group('episodes', () {
|
|
test('neighbours of an episode with the clicker\'s account', () async {
|
|
final (accounts, _) = await linkedAccounts(http: playbackFake());
|
|
final plan = await resolveJellyfinRef(accounts, episodeRef, relay: false, relayUrl: null)
|
|
as OwnPlay;
|
|
final around = await findAdjacentEpisodes(plan);
|
|
expect(around.previous, isNull, reason: 'S1E6 is first in the fixture');
|
|
expect(around.next!.item.id, nextEpisodeId);
|
|
expect(around.next!.label, 'S1E7 Battle with the Heroes');
|
|
final media = mediaForPick(around.next!.pick);
|
|
expect(media.jellyfin!.itemId, nextEpisodeId);
|
|
expect(media.jellyfin!.subtitle, 'Black Summoner · S1E7');
|
|
});
|
|
|
|
test('one auto-advance swapper: the lowest id among members with their own account', () {
|
|
final members = [
|
|
member('m10', 'Ann', jf: {fixtureServerId: true}),
|
|
member('m2', 'Bob', jf: {fixtureServerId: true}),
|
|
member('m1', 'Guest', jf: {fixtureServerId: false}),
|
|
member('m0', 'Old', jf: {fixtureServerId: true}, gone: true),
|
|
member('m3', 'Fresh'),
|
|
];
|
|
expect(autoAdvanceSwapper(members, fixtureServerId), 'm2');
|
|
expect(autoAdvanceSwapper(members.reversed.toList(), fixtureServerId), 'm2',
|
|
reason: 'order does not matter: every client agrees');
|
|
expect(autoAdvanceSwapper(members, 'other-server'), isNull);
|
|
expect(compareMemberIds('m9', 'm10'), lessThan(0));
|
|
});
|
|
|
|
test('member route: via server only for relay viewers of the current item', () {
|
|
DeliveryRoute? route(MemberView m, {bool relay = true}) =>
|
|
routeOf(m, DeliveryKind.jellyfin, '', episodeRef, relay);
|
|
expect(route(member('m1', 'G', jf: {fixtureServerId: false})), DeliveryRoute.server);
|
|
expect(route(member('m1', 'G', jf: {fixtureServerId: false}), relay: false), isNull);
|
|
expect(route(member('m1', 'G', jf: {fixtureServerId: true})), DeliveryRoute.direct);
|
|
expect(route(member('m1', 'G')), isNull);
|
|
});
|
|
});
|
|
|
|
group('track carry-over', () {
|
|
test('choice from Jellyfin streams carries to the next episode by traits', () {
|
|
final source = fixtureSource();
|
|
// mpv aid 2 / sid 2: English eac3 audio, "English (Signs)" subtitles.
|
|
final choice = TrackChoice.of(aid: 2, sid: 2, source: source);
|
|
expect(choice.audio!.language, 'eng');
|
|
expect(choice.subtitle!.title, 'English (Signs) [Generated]');
|
|
// Next file: same tracks in another order (English audio first).
|
|
final next = JellyfinMediaSource(id: 'n', mediaStreams: [
|
|
const JellyfinMediaStream(index: 0, type: JellyfinStreamType.video),
|
|
const JellyfinMediaStream(
|
|
index: 1, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'eac3', channels: 2),
|
|
const JellyfinMediaStream(
|
|
index: 2, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'eac3', channels: 2),
|
|
const JellyfinMediaStream(
|
|
index: 3, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Full Subtitle)'),
|
|
const JellyfinMediaStream(
|
|
index: 4, type: JellyfinStreamType.subtitle, language: 'eng', title: 'English (Signs)'),
|
|
]);
|
|
final pick = chooseTracks(choice, source: next);
|
|
expect(pick.aid, 1, reason: 'English is mpv aid 1 there');
|
|
expect(pick.sid, 2, reason: 'Signs, not Full');
|
|
expect(pick.subtitlesOff, isFalse);
|
|
final off = chooseTracks(TrackChoice.of(aid: 1, sid: null, source: source), source: next);
|
|
expect(off.aid, 2, reason: 'Japanese');
|
|
expect(off.subtitlesOff, isTrue);
|
|
});
|
|
|
|
test('relay viewers match on mpv\'s track list', () {
|
|
const tracks = [
|
|
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'jpn', title: 'Japanese', codec: 'aac'),
|
|
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'eng', title: 'English', codec: 'aac'),
|
|
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Signs'),
|
|
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'eng', title: 'Full'),
|
|
];
|
|
final choice = TrackChoice.of(aid: 2, sid: 2, tracks: tracks);
|
|
expect(choice.audio!.title, 'English');
|
|
final pick = chooseTracks(choice, tracks: const [
|
|
MpvTrack(id: 1, type: JellyfinStreamType.audio, language: 'en', title: 'English'),
|
|
MpvTrack(id: 2, type: JellyfinStreamType.audio, language: 'ja', title: 'Japanese'),
|
|
MpvTrack(id: 1, type: JellyfinStreamType.subtitle, language: 'en', title: 'Full Subtitles'),
|
|
MpvTrack(id: 2, type: JellyfinStreamType.subtitle, language: 'en', title: 'Signs & Songs'),
|
|
]);
|
|
expect(pick.aid, 1);
|
|
expect(pick.sid, 1);
|
|
});
|
|
|
|
test('kept per series with a global fallback', () async {
|
|
final prefs = TrackPrefs(store: MemoryJellyfinStore());
|
|
expect(await prefs.load(seriesId: 'a'), isNull);
|
|
final eng = TrackChoice.of(aid: 2, sid: null, source: fixtureSource());
|
|
await prefs.save(eng, seriesId: 'a');
|
|
final jpn = TrackChoice.of(aid: 1, sid: 1, source: fixtureSource());
|
|
await prefs.save(jpn, seriesId: 'b');
|
|
expect((await prefs.load(seriesId: 'a'))!.audio!.language, 'eng');
|
|
expect((await prefs.load(seriesId: 'a'))!.subtitlesOff, isTrue);
|
|
expect((await prefs.load(seriesId: 'b'))!.audio!.language, 'jpn');
|
|
expect((await prefs.load(seriesId: 'new'))!.audio!.language, 'jpn', reason: 'global = last');
|
|
expect((await prefs.load())!.subtitle!.title, 'English (Full Subtitle)');
|
|
});
|
|
});
|
|
|
|
group('browser location', () {
|
|
test('route stacks round-trip and survive in the Jellyfin box', () async {
|
|
final (accounts, _) = await linkedAccounts();
|
|
final stack = [
|
|
const HomeRoute(),
|
|
const LibraryRoute(
|
|
JellyfinItem(id: 'lib1', name: 'Shows', type: 'CollectionFolder', collectionType: 'tvshows')),
|
|
SeriesRoute(const JellyfinItem(id: seriesId, name: 'Black Summoner', type: 'Series'),
|
|
seasonId: seasonId),
|
|
];
|
|
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack(stack));
|
|
final back = decodeBrowseStack(accounts.browseLocation(fixtureServerId))!;
|
|
expect(back.map((r) => r.key), stack.map((r) => r.key));
|
|
expect((back[1] as LibraryRoute).folder.collectionType, 'tvshows');
|
|
expect((back[2] as SeriesRoute).seasonId, seasonId);
|
|
expect(decodeBrowseStack('junk'), isNull);
|
|
});
|
|
|
|
testWidgets('the browser reopens where it was, per server', (tester) async {
|
|
await setSize(tester, const Size(1600, 1000));
|
|
final (accounts, _) = await linkedAccounts();
|
|
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([
|
|
const HomeRoute(),
|
|
SeriesRoute(const JellyfinItem(id: arifuretaId, name: 'Arifureta', type: 'Series'),
|
|
seasonId: season2Id),
|
|
]));
|
|
await tester.pumpWidget(scoped(_graph, app(JellyfinBrowser(accounts: accounts, onSelect: (_) {}, onPutUp: (_) {}))));
|
|
await settle(tester);
|
|
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
|
|
expect(s2.selected, isTrue);
|
|
// Back to Home is remembered too.
|
|
await tester.tap(find.byTooltip('Back (Esc)'));
|
|
await settle(tester);
|
|
expect(decodeBrowseStack(accounts.browseLocation(fixtureServerId))!.map((r) => r.key), ['home']);
|
|
});
|
|
});
|
|
|
|
group('session widgets', () {
|
|
final next = EpisodeLink(
|
|
const JellyfinItem(
|
|
id: nextEpisodeId, name: 'Battle with the Heroes', type: 'Episode',
|
|
parentIndexNumber: 1, indexNumber: 7),
|
|
const JellyfinMedia(
|
|
serverId: fixtureServerId, baseUrl: 'b', itemId: nextEpisodeId, title: 'Battle with the Heroes'));
|
|
|
|
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
|
var play = 0, cancel = 0;
|
|
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
|
next: next, countdown: null, onPlayNow: () => play++, onCancel: () => cancel++))));
|
|
expect(find.text('Next: S1E7 Battle with the Heroes'), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('auto-advance-cancel')), findsNothing);
|
|
await tester.tap(find.byKey(const ValueKey('play-next')));
|
|
expect(play, 1);
|
|
|
|
await tester.pumpWidget(scoped(_graph, app(EndOfEpisodeCard(
|
|
next: next,
|
|
countdown: AutoAdvance(mediaId: 3, secondsLeft: 7, next: next, swapper: false),
|
|
onPlayNow: () => play++,
|
|
onCancel: () => cancel++))));
|
|
expect(find.text('Next episode in 7 s'), findsOneWidget);
|
|
await tester.tap(find.byKey(const ValueKey('auto-advance-cancel')));
|
|
expect(cancel, 1);
|
|
});
|
|
|
|
testWidgets('next-episode pill: faint at rest, lit on hover, click puts it up', (tester) async {
|
|
var pressed = 0;
|
|
await tester.pumpWidget(scoped(_graph, app(Align(child: NextEpisodePill(episode: next, onPressed: () => pressed++)))));
|
|
double opacity() => tester.widget<AnimatedOpacity>(find.descendant(
|
|
of: find.byType(NextEpisodePill), matching: find.byType(AnimatedOpacity))).opacity;
|
|
expect(opacity(), lessThan(0.5));
|
|
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
|
await mouse.addPointer(location: Offset.zero);
|
|
addTearDown(mouse.removePointer);
|
|
await mouse.moveTo(tester.getCenter(find.byKey(const ValueKey('next-episode-pill'))));
|
|
await tester.pumpAndSettle();
|
|
expect(opacity(), 1);
|
|
await tester.tap(find.byKey(const ValueKey('next-episode-pill')));
|
|
expect(pressed, 1);
|
|
});
|
|
|
|
testWidgets('blocked card offers to link an account', (tester) async {
|
|
var link = 0;
|
|
await tester.pumpWidget(scoped(_graph, app(JellyfinBlockedCard(
|
|
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
|
|
onLinkAccount: () => link++,
|
|
onRetry: () {}))));
|
|
expect(find.text('No Jellyfin account here for Example Server, and nobody is passing it on'),
|
|
findsOneWidget);
|
|
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
|
|
expect(link, 1);
|
|
});
|
|
});
|
|
}
|