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
@@ -0,0 +1,346 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import 'accounts.dart';
|
||||
import 'format.dart';
|
||||
|
||||
// Jellyfin media in a session: turning picks and pasted links into refs
|
||||
// (the wire carries only `{serverId, itemId, ...}`), the hosted server's
|
||||
// relay credential, and how this viewer plays a ref (own account, relay,
|
||||
// or neither).
|
||||
|
||||
/// The wire ref of a browser pick.
|
||||
JellyfinRef refForPick(JellyfinMedia m) => JellyfinRef(
|
||||
serverId: m.serverId,
|
||||
itemId: m.itemId,
|
||||
mediaSourceId: m.mediaSourceId,
|
||||
title: m.title,
|
||||
subtitle: m.subtitle.isEmpty ? null : m.subtitle,
|
||||
runtimeSecs: m.runtime == null ? null : m.runtime!.inMilliseconds / 1000,
|
||||
);
|
||||
|
||||
/// The `swap` media for a browser pick: a ref, never a URL.
|
||||
HelloMedia mediaForPick(JellyfinMedia m) => HelloMedia(
|
||||
name: m.title.isEmpty ? 'Jellyfin item' : m.title,
|
||||
size: 0,
|
||||
duration: 0,
|
||||
jellyfin: refForPick(m));
|
||||
|
||||
/// The pick a ref stands for on a linked server (for highlighting it in
|
||||
/// the browser); null when this install has no account there.
|
||||
JellyfinMedia? pickForRef(JellyfinAccounts accounts, JellyfinRef ref) {
|
||||
final a = accounts.account(ref.serverId);
|
||||
if (a == null) return null;
|
||||
return JellyfinMedia(
|
||||
serverId: ref.serverId,
|
||||
baseUrl: a.baseUrl,
|
||||
itemId: ref.itemId,
|
||||
mediaSourceId: ref.mediaSourceId,
|
||||
title: ref.title,
|
||||
subtitle: ref.subtitle ?? '',
|
||||
runtime: ref.runtimeSecs == null
|
||||
? null
|
||||
: Duration(milliseconds: (ref.runtimeSecs! * 1000).round()),
|
||||
);
|
||||
}
|
||||
|
||||
/// The ref in a `state.media` map, or null for other media.
|
||||
JellyfinRef? refOfStateMedia(Map<String, Object?>? media) {
|
||||
final jf = media?['jellyfin'];
|
||||
if (jf is! Map) return null;
|
||||
try {
|
||||
final ref = JellyfinRef.fromJson(Map<String, Object?>.from(jf));
|
||||
return ref.serverId.isEmpty || ref.itemId.isEmpty ? null : ref;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Same item (server, item, source); titles may differ.
|
||||
bool sameJellyfinItem(JellyfinRef? a, JellyfinRef? b) =>
|
||||
a != null &&
|
||||
b != null &&
|
||||
a.serverId == b.serverId &&
|
||||
a.itemId == b.itemId &&
|
||||
a.mediaSourceId == b.mediaSourceId;
|
||||
|
||||
final _sourceId = RegExp(r'^[A-Za-z0-9-]{1,64}$');
|
||||
|
||||
/// What a pasted link becomes on the wire. A recognised Jellyfin item link
|
||||
/// (`parseJellyfinLink`) becomes a ref when its ServerId is known (in the
|
||||
/// link or from `/System/Info/Public`); its `api_key` is never shared then.
|
||||
/// Anything else, or a server that does not answer, stays a plain link.
|
||||
/// The title comes from this install's account on that server when there
|
||||
/// is one.
|
||||
Future<HelloMedia> mediaForLink(JellyfinAccounts accounts, String url,
|
||||
{Duration timeout = const Duration(seconds: 6)}) async {
|
||||
final plain = HelloMedia(name: linkName(url), size: 0, duration: 0, url: url);
|
||||
final link = parseJellyfinLink(url);
|
||||
if (link == null) return plain;
|
||||
await accounts.ensureLoaded();
|
||||
var serverId = link.serverId;
|
||||
if (serverId == null || serverId.isEmpty) {
|
||||
final probe = accounts.anonymousClient(link.baseUrl);
|
||||
try {
|
||||
serverId = (await probe.publicInfo().timeout(timeout)).id;
|
||||
} catch (e) {
|
||||
debugPrint('jellyfin link: no server id from ${link.baseUrl}: $e');
|
||||
return plain;
|
||||
} finally {
|
||||
probe.close();
|
||||
}
|
||||
if (serverId.isEmpty) return plain;
|
||||
}
|
||||
final src = Uri.tryParse(url)?.queryParameters['mediaSourceId'];
|
||||
var ref = JellyfinRef(
|
||||
serverId: serverId,
|
||||
itemId: link.itemId,
|
||||
mediaSourceId: src != null && _sourceId.hasMatch(src) && src != link.itemId ? src : null,
|
||||
);
|
||||
final account = accounts.account(serverId);
|
||||
final client = accounts.clientFor(serverId);
|
||||
if (account != null && client != null) {
|
||||
try {
|
||||
final pick = pickFor(account, await client.item(link.itemId).timeout(timeout));
|
||||
ref = refForPick(JellyfinMedia(
|
||||
serverId: serverId,
|
||||
baseUrl: account.baseUrl,
|
||||
itemId: link.itemId,
|
||||
mediaSourceId: ref.mediaSourceId,
|
||||
title: pick.title,
|
||||
subtitle: pick.subtitle,
|
||||
runtime: pick.runtime,
|
||||
));
|
||||
} catch (e) {
|
||||
if (e is JellyfinException && e.isUnauthorized) accounts.markExpired(serverId);
|
||||
}
|
||||
}
|
||||
return HelloMedia(
|
||||
name: ref.title.isEmpty ? 'Jellyfin item' : ref.title, size: 0, duration: 0, jellyfin: ref);
|
||||
}
|
||||
|
||||
/// Display name for a link: last path segment, decoded, never the query
|
||||
/// (Jellyfin-style links carry api_key there and names are broadcast).
|
||||
String linkName(String url) {
|
||||
final uri = Uri.tryParse(url.trim());
|
||||
if (uri == null) return 'link';
|
||||
final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList();
|
||||
if (segs.isNotEmpty) {
|
||||
try {
|
||||
return Uri.decodeComponent(segs.last);
|
||||
} catch (_) {
|
||||
return segs.last;
|
||||
}
|
||||
}
|
||||
return uri.host.isNotEmpty ? uri.host : 'link';
|
||||
}
|
||||
|
||||
/// The hosted server's relay login: the account for [serverId] (the
|
||||
/// hosted item's server), else the default account, when its token is not
|
||||
/// known to be expired. The device fields are exactly the ones this app
|
||||
/// sends with that token ([JellyfinAccounts.clientInfo]): Jellyfin rewrites
|
||||
/// a token's device from the request header, so the relay must not differ.
|
||||
JellyfinCredential? relayCredentialFor(JellyfinAccounts accounts, {String? serverId}) {
|
||||
bool usable(JellyfinAccount? a) =>
|
||||
a != null && accounts.statusOf(a.serverId) != JellyfinAccountStatus.expired;
|
||||
var a = serverId == null ? null : accounts.account(serverId);
|
||||
if (!usable(a)) a = accounts.defaultAccount;
|
||||
if (!usable(a)) return null;
|
||||
final info = accounts.clientInfo;
|
||||
return JellyfinCredential(
|
||||
baseUrl: a!.baseUrl,
|
||||
serverId: a.serverId,
|
||||
token: a.token,
|
||||
client: info.client,
|
||||
device: info.device,
|
||||
deviceId: info.deviceId,
|
||||
version: info.version,
|
||||
);
|
||||
}
|
||||
|
||||
/// `/j/...` URL of the current Jellyfin media on the host this client
|
||||
/// dialled, built like the `/m/...` one.
|
||||
String relayUrlFor(String host, int port, String token, String itemId) =>
|
||||
'http://$host:$port/j/default/$token/${Uri.encodeComponent(itemId)}';
|
||||
|
||||
// ---------------------------------------------------------------- playing
|
||||
|
||||
/// How this viewer plays a ref.
|
||||
sealed class JellyfinPlan {
|
||||
const JellyfinPlan();
|
||||
|
||||
/// `report.jf` for this plan; null when it plays nothing.
|
||||
bool? get jfAccess;
|
||||
}
|
||||
|
||||
/// Direct play with this install's account: the static stream, the token
|
||||
/// in [headers] (not in the URL), progress reported per [info].
|
||||
final class OwnPlay extends JellyfinPlan {
|
||||
final JellyfinClient client;
|
||||
final JellyfinAccount account;
|
||||
final JellyfinPlaybackInfo info;
|
||||
final JellyfinMediaSource source;
|
||||
/// Full item (series, episode numbers); null if that call failed.
|
||||
final JellyfinItem? item;
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
const OwnPlay({
|
||||
required this.client,
|
||||
required this.account,
|
||||
required this.info,
|
||||
required this.source,
|
||||
required this.item,
|
||||
required this.url,
|
||||
required this.headers,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get jfAccess => true;
|
||||
}
|
||||
|
||||
/// Through the sync server's relay (`/j/...`).
|
||||
final class RelayPlay extends JellyfinPlan {
|
||||
final String url;
|
||||
const RelayPlay(this.url);
|
||||
|
||||
@override
|
||||
bool get jfAccess => false;
|
||||
}
|
||||
|
||||
/// Neither: no (usable) account here and the server cannot relay.
|
||||
final class BlockedPlay extends JellyfinPlan {
|
||||
final String serverId;
|
||||
/// Known only when this install has (had) an account there.
|
||||
final String? serverName;
|
||||
/// Why the own account did not work, if there is one.
|
||||
final String? problem;
|
||||
const BlockedPlay({required this.serverId, this.serverName, this.problem});
|
||||
|
||||
@override
|
||||
bool? get jfAccess => null;
|
||||
|
||||
String get message {
|
||||
final where = serverName == null || serverName!.isEmpty ? 'that Jellyfin server' : serverName!;
|
||||
final base = 'This needs a Jellyfin account on $where (or the host can relay)';
|
||||
return problem == null ? base : '$base — $problem';
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks how to play [ref]: this install's account first (PlaybackInfo
|
||||
/// with its own user; a 401 marks the account expired), else the relay
|
||||
/// when the server offers it ([relay] = `state.relay`, [relayUrl] this
|
||||
/// member's `/j/...` URL), else [BlockedPlay].
|
||||
Future<JellyfinPlan> resolveJellyfinRef(
|
||||
JellyfinAccounts accounts,
|
||||
JellyfinRef ref, {
|
||||
required bool relay,
|
||||
required String? relayUrl,
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
await accounts.ensureLoaded();
|
||||
final account = accounts.account(ref.serverId);
|
||||
String? problem;
|
||||
if (account != null && accounts.statusOf(ref.serverId) == JellyfinAccountStatus.expired) {
|
||||
problem = 'your sign-in expired, reconnect it in Integrations';
|
||||
} else if (account != null) {
|
||||
final client = accounts.clientFor(ref.serverId)!;
|
||||
try {
|
||||
final infoF = client.playbackInfo(ref.itemId, mediaSourceId: ref.mediaSourceId).timeout(timeout);
|
||||
final itemF = client
|
||||
.item(ref.itemId)
|
||||
.timeout(timeout)
|
||||
.then<JellyfinItem?>((i) => i, onError: (Object _) => null);
|
||||
final info = await infoF;
|
||||
final item = await itemF;
|
||||
final source = (ref.mediaSourceId == null
|
||||
? null
|
||||
: info.mediaSources.where((s) => s.id == ref.mediaSourceId).firstOrNull) ??
|
||||
info.primarySource;
|
||||
if (source == null) throw const JellyfinException('no playable source');
|
||||
return OwnPlay(
|
||||
client: client,
|
||||
account: account,
|
||||
info: info,
|
||||
source: source,
|
||||
item: item,
|
||||
url: client.shareableStreamUrl(ref.itemId, mediaSourceId: source.id).toString(),
|
||||
headers: client.authHeaders,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('jellyfin resolve ${ref.itemId} with own account failed: $e');
|
||||
if (e is JellyfinException && e.isUnauthorized) {
|
||||
accounts.markExpired(ref.serverId);
|
||||
problem = 'your sign-in expired, reconnect it in Integrations';
|
||||
} else if (e is JellyfinException && e.statusCode == 404) {
|
||||
problem = 'your account cannot see this item';
|
||||
} else {
|
||||
problem = 'could not reach it';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (relay && relayUrl != null && relayUrl.isNotEmpty) return RelayPlay(relayUrl);
|
||||
return BlockedPlay(serverId: ref.serverId, serverName: account?.displayName, problem: problem);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ next episode
|
||||
|
||||
/// A neighbouring episode as this viewer can put it up.
|
||||
class EpisodeLink {
|
||||
final JellyfinItem item;
|
||||
final JellyfinMedia pick;
|
||||
const EpisodeLink(this.item, this.pick);
|
||||
|
||||
/// "S1E7 Battle with the Heroes".
|
||||
String get label => [?item.episodeLabel, item.name].join(' ');
|
||||
}
|
||||
|
||||
/// The episodes before and after the one [plan] plays, with its account.
|
||||
/// Both null for movies, at the ends of a series, or on failure.
|
||||
Future<({EpisodeLink? previous, EpisodeLink? next})> findAdjacentEpisodes(OwnPlay plan,
|
||||
{Duration timeout = const Duration(seconds: 15)}) async {
|
||||
const none = (previous: null, next: null);
|
||||
final item = plan.item;
|
||||
if (item == null || item.kind != JellyfinItemKind.episode || item.seriesId == null) return none;
|
||||
try {
|
||||
final around = await plan.client.adjacentEpisodes(item).timeout(timeout);
|
||||
EpisodeLink? link(JellyfinItem? i) => i == null ? null : EpisodeLink(i, pickFor(plan.account, i));
|
||||
return (previous: link(around.previous), next: link(around.next));
|
||||
} catch (e) {
|
||||
debugPrint('episode lookup failed: $e');
|
||||
return none;
|
||||
}
|
||||
}
|
||||
|
||||
/// When the floating "Next episode" pill appears: the last two minutes of
|
||||
/// [runtime] (all of it for shorter media). One place to switch it to the
|
||||
/// item's outro / credits segment later.
|
||||
Duration nextPromptStart(JellyfinItem? item, Duration runtime) {
|
||||
const lead = Duration(minutes: 2);
|
||||
return runtime > lead ? runtime - lead : Duration.zero;
|
||||
}
|
||||
|
||||
/// Who performs the auto-advance swap, so several counting clients do not
|
||||
/// swap twice: the lowest member id among members that play the item with
|
||||
/// their own account (`jf[serverId] == true`, not stale). Only what every
|
||||
/// client sees in `state` decides (who put the item up is not in it: a
|
||||
/// late joiner never saw that swap), so all clients agree.
|
||||
String? autoAdvanceSwapper(List<MemberView> members, String serverId) {
|
||||
final eligible = [
|
||||
for (final m in members)
|
||||
if (!m.stale && m.jf[serverId] == true) m
|
||||
];
|
||||
if (eligible.isEmpty) return null;
|
||||
eligible.sort((a, b) => compareMemberIds(a.id, b.id));
|
||||
return eligible.first.id;
|
||||
}
|
||||
|
||||
/// "m2" before "m10": numeric suffix order, then plain text.
|
||||
int compareMemberIds(String a, String b) {
|
||||
final na = int.tryParse(a.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
final nb = int.tryParse(b.replaceFirst(RegExp(r'^\D+'), ''));
|
||||
if (na != null && nb != null && na != nb) return na.compareTo(nb);
|
||||
return a.compareTo(b);
|
||||
}
|
||||
Reference in new issue
Block a user