Errors: refuse links the probe can't play (with Put it up anyway), plain error texts, and others see who can't play

report.error / state.members[].error (optional, no protocol bump): an
errored member is out of the ready gate until its player opens the media
or the media changes; others see "Bob can't play this: ..." and a mark
on the avatar; your own pill offers Retry (reload).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-10 21:29:41 +03:00
1 parent 1b04e002c2
commit d3be13ecda
21 files changed
+445 -72

No files matched your search

+2
View File
@@ -50,6 +50,8 @@ abstract class RelayConstants {
static const int maxBannerBytes = 768 * 1024;
static const int maxNameLength = 32;
static const int maxChatLength = 500;
/// Characters of `report.error` the server keeps.
static const int maxMemberErrorLength = 120;
/// Nudges one member may send: at most this many per second and per
/// minute; the server drops the rest silently.
static const int nudgesPerSec = 3;
+53 -4
View File
@@ -75,6 +75,38 @@ String notAVideoText(String? ytDlpError) =>
/// The text for a page with no video a relay can read.
const noRelayFormatText = "This page has no video a relay can read. Choose 'Everyone fetches it' instead.";
/// The plain-words texts of [playErrorText].
const needsCookiesText = 'This site needs cookies — Settings → Player → yt-dlp → Cookies';
const notAllowedText = 'Not allowed (403)';
const notFoundText = 'Not found (404)';
const unsupportedUrlText = 'Not a video page yt-dlp knows';
const unreachableText = "Couldn't reach the site";
const _plainTexts = {needsCookiesText, notAllowedText, notFoundText, unsupportedUrlText, unreachableText};
final _cookiesHint = RegExp(r'\b412\b|precondition|log ?in\b|logged|cookies|sign ?in\b', caseSensitive: false);
final _forbidden = RegExp(r'(^|[\s(:])403\b|forbidden', caseSensitive: false);
final _notFound = RegExp(r'(^|[\s(:])404\b|\b410\b|not found', caseSensitive: false);
final _unreachable = RegExp(
r'SocketException|HandshakeException|TimeoutException|timed out|failed host lookup|connection (refused|reset|closed)|'
r'network is unreachable|no route to host|name or service not known|getaddrinfo|unable to download webpage',
caseSensitive: false);
/// Why a link or media cannot be played, in plain words, from the raw
/// error of yt-dlp, ffmpeg/mpv or the link probe; anything it does not
/// know comes back as is (one line, at most 100 characters).
String playErrorText(String raw) {
final s = raw.trim();
// First: its message quotes the URL, which may say anything.
if (s.toLowerCase().contains('unsupported url')) return unsupportedUrlText;
if (_cookiesHint.hasMatch(s)) return needsCookiesText;
if (_forbidden.hasMatch(s)) return notAllowedText;
if (_notFound.hasMatch(s)) return notFoundText;
if (_unreachable.hasMatch(s)) return unreachableText;
final line = s.replaceAll(RegExp(r'\s+'), ' ');
if (line.isEmpty) return "Couldn't open it";
return line.length > 100 ? '${line.substring(0, 99)}…' : line;
}
class LinkProbeException implements Exception {
final String message;
const LinkProbeException(this.message);
@@ -94,15 +126,30 @@ class LinkProbe {
final String? pageTitle;
/// Why yt-dlp could not read the page.
final String? ytDlpError;
/// The HTTP status of the probe's GET; 0 when unknown.
final int status;
const LinkProbe.file(this.url, this.size)
: isPage = false,
info = null,
pageTitle = null,
ytDlpError = null;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError})
ytDlpError = null,
status = 0;
const LinkProbe.page(this.url, {this.info, this.pageTitle, this.ytDlpError, this.status = 0})
: isPage = true,
size = -1;
/// Why nobody may be able to play this link ([playErrorText]), or null
/// when it looks playable: a page yt-dlp could not read, or a link
/// answering 4xx.
String? get problem {
if (!isPage || info != null) return null;
final err = ytDlpError == null ? null : playErrorText(ytDlpError!);
// A 4xx says more than yt-dlp's words it has no plain text for.
final known = err != null && _plainTexts.contains(err);
if (!known && status >= 400 && status < 500) return playErrorText('HTTP Error $status');
return err;
}
/// The name to show: yt-dlp's title, the page's, else the host; null
/// for a file (named by its file name).
String? get title {
@@ -134,12 +181,14 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
try {
String? body;
Object? getError;
var status = 0;
try {
final req = await c.getUrl(url).timeout(const Duration(seconds: 15));
req.headers
..set(HttpHeaders.rangeHeader, 'bytes=0-0')
..set(HttpHeaders.acceptEncodingHeader, 'identity');
final res = await req.close().timeout(const Duration(seconds: 15));
status = res.statusCode;
final type = res.headers.contentType?.mimeType ?? '';
if (looksLikeMediaFile(status: res.statusCode, contentType: type, url: url)) {
await res.drain<void>().catchError((_) {});
@@ -155,7 +204,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
String? ytErr;
if (ytDlp != null) {
try {
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()));
return LinkProbe.page(url, info: await ytDlp.resolve(url.toString()), status: status);
} catch (e) {
ytErr = '$e';
}
@@ -170,7 +219,7 @@ Future<LinkProbe> probeLink(Uri url, {YtDlp? ytDlp, HttpClient? client}) async {
body = await _readText(await req.close().timeout(const Duration(seconds: 15)));
} catch (_) {}
}
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr);
return LinkProbe.page(url, pageTitle: body == null ? null : htmlTitle(body), ytDlpError: ytErr, status: status);
} finally {
if (client == null) c.close(force: true);
}
+22 -1
View File
@@ -435,13 +435,17 @@ class ReportMessage {
/// Jellyfin access by `ServerId`: true = plays with its own account,
/// false = through the server's relay. Null leaves the last one in place.
final Map<String, bool>? jf;
/// Why the player cannot open [mediaId] (short plain text); empty while
/// it can.
final String error;
const ReportMessage(
{required this.position,
required this.buffered,
required this.eventId,
this.duration = 0,
this.mediaId = 0,
this.jf});
this.jf,
this.error = ''});
Map<String, Object?> toJson() => {
't': 'report',
@@ -451,6 +455,7 @@ class ReportMessage {
if (duration > 0) 'duration': duration,
if (mediaId != 0) 'mediaId': mediaId,
if (jf != null) 'jf': jf,
if (error.isNotEmpty) 'error': error,
};
factory ReportMessage.fromJson(Map<String, Object?> json) {
@@ -462,10 +467,20 @@ class ReportMessage {
duration: finiteDouble(json['duration'], 'duration'),
mediaId: finiteInt(json['mediaId'], 'mediaId'),
jf: jfAccessFromJson(json['jf']),
error: memberErrorFromJson(json['error']),
);
}
}
/// Reads a `report.error` / `members[].error`: trimmed, single line, at
/// most [RelayConstants.maxMemberErrorLength] characters; empty otherwise.
String memberErrorFromJson(Object? value) {
if (value is! String) return '';
final line = value.replaceAll(RegExp(r'\s+'), ' ').trim();
const max = RelayConstants.maxMemberErrorLength;
return line.length > max ? line.substring(0, max) : line;
}
/// Reads a `jf` map (`{serverId: bool}`), dropping non-bool entries and
/// keys over 64 characters, at most 8 entries (it rides in every `state`);
/// null when absent or not a map.
@@ -582,6 +597,9 @@ class MemberView {
final bool host;
/// Sitting out: connected, outside the ready gate, not playing.
final bool out;
/// Why its player cannot open the current media (`report.error`); empty
/// while it can. Errored: outside the ready gate.
final String error;
const MemberView({
required this.id,
required this.name,
@@ -593,6 +611,7 @@ class MemberView {
this.jf = const {},
this.host = false,
this.out = false,
this.error = '',
});
Map<String, Object?> toJson() => {
@@ -606,6 +625,7 @@ class MemberView {
if (jf.isNotEmpty) 'jf': jf,
if (host) 'host': true,
if (out) 'out': true,
if (error.isNotEmpty) 'error': error,
};
factory MemberView.fromJson(Map<String, Object?> json) => MemberView(
@@ -619,6 +639,7 @@ class MemberView {
jf: jfAccessFromJson(json['jf']) ?? const {},
host: json['host'] == true,
out: json['out'] == true,
error: memberErrorFromJson(json['error']),
);
}
+28 -6
View File
@@ -56,6 +56,9 @@ class EngineMember {
bool host = false;
/// Sitting out: never in the set, never ready, its buffer ignored.
bool out = false;
/// Its player cannot open the current media (`report.error`): outside
/// the ready gate until a report clears it or the media changes.
String error = '';
DateTime lastSeen;
EngineMember({
required this.id,
@@ -97,6 +100,7 @@ class EngineMember {
jf: jf,
host: host,
out: out,
error: error,
);
}
@@ -197,6 +201,7 @@ class SyncEngine {
final wasStale = isStale(member);
member.lastSeen = now;
if (!member.out &&
member.error.isEmpty &&
!member.inSet &&
(member.ready || (wasStale && transport != Transport.playing))) {
member.inSet = true;
@@ -307,12 +312,12 @@ class SyncEngine {
_seen(member);
// Sitting out: back first ([applyOut]), then ready.
if (member.out) return;
if (member.ready == ready && member.inSet) {
if (member.ready == ready && (member.inSet || member.error.isNotEmpty)) {
evaluate();
return;
}
member.ready = ready;
if (ready) member.inSet = true;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: member.name,
action: ready ? 'ready' : 'unready',
@@ -328,13 +333,13 @@ class SyncEngine {
for (final member in members.values) {
if (member.out) continue;
member.ready = ready;
if (ready) member.inSet = true;
if (ready && member.error.isEmpty) member.inSet = true;
}
} else {
final member = byId(target);
if (member == null || member.out) return;
member.ready = ready;
if (ready) member.inSet = true;
if (ready && member.error.isEmpty) member.inSet = true;
feed.add(FeedMessage(
actor: actorName,
action: ready ? 'force-ready' : 'force-unready',
@@ -366,7 +371,7 @@ class SyncEngine {
member.out = out;
member.ready = false;
member.buffered = 0;
member.inSet = !out && transport != Transport.playing;
member.inSet = !out && member.error.isEmpty && transport != Transport.playing;
feed.add(FeedMessage(
actor: actor.name, action: out ? 'out' : 'back', target: self ? null : member.name, at: now));
evaluate();
@@ -408,6 +413,7 @@ class SyncEngine {
// (a stale member rejoins the set with its next frame if still ready).
final t = now;
for (final member in members.values) {
member.error = '';
member.inSet = !member.out && !isStale(member, t);
member.buffered = 0;
}
@@ -416,10 +422,11 @@ class SyncEngine {
}
void applyReport(String token, double reportPosition, double buffered, int reportEventId,
{double duration = 0, int mediaId = 0, Map<String, bool>? jf}) {
{double duration = 0, int mediaId = 0, Map<String, bool>? jf, String error = ''}) {
final member = members[token];
if (member == null) return;
if (!reportPosition.isFinite) return;
_setError(member, mediaId == this.mediaId ? error : '');
_seen(member);
if (jf != null) member.jf = Map.unmodifiable(jf);
member.buffered = member.out || !buffered.isFinite || buffered < 0 ? 0 : buffered;
@@ -433,6 +440,21 @@ class SyncEngine {
evaluate();
}
/// An error takes the member out of the set (the gate stops waiting for
/// it); clearing it puts it back like a join: in the set unless playing
/// (then from its next `ready`).
void _setError(EngineMember member, String error) {
if (member.error == error) return;
member.error = error;
if (error.isNotEmpty) {
member.inSet = false;
} else if (!member.out) {
member.inSet = transport != Transport.playing;
}
// Leaving the set may let the others start.
evaluate();
}
void sweepStale() {
var changed = false;
for (final member in members.values) {
+46
View File
@@ -85,6 +85,52 @@ void main() {
});
});
group('player errors', () {
void fail(String token, String error, {int? mediaId}) => engine.applyReport(
token, engine.positionAt(), 0, engine.eventId,
mediaId: mediaId ?? engine.mediaId, error: error);
test('an errored member leaves the gate; clearing puts it back', () {
join('a', 'alice');
join('b', 'bob');
engine.applyReady('a', true);
engine.applyReady('b', true);
buffer('a', 4.0);
expect(engine.transport, Transport.held, reason: 'bob has no buffer');
fail('b', "Not allowed (403)");
final b = engine.members['b']!;
expect([b.error, b.inSet], ['Not allowed (403)', false]);
expect(engine.transport, Transport.playing, reason: 'alice plays alone');
final view = engine.broadcast().members;
expect(view.last.toJson()['error'], 'Not allowed (403)');
expect(view.first.toJson().containsKey('error'), isFalse);
engine.applyReady('b', true);
expect(b.inSet, isFalse, reason: 'ready does not bring it back while errored');
// A report of another media does not set it; a clean one clears it.
fail('b', 'x', mediaId: engine.mediaId - 1);
expect(b.error, isEmpty);
expect(b.inSet, isTrue, reason: 'ready and playing: back like a stale return');
expect(engine.transport, Transport.held, reason: 'bob is buffering');
});
test('a swap clears it; held, clearing puts it in the set', () {
join('a', 'alice');
join('b', 'bob');
fail('b', 'Not found (404)');
engine.applySwap('a', const HelloMedia(name: 'n', size: 1, duration: 0, url: 'u'));
final b = engine.members['b']!;
expect([b.error, b.inSet], ['', true]);
fail('b', 'Not found (404)');
expect(b.inSet, isFalse);
buffer('b', 4.0);
expect([b.error, b.inSet], ['', true]);
expect(ReportMessage.fromJson({
't': 'report', 'position': 0, 'buffered': 0, 'eventId': 0,
'error': ' a\nb ${'x' * 200}'}).error.length,
RelayConstants.maxMemberErrorLength);
});
});
group('membership + gate', () {
test('join while held lands in the set, playback stays held', () {
join('a', 'alice');
+18
View File
@@ -115,6 +115,24 @@ void main() {
});
});
test('play errors in plain words; a probe refuses unread pages and 4xx links', () {
expect(playErrorText('[BiliBili] BV1x: Unable to download webpage: HTTP Error 412: Precondition Failed'),
needsCookiesText);
expect(playErrorText('Sign in to confirm your age'), needsCookiesText);
expect(playErrorText('the video server answered HTTP error 403 Forbidden'), notAllowedText);
expect(playErrorText('HTTP Error 404: Not Found'), notFoundText);
expect(playErrorText('Unsupported URL: https://example.com/login/403'), unsupportedUrlText);
expect(playErrorText('cannot open the link: SocketException: Failed host lookup'), unreachableText);
expect(playErrorText('the video stream could not be opened'), 'the video stream could not be opened');
expect(playErrorText(notAllowedText), notAllowedText);
final u = Uri.parse('https://x/v.mp4');
expect(LinkProbe.page(u, ytDlpError: 'HTTP Error 412: Precondition Failed').problem, needsCookiesText);
expect(LinkProbe.page(u, ytDlpError: 'odd failure', status: 403).problem, notAllowedText);
expect(LinkProbe.page(u, ytDlpError: 'odd failure').problem, 'odd failure');
expect(LinkProbe.page(u, info: YtDlpInfo.parse({'title': 'x', 'formats': []}), status: 404).problem, isNull);
expect(LinkProbe.file(u, 10).problem, isNull);
});
test('viewer URL: one track plain, several an mpv EDL with %length% framing', () {
expect(relayViewerUrl('http://h:1/r/default/t/3', 'a/b c'), 'http://h:1/r/default/t/3/a_b%20c');
const v = 'http://h:1/r/default/t/3/0/x', a = 'http://h:1/r/default/t/3/1/x';