Integrations links several Jellyfin servers (Quick Connect or password, app-obtained tokens only, status/reconnect/sign out). The Host tab's Jellyfin source is a full browser: continue watching, next up, latest per library, library grids with sort/filter/infinite scroll, series pages with smart resume/next-up, details, grouped search, inspector pane on wide screens, keyboard navigation. MediaPicker selection is now a sealed PickedMedia. Hosting a Jellyfin pick is interim (host's stream URL) until per-viewer playback lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
17 KiB
Dart
375 lines
17 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hive_ce/hive.dart';
|
|
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/media_picker.dart';
|
|
|
|
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
|
|
String jellyfinFixture(String name) =>
|
|
File('../../packages/core/test/fixtures/jellyfin/$name.json').readAsStringSync();
|
|
|
|
const fixtureServerId = '122fce17c5b9413088af4be266bad6f4';
|
|
const fixtureUserId = 'f00703a1d1ac44fe8a8a0ddf2ca05757';
|
|
const arifuretaId = '550eff23b37ec61082c988678ccc214c';
|
|
const season2Id = '331a23499d051bc288d281f2c72557a9';
|
|
|
|
typedef FakeHandler = JellyfinHttpResponse Function(Uri uri, String? body);
|
|
|
|
/// Fake Jellyfin: routes by "METHOD /path" to fixtures, filters episode and
|
|
/// next-up lists by their query like the server does, records requests.
|
|
/// [approved] flips the Quick Connect state.
|
|
class FakeJellyfin implements JellyfinHttp {
|
|
final requests = <({String method, Uri uri, Map<String, String> headers})>[];
|
|
final routes = <String, FakeHandler>{};
|
|
bool approved = false;
|
|
bool offline = false;
|
|
int meStatus = 200;
|
|
|
|
FakeJellyfin() {
|
|
fixture('GET', '/System/Info/Public', 'pub');
|
|
on('GET', '/QuickConnect/Enabled', (_, _) => const JellyfinHttpResponse(200, 'true'));
|
|
fixture('POST', '/QuickConnect/Initiate', 'quickconnect_initiate');
|
|
on('GET', '/QuickConnect/Connect', (_, _) => JellyfinHttpResponse(
|
|
200, jsonEncode({'Authenticated': approved, 'Secret': 's', 'Code': '437392'})));
|
|
fixture('POST', '/Users/AuthenticateWithQuickConnect', 'auth_result');
|
|
on('POST', '/Users/AuthenticateByName', (_, body) {
|
|
final pw = (jsonDecode(body!) as Map)['Pw'];
|
|
return pw == 'right'
|
|
? JellyfinHttpResponse(200, jellyfinFixture('auth_result'))
|
|
: const JellyfinHttpResponse(401, '');
|
|
});
|
|
on('GET', '/Users/Me', (_, _) => JellyfinHttpResponse(
|
|
meStatus, meStatus == 200 ? jellyfinFixture('me') : ''));
|
|
on('POST', '/Sessions/Logout', (_, _) => const JellyfinHttpResponse(204, ''));
|
|
fixture('GET', '/UserViews', 'views');
|
|
on('GET', '/UserItems/Resume', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('resume')));
|
|
on('GET', '/Shows/NextUp', (uri, _) {
|
|
final series = uri.queryParameters['seriesId'];
|
|
if (series == null) return JellyfinHttpResponse(200, jellyfinFixture('nextup'));
|
|
// Arifureta's next up: S2E4.
|
|
final eps = _items('episodes').where((e) => e['SeriesId'] == series).toList();
|
|
return _page(eps.isEmpty ? const [] : [eps[3]]);
|
|
});
|
|
fixture('GET', '/Items/Latest', 'latest_shows');
|
|
on('GET', '/Items', (uri, _) {
|
|
final q = uri.queryParameters;
|
|
if ((q['searchTerm'] ?? '').isNotEmpty) {
|
|
return q['searchTerm']!.toLowerCase().startsWith('ari')
|
|
? JellyfinHttpResponse(200, jellyfinFixture('search'))
|
|
: _page(const []);
|
|
}
|
|
return JellyfinHttpResponse(
|
|
200, jellyfinFixture(q['includeItemTypes'] == 'Movie' ? 'movies_page' : 'series_page'));
|
|
});
|
|
on('GET', '/Items/$arifuretaId', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('series_detail')));
|
|
on('GET', '/Items/b18826be994070a79f9b52bc0f66f052',
|
|
(_, _) => JellyfinHttpResponse(200, jellyfinFixture('episode_detail')));
|
|
on('GET', '/Shows/$arifuretaId/Seasons', (_, _) => JellyfinHttpResponse(200, jellyfinFixture('seasons')));
|
|
on('GET', '/Shows/$arifuretaId/Episodes', (uri, _) {
|
|
final season = uri.queryParameters['seasonId'];
|
|
return _page(_items('episodes').where((e) => season == null || e['SeasonId'] == season).toList());
|
|
});
|
|
}
|
|
|
|
static List<Map<String, dynamic>> _items(String fixture) =>
|
|
((jsonDecode(jellyfinFixture(fixture)) as Map)['Items'] as List).cast<Map<String, dynamic>>();
|
|
|
|
static JellyfinHttpResponse _page(List<Object> items) =>
|
|
JellyfinHttpResponse(200, jsonEncode({'Items': items, 'TotalRecordCount': items.length}));
|
|
|
|
void on(String method, String path, FakeHandler h) => routes['$method $path'] = h;
|
|
void fixture(String method, String path, String name) =>
|
|
on(method, path, (_, _) => JellyfinHttpResponse(200, jellyfinFixture(name)));
|
|
|
|
int count(String method, String path) =>
|
|
requests.where((r) => r.method == method && r.uri.path == path).length;
|
|
|
|
@override
|
|
Future<JellyfinHttpResponse> send(String method, Uri uri,
|
|
{Map<String, String> headers = const {}, String? body}) async {
|
|
requests.add((method: method, uri: uri, headers: headers));
|
|
if (offline) throw JellyfinException('$method ${uri.path}: connection refused');
|
|
final h = routes['$method ${uri.path}'];
|
|
return h == null ? const JellyfinHttpResponse(404, '') : h(uri, body);
|
|
}
|
|
|
|
@override
|
|
void close() {}
|
|
}
|
|
|
|
JellyfinAuthResult fixtureAuth() =>
|
|
JellyfinAuthResult.fromJson(jsonDecode(jellyfinFixture('auth_result')) as Map<String, dynamic>);
|
|
|
|
JellyfinServerInfo fixtureServer() =>
|
|
JellyfinServerInfo.fromJson(jsonDecode(jellyfinFixture('pub')) as Map<String, dynamic>);
|
|
|
|
/// Accounts on a fake server with one linked account (no validation run).
|
|
Future<(JellyfinAccounts, FakeJellyfin)> linkedAccounts({FakeJellyfin? http}) async {
|
|
final fake = http ?? FakeJellyfin();
|
|
final accounts = JellyfinAccounts(
|
|
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test PC');
|
|
await accounts.ensureLoaded(validate: false);
|
|
await accounts.link(
|
|
baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
|
|
return (accounts, fake);
|
|
}
|
|
|
|
void main() {
|
|
group('accounts', () {
|
|
test('persist in their Hive box with a stable device id', () async {
|
|
final dir = Directory.systemTemp.createTempSync('jf_accounts_');
|
|
addTearDown(() => dir.deleteSync(recursive: true));
|
|
Hive.init(dir.path);
|
|
final fake = FakeJellyfin();
|
|
|
|
final a = JellyfinAccounts(httpFactory: () => fake, deviceName: 'PC');
|
|
await a.ensureLoaded(validate: false);
|
|
final deviceId = a.deviceId;
|
|
expect(deviceId, hasLength(32));
|
|
await a.link(baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
|
|
await a.rememberPick(const JellyfinMedia(
|
|
serverId: fixtureServerId, baseUrl: 'https://example.invalid', itemId: 'abc', title: 'Ep'));
|
|
await Hive.close();
|
|
|
|
final b = JellyfinAccounts(httpFactory: () => fake, deviceName: 'PC');
|
|
await b.ensureLoaded(validate: false);
|
|
expect(b.deviceId, deviceId);
|
|
final acc = b.accounts.single;
|
|
expect(acc.serverId, fixtureServerId);
|
|
expect(acc.serverName, 'Example Server');
|
|
expect(acc.userName, 'testuser');
|
|
expect(acc.token, 'TEST_ACCESS_TOKEN');
|
|
expect(b.defaultServerId, fixtureServerId);
|
|
expect(b.lastPick?.itemId, 'abc');
|
|
// The header names this app and install.
|
|
expect(b.clientInfo.client, 'Watch Together');
|
|
expect(b.clientInfo.deviceId, deviceId);
|
|
await Hive.close();
|
|
});
|
|
|
|
test('token check: online, expired stays linked, offline is not a sign-out', () async {
|
|
final (accounts, fake) = await linkedAccounts();
|
|
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.online);
|
|
expect(fake.requests.last.headers['Authorization'], contains('Token="TEST_ACCESS_TOKEN"'));
|
|
|
|
fake.meStatus = 401;
|
|
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.expired);
|
|
expect(accounts.accounts, hasLength(1));
|
|
|
|
fake.offline = true;
|
|
expect(await accounts.validate(fixtureServerId), JellyfinAccountStatus.offline);
|
|
expect(accounts.errorOf(fixtureServerId), contains('connection refused'));
|
|
expect(accounts.accounts, hasLength(1));
|
|
});
|
|
|
|
test('ensureLoaded validates in the background', () async {
|
|
final fake = FakeJellyfin()..meStatus = 401;
|
|
final store = MemoryJellyfinStore();
|
|
final first = JellyfinAccounts(store: store, httpFactory: () => fake);
|
|
await first.ensureLoaded(validate: false);
|
|
await first.link(baseUrl: 'https://example.invalid', server: fixtureServer(), auth: fixtureAuth());
|
|
final again = JellyfinAccounts(store: store, httpFactory: () => fake);
|
|
await again.ensureLoaded();
|
|
await pumpEventQueue();
|
|
expect(again.statusOf(fixtureServerId), JellyfinAccountStatus.expired);
|
|
});
|
|
|
|
test('sign out revokes the token and unlinks; default moves on', () async {
|
|
final (accounts, fake) = await linkedAccounts();
|
|
final otherAuth = JellyfinAuthResult(
|
|
accessToken: 'T2',
|
|
user: const JellyfinUser(id: 'u2', name: 'other'),
|
|
serverId: 'other-server');
|
|
await accounts.link(
|
|
baseUrl: 'https://two.invalid',
|
|
server: const JellyfinServerInfo(id: 'other-server', name: 'Two', version: '10.11'),
|
|
auth: otherAuth);
|
|
expect(accounts.defaultServerId, fixtureServerId);
|
|
await accounts.setDefault('other-server');
|
|
expect(accounts.defaultServerId, 'other-server');
|
|
|
|
await accounts.signOut('other-server');
|
|
expect(fake.count('POST', '/Sessions/Logout'), 1);
|
|
expect(fake.requests.last.headers['Authorization'], contains('Token="T2"'));
|
|
expect(accounts.accounts.map((a) => a.serverId), [fixtureServerId]);
|
|
expect(accounts.defaultServerId, fixtureServerId);
|
|
});
|
|
|
|
test('signing in again replaces the account and revokes the old token', () async {
|
|
final (accounts, fake) = await linkedAccounts();
|
|
final fresh = JellyfinAuthResult(
|
|
accessToken: 'NEW', user: fixtureAuth().user, serverId: fixtureServerId);
|
|
await accounts.link(baseUrl: 'https://alt.invalid', server: fixtureServer(), auth: fresh);
|
|
await pumpEventQueue();
|
|
expect(accounts.accounts.single.token, 'NEW');
|
|
expect(accounts.accounts.single.urls, ['https://alt.invalid', 'https://example.invalid']);
|
|
expect(fake.count('POST', '/Sessions/Logout'), 1);
|
|
});
|
|
});
|
|
|
|
group('sign-in flow', () {
|
|
late FakeJellyfin fake;
|
|
late JellyfinAccounts accounts;
|
|
setUp(() async {
|
|
fake = FakeJellyfin();
|
|
accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => fake);
|
|
await accounts.ensureLoaded(validate: false);
|
|
});
|
|
|
|
test('bad address and non-Jellyfin servers stay on the address step', () async {
|
|
final flow = JellyfinSignIn(accounts, pollInterval: Duration.zero);
|
|
await flow.probe(' ');
|
|
expect(flow.step, JellyfinSignInStep.address);
|
|
expect(flow.error, isNotNull);
|
|
fake.offline = true;
|
|
await flow.probe('jelly.example');
|
|
expect(flow.step, JellyfinSignInStep.address);
|
|
expect(flow.error, contains('No Jellyfin server'));
|
|
});
|
|
|
|
test('Quick Connect: code, poll until approved, linked', () async {
|
|
final flow = JellyfinSignIn(accounts, pollInterval: const Duration(milliseconds: 1));
|
|
await flow.probe('example.invalid');
|
|
expect(flow.step, JellyfinSignInStep.method);
|
|
expect(flow.server?.name, 'Example Server');
|
|
expect(flow.quickConnectAvailable, isTrue);
|
|
final done = flow.startQuickConnect();
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
expect(flow.code, '437392');
|
|
expect(flow.step, JellyfinSignInStep.quickConnect);
|
|
fake.approved = true;
|
|
await done;
|
|
expect(flow.step, JellyfinSignInStep.done);
|
|
expect(accounts.accounts.single.userName, 'testuser');
|
|
expect(accounts.accounts.single.urls, ['https://example.invalid']);
|
|
});
|
|
|
|
test('Quick Connect cancel stops polling', () async {
|
|
final flow = JellyfinSignIn(accounts, pollInterval: const Duration(milliseconds: 1));
|
|
await flow.probe('https://example.invalid');
|
|
final done = flow.startQuickConnect();
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
flow.cancel();
|
|
await done;
|
|
final polls = fake.count('GET', '/QuickConnect/Connect');
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
expect(fake.count('GET', '/QuickConnect/Connect'), polls);
|
|
expect(flow.step, JellyfinSignInStep.method);
|
|
expect(accounts.isEmpty, isTrue);
|
|
});
|
|
|
|
test('password: wrong then right', () async {
|
|
final flow = JellyfinSignIn(accounts);
|
|
await flow.probe('https://example.invalid');
|
|
flow.usePassword();
|
|
await flow.signIn('testuser', 'wrong');
|
|
expect(flow.step, JellyfinSignInStep.password);
|
|
expect(flow.error, 'Wrong username or password');
|
|
await flow.signIn('testuser', 'right');
|
|
expect(flow.step, JellyfinSignInStep.done);
|
|
expect(accounts.accounts.single.token, 'TEST_ACCESS_TOKEN');
|
|
});
|
|
|
|
test('reconnect refuses a different server', () async {
|
|
final (linked, _) = await linkedAccounts();
|
|
final other = JellyfinAccount(
|
|
serverId: 'someone-else', serverName: 'X', urls: const ['https://example.invalid'],
|
|
userId: 'u', userName: 'n', token: 't');
|
|
final flow = JellyfinSignIn(linked, reconnect: other);
|
|
await flow.probe(other.baseUrl);
|
|
expect(flow.step, JellyfinSignInStep.address);
|
|
expect(flow.error, contains('different Jellyfin server'));
|
|
});
|
|
});
|
|
|
|
group('picked media', () {
|
|
test('controller selection is a PickedMedia per source', () {
|
|
final c = MediaPickerController();
|
|
expect(c.selection, isNull);
|
|
c.pasteText('https://example.com/a.mkv');
|
|
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
|
c.pasteText('/home/me/b.mkv');
|
|
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
|
c.source = MediaKind.jellyfin;
|
|
expect(c.selection, isNull);
|
|
const m = JellyfinMedia(
|
|
serverId: 's', baseUrl: 'https://j.example', itemId: 'i', title: 'Ep', subtitle: 'Show · S1E6');
|
|
c.pickJellyfin(m);
|
|
expect(c.selection, m);
|
|
c.source = MediaKind.file;
|
|
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
|
expect(c.jellyfin, m, reason: 'kept while another source shows');
|
|
c.dispose();
|
|
});
|
|
|
|
test('a saved Jellyfin pick starts on the Jellyfin source', () {
|
|
const m = JellyfinMedia(serverId: 's', baseUrl: 'b', itemId: 'i', title: 't');
|
|
expect(MediaPickerController(jellyfin: m).source, MediaKind.jellyfin);
|
|
expect(MediaPickerController(jellyfin: m, mediaPath: '/x.mkv').source, MediaKind.file);
|
|
});
|
|
|
|
test('JellyfinMedia round-trips and labels', () {
|
|
const m = JellyfinMedia(
|
|
serverId: 's',
|
|
baseUrl: 'https://j.example',
|
|
itemId: 'i',
|
|
mediaSourceId: 'src',
|
|
title: 'Rice and Thieves and Heroes',
|
|
subtitle: 'Black Summoner · S1E6',
|
|
runtime: Duration(minutes: 23));
|
|
final back = JellyfinMedia.fromJson(jsonDecode(jsonEncode(m.toJson())))!;
|
|
expect(back, m);
|
|
expect(back.runtime, const Duration(minutes: 23));
|
|
expect(back.subtitle, 'Black Summoner · S1E6');
|
|
expect(mediaSummary(m), 'Playing: Rice and Thieves and Heroes — Black Summoner · S1E6');
|
|
expect(mediaSummary(const FileMedia('/a/b/movie.mkv')), 'Playing: movie.mkv');
|
|
expect(mediaSummary(null), 'No media selected (optional).');
|
|
expect(JellyfinMedia.fromJson({'serverId': 1}), isNull);
|
|
});
|
|
|
|
test('pickFor labels an episode "Series · S1E6"', () async {
|
|
final (accounts, _) = await linkedAccounts();
|
|
final ep = JellyfinItem.fromJson(jsonDecode(jellyfinFixture('episode_detail')) as Map<String, dynamic>);
|
|
final m = pickFor(accounts.accounts.single, ep);
|
|
expect(m.serverId, fixtureServerId);
|
|
expect(m.itemId, 'b18826be994070a79f9b52bc0f66f052');
|
|
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', () {
|
|
test('time left and subtitles', () {
|
|
final resume = JellyfinItemPage.fromJson(jsonDecode(jellyfinFixture('resume')) as Map<String, dynamic>).items;
|
|
final ep = resume[1];
|
|
expect(timeLeft(ep), endsWith('min left'));
|
|
expect(cardSubtitle(ep), startsWith('S1E10 · '));
|
|
expect(progressOf(ep), greaterThan(0));
|
|
expect(formatRuntime(const Duration(minutes: 102)), '1 h 42 min');
|
|
expect(formatRuntime(const Duration(minutes: 24)), '24 min');
|
|
});
|
|
});
|
|
}
|