Sessions leave redacted logs; viewers wait for a member relay's source before opening it

Logs: one redaction pass (query strings to names, member tokens,
passwords, cookies, auth headers) for every line; daily files with size
and age caps in the app (hosted server included) and the dedicated
server (--log-dir); swaps, relay channels, per-member /r /m /j outcomes,
server fetches, mpv load errors, yt-dlp runs. Settings > About: Copy
diagnostics, Open logs folder.

Bilibili through someone else: the viewer opened /r/ before the source
dialled in; the server holds that request and mpv gives up after 5 s
("could not be opened"). Viewers now open a member relay once it is
live, the putter starts its source before its own player, and track
sizes are read in parallel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-12 19:05:32 +03:00
1 parent 572104f49e
commit 4ab37c55cf
25 files changed
+1124 -108

No files matched your search

+2
View File
@@ -9,6 +9,7 @@ import 'package:window_manager/window_manager.dart';
import 'app.dart';
import 'src/playback/yt_dlp_app.dart';
import 'src/platform/app_log.dart';
import 'src/platform/desktop_entry.dart';
import 'src/platform/window_memory.dart';
import 'src/screens/home/ui.dart';
@@ -41,6 +42,7 @@ Future<void> main(List<String> args) async {
),
);
WidgetsFlutterBinding.ensureInitialized();
await startAppLogging();
await Hive.initFlutter();
await AppSettings.instance.ensureLoaded();
await windowManager.ensureInitialized();
@@ -0,0 +1,98 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart';
import '../playback/yt_dlp_app.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
// and the "Copy diagnostics" text of Settings → About.
final _appLog = createLogger('app');
/// The logs folder, once [startAppLogging] ran.
String? appLogDir;
/// Opens the log files and routes `debugPrint` and uncaught errors into
/// the log. Needs Flutter's binding (path_provider).
Future<void> startAppLogging() async {
try {
final base = await getApplicationSupportDirectory();
final dir = '${base.path}${Platform.pathSeparator}logs';
if (startLogFiles(dir) != null) appLogDir = dir;
} catch (e) {
_appLog.warning('no log files: $e');
}
// Everything the app prints for diagnosis lands in the file too (the
// log's console output replaces the print).
debugPrint = (String? message, {int? wrapWidth}) {
if (message == null || message.isEmpty) return;
final bad = RegExp(r'fail|error|crash|exception', caseSensitive: false).hasMatch(message);
bad ? _appLog.warning(message) : _appLog.info(message);
};
PlatformDispatcher.instance.onError = (error, stack) {
_appLog.severe('uncaught: $error', error, stack);
return false;
};
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}');
}
/// The text "Copy diagnostics" gives: versions, then the last [since] of
/// the app log (its hosted server's records included), already redacted.
Future<String> diagnosticsText({Duration since = const Duration(hours: 6)}) async {
final b = StringBuffer()
..writeln('$appName ${displayVersion()}')
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln('Dart: ${Platform.version.split(' ').first}');
try {
final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
b.writeln('mpv: ${mpv ?? 'no player yet'}');
} catch (_) {
b.writeln('mpv: unknown');
}
try {
final y = await AppYtDlp.instance.ytDlp.timeout(const Duration(seconds: 2));
final cookies = y.cookies.read(count: false);
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
'cookies: ${cookies == null ? 'none' : 'set'}');
} catch (_) {
b.writeln('yt-dlp: unknown');
}
final files = logFiles;
b.writeln('Log: ${files?.currentPath ?? 'none (console only)'}');
b.writeln('--- last ${since.inHours} h of the log ---');
b.write(files?.recent(since) ?? '');
return redactLog(b.toString());
}
/// Saves [text] next to the logs as `diagnostics-<time>.txt`; its path,
/// or null when it could not be written.
Future<String?> saveDiagnostics(String text) async {
final dir = appLogDir;
if (dir == null) return null;
final t = DateTime.now();
String two(int n) => n.toString().padLeft(2, '0');
final path = '$dir${Platform.pathSeparator}diagnostics-${t.year}${two(t.month)}${two(t.day)}-'
'${two(t.hour)}${two(t.minute)}${two(t.second)}.txt';
try {
// Older ones go after the log files' week.
for (final e in Directory(dir).listSync()) {
if (e is File &&
e.uri.pathSegments.last.startsWith('diagnostics-') &&
t.difference(e.statSync().modified) > const Duration(days: 7)) {
e.deleteSync();
}
}
await File(path).writeAsString(text);
return path;
} catch (e) {
_appLog.warning('cannot save diagnostics: $e');
return null;
}
}
@@ -8,7 +8,7 @@ import 'package:flutter/widgets.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:sync_relay_core/core.dart' show JellyfinStreamType, LogThrottle, createLogger;
import '../jellyfin/track_prefs.dart' show MpvTrack;
import '../shaders/shader_assets.dart';
@@ -361,6 +361,7 @@ class PlaybackController {
// an error there while shaders are on is a shader that failed to load
// or compile (mpv then renders without it).
void _onLog(PlayerLog log) {
_toAppLog(log);
if (log.prefix == 'ytdl_hook' && (log.level == 'error' || log.level == 'fatal')) {
final text = log.text.trim().replaceFirst(RegExp(r'^ERROR:\s*'), '');
if (text.isNotEmpty) {
@@ -380,6 +381,8 @@ class PlaybackController {
if (!_loadErrorSent && _loadFailed(log, duration.peek())) {
_loadErrorSent = true;
final http = _httpError;
_mpvLog.warning('cannot open the media: [${log.prefix}] ${log.text.trim()}'
'${http == null ? ' (no HTTP error logged; a request that timed out after 5 s looks like this)' : ' ($http)'}');
onLoadError?.call(http == null ? 'the video stream could not be opened' : 'the video server answered $http');
return;
}
@@ -402,6 +405,23 @@ class PlaybackController {
}
}
// mpv's warnings and errors (the session player's; ffmpeg's through
// whichever player logs them) go to the app log, a few per kind at a
// time: a refused request makes ffmpeg retry and warn on every try.
static final _mpvLog = createLogger('mpv');
static final _mpvThrottle = LogThrottle(burst: 5);
static void _toAppLog(PlayerLog log, {String player = ''}) {
final level = log.level;
if (level != 'warn' && level != 'error' && level != 'fatal') return;
final text = log.text.trim();
if (text.isEmpty) return;
final pass = _mpvThrottle.allow('$player${log.prefix} $level ${text.length > 40 ? text.substring(0, 40) : text}');
if (pass == null) return;
final line = '$pass$player[${log.prefix}] $text';
level == 'warn' ? _mpvLog.info(line) : _mpvLog.warning(line);
}
/// [log] says the media could not be opened: before a [duration] is
/// known, mpv's fatal "nothing to play" or a demuxer error, or a yt-dlp
/// hook error.
@@ -438,6 +458,19 @@ class PlaybackController {
}
}
/// `mpv-version` and `ffmpeg-version` of the session player, for
/// diagnostics; null when there is no player.
Future<String?> mpvVersion() async {
final p = _player;
if (p == null) return null;
try {
final dynamic platform = p.platform;
return '${await platform?.getProperty('mpv-version')}, ffmpeg ${await platform?.getProperty('ffmpeg-version')}';
} catch (_) {
return null;
}
}
/// Opens [url] paused. [headers] go with every request for it (mpv
/// `http-header-fields`): a Jellyfin token travels there, not in the URL.
/// Each media gets its own player: when [preload] holds the same [url]
@@ -708,6 +741,7 @@ class PlaybackController {
// that is gone (a promotion disposes it) the next one created, which
// can be this preload. Its HTTP errors still name the session's.
if (log.prefix == 'ffmpeg') return _onLog(log);
_toAppLog(log, player: 'preload ');
if (_loadFailed(log, p.state.duration)) pre.fail();
});
try {
@@ -1,12 +1,14 @@
import 'package:flutter/material.dart';
import '../../platform/app_log.dart';
import '../../update/update_controller.dart';
import '../../widgets/copy_open.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'update_indicator.dart';
/// Name, version, update state and the source link (Settings → About).
/// Name, version, update state, the source link and the logs (Settings →
/// About).
class AboutPanel extends StatelessWidget {
final UpdateController? updates;
const AboutPanel({super.key, this.updates});
@@ -41,8 +43,45 @@ class AboutPanel extends StatelessWidget {
),
],
),
const SizedBox(height: kGap),
Text('Something went wrong? Copy diagnostics and send them to whoever helps you: '
'versions and the last hours of the log, passwords, tokens and cookies left out.',
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonalIcon(
onPressed: _copyDiagnostics,
icon: const Icon(Icons.bug_report_outlined, size: 18),
label: const Text('Copy diagnostics'),
),
OutlinedButton.icon(
onPressed: appLogDir == null ? null : () => _openLogs(),
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Open logs folder'),
),
],
),
],
),
);
}
static Future<void> _copyDiagnostics() async {
final text = await diagnosticsText();
final saved = await saveDiagnostics(text);
await copyToClipboard(text,
done: saved == null ? 'Diagnostics copied' : 'Diagnostics copied (also saved in the logs folder)');
}
static Future<void> _openLogs() async {
final dir = appLogDir;
if (dir == null) return;
// The file manager opens a folder path the way a browser opens a link.
if (!await openInBrowser(Uri.directory(dir).toString())) {
await copyToClipboard(dir, done: "Couldn't open it — folder path copied");
}
}
}
@@ -33,6 +33,8 @@ import 'now_playing.dart';
import 'now_playing_info.dart';
import 'switch_trace.dart';
final _log = createLogger('session');
/// Thrown when a connect is superseded by cancel/leave before it finished.
class ConnectCancelled implements Exception {
const ConnectCancelled();
@@ -387,7 +389,7 @@ class SessionCoordinator {
}
void _setPlayerError(String raw) {
debugPrint('player cannot open media $_mediaId: $raw');
_log.warning('player cannot open media $_mediaId: $raw');
_playerErrorFor = _mediaId;
_playerErrorDuration = playback.duration.peek();
playerError.value = playErrorText(raw);
@@ -563,6 +565,8 @@ class SessionCoordinator {
/// the player held before (a reconnect) keeps it.
Future<bool> _openMedia(String url, {Map<String, String>? headers}) async {
if (url == _openUrl) return true;
_log.info('media $_mediaId: open ${_urlKind(url, headers)}: ${logUrl(url)}'
'${headers == null || headers.isEmpty ? '' : ' (headers: ${headers.keys.join(', ')})'}');
final keepPlayer = _playerMediaId == _openedFor;
_openedFor = _playerMediaId;
_openUrl = url;
@@ -610,6 +614,23 @@ class SessionCoordinator {
_sendReport(r);
}
/// What [url] is, for the log.
String _urlKind(String url, Map<String, String>? headers) {
if (url.startsWith('edl://')) return 'relay (tracks)';
final u = Uri.tryParse(url);
if (u == null || !(u.isScheme('http') || u.isScheme('https'))) return 'local file';
if (u.host == _wsHost && u.port == _wsPort && u.pathSegments.isNotEmpty) {
return switch (u.pathSegments.first) {
'r' => 'relay',
'm' => "the host's file",
'j' => "Jellyfin through the server's login",
_ => 'server URL',
};
}
if (headers != null && headers.isNotEmpty) return 'Jellyfin, own sign-in';
return 'link, fetched here';
}
/// A web link (not this server's): mpv's yt-dlp hook gets the app's
/// managed yt-dlp, downloaded first if needed; a media file name does
/// not wait for that.
@@ -656,6 +677,12 @@ class SessionCoordinator {
/// The server-fetched media this viewer opens once it is live.
int _awaitLiveFor = -1;
/// 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 left, the server's buffer is all there is: open that).
bool _waitForSource(StateBroadcast r, MediaRelay relay) =>
!relay.live && (relay.fetch || r.members.any((m) => m.id == relay.source));
/// Relayed through a member whose channel is down: say who everyone is
/// waiting for (the buffer still plays what it has).
void _relayStatus(StateBroadcast r) {
@@ -881,21 +908,27 @@ class SessionCoordinator {
memberRelayUrl:
relay != null && !mine ? _relayMediaUrlFor(name, r.mediaId, const [], relay.queueId) : null);
} else if (origin is _FileOrigin) {
await _openMedia(origin.path);
// The source first: viewers wait on it, not on this player.
_startRelaySource(r.mediaId, RelayMedia.single(FileRelayBytes(File(origin.path))),
announce: true, queueId: relay?.queueId ?? '');
await _openMedia(origin.path);
} else if (origin is _LinkOrigin) {
await _openMedia(origin.url);
_startRelaySource(
r.mediaId, LinkRelayMedia(Uri.parse(origin.url), ytDlp: origin.ytDlp, probe: origin.probe),
announce: true, queueId: relay?.queueId ?? '');
await _openMedia(origin.url);
} else if (relay != null && !mine) {
if (relay.fetch && !relay.live) {
// The server is still getting it (yt-dlp takes seconds; mpv
// gives up on a silent request after 5): open once it is live.
if (_waitForSource(r, relay)) {
// The server is still getting it (yt-dlp takes seconds), or the
// member passing it on has not dialled in yet (its app opens the
// link first): mpv gives up on a silent request after 5 s, so
// open once it is live.
_openUrl = null;
_awaitLiveFor = r.mediaId;
health.value = const HealthStatus(HealthSeverity.info, 'the server is getting the video…');
final who = r.members.where((m) => m.id == relay.source).firstOrNull?.name;
_log.info('media ${r.mediaId}: waiting for ${relay.fetch ? 'the server' : who} before opening the relay');
health.value = HealthStatus(HealthSeverity.info,
relay.fetch ? 'the server is getting the video…' : 'waiting for $who to pass the video on…');
} else {
await _openMedia(_relayMediaUrlFor(name, r.mediaId, relay.tracks, relay.queueId));
}
@@ -915,7 +948,7 @@ class SessionCoordinator {
_seekGate.reset();
_clearPendingSeek();
if (_mediaId == r.mediaId) settledMediaId.value = r.mediaId;
} else if (_awaitLiveFor == r.mediaId && (stateMediaRelay(r)?.live ?? false)) {
} else if (_awaitLiveFor == r.mediaId && stateMediaRelay(r) != null && !_waitForSource(r, stateMediaRelay(r)!)) {
_awaitLiveFor = -1;
final relay = stateMediaRelay(r)!;
await _openMedia(
@@ -57,9 +57,9 @@ class ServerIsolate {
if (msg is! Map) return;
switch (msg['t']) {
case 'log':
final err = msg['error'];
final err = msg['error'], stack = msg['stack'];
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
'${err == null ? '' : ' ($err)'}');
'${err == null ? '' : ' ($err)'}${stack == null ? '' : '\n$stack'}');
case 'ready':
if (!startup.isCompleted) {
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
@@ -379,7 +379,8 @@ class SessionState {
ytDlpDir: await _sharedYtDlpDir(),
),
loopbackOnly: soloSession,
onLog: (line) => debugPrint('[server] $line'),
// Its records join the app's log as `server.<logger>`.
onLog: (line) => relogLine('server', line),
onCrash: (cause) => _onServerCrash(server, cause),
);
} catch (e, st) {
+41
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
@@ -169,6 +170,46 @@ void main() {
}
});
test('a source that dials in after mpv would have given up (5 s): viewers wait for it, then play',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();
final alice = await member('alice');
// Bob is a bare client: he puts a file up and starts passing it on
// only 7 s later (a slow first upstream read, a slow own player).
final ws = await WebSocket.connect('ws://127.0.0.1:${server.boundPort}/sync');
final welcome = Completer<Map>();
ws.listen((f) {
final j = jsonDecode('$f') as Map;
if (j['t'] == 'welcome' && !welcome.isCompleted) welcome.complete(j);
}, onError: (_) {});
ws.add(jsonEncode(const HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'pw',
user: HelloUser(name: 'bob'),
media: HelloMedia(name: '', size: 0, duration: 0))
.toJson()));
final token = (await welcome.future.timeout(const Duration(seconds: 5)))['token'] as String;
final size = await File(file).length();
ws.add(jsonEncode({
't': 'swap',
'media': {'name': 'late.mkv', 'size': size, 'duration': 0, 'relay': <String, Object?>{}},
}));
await _poll(() => stateMediaRelay(alice.serverState.peek()!)?.source.isNotEmpty ?? false, 'relay state');
await Future.delayed(const Duration(seconds: 7));
expect(alice.playerError.peek(), isEmpty, reason: 'no "could not be opened" while the source is on its way');
expect(alice.health.peek().message, contains('waiting for bob'));
final src = RelaySource(Uri.parse('ws://127.0.0.1:${server.boundPort}/r/default/$token/source'),
RelayMedia.single(FileRelayBytes(File(file))));
await src.start();
try {
await _poll(() => alice.playback.durationSecs > 19, 'alice opened it once the source was up');
expect(alice.playerError.peek(), isEmpty);
} finally {
await src.close();
await ws.close();
}
});
test('the source drops uncleanly and resumes: it keeps its copy and keeps serving',
timeout: const Timeout(Duration(minutes: 2)), () async {
final file = await _media();