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:
1 parent
1b04e002c2
commit
d3be13ecda
21 files changed
+445
-72
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user