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:
1 parent
572104f49e
commit
4ab37c55cf
25 files changed
+1124
-108
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user