Relay: viewers keep the file size (headers within 4 s, connection: close), retries back off, 412 re-resolves

Two server behaviours left ffmpeg (8.1/9.0) without the file size, after
which every seek failed with "size -38" until a reload:
- dart:io holds response headers until body bytes exist: a stalled
  source meant no headers within mpv's 5 s timeout. Relay byte responses
  are now written on the raw connection, headers with the first bytes or
  after 4 s, and a swap ends waiting reads with a plain close.
- every response closed its socket without saying connection: close, so
  ffmpeg 8.x reused a dead connection for its next seek (pol's X clips).
  The server now says it whenever the request asks.
A failed pull waits 250 ms doubling to 8 s instead of re-pulling at once
(the ~130 ms storm that drew Bilibili's 412/403); 412 counts as an expired
URL and re-resolves the page, at most once per 60 s. The app reloads a
player once per media when ffmpeg reports the lost size anyway. P37: a
read that waits past ffmpeg's retry budget still ends the file early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 01:27:34 +03:00
1 parent 5e3834e6ba
commit e91539d113
17 files changed
+575 -63

No files matched your search

@@ -184,6 +184,13 @@ abstract interface class PlayerHost {
int get live;
}
/// [log] says ffmpeg's HTTP stream lost the file's size and so every
/// seek on it fails ("Seek failed (to 3417735, size -38)": -38 is ENOSYS,
/// what ffmpeg answers for the size once a reconnect's or a seek's request
/// failed). Only a fresh open gets it back.
bool mpvStreamLost(PlayerLogLine log) =>
log.prefix == 'ffmpeg' && log.text.contains('Seek failed') && log.text.contains('size -38)');
/// [log] says the media could not be opened: before a [duration] is known,
/// mpv's fatal "nothing to play" or a demuxer error.
bool mpvLoadFailed(PlayerLogLine log, Duration duration) {
@@ -63,6 +63,10 @@ class PlaybackController {
/// e.g. a CDN's 403), at most once per [beginOpen].
void Function(String error)? onLoadError;
/// The current media's HTTP stream lost its size (see [mpvStreamLost]):
/// at most once per player and media; the coordinator opens it again.
void Function()? onStreamLost;
/// Seeks from the controls (seek bar, the video widget's own) go here
/// instead of to mpv.
void Function(Duration position)? onUserSeek;
@@ -71,6 +75,7 @@ class PlaybackController {
// load error was reported.
String? _httpError;
bool _loadErrorSent = false;
bool _streamLostSent = false;
/// The current player. Only while [alive].
HostedPlayer get player => _player!;
@@ -144,6 +149,7 @@ class PlaybackController {
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_streamLostSent = false;
_shaders = '';
_loaded = loaded && p.state.duration > Duration.zero;
_opened = loaded;
@@ -196,6 +202,7 @@ class PlaybackController {
_chapterJson = '';
_httpError = null;
_loadErrorSent = false;
_streamLostSent = false;
_loaded = false;
chapters.value = const [];
tracks.value = const [];
@@ -286,7 +293,14 @@ class PlaybackController {
// error while shaders are on is a shader that failed to load or compile
// (mpv then renders without it).
void _onLog(PlayerLogLine log) {
if (log.prefix == 'ffmpeg') return;
if (log.prefix == 'ffmpeg') {
if (!_streamLostSent && mpvStreamLost(log)) {
_streamLostSent = true;
_log.warning('the media stream lost its size, every seek fails: [ffmpeg] ${log.text.trim()}');
onStreamLost?.call();
}
return;
}
if (!_loadErrorSent && mpvLoadFailed(log, duration.peek())) {
_loadErrorSent = true;
final http = _httpError;
@@ -426,6 +426,7 @@ class SessionCoordinator {
if (_playerReady) return;
try {
playback.onLoadError = _setPlayerError;
playback.onStreamLost = _streamLost;
playback.onUserSeek = (d) => _userSeek(d.inMicroseconds / 1e6);
_bufferSub ??= playback.buffered.subscribe((_) => _onBuffered());
await media.start();
@@ -479,6 +480,19 @@ class SessionCoordinator {
}
bool _reloadingVideo = false;
// The media whose lost stream was reloaded already (once per media).
int _streamReloadedFor = -1;
/// The player's HTTP stream lost the file's size (an old server, a
/// request that timed out): every seek on it would fail until a fresh
/// open. "Reload video" once per media, as the user would.
void _streamLost() {
if (_streamReloadedFor == _mediaId || _mediaId < 0) return;
_streamReloadedFor = _mediaId;
_log.info('media $_mediaId: the stream lost its size; reloading the video once');
unawaited(reloadVideo());
}
void Function()? _bufferSub;
/// "Reload video" (button, Ctrl+R, status menu): fresh
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/platform/ports/player_host.dart';
// ffmpeg's lost stream (its HTTP context lost the file's size, every seek
// fails until a fresh open): the lines the coordinator reloads the video
// on, once per media.
void main() {
test('a seek that failed for want of the size is a lost stream', () {
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'error', 'Seek failed (to 3417735, size -38)')), isTrue);
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'warn', 'Seek failed (to 11997, size -38)\n')), isTrue);
});
test('anything else is not', () {
// A seek that failed with the size known, a reconnect, another prefix.
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'error', 'Seek failed (to 20851, size 1023081)')), isFalse);
expect(mpvStreamLost(const PlayerLogLine('ffmpeg', 'warn', 'http: Will reconnect at 3407872 in 0 second(s)')),
isFalse);
expect(mpvStreamLost(const PlayerLogLine('lavf', 'warn', 'Seek failed (to 1, size -38)')), isFalse);
});
}