- PlayerHost port (MediaKitHost): players by role, pins, hwdec policy, render probe, log routing, disposal order; the only media_kit user. - MediaPipeline: plan (one pure switch over state, origin, caps, presence) -> resolve -> open (prepared player promoted, else fresh) -> ready; commands queued during a switch apply once (P15); viewers play the buffer and wait for a dropped source without seek loops (D6). - Every web page resolved by our yt-dlp into an EDL with headers and cookies (danmaku excluded); mpv's ytdl_hook no longer used (ytdl=no). - D11: sign-in errors say which cookies were used and whether they hold any for the site. - Relay: 256 KiB slices read as they fill, superseded pulls released (seek-to-play ~4.2 s -> ~2.8 s, P29); own Jellyfin relay channel only when someone needs it (P28). - STARSYNC_SMOKE run and a Windows CI smoke step after the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
231 lines
8.2 KiB
Dart
231 lines
8.2 KiB
Dart
import 'package:sync_relay_core/core.dart';
|
|
|
|
import '../jellyfin/session_media.dart' show refOfStateMedia, relayUrlFor;
|
|
import 'delivery.dart' show stateMediaRelay;
|
|
import 'party.dart' show isPresent;
|
|
|
|
// The plan step of the media pipeline: from the server's state, what this
|
|
// member serves, its own capabilities and who is present, where this app
|
|
// reads the current media from. One switch, pure: `media_plan_test` holds
|
|
// the table.
|
|
|
|
/// What this member serves for the media it put up (set at its swap,
|
|
/// claimed by the state that names it as the relay's source).
|
|
sealed class MediaOrigin {
|
|
const MediaOrigin();
|
|
}
|
|
|
|
/// A file on this computer, passed on through the relay.
|
|
final class FileOrigin extends MediaOrigin {
|
|
final String path;
|
|
const FileOrigin(this.path);
|
|
}
|
|
|
|
/// A link this computer reads and passes on ("through me").
|
|
final class LinkOrigin extends MediaOrigin {
|
|
final String url;
|
|
|
|
/// What the link is (a page goes through [ytDlp]).
|
|
final LinkProbe probe;
|
|
final YtDlp ytDlp;
|
|
const LinkOrigin(this.url, this.probe, this.ytDlp);
|
|
}
|
|
|
|
/// A Jellyfin item: served with this install's account, else with the
|
|
/// pasted [link] (its api_key stays on this machine).
|
|
final class JellyfinOrigin extends MediaOrigin {
|
|
final String? link;
|
|
const JellyfinOrigin(this.link);
|
|
}
|
|
|
|
/// Where this app's server is, as this member dialled it.
|
|
class ServerAddress {
|
|
final String host;
|
|
final int port;
|
|
final String token;
|
|
const ServerAddress(this.host, this.port, this.token);
|
|
|
|
/// This viewer's `/m` URL of your server's file [name].
|
|
String fileUrl(String name) => 'http://$host:$port/m/default/$token/${Uri.encodeComponent(name)}';
|
|
|
|
/// This viewer's `/r` URL of a relay ([key]: the media id, or the queue
|
|
/// id of one started in Up next); with tracks, a pieces playlist or an
|
|
/// mpv EDL of every track.
|
|
String relayUrl(String name, String key, [List<RelayTrackSpec> tracks = const []]) =>
|
|
relayViewerUrl('http://$host:$port/r/default/$token/$key', name, tracks: tracks);
|
|
|
|
/// This member's `/j` URL of [ref] (the server's Jellyfin login).
|
|
String jellyfinUrl(JellyfinRef ref) => relayUrlFor(host, port, token, ref.itemId);
|
|
|
|
/// The relay channel this member feeds ([queueId]: the warm relay of
|
|
/// that Up next item).
|
|
Uri sourceChannel([String queueId = '']) => Uri.parse('ws://$host:$port/r/default/$token/source'
|
|
'${queueId.isEmpty ? '' : '?q=${Uri.encodeQueryComponent(queueId)}'}');
|
|
}
|
|
|
|
/// Where this app reads the current media from.
|
|
sealed class SourcePlan {
|
|
const SourcePlan();
|
|
|
|
/// The URL a player opens; null when there is nothing to open yet.
|
|
String? get url => null;
|
|
}
|
|
|
|
/// No media, or nothing this app can open (yet).
|
|
final class NoSource extends SourcePlan {
|
|
const NoSource();
|
|
}
|
|
|
|
/// This member passes its file on: the player plays the file itself, the
|
|
/// relay source feeds the server.
|
|
final class OwnFile extends SourcePlan {
|
|
final String path;
|
|
const OwnFile(this.path);
|
|
@override
|
|
String get url => path;
|
|
}
|
|
|
|
/// This member passes a link on: it plays the link itself (a page through
|
|
/// its yt-dlp), the relay source reads it for the server.
|
|
final class OwnLink extends SourcePlan {
|
|
@override
|
|
final String url;
|
|
final LinkOrigin origin;
|
|
const OwnLink(this.url, this.origin);
|
|
}
|
|
|
|
/// Your server's own file (`/m`).
|
|
final class ServerFile extends SourcePlan {
|
|
@override
|
|
final String url;
|
|
const ServerFile(this.url);
|
|
}
|
|
|
|
/// A link everyone opens itself; [page]: a web page, resolved by this
|
|
/// app's yt-dlp (the pipeline's resolve step).
|
|
final class DirectLink extends SourcePlan {
|
|
@override
|
|
final String url;
|
|
final bool page;
|
|
const DirectLink(this.url, {required this.page});
|
|
}
|
|
|
|
/// The server's relay (`/r`): a member passing it on ([sourceId]) or the
|
|
/// server's own fetch ([fetch]). [waiting]: nobody feeds it yet, so it opens
|
|
/// once `relay.live` (mpv gives up on a silent request after 5 s).
|
|
final class RelayedSource extends SourcePlan {
|
|
@override
|
|
final String url;
|
|
final bool fetch;
|
|
final String sourceId;
|
|
final bool waiting;
|
|
const RelayedSource(this.url, {required this.fetch, required this.sourceId, required this.waiting});
|
|
}
|
|
|
|
/// A Jellyfin item: this app's own account first ([ownAccount]), then the
|
|
/// server's login ([serverLogin], `/j`), then the member who put it up
|
|
/// ([memberRelay], `/r`); the resolve step decides. [origin]: this member
|
|
/// put it up (it passes it on to viewers without an account).
|
|
final class JellyfinSource extends SourcePlan {
|
|
final JellyfinRef ref;
|
|
final bool ownAccount;
|
|
final String? serverLogin;
|
|
final String? memberRelay;
|
|
final JellyfinOrigin? origin;
|
|
const JellyfinSource(this.ref, {required this.ownAccount, this.serverLogin, this.memberRelay, this.origin});
|
|
}
|
|
|
|
/// A page link this app cannot resolve (no yt-dlp here: `caps.resolve`
|
|
/// false): [via] names who can pass it on.
|
|
final class Unresolvable extends SourcePlan {
|
|
final String link;
|
|
final String via;
|
|
const Unresolvable(this.link, this.via);
|
|
|
|
String get reason => "This device can't open web pages; ${via.isEmpty ? 'ask someone' : via} to pass it on (Through me)";
|
|
}
|
|
|
|
/// What [planMedia] reads.
|
|
class PlanInput {
|
|
final StateBroadcast state;
|
|
final String selfId;
|
|
final ClientCaps caps;
|
|
final ServerAddress server;
|
|
|
|
/// What this member serves for [StateBroadcast.mediaId] (null: nothing).
|
|
final MediaOrigin? origin;
|
|
|
|
/// This app plays Jellyfin [serverId] with its own account.
|
|
final bool Function(String serverId) ownJellyfin;
|
|
const PlanInput({
|
|
required this.state,
|
|
required this.selfId,
|
|
required this.caps,
|
|
required this.server,
|
|
this.origin,
|
|
required this.ownJellyfin,
|
|
});
|
|
}
|
|
|
|
/// A relay nobody feeds yet: the server's fetch before it is read, or a
|
|
/// member's before its channel is up while that member is still here
|
|
/// (once it is gone or left, the server's buffer is all there is: open
|
|
/// that).
|
|
bool relayWaitsForSource(StateBroadcast r, MediaRelay relay) =>
|
|
!relay.live && !relay.sourceGone && (relay.fetch || r.members.any((m) => m.id == relay.source));
|
|
|
|
/// The members that can resolve a page link (`caps.resolve`), present
|
|
/// ones, lowest id first.
|
|
List<MemberView> linkResolvers(StateBroadcast r, {String except = ''}) =>
|
|
[for (final m in r.members) if (m.caps.resolve && isPresent(m) && m.id != except) m];
|
|
|
|
/// Where this app reads [input]'s media from.
|
|
SourcePlan planMedia(PlanInput input) {
|
|
final r = input.state;
|
|
final media = r.media;
|
|
if (media == null) return const NoSource();
|
|
final name = (media['name'] as String?) ?? '';
|
|
final relay = stateMediaRelay(r);
|
|
final mine = relay != null && relay.source.isNotEmpty && relay.source == input.selfId;
|
|
final origin = mine ? input.origin : null;
|
|
final key = relay == null || relay.queueId.isEmpty ? '${r.mediaId}' : relay.queueId;
|
|
|
|
final ref = refOfStateMedia(media);
|
|
if (ref != null) {
|
|
return JellyfinSource(
|
|
ref,
|
|
ownAccount: input.ownJellyfin(ref.serverId),
|
|
serverLogin: r.relay && input.server.token.isNotEmpty ? input.server.jellyfinUrl(ref) : null,
|
|
memberRelay: relay != null && !mine ? input.server.relayUrl(name, key) : null,
|
|
origin: origin is JellyfinOrigin ? origin : (mine ? const JellyfinOrigin(null) : null),
|
|
);
|
|
}
|
|
switch (origin) {
|
|
case FileOrigin(:final path):
|
|
return OwnFile(path);
|
|
case LinkOrigin(:final url):
|
|
return OwnLink(url, origin);
|
|
case JellyfinOrigin() || null:
|
|
break;
|
|
}
|
|
if (relay != null) {
|
|
// Another member's relay or the server's fetch; this member's own
|
|
// relay without its origin (a restarted app) reads the server's buffer.
|
|
return RelayedSource(input.server.relayUrl(name, key, relay.tracks),
|
|
fetch: relay.fetch, sourceId: relay.source, waiting: !mine && relayWaitsForSource(r, relay));
|
|
}
|
|
final url = media['url'];
|
|
if (url is String && url.isNotEmpty) {
|
|
final uri = Uri.tryParse(url);
|
|
final web = uri != null && (uri.isScheme('http') || uri.isScheme('https'));
|
|
final page = web && !hasMediaExtension(uri);
|
|
if (page && !input.caps.resolve) {
|
|
final who = linkResolvers(r, except: input.selfId).firstOrNull;
|
|
return Unresolvable(url, who == null ? '' : 'ask ${who.name}');
|
|
}
|
|
return DirectLink(url, page: page);
|
|
}
|
|
if (name.isNotEmpty && input.server.token.isNotEmpty) return ServerFile(input.server.fileUrl(name));
|
|
return const NoSource();
|
|
}
|