Jellyfin in the app: account linking and library browser
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>
This commit is contained in:
1 parent
45f4c1bc84
commit
773a04369e
27 files changed
+5169
-228
No files matched your search
@@ -85,12 +85,12 @@ void main() {
|
||||
expect(c.source, MediaKind.file);
|
||||
c.pasteText(' https://example.com/a.mkv ');
|
||||
expect(c.source, MediaKind.link);
|
||||
expect(c.selection, (mediaPath: '', linkUrl: 'https://example.com/a.mkv'));
|
||||
expect(c.selection, const LinkMedia('https://example.com/a.mkv'));
|
||||
c.pasteText('/home/me/b.mkv');
|
||||
expect(c.source, MediaKind.file);
|
||||
expect(c.selection, (mediaPath: '/home/me/b.mkv', linkUrl: ''));
|
||||
expect(c.selection, const FileMedia('/home/me/b.mkv'));
|
||||
c.source = MediaKind.jellyfin;
|
||||
expect(c.selection, (mediaPath: '', linkUrl: ''));
|
||||
expect(c.selection, isNull);
|
||||
c.dispose();
|
||||
});
|
||||
|
||||
@@ -238,10 +238,11 @@ void main() {
|
||||
expect(find.text('Paste'), findsOneWidget);
|
||||
await tester.tap(find.text('Jellyfin'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link your Jellyfin in Integrations — coming soon'), findsOneWidget);
|
||||
// No linked server: sign-in right in the picker.
|
||||
expect(find.text('Add a Jellyfin server'), findsOneWidget);
|
||||
await tester.tap(find.text('Open Integrations'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Link account'), findsOneWidget);
|
||||
expect(find.text('Add server'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts.dart';
|
||||
import 'package:watch_together/src/jellyfin/accounts_panel.dart';
|
||||
import 'package:watch_together/src/jellyfin/browser.dart';
|
||||
import 'package:watch_together/src/screens/home/connect_flow.dart';
|
||||
import 'package:watch_together/src/screens/home/host_tab.dart';
|
||||
import 'package:watch_together/src/screens/home/media_picker.dart';
|
||||
import 'package:watch_together/src/state/session_state.dart';
|
||||
|
||||
import 'jellyfin_accounts_test.dart'
|
||||
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
|
||||
|
||||
Future<void> setSize(WidgetTester tester, Size size) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
}
|
||||
|
||||
Widget app(Widget child) => MaterialApp(
|
||||
theme: ThemeData.dark(),
|
||||
home: Scaffold(body: Padding(padding: const EdgeInsets.all(16), child: child)),
|
||||
);
|
||||
|
||||
/// Lets fake requests, debounces and image errors settle without waiting
|
||||
/// on the progress spinners (pumpAndSettle would never return).
|
||||
Future<void> settle(WidgetTester tester, [int frames = 6]) async {
|
||||
for (var i = 0; i < frames; i++) {
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
/// MediaPicker on the Jellyfin source with one linked fake server.
|
||||
Future<(MediaPickerController, JellyfinAccounts, FakeJellyfin)> pumpPicker(
|
||||
WidgetTester tester, Size size) async {
|
||||
await setSize(tester, size);
|
||||
// Memory store + fake transport: no real IO, so no runAsync (futures
|
||||
// made there would resume outside the test's fake clock).
|
||||
final (accounts, fake) = await linkedAccounts();
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(MediaPicker(controller: c, jellyfin: accounts)));
|
||||
await settle(tester);
|
||||
return (c, accounts, fake);
|
||||
}
|
||||
|
||||
Future<void> openArifureta(WidgetTester tester) async {
|
||||
await tester.enterText(find.byKey(const ValueKey('jf-search')), 'arifureta');
|
||||
await settle(tester, 10);
|
||||
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('card-$arifuretaId')));
|
||||
await settle(tester);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('home rows: continue watching with time left, next up, latest', (tester) async {
|
||||
await pumpPicker(tester, const Size(1600, 2400));
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
expect(find.text('Next up'), findsOneWidget);
|
||||
expect(find.text('Latest in Shows'), findsOneWidget);
|
||||
expect(find.widgetWithText(ActionChip, 'Movies'), findsOneWidget);
|
||||
// Continue watching: series name on top, "S1E10 · N min left · title" below.
|
||||
expect(find.text('From Old Country Bumpkin to Master Swordsman'), findsWidgets);
|
||||
expect(find.textContaining(RegExp(r'^S1E10 · \d+ min left')), findsOneWidget);
|
||||
expect(find.text('Black Summoner'), findsWidgets);
|
||||
expect(find.text('Monogatari'), findsWidgets);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('series page: smart next button, season switching, pick an episode',
|
||||
(tester) async {
|
||||
final (c, _, _) = await pumpPicker(tester, const Size(1600, 1000));
|
||||
await openArifureta(tester);
|
||||
// Opens on the season holding next up (S2E4).
|
||||
expect(find.byKey(const ValueKey('season-$season2Id')), findsOneWidget);
|
||||
final s2 = tester.widget<ChoiceChip>(find.byKey(const ValueKey('season-$season2Id')));
|
||||
expect(s2.selected, isTrue);
|
||||
expect(find.text('1. Irregular'), findsOneWidget);
|
||||
expect(find.textContaining('Next up: S2E4'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(ChoiceChip, 'Season 3'));
|
||||
await settle(tester);
|
||||
expect(find.text('1. Irregular'), findsNothing);
|
||||
expect(find.text('1. Haulias Assemble'), findsWidgets);
|
||||
|
||||
// Picking an episode fills the controller.
|
||||
await tester.tap(find.text('2. Roar of Revolution').first);
|
||||
await tester.pump();
|
||||
final sel = c.selection;
|
||||
expect(sel, isA<JellyfinMedia>());
|
||||
sel as JellyfinMedia;
|
||||
expect(sel.serverId, fixtureServerId);
|
||||
expect(sel.title, 'Roar of Revolution');
|
||||
expect(sel.subtitle, "Arifureta: From Commonplace to World's Strongest · S3E2");
|
||||
expect(sel.baseUrl, 'https://example.invalid');
|
||||
|
||||
// The smart button picks next up.
|
||||
await tester.tap(find.byKey(const ValueKey('series-next')));
|
||||
await tester.pump();
|
||||
expect((c.selection as JellyfinMedia).subtitle, endsWith('S2E4'));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keyboard: / searches, Esc clears then goes back, arrows move', (tester) async {
|
||||
await pumpPicker(tester, const Size(1600, 1000));
|
||||
final search = find.byKey(const ValueKey('jf-search'));
|
||||
|
||||
// Focus a card, then arrow right moves to the next card.
|
||||
final first = find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3'));
|
||||
Focus.of(tester.element(find.descendant(of: first, matching: find.byType(Column)).first))
|
||||
.requestFocus();
|
||||
await tester.pump();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
final ink = FocusManager.instance.primaryFocus!.context!.findAncestorWidgetOfExactType<InkWell>()!;
|
||||
expect((ink.key as ValueKey<String>).value, 'card-43c0f58d4f0c13e4df01d737b695fae7');
|
||||
|
||||
// "/" jumps to search.
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.slash, character: '/');
|
||||
await tester.pump();
|
||||
expect(tester.widget<TextField>(search).focusNode!.hasFocus, isTrue);
|
||||
await tester.enterText(search, 'arifureta');
|
||||
await settle(tester, 10);
|
||||
expect(find.byKey(const ValueKey('search-results')), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('search-results')), findsNothing);
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
|
||||
// Into the series and back with Esc.
|
||||
await openArifureta(tester);
|
||||
expect(find.byKey(const ValueKey('series-next')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('crumb-0')), findsOneWidget);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('series-next')), findsNothing);
|
||||
expect(find.text('Continue watching'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('library grid with sort and filters', (tester) async {
|
||||
final (_, _, fake) = await pumpPicker(tester, const Size(1600, 1000));
|
||||
await tester.tap(find.widgetWithText(ActionChip, 'Shows'));
|
||||
await settle(tester);
|
||||
expect(find.text('218 items'), findsOneWidget);
|
||||
expect(find.text('Alma-chan Wants to Be a Family!'), findsWidgets);
|
||||
await tester.tap(find.byKey(const ValueKey('filter-unplayed')));
|
||||
await settle(tester);
|
||||
final q = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
|
||||
expect(q['filters'], 'IsUnplayed');
|
||||
expect(q['includeItemTypes'], 'Series');
|
||||
expect(q['recursive'], 'true');
|
||||
await tester.tap(find.byKey(const ValueKey('library-sort')));
|
||||
await settle(tester, 2);
|
||||
await tester.tap(find.byKey(const ValueKey('sort-added')));
|
||||
await settle(tester);
|
||||
final q2 = fake.requests.lastWhere((r) => r.uri.path == '/Items').uri.queryParameters;
|
||||
expect(q2['sortBy'], startsWith('DateCreated'));
|
||||
expect(q2['sortOrder'], 'Descending');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('layouts: narrow has no inspector, ultrawide adds it and fits more columns',
|
||||
(tester) async {
|
||||
await pumpPicker(tester, const Size(820, 700));
|
||||
expect(find.byKey(const ValueKey('jf-inspector')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
final narrowCard = tester.getSize(find.byKey(const ValueKey('card-b79037c4a4c2707287dd4a29965220f3')));
|
||||
|
||||
await pumpPicker(tester, const Size(3440, 1440));
|
||||
expect(find.byKey(const ValueKey('jf-inspector')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
// Rows fill the width with whole cards.
|
||||
final wideCards = find.byWidgetPredicate((w) =>
|
||||
w is InkWell && w.key is ValueKey<String> && (w.key as ValueKey<String>).value.startsWith('card-'));
|
||||
expect(wideCards.evaluate().length, greaterThan(10));
|
||||
expect(narrowCard.width, greaterThan(200));
|
||||
|
||||
// Hover feeds the inspector.
|
||||
final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse);
|
||||
addTearDown(gesture.removePointer);
|
||||
await gesture.addPointer(location: Offset.zero);
|
||||
await gesture.moveTo(tester.getCenter(find.byKey(const ValueKey('card-b18826be994070a79f9b52bc0f66f052')).first));
|
||||
await tester.pump();
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('jf-inspector')),
|
||||
matching: find.textContaining('Rice and Thieves and Heroes')),
|
||||
findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Integrations: Quick Connect shows the code, approve links, cancel stops',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1200, 900));
|
||||
final fake = FakeJellyfin();
|
||||
final accounts = JellyfinAccounts(
|
||||
store: MemoryJellyfinStore(), httpFactory: () => fake, deviceName: 'Test');
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
await tester.pumpWidget(app(SingleChildScrollView(
|
||||
child: JellyfinAccountsPanel(
|
||||
accounts: accounts, pollInterval: const Duration(milliseconds: 100)))));
|
||||
await tester.tap(find.byKey(const ValueKey('jf-add-server')));
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byKey(const ValueKey('jf-address')), 'example.invalid');
|
||||
await tester.tap(find.byKey(const ValueKey('jf-connect')));
|
||||
await settle(tester, 2);
|
||||
expect(find.text('Example Server'), findsOneWidget);
|
||||
expect(find.textContaining('Jellyfin 10.11.11'), findsOneWidget);
|
||||
|
||||
// Cancel: the code goes away and polling stops.
|
||||
await tester.tap(find.byKey(const ValueKey('jf-quick-connect')));
|
||||
await settle(tester, 2);
|
||||
expect(find.text('437392'), findsOneWidget);
|
||||
expect(find.textContaining('Profile → Quick Connect'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('jf-qc-cancel')));
|
||||
await settle(tester, 4);
|
||||
final polls = fake.count('GET', '/QuickConnect/Connect');
|
||||
await settle(tester, 6);
|
||||
expect(fake.count('GET', '/QuickConnect/Connect'), polls);
|
||||
expect(find.text('437392'), findsNothing);
|
||||
|
||||
// Again, and approve it.
|
||||
await tester.tap(find.byKey(const ValueKey('jf-quick-connect')));
|
||||
await settle(tester, 2);
|
||||
expect(find.text('437392'), findsOneWidget);
|
||||
fake.approved = true;
|
||||
await settle(tester, 6);
|
||||
expect(accounts.accounts.single.userName, 'testuser');
|
||||
expect(find.byKey(const ValueKey('jf-account-$fixtureServerId')), findsOneWidget);
|
||||
expect(find.text('testuser on Example Server'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('jf-sign-in')), findsNothing);
|
||||
|
||||
// Expired token: Reconnect shows, the account stays.
|
||||
accounts.markExpired(fixtureServerId);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('jf-reconnect-$fixtureServerId')), findsOneWidget);
|
||||
|
||||
// Sign out asks, revokes, unlinks.
|
||||
await tester.tap(find.byKey(const ValueKey('jf-sign-out-$fixtureServerId')));
|
||||
await tester.pump();
|
||||
await tester.tap(find.byKey(const ValueKey('jf-sign-out-confirm')));
|
||||
await settle(tester, 4);
|
||||
expect(fake.count('POST', '/Sessions/Logout'), 1);
|
||||
expect(accounts.isEmpty, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('no linked server: the picker offers sign-in inline', (tester) async {
|
||||
await setSize(tester, const Size(1200, 900));
|
||||
final accounts = JellyfinAccounts(store: MemoryJellyfinStore(), httpFactory: () => FakeJellyfin());
|
||||
await accounts.ensureLoaded(validate: false);
|
||||
final c = MediaPickerController(source: MediaKind.jellyfin);
|
||||
addTearDown(c.dispose);
|
||||
await tester.pumpWidget(app(SingleChildScrollView(
|
||||
child: MediaPicker(controller: c, jellyfin: accounts, onOpenIntegrations: () {}))));
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('jf-sign-in')), findsOneWidget);
|
||||
expect(find.text('Open Integrations'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('expired account shows a reconnect banner in the browser', (tester) async {
|
||||
final (_, accounts, _) = await pumpPicker(tester, const Size(1400, 900));
|
||||
accounts.markExpired(fixtureServerId);
|
||||
await tester.pump();
|
||||
expect(find.byKey(const ValueKey('jf-status-banner')), findsOneWidget);
|
||||
expect(find.text('Reconnect'), findsOneWidget);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Host tab: browser beside the server column, enlarged with a host bar, stacked when narrow',
|
||||
(tester) async {
|
||||
await setSize(tester, const Size(1800, 1000));
|
||||
final (accounts, _) = await linkedAccounts();
|
||||
const pick = JellyfinMedia(
|
||||
serverId: fixtureServerId,
|
||||
baseUrl: 'https://example.invalid',
|
||||
itemId: 'b18826be994070a79f9b52bc0f66f052',
|
||||
title: 'Rice and Thieves and Heroes',
|
||||
subtitle: 'Black Summoner · S1E6');
|
||||
await accounts.rememberPick(pick);
|
||||
final fields = HostFields(ConnectForm(), jellyfin: accounts);
|
||||
final flow = ConnectFlow();
|
||||
addTearDown(() {
|
||||
fields.dispose();
|
||||
flow.dispose();
|
||||
});
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
theme: ThemeData.dark(), home: Scaffold(body: HostTab(flow: flow, fields: fields))));
|
||||
await settle(tester);
|
||||
// The saved pick comes back on the Jellyfin source.
|
||||
expect(fields.media.selection, pick);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('host-column')), findsOneWidget);
|
||||
expect(find.text('Playing: Rice and Thieves and Heroes — Black Summoner · S1E6'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('jf-expand')));
|
||||
await settle(tester, 2);
|
||||
expect(find.byKey(const ValueKey('host-column')), findsNothing);
|
||||
expect(find.byKey(const ValueKey('host-bar')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('jf-expand')));
|
||||
await settle(tester, 2);
|
||||
tester.view.physicalSize = const Size(800, 900);
|
||||
await settle(tester, 2);
|
||||
expect(find.byKey(const ValueKey('host-picker')), findsOneWidget);
|
||||
expect(find.byType(JellyfinBrowser), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user