Jellyfin refs on the wire + guest relay (proto 7)
- 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>
This commit is contained in:
1 parent
fb2927c094
commit
45f4c1bc84
18 files changed
+1363
-66
No files matched your search
@@ -0,0 +1,91 @@
|
||||
// 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)));
|
||||
}
|
||||
Reference in new issue
Block a user