Jellyfin end to end: per-viewer playback, progress, episodes, swap browser
- Host/swap send refs only; pasted Jellyfin links become refs; the host hands the relay its own app-obtained credential. - Each viewer resolves with their own account (token in a header) or via the relay, else a clear blocked card with Link account. - Per-account progress reporting (Playing/Progress/Stopped), 401 marks the account expired. - Previous/next episode in the controls (< > / Shift+P/N), floating Next episode pill near the end, auto-advance countdown (one designated swapper), track carry-over per series. - Swap panel uses the shared MediaPicker; browser location remembered per server and opens on the current series. - Fix: states are applied one at a time (a stale pause could land after a newer play). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
773a04369e
commit
ae4edc29e8
33 files changed
+3541
-175
No files matched your search
@@ -7,7 +7,7 @@ import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/format.dart';
|
||||
import 'package:watch_together/src/jellyfin/sign_in.dart';
|
||||
import 'package:watch_together/src/screens/home/host_tab.dart' show interimJellyfinLink, mediaSummary;
|
||||
import 'package:watch_together/src/screens/home/host_tab.dart' show mediaSummary;
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
|
||||
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
|
||||
@@ -342,22 +342,6 @@ void main() {
|
||||
expect(m.subtitle, 'Black Summoner · S1E6');
|
||||
expect(m.runtime, isNotNull);
|
||||
});
|
||||
|
||||
test('INTERIM: the host plays a Jellyfin pick as its authorised stream link', () async {
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
const m = JellyfinMedia(
|
||||
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 't');
|
||||
final url = Uri.parse(interimJellyfinLink(accounts, m));
|
||||
expect(url.path, '/Videos/abc/stream');
|
||||
expect(url.queryParameters['static'], 'true');
|
||||
expect(url.queryParameters['api_key'], 'TEST_ACCESS_TOKEN');
|
||||
expect(
|
||||
() => interimJellyfinLink(
|
||||
accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
|
||||
throwsFormatException);
|
||||
accounts.markExpired(fixtureServerId);
|
||||
expect(() => interimJellyfinLink(accounts, m), throwsFormatException);
|
||||
});
|
||||
});
|
||||
|
||||
group('format', () {
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:sync_relay_server/server.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
|
||||
// Two account holders and a guest watch Jellyfin episodes together over a
|
||||
// real sync server, real mpv and a local fake Jellyfin (never a real one):
|
||||
// account holders direct-play with their own token and report progress,
|
||||
// the guest plays through the server's /j relay with the host credential.
|
||||
|
||||
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
const _sid = 'f00dfeedf00dfeedf00dfeedf00dfeed';
|
||||
const _series = '5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e';
|
||||
const _ep1 = 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1';
|
||||
const _ep2 = 'e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2';
|
||||
|
||||
/// 20 s, h264 + two audio tracks (Japanese first, English second).
|
||||
Future<String> _media() async {
|
||||
final path = '$_tmp/jellyfin_e2e_20s.mkv';
|
||||
if (await File(path).exists()) return path;
|
||||
final r = await Process.run('ffmpeg', [
|
||||
'-y', '-v', 'error',
|
||||
'-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=24',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=440:sample_rate=48000',
|
||||
'-f', 'lavfi', '-i', 'sine=frequency=660:sample_rate=48000',
|
||||
'-map', '0:v', '-map', '1:a', '-map', '2:a',
|
||||
'-t', '20', '-c:v', 'libx264', '-preset', 'ultrafast', '-g', '24', '-pix_fmt', 'yuv420p',
|
||||
'-c:a', 'aac',
|
||||
'-metadata:s:a:0', 'language=jpn', '-metadata:s:a:0', 'title=Japanese',
|
||||
'-metadata:s:a:1', 'language=eng', '-metadata:s:a:1', 'title=English',
|
||||
path,
|
||||
]);
|
||||
if (r.exitCode != 0) fail('ffmpeg failed: ${r.stderr}');
|
||||
return path;
|
||||
}
|
||||
|
||||
class _Call {
|
||||
final String method;
|
||||
final String path;
|
||||
final String auth;
|
||||
final Map<String, dynamic>? body;
|
||||
_Call(this.method, this.path, this.auth, this.body);
|
||||
}
|
||||
|
||||
/// Just enough Jellyfin: public info, users, PlaybackInfo, items, episodes,
|
||||
/// the static stream with ranges, and the three Sessions reports. Every
|
||||
/// request is recorded with its Authorization header.
|
||||
class _FakeJellyfin {
|
||||
final String file;
|
||||
final tokens = {'ATOKEN': 'ua', 'CTOKEN': 'uc', 'HOSTTOKEN': 'uh'};
|
||||
final calls = <_Call>[];
|
||||
late HttpServer _server;
|
||||
_FakeJellyfin(this.file);
|
||||
|
||||
String get base => 'http://127.0.0.1:${_server.port}';
|
||||
|
||||
Future<void> start() async {
|
||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_server.listen(_handle);
|
||||
}
|
||||
|
||||
Future<void> stop() => _server.close(force: true);
|
||||
|
||||
static Map<String, String> authFields(String header) => {
|
||||
for (final m in RegExp(r'(\w+)="([^"]*)"').allMatches(header))
|
||||
m.group(1)!: Uri.decodeComponent(m.group(2)!),
|
||||
};
|
||||
|
||||
Map<String, Object?> _item(String id) => {
|
||||
'Id': id,
|
||||
'Name': id == _ep1 ? 'Pilot' : 'Second',
|
||||
'Type': 'Episode',
|
||||
'ServerId': _sid,
|
||||
'SeriesId': _series,
|
||||
'SeriesName': 'Fake Show',
|
||||
'SeasonId': 'seas1',
|
||||
'ParentIndexNumber': 1,
|
||||
'IndexNumber': id == _ep1 ? 1 : 2,
|
||||
'RunTimeTicks': 20 * jellyfinTicksPerSecond,
|
||||
};
|
||||
|
||||
Future<void> _handle(HttpRequest req) async {
|
||||
final auth = req.headers.value('authorization') ?? '';
|
||||
final bodyText = await utf8.decoder.bind(req).join();
|
||||
Map<String, dynamic>? body;
|
||||
try {
|
||||
body = bodyText.isEmpty ? null : jsonDecode(bodyText) as Map<String, dynamic>;
|
||||
} catch (_) {}
|
||||
calls.add(_Call(req.method, req.uri.path, auth, body));
|
||||
final res = req.response;
|
||||
Future<void> json(Object v) async {
|
||||
res.headers.contentType = ContentType.json;
|
||||
res.write(jsonEncode(v));
|
||||
await res.close();
|
||||
}
|
||||
|
||||
final path = req.uri.path;
|
||||
if (path == '/System/Info/Public') {
|
||||
return json({'Id': _sid, 'ServerName': 'Fake Jellyfin', 'Version': '10.11.11'});
|
||||
}
|
||||
final token = authFields(auth)['Token'] ?? req.uri.queryParameters['api_key'];
|
||||
final user = tokens[token];
|
||||
if (user == null) {
|
||||
res.statusCode = 401;
|
||||
return res.close();
|
||||
}
|
||||
if (path == '/Users/Me') return json({'Id': user, 'Name': user, 'ServerId': _sid});
|
||||
final m = RegExp(r'^/Items/(\w+)(/PlaybackInfo)?$').firstMatch(path);
|
||||
if (m != null && m.group(2) != null) {
|
||||
return json({
|
||||
'PlaySessionId': 'ps-$user-${m.group(1)}',
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': m.group(1),
|
||||
'SupportsDirectPlay': true,
|
||||
'MediaStreams': [
|
||||
{'Index': 0, 'Type': 'Video', 'Codec': 'h264'},
|
||||
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'Title': 'Japanese', 'Codec': 'aac'},
|
||||
{'Index': 2, 'Type': 'Audio', 'Language': 'eng', 'Title': 'English', 'Codec': 'aac'},
|
||||
],
|
||||
}
|
||||
],
|
||||
});
|
||||
}
|
||||
if (m != null) return json(_item(m.group(1)!));
|
||||
if (path == '/Shows/$_series/Episodes') {
|
||||
return json({'Items': [_item(_ep1), _item(_ep2)], 'TotalRecordCount': 2});
|
||||
}
|
||||
if (path.startsWith('/Sessions/Playing')) {
|
||||
res.statusCode = 204;
|
||||
return res.close();
|
||||
}
|
||||
if (RegExp(r'^/Videos/\w+/stream$').hasMatch(path)) return _stream(req);
|
||||
res.statusCode = 404;
|
||||
await res.close();
|
||||
}
|
||||
|
||||
Future<void> _stream(HttpRequest req) async {
|
||||
final f = File(file);
|
||||
final size = await f.length();
|
||||
final res = req.response;
|
||||
res.headers.set('Accept-Ranges', 'bytes');
|
||||
res.headers.contentType = ContentType('video', 'x-matroska');
|
||||
var start = 0, end = size - 1;
|
||||
final range = RegExp(r'bytes=(\d*)-(\d*)').firstMatch(req.headers.value('range') ?? '');
|
||||
if (range != null) {
|
||||
start = int.tryParse(range.group(1)!) ?? 0;
|
||||
end = int.tryParse(range.group(2)!) ?? size - 1;
|
||||
if (end >= size) end = size - 1;
|
||||
res.statusCode = 206;
|
||||
res.headers.set('Content-Range', 'bytes $start-$end/$size');
|
||||
}
|
||||
res.contentLength = end - start + 1;
|
||||
try {
|
||||
await res.addStream(f.openRead(start, end + 1));
|
||||
await res.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
List<_Call> sessions({String? token}) => [
|
||||
for (final c in calls)
|
||||
if (c.path.startsWith('/Sessions/Playing') &&
|
||||
(token == null || authFields(c.auth)['Token'] == token))
|
||||
c
|
||||
];
|
||||
|
||||
List<_Call> streams(String token) => [
|
||||
for (final c in calls)
|
||||
if (c.path.startsWith('/Videos/') && authFields(c.auth)['Token'] == token) c
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _poll(FutureOr<bool> Function() cond, String what,
|
||||
{Duration timeout = const Duration(seconds: 30)}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (await cond()) return;
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
fail('timed out waiting for $what');
|
||||
}
|
||||
|
||||
Future<JellyfinAccounts> _account(String base, String token, String userId, String device) async {
|
||||
final a = JellyfinAccounts(store: MemoryJellyfinStore(), deviceName: device);
|
||||
await a.ensureLoaded(validate: false);
|
||||
await a.link(
|
||||
baseUrl: base,
|
||||
server: const JellyfinServerInfo(id: _sid, name: 'Fake Jellyfin', version: '10.11.11'),
|
||||
auth: JellyfinAuthResult(
|
||||
accessToken: token, user: JellyfinUser(id: userId, name: userId), serverId: _sid),
|
||||
);
|
||||
return a;
|
||||
}
|
||||
|
||||
String _kind(_Call c) => c.path.split('/').last;
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'account holders direct-play and report, the guest plays through the relay, all in sync',
|
||||
timeout: const Timeout(Duration(minutes: 4)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final jelly = _FakeJellyfin(await _media());
|
||||
await jelly.start();
|
||||
const relayCred = (client: 'Watch Together', device: 'HostBox', deviceId: 'host-dev-1', version: '9.9.9');
|
||||
final server = SyncServer(AppConfig(
|
||||
port: 0,
|
||||
secret: 'jf',
|
||||
name: 'jf',
|
||||
jellyfin: JellyfinCredential(
|
||||
baseUrl: jelly.base,
|
||||
serverId: _sid,
|
||||
token: 'HOSTTOKEN',
|
||||
client: relayCred.client,
|
||||
device: relayCred.device,
|
||||
deviceId: relayCred.deviceId,
|
||||
version: relayCred.version,
|
||||
),
|
||||
));
|
||||
await server.start();
|
||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||
final accC = await _account(jelly.base, 'CTOKEN', 'uc', 'C-PC');
|
||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await accB.ensureLoaded(validate: false);
|
||||
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final c = SessionCoordinator(jellyfin: accC, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final all = [a, b, c];
|
||||
ConnectParams params(String name, [HelloMedia? media]) => ConnectParams(
|
||||
host: '127.0.0.1', port: server.boundPort, secret: 'jf', name: name, media: media);
|
||||
try {
|
||||
// A hosts the episode as a ref: no URL or token on the wire.
|
||||
await a.connect(params('alice', const HelloMedia(
|
||||
name: 'Pilot',
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1, title: 'Pilot', subtitle: 'Fake Show · S1E1'))));
|
||||
await _poll(() => a.serverState.peek()?.media?['jellyfin'] != null, 'ref on the server');
|
||||
expect(a.serverState.peek()!.media!.containsKey('url'), isFalse);
|
||||
expect(a.serverState.peek()!.relay, isTrue);
|
||||
await b.connect(params('guest'));
|
||||
await c.connect(params('carol'));
|
||||
for (final x in all) {
|
||||
await x.playback.player.setVolume(0);
|
||||
}
|
||||
await _poll(() => all.every((x) => x.playback.durationSecs > 19), 'all three opened the item');
|
||||
expect(a.jellyfinPlan.peek(), isA<OwnPlay>());
|
||||
await _poll(() {
|
||||
final members = a.serverState.peek()?.members ?? const [];
|
||||
final jf = {for (final m in members) m.name: m.jf[_sid]};
|
||||
return jf['alice'] == true && jf['carol'] == true && jf['guest'] == false;
|
||||
}, 'jf access in the member list');
|
||||
expect(a.nextEpisode.peek()?.item.id, _ep2);
|
||||
expect(b.nextEpisode.peek(), isNull, reason: 'guests cannot resolve the next one');
|
||||
|
||||
for (final x in all) {
|
||||
x.toggleReady();
|
||||
}
|
||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'all playing');
|
||||
await Future.delayed(const Duration(seconds: 4));
|
||||
final pos = [for (final x in all) x.playback.positionSecs];
|
||||
// ignore: avoid_print
|
||||
print('positions after 4 s: $pos');
|
||||
for (final p in pos) {
|
||||
expect((p - pos.first).abs(), lessThan(1.0), reason: 'in sync: $pos');
|
||||
}
|
||||
|
||||
// Pause (A unreadies): an immediate Progress with IsPaused.
|
||||
a.toggleReady();
|
||||
await _poll(() => !a.playback.player.state.playing, 'held');
|
||||
await _poll(
|
||||
() => jelly.sessions(token: 'ATOKEN').any((s) =>
|
||||
_kind(s) == 'Progress' && s.body?['IsPaused'] == true),
|
||||
'paused progress');
|
||||
a.toggleReady();
|
||||
await _poll(() => all.every((x) => x.playback.player.state.playing), 'playing again');
|
||||
|
||||
// Account holders see the "Next episode" prompt (this item is
|
||||
// shorter than its 2 min lead); the guest cannot put it up.
|
||||
await _poll(() => a.nextPromptVisible.peek(), 'next prompt');
|
||||
expect(b.nextPromptVisible.peek(), isFalse);
|
||||
|
||||
// Alice and the guest pick English; it carries to the next episode.
|
||||
await a.selectTrack(aid: 2);
|
||||
await b.selectTrack(aid: 2);
|
||||
await _poll(
|
||||
() => jelly.sessions(token: 'ATOKEN').any((s) =>
|
||||
_kind(s) == 'Progress' && s.body?['AudioStreamIndex'] == 2),
|
||||
'track change reported');
|
||||
|
||||
// Near the end: finished -> Stopped, then one auto-advance swap.
|
||||
a.seekTo(16);
|
||||
await _poll(() => a.serverState.peek()?.transport == 'finished', 'finished',
|
||||
timeout: const Duration(seconds: 20));
|
||||
final finishedId = a.serverState.peek()!.mediaId;
|
||||
await _poll(() => a.autoAdvance.peek() != null && c.autoAdvance.peek() != null,
|
||||
'both account holders count down');
|
||||
expect(b.autoAdvance.peek(), isNull);
|
||||
final swappers = [a.autoAdvance.peek()!.swapper, c.autoAdvance.peek()!.swapper];
|
||||
expect(swappers.where((s) => s), hasLength(1),
|
||||
reason: 'exactly one designated swapper: the lowest member id (alice, m1)');
|
||||
expect(a.autoAdvance.peek()!.swapper, isTrue);
|
||||
await _poll(() => (a.serverState.peek()?.mediaId ?? 0) > finishedId, 'advanced',
|
||||
timeout: const Duration(seconds: 20));
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
expect(a.serverState.peek()!.mediaId, finishedId + 1, reason: 'no double swap');
|
||||
expect(refOfStateMedia(a.serverState.peek()!.media)!.itemId, _ep2);
|
||||
await _poll(() => all.every((x) => x.playback.durationSecs > 19 && x.jellyfinPlan.peek() != null),
|
||||
'all opened the next episode');
|
||||
expect(b.jellyfinPlan.peek(), isA<RelayPlay>());
|
||||
expect((b.jellyfinPlan.peek() as RelayPlay).url, endsWith('/$_ep2'));
|
||||
await _poll(() async => (await a.playback.currentTrackIds()).aid == 2, 'alice keeps English');
|
||||
await _poll(() async => (await b.playback.currentTrackIds()).aid == 2, 'guest keeps English');
|
||||
expect((await c.playback.currentTrackIds()).aid, 1, reason: 'carol never chose');
|
||||
expect(a.previousEpisode.peek()?.item.id, _ep1);
|
||||
expect(a.nextEpisode.peek(), isNull);
|
||||
|
||||
await a.leave();
|
||||
await c.leave();
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
// Progress only from account holders, exactly their own device.
|
||||
final infoA = accA.clientInfo, infoC = accC.clientInfo;
|
||||
void ownHeader(_Call call, JellyfinClientInfo info, String token) {
|
||||
final f = _FakeJellyfin.authFields(call.auth);
|
||||
expect(f, {
|
||||
'Client': info.client,
|
||||
'Device': info.device,
|
||||
'DeviceId': info.deviceId,
|
||||
'Version': info.version,
|
||||
'Token': token,
|
||||
}, reason: '${call.method} ${call.path}');
|
||||
}
|
||||
|
||||
final sessions = jelly.sessions();
|
||||
expect(sessions, isNotEmpty);
|
||||
expect(sessions.every((s) => ['ATOKEN', 'CTOKEN'].contains(_FakeJellyfin.authFields(s.auth)['Token'])),
|
||||
isTrue, reason: 'no reports from the guest or the relay credential');
|
||||
final aCalls = jelly.sessions(token: 'ATOKEN');
|
||||
for (final s in aCalls) {
|
||||
ownHeader(s, infoA, 'ATOKEN');
|
||||
}
|
||||
for (final s in jelly.sessions(token: 'CTOKEN')) {
|
||||
ownHeader(s, infoC, 'CTOKEN');
|
||||
}
|
||||
final ep1 = [for (final s in aCalls) if (s.body?['ItemId'] == _ep1) _kind(s)];
|
||||
final ep2 = [for (final s in aCalls) if (s.body?['ItemId'] == _ep2) _kind(s)];
|
||||
// ignore: avoid_print
|
||||
print('alice reports: ep1 $ep1, ep2 $ep2');
|
||||
expect(ep1.first, 'Playing');
|
||||
expect(ep1, contains('Progress'));
|
||||
expect(ep1.last, 'Stopped');
|
||||
expect(ep1.where((k) => k == 'Stopped'), hasLength(1));
|
||||
final stopped1 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep1);
|
||||
expect(stopped1.body!['PositionTicks'], greaterThan(19 * jellyfinTicksPerSecond),
|
||||
reason: 'played to the end');
|
||||
expect(stopped1.body!['PlaySessionId'], 'ps-ua-$_ep1');
|
||||
expect(ep2.first, 'Playing');
|
||||
expect(ep2.last, 'Stopped', reason: 'leaving reports Stopped');
|
||||
final playing = aCalls.firstWhere((s) => _kind(s) == 'Playing');
|
||||
expect(playing.body!['AudioStreamIndex'], 1, reason: 'mpv aid 1 = Jellyfin index 1');
|
||||
expect(playing.body!['SubtitleStreamIndex'], -1);
|
||||
final stopped2 = aCalls.lastWhere((s) => s.body?['ItemId'] == _ep2);
|
||||
expect(stopped2.body!['PlaySessionId'], 'ps-ua-$_ep2');
|
||||
expect(aCalls.lastWhere((s) => _kind(s) != 'Stopped' && s.body?['ItemId'] == _ep2)
|
||||
.body!['AudioStreamIndex'], 2, reason: 'English carried over and reported');
|
||||
|
||||
// Streams: own token with own fields; the relay with exactly the
|
||||
// configured credential.
|
||||
expect(jelly.streams('ATOKEN'), isNotEmpty);
|
||||
for (final s in jelly.streams('ATOKEN')) {
|
||||
ownHeader(s, infoA, 'ATOKEN');
|
||||
}
|
||||
final relayed = jelly.streams('HOSTTOKEN');
|
||||
expect(relayed, isNotEmpty, reason: 'the guest played through the relay');
|
||||
for (final s in relayed) {
|
||||
expect(_FakeJellyfin.authFields(s.auth), {
|
||||
'Client': relayCred.client,
|
||||
'Device': relayCred.device,
|
||||
'DeviceId': relayCred.deviceId,
|
||||
'Version': relayCred.version,
|
||||
'Token': 'HOSTTOKEN',
|
||||
});
|
||||
}
|
||||
expect(jelly.calls.any((c) => c.path.startsWith('/Videos/') && c.auth.isEmpty), isFalse,
|
||||
reason: 'no token-less (api_key) stream requests');
|
||||
} finally {
|
||||
for (final x in all) {
|
||||
await x.dispose();
|
||||
}
|
||||
await server.stop();
|
||||
await jelly.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'a guest with no account and no relay sees the blocked state, then plays after linking',
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
() async {
|
||||
MediaKit.ensureInitialized();
|
||||
final jelly = _FakeJellyfin(await _media());
|
||||
await jelly.start();
|
||||
final server = SyncServer(const AppConfig(port: 0, secret: '', name: 'nr'));
|
||||
await server.start();
|
||||
final accA = await _account(jelly.base, 'ATOKEN', 'ua', 'A-PC');
|
||||
final accB = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await accB.ensureLoaded(validate: false);
|
||||
final a = SessionCoordinator(jellyfin: accA, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
final b = SessionCoordinator(jellyfin: accB, trackPrefs: TrackPrefs(store: MemoryJellyfinStore()));
|
||||
try {
|
||||
await a.connect(ConnectParams(
|
||||
host: '127.0.0.1',
|
||||
port: server.boundPort,
|
||||
secret: '',
|
||||
name: 'alice',
|
||||
media: const HelloMedia(
|
||||
name: 'Pilot', size: 0, duration: 0, jellyfin: JellyfinRef(serverId: _sid, itemId: _ep1))));
|
||||
await b.connect(ConnectParams(host: '127.0.0.1', port: server.boundPort, secret: '', name: 'bob'));
|
||||
await _poll(() => b.jellyfinBlocked.peek() != null, 'blocked');
|
||||
expect(b.serverState.peek()!.relay, isFalse);
|
||||
expect(b.jellyfinBlocked.peek()!.message, contains('needs a Jellyfin account'));
|
||||
// Linking an account (Integrations) retries by itself.
|
||||
await accB.link(
|
||||
baseUrl: jelly.base,
|
||||
server: const JellyfinServerInfo(id: _sid, name: 'Fake Jellyfin', version: '10.11.11'),
|
||||
auth: const JellyfinAuthResult(
|
||||
accessToken: 'CTOKEN', user: JellyfinUser(id: 'uc', name: 'uc'), serverId: _sid));
|
||||
await _poll(() => b.jellyfinPlan.peek() is OwnPlay && b.playback.durationSecs > 19,
|
||||
'plays with the new account');
|
||||
expect(b.jellyfinBlocked.peek(), isNull);
|
||||
} finally {
|
||||
await a.dispose();
|
||||
await b.dispose();
|
||||
await server.stop();
|
||||
await jelly.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
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:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/browse_scope.dart';
|
||||
import 'package:watch_together/src/jellyfin/progress.dart';
|
||||
import 'package:watch_together/src/jellyfin/session_media.dart';
|
||||
import 'package:watch_together/src/jellyfin/track_prefs.dart';
|
||||
import 'package:watch_together/src/screens/home/host_tab.dart' show hostJellyfinMedia;
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
|
||||
import 'package:watch_together/src/screens/session/swap_panel.dart';
|
||||
import 'package:watch_together/src/screens/session_screen.dart' show viaHostOf;
|
||||
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
|
||||
|
||||
import 'jellyfin_accounts_test.dart';
|
||||
import 'jellyfin_browser_test.dart' show app, setSize, settle;
|
||||
|
||||
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, '');
|
||||
});
|
||||
}
|
||||
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 stale = false}) =>
|
||||
MemberView(
|
||||
id: id, name: name, ready: true, inSet: true, buffered: 10, stale: stale, 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,
|
||||
'This needs a Jellyfin account on that Jellyfin server (or the host can relay)');
|
||||
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('on 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 = hostJellyfinMedia(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(() => hostJellyfinMedia(accounts, const JellyfinMedia(serverId: 'gone', baseUrl: 'x', itemId: 'i', title: 't')),
|
||||
throwsFormatException);
|
||||
accounts.markExpired(fixtureServerId);
|
||||
expect(() => hostJellyfinMedia(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 credential: the account token with exactly its own device fields', () async {
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
final cred = relayCredentialFor(accounts, serverId: fixtureServerId)!;
|
||||
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');
|
||||
// Another server's item: the default account still relays its own.
|
||||
expect(relayCredentialFor(accounts, serverId: 'other')!.serverId, fixtureServerId);
|
||||
accounts.markExpired(fixtureServerId);
|
||||
expect(relayCredentialFor(accounts, serverId: fixtureServerId), isNull);
|
||||
final none = JellyfinAccounts(store: MemoryJellyfinStore());
|
||||
await none.ensureLoaded(validate: false);
|
||||
expect(relayCredentialFor(none), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
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': 3005000000,
|
||||
});
|
||||
expect(errors, isEmpty);
|
||||
});
|
||||
|
||||
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('the floating prompt starts two minutes before the end', () {
|
||||
expect(nextPromptStart(null, const Duration(minutes: 24)), const Duration(minutes: 22));
|
||||
expect(nextPromptStart(null, const Duration(seconds: 90)), Duration.zero);
|
||||
});
|
||||
|
||||
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}, stale: 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 badge: via host only for relay viewers of the current item', () {
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), episodeRef), isTrue);
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: true}), episodeRef), isFalse);
|
||||
expect(viaHostOf(member('m1', 'G'), episodeRef), isFalse);
|
||||
expect(viaHostOf(member('m1', 'G', jf: {fixtureServerId: false}), null), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
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),
|
||||
]));
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
|
||||
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('next / previous buttons: tooltip, hidden without an episode', (tester) async {
|
||||
var pressed = 0;
|
||||
await tester.pumpWidget(app(Row(children: [
|
||||
EpisodeSkipButton(episode: null, next: false, onPressed: () {}),
|
||||
EpisodeSkipButton(episode: next, next: true, onPressed: () => pressed++),
|
||||
])));
|
||||
expect(find.byKey(const ValueKey('episode-previous')), findsNothing);
|
||||
expect(find.byTooltip('Next: S1E7 Battle with the Heroes (> or Shift+N)'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('episode-next')));
|
||||
expect(pressed, 1);
|
||||
});
|
||||
|
||||
testWidgets('end card: Next prompt, countdown with Cancel', (tester) async {
|
||||
var play = 0, cancel = 0;
|
||||
await tester.pumpWidget(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(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(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(app(JellyfinBlockedCard(
|
||||
blocked: const BlockedPlay(serverId: fixtureServerId, serverName: 'Example Server'),
|
||||
onLinkAccount: () => link++,
|
||||
onRetry: () {})));
|
||||
expect(find.text('This needs a Jellyfin account on Example Server (or the host can relay)'),
|
||||
findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('link-jellyfin-account')));
|
||||
expect(link, 1);
|
||||
});
|
||||
|
||||
testWidgets('swap panel: the shared picker, File only for the host', (tester) async {
|
||||
await setSize(tester, const Size(1400, 900));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
final c = MediaPickerController(source: MediaKind.link);
|
||||
addTearDown(c.dispose);
|
||||
final swapped = <PickedMedia>[];
|
||||
var closed = 0;
|
||||
await tester.pumpWidget(app(SwapPanel(
|
||||
controller: c,
|
||||
accounts: accounts,
|
||||
isHost: false,
|
||||
onSwap: (m) async {
|
||||
swapped.add(m);
|
||||
return null;
|
||||
},
|
||||
onClose: () => closed++,
|
||||
)));
|
||||
await settle(tester);
|
||||
expect(find.widgetWithText(ButtonSegment<MediaKind>, 'File'), findsNothing);
|
||||
expect(find.text('File'), findsNothing);
|
||||
expect(find.text('Jellyfin'), findsOneWidget);
|
||||
await tester.enterText(find.byType(TextField), 'https://cdn.example/movie.mkv');
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('swap-go')));
|
||||
await settle(tester);
|
||||
expect(swapped, [const LinkMedia('https://cdn.example/movie.mkv')]);
|
||||
expect(closed, 1);
|
||||
|
||||
// The Jellyfin source is the library browser; a pick goes up as is.
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await settle(tester);
|
||||
expect(find.byKey(const ValueKey('media-jellyfin')), findsOneWidget);
|
||||
|
||||
await tester.pumpWidget(app(SwapPanel(
|
||||
controller: c, accounts: accounts, isHost: true, onSwap: (_) async => 'x', onClose: () {})));
|
||||
await settle(tester);
|
||||
expect(find.text('File'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('swap panel opens on the series of the episode playing, episode highlighted',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1600, 1000));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
// Somewhere else remembered: the series start wins.
|
||||
await accounts.saveBrowseLocation(fixtureServerId, encodeBrowseStack([const HomeRoute()]));
|
||||
final eps = (jsonDecode(jellyfinFixture('episodes')) as Map)['Items'] as List;
|
||||
final ep = JellyfinItem.fromJson((eps.firstWhere((e) => e['SeasonId'] == season2Id) as Map)
|
||||
.cast<String, dynamic>());
|
||||
final pick = pickForRef(accounts,
|
||||
JellyfinRef(serverId: fixtureServerId, itemId: ep.id, title: ep.name))!;
|
||||
final c = MediaPickerController(jellyfin: pick, source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(SwapPanel(
|
||||
controller: c,
|
||||
accounts: accounts,
|
||||
isHost: false,
|
||||
jellyfinStart: BrowseStart.seriesOf(fixtureServerId, ep),
|
||||
onSwap: (_) async => null,
|
||||
onClose: () {},
|
||||
)));
|
||||
await settle(tester, 10);
|
||||
expect(tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id'))).selected,
|
||||
isTrue);
|
||||
expect(find.text('Put up: ${pick.label}'), findsOneWidget);
|
||||
final row = tester.widget<Material>(find
|
||||
.ancestor(of: find.byKey(ValueKey('episode-${ep.id}')), matching: find.byType(Material))
|
||||
.first);
|
||||
expect((row.shape as RoundedRectangleBorder).side.color, isNot(Colors.transparent),
|
||||
reason: 'the episode playing is highlighted');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||
import 'package:watch_together/src/state/server_isolate.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
@@ -162,4 +163,59 @@ void main() {
|
||||
await session.stopServer();
|
||||
}
|
||||
});
|
||||
|
||||
test('host path: a Jellyfin pick goes up as a ref with the host relay; File swap',
|
||||
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||
MediaKit.ensureInitialized();
|
||||
final tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
Hive.init('$tmp/hive_isolate_jf_test');
|
||||
const sid = 'abcdefabcdefabcdefabcdefabcdef12';
|
||||
final accounts = JellyfinAccounts.instance;
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
// An unreachable Jellyfin: only the wire and the relay flag matter here.
|
||||
await accounts.link(
|
||||
baseUrl: 'http://127.0.0.1:9',
|
||||
server: const JellyfinServerInfo(id: sid, name: 'J', version: '10.11.11'),
|
||||
auth: const JellyfinAuthResult(
|
||||
accessToken: 'HOSTTOKEN', user: JellyfinUser(id: 'u', name: 'u'), serverId: sid));
|
||||
final session = SessionState.instance;
|
||||
final port = await _freePort();
|
||||
_Raw? friend;
|
||||
final file = File('$tmp/host file swap.mkv')..writeAsBytesSync(List.filled(4096, 7));
|
||||
try {
|
||||
await session.host(
|
||||
ConnectParams(
|
||||
host: '',
|
||||
port: port,
|
||||
secret: '',
|
||||
name: 'host',
|
||||
media: const HelloMedia(
|
||||
name: 'Pilot',
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: JellyfinRef(serverId: sid, itemId: 'e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1e1', title: 'Pilot'))),
|
||||
ConnectForm());
|
||||
await session.coordinator.playback.player.setVolume(0);
|
||||
friend = await _Raw.join(port, 'friend');
|
||||
final state = await friend.next('state', where: (m) => m['media'] != null);
|
||||
final media = state['media'] as Map;
|
||||
expect(media['jellyfin'], isA<Map>());
|
||||
expect(media.containsKey('url'), isFalse);
|
||||
expect(state['relay'], isTrue, reason: "the host's own sign-in relays for guests");
|
||||
expect(jsonEncode(state), isNot(contains('HOSTTOKEN')));
|
||||
|
||||
expect(await session.coordinator.swapFile(file.path), isNull);
|
||||
final swapped = await friend.next('state',
|
||||
where: (m) => (m['media'] as Map?)?['name'] == 'host file swap.mkv');
|
||||
expect((swapped['media'] as Map)['jellyfin'], isNull);
|
||||
expect(swapped['relay'], isNull);
|
||||
expect(await session.coordinator.swapFile('$tmp/missing.mkv'), contains('not found'));
|
||||
} finally {
|
||||
await friend?.ws.close();
|
||||
await session.coordinator.leave(silent: true);
|
||||
await session.stopServer();
|
||||
await accounts.signOut(sid);
|
||||
if (file.existsSync()) file.deleteSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user