- Media can be a Jellyfin ref {serverId, itemId, mediaSourceId, title,
subtitle, runtimeSecs}; the server never stores or broadcasts a URL
or token for it.
- Optional server-side Jellyfin credential (serve map / --jellyfin
file) enables GET /j/<sid>/<token>/<itemId>: range-faithful proxy of
the active item only, exact client header fields, upstream aborted on
disconnect, bytes counted. welcome.relayUrl / state.relay advertise it.
- Members report jf access (own account vs relay).
- /m now sends the real Content-Type (Relic overrode it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
92 lines
3.8 KiB
Dart
92 lines
3.8 KiB
Dart
// Live relay test against a real Jellyfin server. Skipped unless the same
|
|
// variables as packages/core/test/jellyfin_live_test.dart are set:
|
|
//
|
|
// JELLYFIN_TEST_URL, JELLYFIN_TEST_TOKEN, JELLYFIN_TEST_CLIENT,
|
|
// JELLYFIN_TEST_DEVICE, JELLYFIN_TEST_DEVICE_ID, JELLYFIN_TEST_VERSION
|
|
//
|
|
// The device fields must be the token's own (Jellyfin overwrites a token's
|
|
// stored device fields with the header's). Read-only: it relays 1 KiB of one
|
|
// episode and compares it with the same range fetched directly.
|
|
library;
|
|
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:sync_relay_core/core.dart';
|
|
import 'package:sync_relay_server/server.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
final env = Platform.environment;
|
|
const keys = [
|
|
'JELLYFIN_TEST_URL',
|
|
'JELLYFIN_TEST_TOKEN',
|
|
'JELLYFIN_TEST_CLIENT',
|
|
'JELLYFIN_TEST_DEVICE',
|
|
'JELLYFIN_TEST_DEVICE_ID',
|
|
'JELLYFIN_TEST_VERSION',
|
|
];
|
|
final skip = keys.every(env.containsKey) ? null : 'JELLYFIN_TEST_* not set';
|
|
|
|
test('relays a 1 KiB range byte-exact', () async {
|
|
final cred = JellyfinCredential(
|
|
baseUrl: env['JELLYFIN_TEST_URL']!,
|
|
token: env['JELLYFIN_TEST_TOKEN']!,
|
|
client: env['JELLYFIN_TEST_CLIENT']!,
|
|
device: env['JELLYFIN_TEST_DEVICE']!,
|
|
deviceId: env['JELLYFIN_TEST_DEVICE_ID']!,
|
|
version: env['JELLYFIN_TEST_VERSION']!,
|
|
);
|
|
final jf = JellyfinClient(baseUrl: cred.baseUrl, clientInfo: cred.clientInfo, accessToken: cred.token);
|
|
await jf.validateToken();
|
|
final episode = (await jf.items(const JellyfinItemQuery(
|
|
includeItemTypes: [JellyfinItemType.episode], recursive: true, limit: 1)))
|
|
.items
|
|
.single;
|
|
|
|
final server = SyncServer(AppConfig(port: 0, secret: '', name: 'live', jellyfin: cred));
|
|
await server.start();
|
|
final http = HttpClient();
|
|
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
|
|
try {
|
|
expect(server.hub.jellyfin!.serverId, isNotEmpty, reason: 'server id looked up');
|
|
final frames = ws.map((m) => jsonDecode('$m') as Map).asBroadcastStream();
|
|
ws.add(jsonEncode(HelloMessage(
|
|
proto: RelayConstants.protoVersion,
|
|
secret: '',
|
|
user: const HelloUser(name: 'live'),
|
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
|
.toJson()));
|
|
final welcome = await frames.firstWhere((f) => f['t'] == 'welcome').timeout(const Duration(seconds: 5));
|
|
ws.add(jsonEncode(SwapIntent(
|
|
media: HelloMedia(
|
|
name: episode.name,
|
|
size: 0,
|
|
duration: 0,
|
|
jellyfin: JellyfinRef(
|
|
serverId: server.hub.jellyfin!.serverId, itemId: episode.id, title: episode.name))).toJson()));
|
|
await frames.firstWhere((f) => f['t'] == 'state' && f['relay'] == true).timeout(const Duration(seconds: 5));
|
|
|
|
final req = await http.get('127.0.0.1', server.boundPort, '/j/default/${welcome['token']}/${episode.id}');
|
|
req.headers.set('range', 'bytes=0-1023');
|
|
final res = await req.close();
|
|
expect(res.statusCode, 206);
|
|
final relayed = [for (final c in await res.toList()) ...c];
|
|
expect(relayed, hasLength(1024));
|
|
|
|
final direct = await http.getUrl(jf.shareableStreamUrl(episode.id));
|
|
jf.authHeaders.forEach(direct.headers.set);
|
|
direct.headers.set('range', 'bytes=0-1023');
|
|
final directRes = await direct.close();
|
|
expect(res.headers.value('content-range'), directRes.headers.value('content-range'));
|
|
expect(relayed, [for (final c in await directRes.toList()) ...c]);
|
|
expect(server.session.totalBytesServed, 1024);
|
|
} finally {
|
|
await ws.close();
|
|
http.close(force: true);
|
|
jf.close();
|
|
await server.stop();
|
|
}
|
|
}, skip: skip, timeout: const Timeout(Duration(seconds: 60)));
|
|
}
|