diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 00000000..708e6bd2 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,71 @@ +name: Windows portable build + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + windows: + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Build self-contained Windows x64 package + shell: pwsh + run: ./src/Tools/build-portable.ps1 + - uses: msys2/setup-msys2@v2 + with: + install: gettext + - name: Compile translations + shell: msys2 {0} + run: | + set -euo pipefail + for po in lang/po/*.po; do + language="$(basename "$po" .po)" + destination="artifacts/mpv.net-win-x64/Locale/$language/LC_MESSAGES" + mkdir -p "$destination" + msgfmt "$po" -o "$destination/mpvnet.mo" + done + - name: Check packaged player and FFmpeg on Windows + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $package = Join-Path $PWD 'artifacts/mpv.net-win-x64' + & "$package/ffmpeg.exe" -version + if ($LASTEXITCODE) { throw 'Packaged FFmpeg failed to start.' } + $config = Join-Path $env:RUNNER_TEMP 'mpvnet-smoke-config' + New-Item $config -ItemType Directory -Force | Out-Null + $stdout = Join-Path $env:RUNNER_TEMP 'mpvnet-version.txt' + $stderr = Join-Path $env:RUNNER_TEMP 'mpvnet-errors.txt' + $process = Start-Process "$package/mpvnet.com" -ArgumentList @( + '--version', '--process-instance=multi', "--config-dir=$config" + ) -WorkingDirectory $package -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru + if (-not $process.WaitForExit(60000)) { + $process.Kill($true) + throw 'Packaged player startup timed out.' + } + $output = Get-Content $stdout -Raw + Get-Content $stderr + Write-Output $output + if ($process.ExitCode -ne 0 -or $output -notmatch 'mpv.net v' -or $output -notmatch 'mpv v') { + throw 'Packaged player did not initialize libmpv and report its version.' + } + if (-not (Get-ChildItem "$package/Locale" -Recurse -Filter '*.mo')) { + throw 'Compiled translations are missing.' + } + - name: Upload ready-to-run portable package + uses: actions/upload-artifact@v4 + with: + name: mpv.net-win-x64 + path: artifacts/mpv.net-win-x64/ + if-no-files-found: error + retention-days: 30 diff --git a/docs/manual.md b/docs/manual.md index b3ba24e7..8067a433 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -13,6 +13,8 @@ Table of contents * [Support](#support) * [Config Folder](#config-folder) * [Input and context menu](#input-and-context-menu) +* [Continuing interrupted playback](#continuing-interrupted-playback) +* [Saving a buffered video](#saving-a-buffered-video) * [Command Line Interface](#command-line-interface) * [Terminal](#terminal) * [mpv.net specific commands](#mpvnet-specific-commands) @@ -132,6 +134,93 @@ The most important files and folders in the config folder are: - `script-opts` folder containing user scripts configuration files. +Continuing interrupted playback +------------------------------- + +**Continue** is the first item in the right-click context menu, including legacy +custom menus. It resumes healthy paused playback without discarding its buffer. +After an interruption, or when playback is stalled, it reopens the last successfully +checkpointed file/URL at its saved fractional position. After restarting mpv.net, +click Continue to recover the last playback session. It is also available as +`script-message-to mpvnet continue` for custom key bindings. + +Recovery checkpoints are written once per second while playback advances, and +immediately after seeking, pausing or changing key playback settings. They include +the original source, mpv's `watch-later-options` (independent of whether watch-later +resume is enabled), volume/mute, speed, selected tracks, subtitle/audio delays, +video adjustments, external tracks, and connection settings such as HTTP headers, +referrer and yt-dlp format. Continue starts playing even if the checkpoint was +paused. A failed connection keeps the checkpoint so Continue can be tried again +after the network returns. + +The checkpoint is stored as `continue.json` in the config directory, including +source URLs and any saved HTTP headers. Writes use a flushed temporary file and +atomic replacement; `continue.json.bak` retains the previous complete checkpoint +and is used if the primary file is unreadable. Recovery does not depend on a normal +exit or shutdown handler. Delete both files to forget the saved session. + +An abrupt power loss can lose progress since the last completed checkpoint +(normally up to one second, plus any storage/system delay). Exact seeking requires +a seekable source and a reachable server; deleted files, expired direct URLs, +missing external tracks, and live streams without DVR cannot be restored under +every condition. A failed or imprecise restore reports an error and preserves the +checkpoint. This restores playback state, not the previous session's temporary +downloaded cache; reconnecting may need to buffer again. + + +Saving a buffered video +----------------------- + +Use `File > Save Buffered Video...` in the right-click context menu to save a +network video as a Matroska (`.mkv`) file with the audio track selected when the +Save dialog opens. Later track switches do not change that export. Separate audio +and video sources are combined without re-encoding; the selected audio delay is +preserved. The menu command is also available as +`script-message-to mpvnet save-buffered-video` for custom bindings and menus. + +You can save before the entire movie is buffered. Complete, verified cached streams +are reused. Missing beginnings, endings, seek gaps, and audio that was not buffered +before a track switch are completed automatically in a background player. When +mpv cannot prove that the selected stream is complete, that stream's source is read +again from the beginning, rather than splicing potentially mismatched cache +fragments. This can download more than the missing intervals, especially when +audio and video share a container. Streams sharing a source are fetched together. +The foreground playback position, pause state and selected tracks remain unchanged. +The original connection headers, cookies, proxy and TLS settings are used. + +The export checks cached packet continuity, reads missing sources through EOF, +and verifies that every selected stream keeps its packet count and duration after +muxing. Original source gaps and legitimately shorter audio tracks are preserved. +The resulting file is decoded before reporting success, including checking decoder +error messages even when FFmpeg exits successfully. Bad cached data is fetched +again automatically. Transient download failures +are retried up to three times. Choose a destination in the Save dialog; an existing +destination is replaced only after the complete export is verified. A canceled, +failed or truncated download leaves the destination intact. Closing mpv.net cancels +an active export. Temporary export files are removed after completion or failure. +FFmpeg is included in release packages for validation and lossless muxing. For a +source build, run `src/Tools/update-mpv.ps1` to obtain FFmpeg alongside libmpv, or +provide `ffmpeg` on PATH. No FFprobe executable is required by the application. + +Network videos keep their last frame open at the end, including in playlists, +so the buffer can still be saved. Use Next to continue to the next playlist entry. +Stopping, opening another file, or exiting releases the playback buffer; it does +not survive restarting the player. An export already started retains its chosen +source and tracks independently of later foreground playback changes. + +Disk caching is enabled by default and the back buffer allows 512 MiB of packet +metadata (typically sufficient for several hours). Video packets occupy disk space +in mpv's cache directory, potentially as much as the entire video, while metadata +still uses RAM. The disk cache is temporary and is removed when the video closes. +These defaults can be overridden in `mpv.conf` with `cache-on-disk` and +`demuxer-max-back-bytes`. Reduced or disabled caching makes saving read more from +the source. mpv's experimental `dump-cache` can temporarily stall playback for a +large cache export. Verification can also take time on long or high-resolution +videos, and saving requires disk space for intermediate files as well as the final +output. A source that stays unavailable must become accessible before saving can +finish successfully. + + Input and context menu ---------------------- diff --git a/src/MpvNet.Windows/GuiCommand.cs b/src/MpvNet.Windows/GuiCommand.cs index 6f80ac8b..96b1c6fa 100644 --- a/src/MpvNet.Windows/GuiCommand.cs +++ b/src/MpvNet.Windows/GuiCommand.cs @@ -3,6 +3,8 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Globalization; +using System.Threading; +using System.Threading.Tasks; using System.Windows.Forms; using System.Windows.Interop; using System.Windows; @@ -20,6 +22,9 @@ namespace MpvNet; public class GuiCommand { Dictionary>>? _commands; + bool _savingBufferedVideo; + CancellationTokenSource? _saveCancellation; + Task? _saveTask; public event Action? ScaleWindow; public event Action? MoveWindow; @@ -31,6 +36,7 @@ public class GuiCommand public Dictionary>> Commands => _commands ??= new() { ["add-to-path"] = args => AddToPath(), + ["continue"] = args => ContinuePlayback(), ["edit-conf-file"] = EditCongFile, ["load-audio"] = LoadAudio, ["load-sub"] = LoadSubtitle, @@ -40,6 +46,7 @@ public class GuiCommand ["open-optical-media"] = Open_DVD_Or_BD_Folder, ["reg-file-assoc"] = RegisterFileAssociations, ["remove-from-path"] = args => RemoveFromPath(), + ["save-buffered-video"] = args => SaveBufferedVideo(), ["scale-window"] = args => ScaleWindow?.Invoke(float.Parse(args[0], CultureInfo.InvariantCulture)), ["show-about"] = args => ShowDialog(typeof(AboutWindow)), ["show-bindings"] = args => ShowBindings(), @@ -76,6 +83,94 @@ void ShowDialog(Type winType) win?.ShowDialog(); } + async void ContinuePlayback() + { + try + { + await Player.Recovery.ContinueAsync(); + } + catch (OperationCanceledException) + { + // libmpv cancels pending commands during shutdown. + } + catch (Exception ex) + { + Msg.ShowError(ex.Message); + } + } + + public Task CancelVideoSaveAsync() + { + _saveCancellation?.Cancel(); + return _saveTask ?? Task.CompletedTask; + } + + async void SaveBufferedVideo() + { + if (_savingBufferedVideo) + { + Msg.ShowInfo(_("A buffered video is already being saved.")); + return; + } + + _savingBufferedVideo = true; + + try + { + long generation = Player.PlaybackGeneration; + + if (!Player.GetPropertyBool("demuxer-via-network")) + { + Msg.ShowInfo(_("Open a network video before saving its buffer.")); + return; + } + + var selection = BufferedVideo.Capture(Player, generation); + + string title = Player.GetPropertyString("media-title"); + title = string.Concat(title.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + title = title[..Math.Min(title.Length, 120)].TrimEnd(' ', '.'); + + using var dialog = new SaveFileDialog { + Title = _("Save Buffered Video"), + Filter = _("Matroska video (*.mkv)|*.mkv"), + DefaultExt = "mkv", + FileName = "video - " + title + ".mkv", + OverwritePrompt = true + }; + + if (dialog.ShowDialog() != DialogResult.OK) + return; + + Player.CommandV("show-text", _("Saving buffered video..."), "5000"); + _saveCancellation = new(); + _saveTask = BufferedVideo.SaveAsync(Player, dialog.FileName, selection, _saveCancellation.Token, + new Progress(message => { + if (_saveCancellation is { IsCancellationRequested: false }) + Player.CommandV("show-text", message, int.MaxValue.ToString(CultureInfo.InvariantCulture)); + })); + await _saveTask; + if (!_saveCancellation.IsCancellationRequested) + Msg.ShowInfo(_("Buffered video saved.") + BR2 + dialog.FileName); + } + catch (OperationCanceledException) + { + // libmpv cancels pending commands during shutdown. + } + catch (Exception ex) + { + if (_saveCancellation?.IsCancellationRequested != true) Msg.ShowError(ex.Message); + } + finally + { + if (_saveTask != null && Player.IsQuitNeeded) Player.CommandV("show-text", ""); + _saveTask = null; + _saveCancellation?.Dispose(); + _saveCancellation = null; + _savingBufferedVideo = false; + } + } + void LoadSubtitle(IList args) { using var dialog = new OpenFileDialog(); diff --git a/src/MpvNet.Windows/Resources/editor_conf.txt b/src/MpvNet.Windows/Resources/editor_conf.txt index 3f50cd67..b3aca659 100644 --- a/src/MpvNet.Windows/Resources/editor_conf.txt +++ b/src/MpvNet.Windows/Resources/editor_conf.txt @@ -1190,3 +1190,19 @@ file = mpv directory = Demuxer help = Controls how much the demuxer is allowed to buffer ahead.\nSuffixes such as KiB and MiB are supported. url = https://mpv.io/manual/master/#options-demuxer-max-bytes + +name = cache-on-disk +file = mpv +directory = Cache +help = Keep cached packets in a temporary disk file instead of RAM, so buffered network videos can be saved. The file is removed when playback closes.\nmpv.net default: yes +url = https://mpv.io/manual/master/#options-cache-on-disk +default = yes +option = yes +option = no + +name = demuxer-max-back-bytes +file = mpv +directory = Cache +help = Maximum past data retained in the cache. With disk caching this limits packet metadata in RAM, not video bytes on disk. Raising it retains more of a watched video for saving.\nmpv.net default: 512MiB +url = https://mpv.io/manual/master/#options-demuxer-max-back-bytes +default = 512MiB diff --git a/src/MpvNet.Windows/WinForms/MainForm.cs b/src/MpvNet.Windows/WinForms/MainForm.cs index 7a12acb6..311f32aa 100644 --- a/src/MpvNet.Windows/WinForms/MainForm.cs +++ b/src/MpvNet.Windows/WinForms/MainForm.cs @@ -40,6 +40,7 @@ public partial class MainForm : Form int _taskbarButtonCreatedMessage; int _cursorAutohide = 1000; + bool _waitingForVideoSave; bool _contextMenuIsReady; bool _wasMaximized; bool _maxSizeSet; @@ -69,6 +70,7 @@ public MainForm() GuiCommand.Current.ShowMenu += GuiCommand_ShowMenu; Player.Init(Handle, true); + Player.Recovery.Failed += message => Msg.ShowError(message); Player.ObserveProperty("window-maximized", PropChangeWindowMaximized); // bool methods not working correctly Player.ObserveProperty("window-minimized", PropChangeWindowMinimized); // bool methods not working correctly @@ -1421,9 +1423,22 @@ protected override void OnResize(EventArgs e) } } - protected override void OnFormClosing(FormClosingEventArgs e) + protected override async void OnFormClosing(FormClosingEventArgs e) { base.OnFormClosing(e); + if (e.Cancel) return; + var save = GuiCommand.Current.CancelVideoSaveAsync(); + if (!save.IsCompleted) + { + e.Cancel = true; + if (_waitingForVideoSave) return; + _waitingForVideoSave = true; + try { await save; } + catch (OperationCanceledException) { } + catch (Exception ex) { Terminal.WriteError(ex); } + Close(); + return; + } if (Player.IsQuitNeeded) Player.CommandV("quit"); diff --git a/src/MpvNet/BufferedVideo.cs b/src/MpvNet/BufferedVideo.cs new file mode 100644 index 00000000..ddc2cd4f --- /dev/null +++ b/src/MpvNet/BufferedVideo.cs @@ -0,0 +1,260 @@ +using System.Globalization; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +using static MpvNet.Native.LibMpv; + +namespace MpvNet; + +// Immutable selection taken before the Save dialog; later track switches cannot change the export. +public sealed record BufferedVideoSelection(long Generation, double Duration, + double AudioDelay, BufferedVideoTrack[] Tracks, Dictionary Options); + +public sealed record BufferedVideoTrack(string Type, string Source, int Id, bool CanUseCache); + +public static class BufferedVideo +{ + static readonly string[] ConnectionOptions = [ + "http-header-fields", "user-agent", "referrer", "http-proxy", "cookies", "cookies-file", + "tls-verify", "tls-ca-file", "tls-cert-file", "tls-key-file", "demuxer-lavf-o", + "network-timeout", "edition", "config-dir" + ]; + + public static BufferedVideoSelection Capture(MpvClient player, long generation) + { + if (player.PlaybackGeneration != generation || !player.TryGetPropertyString("path", out var path) || path == "") + throw new InvalidOperationException(_("The playing file changed. Please try again.")); + + string json = player.GetPropertyString("track-list"); + using var document = JsonDocument.Parse(json); + var tracks = document.RootElement.EnumerateArray().ToArray(); + string source = player.GetPropertyString("stream-open-filename"); + if (source == "") source = path; + var selected = new List(); + foreach (var track in tracks) + { + string type = track.GetProperty("type").GetString()!; + if (type is not ("video" or "audio") || !Flag(track, "selected")) continue; + bool external = Flag(track, "external"); + string filename = external ? track.GetProperty("external-filename").GetString()! : source; + // External IDs belong to the foreground player, not to the external file's own demuxer. + int id = external ? tracks.TakeWhile(t => !t.Equals(track)).Count(t => + t.GetProperty("type").GetString() == type && Flag(t, "external") && + t.GetProperty("external-filename").GetString() == filename) + 1 : track.GetProperty("id").GetInt32(); + selected.Add(new(type, filename, id, !external && + tracks.Count(t => t.GetProperty("type").GetString() == type && !Flag(t, "external")) == 1)); + } + if (!selected.Any(t => t.Type == "video")) + throw new InvalidOperationException(_("Open a video before saving.")); + + var options = new Dictionary(); + foreach (string name in ConnectionOptions) + if (player.TryGetPropertyString(name, out var value) && value != "") options[name] = value; + double duration = player.GetPropertyDouble("duration"); + double delay = player.GetPropertyDouble("audio-delay"); + if (player.PlaybackGeneration != generation || player.GetPropertyString("track-list") != json) + throw new InvalidOperationException(_("The playing file changed. Please try again.")); + return new(generation, duration, delay, selected.ToArray(), options); + } + + static bool Flag(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.True; + + public static (double Start, double End)? GetCompleteRange(string json) + { + if (string.IsNullOrEmpty(json)) return null; + using var document = JsonDocument.Parse(json); + var state = document.RootElement; + if (!Flag(state, "bof-cached") || !Flag(state, "eof-cached") || + !state.TryGetProperty("seekable-ranges", out var ranges) || ranges.GetArrayLength() != 1) return null; + double start = ranges[0].GetProperty("start").GetDouble(); + double end = ranges[0].GetProperty("end").GetDouble(); + return double.IsFinite(start) && double.IsFinite(end) && end > start ? (start, end) : null; + } + + public static Task SaveAsync(MpvClient player, string filename, long playbackGeneration) => + SaveAsync(player, filename, Capture(player, playbackGeneration)); + + public static async Task SaveAsync(MpvClient player, string filename, BufferedVideoSelection selection, + CancellationToken cancellationToken = default, IProgress? progress = null) + { + using var canceled = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + void Shutdown() => canceled.Cancel(); + player.Shutdown += Shutdown; + var token = canceled.Token; + string directory = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(filename))!, ".mpvnet-save-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(directory); + var tools = new MediaExportTools(); + await tools.CheckAsync(token); + token.ThrowIfCancellationRequested(); + string cache = Path.Combine(directory, "cache.mkv"); + string currentJson = player.GetPropertyString("track-list"); + using var currentTracks = JsonDocument.Parse(currentJson == "" ? "[]" : currentJson); + var cacheable = selection.Tracks.Where(t => t.CanUseCache && + currentTracks.RootElement.EnumerateArray().Any(c => Flag(c, "selected") && !Flag(c, "external") && + c.GetProperty("type").GetString() == t.Type && c.GetProperty("id").GetInt32() == t.Id)).ToHashSet(); + var range = player.PlaybackGeneration == selection.Generation + ? GetCompleteRange(player.GetPropertyString("demuxer-cache-state")) : null; + if (range != null && cacheable.Count > 0) + { + var result = await player.CommandVAsync("dump-cache", Number(range.Value.Start), Number(range.Value.End + 1), cache); + token.ThrowIfCancellationRequested(); + // A changed selection during dumping makes the cache untrustworthy, not the pinned selection. + if (result < 0 || player.PlaybackGeneration != selection.Generation || + player.GetPropertyString("track-list") != currentJson) + File.Delete(cache); + } + + while (true) + { + var inputs = new List<(BufferedVideoTrack Track, string Filename)>(); + var missing = new List(); + var coverage = new Dictionary(); + foreach (var track in selection.Tracks) + { + token.ThrowIfCancellationRequested(); + var measured = cacheable.Contains(track) && File.Exists(cache) + ? await tools.MeasureAsync(cache, track, token) : null; + if (measured != null && measured.Continuous && + measured.Duration >= selection.Duration - measured.Tolerance) + { + inputs.Add((track, cache)); + coverage[track] = measured; + } + else missing.Add(track); + } + int download = 0; + foreach (var source in missing.GroupBy(t => t.Source)) + { + progress?.Report(_("Downloading complete selected streams...")); + string input = Path.Combine(directory, "download-" + download++ + ".mkv"); + for (int attempt = 0; ; attempt++) + { + try + { + // Record the other selected track from the same source as an EOF/duration witness. + var sourceTracks = selection.Tracks.Where(t => t.Source == source.Key).ToArray(); + double sourceDuration = await DownloadAsync(sourceTracks, selection.Options, input, token); + var measured = new Dictionary(); + foreach (var track in sourceTracks) + { + var value = await tools.MeasureAsync(input, track, token); + if (value == null) throw new IOException(_("The selected stream contained no complete media packets.")); + measured[track] = value; + } + double expected = sourceDuration > 0 ? sourceDuration : selection.Duration; + if (!measured.Values.Any(c => c.Duration >= expected - c.Tolerance)) + throw new IOException(_("The source ended before the complete selected stream was received. Reconnect and save again.")); + // A genuine shorter audio track or intentional source gap must remain unchanged. + foreach (var track in source) coverage[track] = measured[track]; + break; + } + catch (IOException) when (attempt < 2) + { + token.ThrowIfCancellationRequested(); + progress?.Report(_("Connection interrupted. Retrying the selected streams...")); + await Task.Delay(500 * (attempt + 1), token); + } + } + foreach (var track in source) inputs.Add((track, input)); + } + inputs = inputs.OrderBy(i => i.Track.Type == "video" ? 0 : 1).ToList(); + + progress?.Report(_("Verifying and saving video...")); + string temporary = Path.Combine(directory, "output" + Path.GetExtension(filename)); + try + { + await tools.MuxAsync(inputs, selection.AudioDelay, temporary, token); + foreach (var track in selection.Tracks) + { + var saved = await tools.MeasureAsync(temporary, track, token); + var original = coverage[track]; + if (saved == null || saved.Packets != original.Packets || + Math.Abs(saved.Duration - original.Duration) > Math.Max(saved.Tolerance, original.Tolerance)) + throw new IOException(_("The saved file did not contain the complete selected video and audio. Please save again.")); + } + await tools.VerifyDecodeAsync(temporary, token); + } + catch (IOException) when (inputs.Any(i => i.Filename == cache)) + { + // Corrupt cached payloads also need a fresh source pass, even if their timestamps look complete. + File.Delete(cache); + continue; + } + token.ThrowIfCancellationRequested(); + File.Move(temporary, filename, true); + break; + } + } + finally + { + player.Shutdown -= Shutdown; + if (Directory.Exists(directory)) Directory.Delete(directory, true); + } + } + + internal static string Number(double value) => value.ToString("R", CultureInfo.InvariantCulture); + + static async Task DownloadAsync(BufferedVideoTrack[] tracks, Dictionary options, string filename, CancellationToken token) + { + var worker = new MpvClient { Handle = mpv_create() }; + if (worker.Handle == 0) throw new IOException(_("Could not start the stream download.")); + Task? loop = null; + try + { + void Option(string name, string value) + { + if (mpv_set_option_string(worker.Handle, GetUtf8Bytes(name), GetUtf8Bytes(value)) < 0) + throw new IOException(_("Could not configure stream download: ") + name); + } + Option("config", "no"); + Option("load-scripts", "no"); + Option("ytdl", "no"); + Option("terminal", "no"); + Option("vo", "null"); + Option("ao", "null"); + Option("ao-null-untimed", "yes"); + Option("untimed", "yes"); + Option("idle", "yes"); + Option("cache", "no"); + Option("pause", "yes"); + Option("resume-playback", "no"); + Option("save-position-on-quit", "no"); + Option("start", "0"); + Option("network-timeout", "30"); + foreach (var pair in options) Option(pair.Key, pair.Value); + Option("vid", tracks.FirstOrDefault(t => t.Type == "video")?.Id.ToString(CultureInfo.InvariantCulture) ?? "no"); + Option("aid", tracks.FirstOrDefault(t => t.Type == "audio")?.Id.ToString(CultureInfo.InvariantCulture) ?? "no"); + Option("sid", "no"); + Option("stream-record", filename); + if (mpv_initialize(worker.Handle) < 0) throw new IOException(_("Could not start the stream download.")); + var finished = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + double duration = 0; + worker.FileLoaded += () => { + duration = worker.GetPropertyDouble("duration"); + worker.SetPropertyBool("pause", false); + }; + worker.EndFile += reason => finished.TrySetResult(reason); + mpv_request_log_messages(worker.Handle, "error"); + worker.LogMessage += (_, _) => finished.TrySetException(new IOException( + _("The selected stream could not be read or written completely. Check the connection and free disk space."))); + loop = Task.Run(worker.EventLoop); + worker.CommandV("loadfile", tracks[0].Source); + var reason = await finished.Task.WaitAsync(token); + if (reason != mpv_end_file_reason.MPV_END_FILE_REASON_EOF || !File.Exists(filename)) + throw new IOException(_("The selected stream could not be downloaded. Check the connection and save again.")); + return duration; + } + finally + { + if (loop != null) + { + worker.CommandV("quit"); + await loop; + } + mpv_destroy(worker.Handle); + } + } +} diff --git a/src/MpvNet/InputConf.cs b/src/MpvNet/InputConf.cs index 61f6feb0..dacb7bb6 100644 --- a/src/MpvNet/InputConf.cs +++ b/src/MpvNet/InputConf.cs @@ -29,7 +29,15 @@ public string Path { var confbindings = InputHelp.Parse(Content); if (HasMenu) + { + var resume = confbindings.FirstOrDefault(binding => binding.Command == InputHelp.ContinueCommand) + ?? InputHelp.CreateContinueBinding(); + resume.Comment = _("Continue"); + resume.IsMenu = true; + confbindings.RemoveAll(binding => binding.Command == InputHelp.ContinueCommand); + confbindings.Insert(0, resume); return (confbindings, confbindings); + } var defaultBindings = InputHelp.GetDefaults(); diff --git a/src/MpvNet/InputHelp.cs b/src/MpvNet/InputHelp.cs index 0da86c23..5cce6dae 100644 --- a/src/MpvNet/InputHelp.cs +++ b/src/MpvNet/InputHelp.cs @@ -5,12 +5,19 @@ namespace MpvNet; public static class InputHelp { + public const string ContinueCommand = "script-message-to mpvnet continue"; + public static Binding CreateContinueBinding() => new("", _("Continue"), ContinueCommand); + public static List GetDefaults() { List b = []; + Add(b, CreateContinueBinding()); + Add(b, new("", "-")); + Add(b, new (_("File"), _("Open Files..."), "script-message-to mpvnet open-files", "o")); Add(b, new (_("File"), _("Open URL or file from clipboard"), "script-message-to mpvnet open-clipboard", "Ctrl+v")); + Add(b, new (_("File"), _("Save Buffered Video..."), "script-message-to mpvnet save-buffered-video")); Add(b, new (_("File"), _("Open DVD/Blu-ray Drive/Folder..."), "script-message-to mpvnet open-optical-media")); Add(b, new (_("File"), "-")); Add(b, new (_("File"), _("Add external audio files..."), "script-message-to mpvnet load-audio")); diff --git a/src/MpvNet/MediaExportTools.cs b/src/MpvNet/MediaExportTools.cs new file mode 100644 index 00000000..78947bf0 --- /dev/null +++ b/src/MpvNet/MediaExportTools.cs @@ -0,0 +1,125 @@ +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; + +namespace MpvNet; + +// FFmpeg only sees local files; mpv resolves protocols and preserves the playback connection options. +internal sealed record ExportCoverage(double Duration, int Packets, bool Continuous, double Tolerance); + +internal sealed class MediaExportTools +{ + static string Tool(string name) + { + string filename = name + (OperatingSystem.IsWindows() ? ".exe" : ""); + string bundled = Path.Combine(AppContext.BaseDirectory, filename); + return File.Exists(bundled) ? bundled : filename; + } + + public async Task CheckAsync(CancellationToken token) + { + await RunAsync("ffmpeg", ["-version"], null, token); + } + + public async Task MeasureAsync(string filename, BufferedVideoTrack track, CancellationToken token) + { + double first = double.PositiveInfinity, end = double.NegativeInfinity, previous = double.NaN; + double largestGap = 0, largestPacket = 0, previousDuration = 0; + int count = 0; + string selector = track.Type == "video" ? "v:0" : "a:0"; + double timeBase = 0; + try + { + await RunAsync("ffmpeg", ["-nostdin", "-v", "error", "-copyts", "-i", filename, + "-map", "0:" + selector, "-c", "copy", "-f", "framecrc", "-"], line => { + if (line.StartsWith("#tb 0: ")) + { + var ratio = line[7..].Split('/'); + timeBase = double.Parse(ratio[0], CultureInfo.InvariantCulture) / + double.Parse(ratio[1], CultureInfo.InvariantCulture); + } + if (line.StartsWith('#') || timeBase <= 0) return; + var fields = line.Split(','); + if (fields.Length < 6) return; + double Read(int index) => double.TryParse(fields[index], NumberStyles.Float, + CultureInfo.InvariantCulture, out var value) ? value * timeBase : double.NaN; + double dts = Read(1), pts = Read(2), packetDuration = Read(3); + if (!double.IsFinite(pts)) return; + if (!double.IsFinite(packetDuration) || packetDuration < 0) packetDuration = 0; + first = Math.Min(first, pts); + end = Math.Max(end, pts + packetDuration); + largestPacket = Math.Max(largestPacket, packetDuration); + if (double.IsFinite(dts)) + { + if (double.IsFinite(previous) && previousDuration > 0) + largestGap = Math.Max(largestGap, dts - previous - previousDuration); + previous = dts; + previousDuration = packetDuration; + } + count++; + }, token); + } + catch (IOException) { return null; } + double tolerance = Math.Max(0.1, largestPacket * 2); + return count > 0 && double.IsFinite(first) && double.IsFinite(end) && end > first + ? new ExportCoverage(end - first, count, largestGap <= 0.003, tolerance) : null; + } + + public async Task MuxAsync(List<(BufferedVideoTrack Track, string Filename)> inputs, + double audioDelay, string output, CancellationToken token) + { + var args = new List { "-nostdin", "-v", "error", "-xerror", "-y" }; + foreach (var input in inputs) + { + if (input.Track.Type == "audio" && audioDelay != 0) + args.AddRange(["-itsoffset", BufferedVideo.Number(audioDelay)]); + args.AddRange(["-i", input.Filename]); + } + for (int i = 0; i < inputs.Count; i++) + args.AddRange(["-map", i + (inputs[i].Track.Type == "video" ? ":v:0" : ":a:0")]); + args.AddRange(["-c", "copy", "-avoid_negative_ts", "make_non_negative", output]); + await RunAsync("ffmpeg", args, null, token); + } + + public Task VerifyDecodeAsync(string filename, CancellationToken token) => + RunAsync("ffmpeg", ["-nostdin", "-v", "error", "-xerror", "-err_detect", "explode", "-threads", "2", "-i", filename, + "-map", "0:v:0", "-map", "0:a:0?", "-f", "null", "-"], null, token); + + static async Task RunAsync(string name, IEnumerable args, Action? line, CancellationToken token) + { + var info = new ProcessStartInfo(Tool(name)) { UseShellExecute = false, CreateNoWindow = true, + RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (string arg in args) info.ArgumentList.Add(arg); + using var process = new Process { StartInfo = info }; + try + { + token.ThrowIfCancellationRequested(); + process.Start(); + } + catch (System.ComponentModel.Win32Exception) + { + throw new IOException(_("The FFmpeg tool is missing from this installation. Reinstall the complete mpv.net package.")); + } + using var canceled = token.Register(() => { try { if (!process.HasExited) process.Kill(true); } catch (InvalidOperationException) { } }); + // Drain both pipes concurrently, without retaining packet lists or credential-bearing diagnostics. + bool reportedError = false; + var stderr = Task.Run(async () => { + while (await process.StandardError.ReadLineAsync() is { } error) + if (!string.IsNullOrWhiteSpace(error)) reportedError = true; + }); + try + { + while (await process.StandardOutput.ReadLineAsync() is { } text) line?.Invoke(text); + await process.WaitForExitAsync(CancellationToken.None); + await stderr; + token.ThrowIfCancellationRequested(); + if (process.ExitCode != 0 || reportedError) throw new IOException(_("Could not verify or save the video: ") + name + " (" + process.ExitCode + ")."); + } + finally + { + if (!process.HasExited) process.Kill(true); + await process.WaitForExitAsync(CancellationToken.None); + await stderr; + } + } +} diff --git a/src/MpvNet/MpvClient.cs b/src/MpvNet/MpvClient.cs index a4890a48..7923aba1 100644 --- a/src/MpvNet/MpvClient.cs +++ b/src/MpvNet/MpvClient.cs @@ -1,5 +1,8 @@  using System.Runtime.InteropServices; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; using static MpvNet.Native.LibMpv; @@ -29,6 +32,11 @@ public class MpvClient public nint Handle { get; set; } + readonly ConcurrentDictionary> _pendingCommands = new(); + long _commandId; + long _playbackGeneration; + public long PlaybackGeneration => Interlocked.Read(ref _playbackGeneration); + public void EventLoop() { while (true) @@ -41,6 +49,9 @@ public void EventLoop() switch (evt.event_id) { case mpv_event_id.MPV_EVENT_SHUTDOWN: + foreach (var pending in _pendingCommands.ToArray()) + if (_pendingCommands.TryRemove(pending.Key, out var completion)) + completion.TrySetCanceled(); OnShutdown(); return; case mpv_event_id.MPV_EVENT_LOG_MESSAGE: @@ -60,6 +71,7 @@ public void EventLoop() break; case mpv_event_id.MPV_EVENT_END_FILE: { + Interlocked.Increment(ref _playbackGeneration); var data = (mpv_event_end_file)Marshal.PtrToStructure(evt.data, typeof(mpv_event_end_file))!; OnEndFile(data); } @@ -80,9 +92,12 @@ public void EventLoop() OnSetPropertyReply(); break; case mpv_event_id.MPV_EVENT_COMMAND_REPLY: + if (_pendingCommands.TryRemove(evt.reply_userdata, out var command)) + command.TrySetResult((mpv_error)evt.error); OnCommandReply(); break; case mpv_event_id.MPV_EVENT_START_FILE: // triggered before MPV_EVENT_FILE_LOADED + Interlocked.Increment(ref _playbackGeneration); OnStartFile(); break; case mpv_event_id.MPV_EVENT_AUDIO_RECONFIG: @@ -222,6 +237,27 @@ public void Command(string command) } public void CommandV(params string[] args) + { + mpv_error err = SendCommand(args); + + if (err < 0) + HandleError(err, "error executing command: " + string.Join("\n", args)); + } + + public Task CommandVAsync(params string[] args) + { + ulong id = (ulong)Interlocked.Increment(ref _commandId); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _pendingCommands[id] = completion; + mpv_error err = SendCommand(args, id); + + if (err < 0 && _pendingCommands.TryRemove(id, out var pending)) + pending.TrySetResult(err); + + return completion.Task; + } + + mpv_error SendCommand(string[] args, ulong id = 0) { int count = args.Length + 1; IntPtr[] pointers = new IntPtr[count]; @@ -236,15 +272,14 @@ public void CommandV(params string[] args) } Marshal.Copy(pointers, 0, rootPtr, count); - mpv_error err = mpv_command(Handle, rootPtr); + mpv_error err = id == 0 ? mpv_command(Handle, rootPtr) : mpv_command_async(Handle, id, rootPtr); foreach (IntPtr ptr in pointers) Marshal.FreeHGlobal(ptr); Marshal.FreeHGlobal(rootPtr); - if (err < 0) - HandleError(err, "error executing command: " + string.Join("\n", args)); + return err; } public string Expand(string? value) @@ -393,6 +428,22 @@ public string GetPropertyString(string name) return ""; } + public bool TryGetPropertyString(string name, out string value) + { + value = ""; + if (Handle == IntPtr.Zero) + return false; + + mpv_error error = mpv_get_property(Handle, GetUtf8Bytes(name), + mpv_format.MPV_FORMAT_STRING, out IntPtr buffer); + if (error < 0) + return false; + + value = ConvertFromUtf8(buffer); + mpv_free(buffer); + return true; + } + public void SetPropertyString(string name, string value) { if (Handle == IntPtr.Zero) diff --git a/src/MpvNet/Native/LibMpv.cs b/src/MpvNet/Native/LibMpv.cs index 60c2fc01..89b9f408 100644 --- a/src/MpvNet/Native/LibMpv.cs +++ b/src/MpvNet/Native/LibMpv.cs @@ -25,6 +25,9 @@ public static class LibMpv [DllImport("libmpv-2.dll", CallingConvention = CallingConvention.Cdecl)] public static extern mpv_error mpv_command(nint mpvHandle, nint strings); + [DllImport("libmpv-2.dll", CallingConvention = CallingConvention.Cdecl)] + public static extern mpv_error mpv_command_async(nint mpvHandle, ulong reply_userdata, nint strings); + [DllImport("libmpv-2.dll", CallingConvention = CallingConvention.Cdecl)] public static extern mpv_error mpv_command_string(nint mpvHandle, [MarshalAs(UnmanagedType.LPUTF8Str)] string command); diff --git a/src/MpvNet/PlaybackRecovery.cs b/src/MpvNet/PlaybackRecovery.cs new file mode 100644 index 00000000..2d170147 --- /dev/null +++ b/src/MpvNet/PlaybackRecovery.cs @@ -0,0 +1,308 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using MpvNet.Help; + +using static MpvNet.Native.LibMpv; + +namespace MpvNet; + +public sealed class PlaybackCheckpoint +{ + public int Version { get; set; } = 1; + public string Path { get; set; } = ""; + public double Position { get; set; } + public bool Seekable { get; set; } + public Dictionary Options { get; set; } = new(); + public List ExternalTracks { get; set; } = new(); +} + +public sealed class RecoveryTrack +{ + public int Id { get; set; } + public string Type { get; set; } = ""; + public string Filename { get; set; } = ""; +} + +public sealed class PlaybackRecovery +{ + static readonly string[] ExtraOptions = [ + "volume", "mute", "speed", "aid", "vid", "sid", "secondary-sid", "sub-visibility", + "audio-delay", "sub-delay", "sub-scale", "sub-pos", "video-zoom", "video-pan-x", + "video-pan-y", "video-rotate", "video-aspect-override", "edition", + "http-header-fields", "user-agent", "referrer", "http-proxy", "cookies", "cookies-file", + "tls-verify", "ytdl", "ytdl-format", "ytdl-raw-options", "demuxer-lavf-o" + ]; + + readonly MpvClient _player; + readonly string _filename; + readonly string[] _options; + readonly object _sync = new(); + bool _loaded; + bool _ready; + bool _reportedWriteError; + bool _restoreFailed; + long _lastSave; + PlaybackCheckpoint? _pending; + bool _pendingStarted; + + public event Action? Failed; + + // Error dialogs must not block libmpv's event loop while holding the checkpoint lock. + void ReportFailure(string message) => TaskHelp.Run(() => Failed?.Invoke(message)); + + public PlaybackRecovery(MpvClient player, string filename) + { + _player = player; + _filename = filename; + _options = player.GetPropertyString("watch-later-options").Split(',') + .Concat(ExtraOptions).Where(name => name != "start" && name != "pause" && name != "") + .Distinct().ToArray(); + + player.StartFile += () => { + lock (_sync) + { + _loaded = _ready = _restoreFailed = false; + _pendingStarted = _pending != null; + } + }; + player.FileLoaded += OnFileLoaded; + player.PlaybackRestart += OnPlaybackRestart; + player.EndFile += reason => { + lock (_sync) + { + _loaded = _ready = false; + if (reason == mpv_end_file_reason.MPV_END_FILE_REASON_ERROR && _pending != null && _pendingStarted) + { + _pending = null; + ReportFailure(_("Could not reconnect. Your saved position is still available; try Continue again when the source is reachable.")); + } + } + }; + player.ObservePropertyDouble("time-pos", value => SaveCheckpoint()); + foreach (string option in new[] { "pause", "speed", "volume", "mute", "aid", "sid", "secondary-sid", "audio-delay", "sub-delay" }) + player.ObserveProperty(option, () => SaveCheckpoint(true)); + } + + public bool SaveCheckpoint(bool force = false) + { + lock (_sync) + { + if (!_loaded || !_ready || _pending != null || + (!force && Stopwatch.GetElapsedTime(_lastSave) < TimeSpan.FromSeconds(1))) + return false; + + try + { + long generation = _player.PlaybackGeneration; + string path = _player.GetPropertyString("path"); + string entry = _player.GetPropertyString("playlist-entry-id"); + string originalPath = path; + if (path == "" || path == "-" || _player.GetPropertyString("seeking") == "yes" || + _player.GetPropertyString("eof-reached") == "yes" || + !_player.TryGetPropertyString("time-pos", out var time) || + !double.TryParse(time, CultureInfo.InvariantCulture, out double position) || + !double.IsFinite(position) || position < 0) + return false; + + if (!path.Contains("://")) + path = System.IO.Path.GetFullPath(path); + + var state = new PlaybackCheckpoint { + Path = path, Position = position, + Seekable = _player.GetPropertyString("seekable") == "yes" + }; + foreach (string option in _options) + if (_player.TryGetPropertyString(option, out string value)) + state.Options[option] = value; + + using var tracks = JsonDocument.Parse(_player.GetPropertyString("track-list")); + foreach (var track in tracks.RootElement.EnumerateArray()) + if (track.TryGetProperty("external-filename", out var file) && file.GetString() is string external) + state.ExternalTracks.Add(new RecoveryTrack { + Id = track.GetProperty("id").GetInt32(), + Type = track.GetProperty("type").GetString()!, Filename = external + }); + + if (_player.PlaybackGeneration != generation || _player.GetPropertyString("path") != originalPath || + _player.GetPropertyString("playlist-entry-id") != entry) + return false; + + WriteCheckpoint(_filename, state); + _lastSave = Stopwatch.GetTimestamp(); + _reportedWriteError = false; + return true; + } + catch (Exception ex) + { + if (!_reportedWriteError) + ReportFailure(_("Could not save the playback recovery checkpoint.") + BR + ex.Message); + _reportedWriteError = true; + return false; + } + } + } + + public async Task ContinueAsync() + { + string[] command; + lock (_sync) + { + SaveCheckpoint(true); + var state = ReadCheckpoint(_filename) ?? throw new InvalidOperationException(_("There is no saved playback to continue.")); + + if (_loaded && _ready && _player.GetPropertyString("path") == state.Path && + _player.GetPropertyString("pause") == "yes" && + _player.GetPropertyString("paused-for-cache") != "yes" && _player.GetPropertyString("eof-reached") != "yes") + { + _player.SetPropertyBool("pause", false); + return; + } + + if (!state.Seekable && state.Position > 0) + throw new InvalidOperationException(_("This stream does not support seeking to the saved position. A live source must provide a DVR or seekable recording to resume exactly.")); + + // Explicit per-file options take precedence over stale/disabled watch-later state. + var options = state.Options.Where(pair => _options.Contains(pair.Key)) + .ToDictionary(pair => pair.Key, pair => pair.Value); + options["start"] = state.Position.ToString("R", CultureInfo.InvariantCulture); + options["pause"] = "no"; + options["hr-seek"] = "yes"; + options["resume-playback"] = "no"; + string encoded = string.Join(",", options.Select(pair => pair.Key + "=%" + + Encoding.UTF8.GetByteCount(pair.Value) + "%" + pair.Value)); + + using var commands = JsonDocument.Parse(_player.GetPropertyString("command-list")); + bool hasIndex = commands.RootElement.EnumerateArray().First(item => item.GetProperty("name").GetString() == "loadfile") + .GetProperty("args").GetArrayLength() >= 4; + command = hasIndex ? ["loadfile", state.Path, "replace", "-1", encoded] : ["loadfile", state.Path, "replace", encoded]; + _pending = state; + _pendingStarted = false; + _restoreFailed = false; + _ready = false; + } + + mpv_error result = await _player.CommandVAsync(command); + if (result < 0) + { + lock (_sync) { _pending = null; } + throw new InvalidOperationException(_("Could not continue playback. Your checkpoint has been kept.") + BR + GetError(result)); + } + } + + void OnFileLoaded() + { + lock (_sync) + { + _loaded = true; + if (_pending == null || !_pendingStarted) + return; + + if (_player.GetPropertyString("path") != _pending.Path) + { + _pending = null; + return; + } + + foreach (var external in _pending.ExternalTracks) + { + if (external.Type is not ("audio" or "sub" or "video")) + continue; + using var tracks = JsonDocument.Parse(_player.GetPropertyString("track-list")); + if (!tracks.RootElement.EnumerateArray().Any(track => + track.GetProperty("type").GetString() == external.Type && + track.TryGetProperty("external-filename", out var file) && file.GetString() == external.Filename)) + _player.CommandV(external.Type == "sub" ? "sub-add" : external.Type == "audio" ? "audio-add" : "video-add", + external.Filename, "auto"); + + using var updated = JsonDocument.Parse(_player.GetPropertyString("track-list")); + var restored = updated.RootElement.EnumerateArray().FirstOrDefault(track => + track.GetProperty("type").GetString() == external.Type && + track.TryGetProperty("external-filename", out var file) && file.GetString() == external.Filename); + if (restored.ValueKind == JsonValueKind.Undefined) + { + _restoreFailed = true; + _player.SetPropertyBool("pause", true); + ReportFailure(_("An external audio, video or subtitle file could not be restored. Your checkpoint has been kept.")); + continue; + } + foreach (string selection in new[] { "aid", "vid", "sid", "secondary-sid" }) + if (_pending.Options.TryGetValue(selection, out var id) && id == external.Id.ToString(CultureInfo.InvariantCulture) && + (selection == "aid" && external.Type == "audio" || selection == "vid" && external.Type == "video" || + selection is "sid" or "secondary-sid" && external.Type == "sub")) + _player.SetPropertyString(selection, restored.GetProperty("id").GetInt32().ToString(CultureInfo.InvariantCulture)); + } + } + } + + void OnPlaybackRestart() + { + lock (_sync) + { + // Restart events from the old stream can still be queued when Continue is clicked. + if (!_loaded || _restoreFailed || _pending != null && !_pendingStarted) + return; + if (_pending != null) + { + if (!_player.TryGetPropertyString("time-pos", out var value) || + !double.TryParse(value, CultureInfo.InvariantCulture, out double actual) || + Math.Abs(actual - _pending.Position) > 1) + { + _restoreFailed = true; + _player.SetPropertyBool("pause", true); + ReportFailure(_("The source could not resume at the saved position. Your checkpoint has been kept.")); + return; + } + _pending = null; + } + _ready = true; + SaveCheckpoint(true); + } + } + + public static PlaybackCheckpoint? ReadCheckpoint(string filename) + { + foreach (string path in new[] { filename, filename + ".bak" }) + { + try + { + if (File.Exists(path)) + { + var state = JsonSerializer.Deserialize(File.ReadAllBytes(path)); + if (state != null && state.Version == 1 && !string.IsNullOrWhiteSpace(state.Path) && + double.IsFinite(state.Position) && state.Position >= 0 && state.Options != null && state.ExternalTracks != null) + return state; + } + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { } + } + return null; + } + + public static void WriteCheckpoint(string filename, PlaybackCheckpoint state) + { + string temporary = filename + "." + Guid.NewGuid().ToString("N") + ".tmp"; + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(filename)!); + try + { + var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Options = FileOptions.WriteThrough }; + if (!OperatingSystem.IsWindows()) + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + using (var stream = new FileStream(temporary, options)) + { + JsonSerializer.Serialize(stream, state); + stream.Flush(true); + } + if (File.Exists(filename)) + File.Replace(temporary, filename, filename + ".bak"); + else + File.Move(temporary, filename); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } +} diff --git a/src/MpvNet/Player.cs b/src/MpvNet/Player.cs index 25310545..fad5e6fa 100644 --- a/src/MpvNet/Player.cs +++ b/src/MpvNet/Player.cs @@ -60,6 +60,7 @@ public class MainPlayer : MpvClient public List Clients { get; } = new List(); List? _audioDevices; + public PlaybackRecovery Recovery { get; private set; } = null!; public event Action? Initialized; public event Action? Pause; @@ -114,6 +115,10 @@ public void Init(IntPtr formHandle, bool processCommandLine) SetPropertyString("osc", "yes"); SetPropertyString("config-dir", ConfigFolder); SetPropertyString("config", "yes"); + + // Keep network packets on disk so a watched video can be saved without downloading it again. + SetPropertyBool("cache-on-disk", true); + SetPropertyString("demuxer-max-back-bytes", "512MiB"); UsedInputConfContent = App.InputConf.GetContent(); @@ -151,6 +156,8 @@ public void Init(IntPtr formHandle, bool processCommandLine) mpv_request_log_messages(Handle, "info"); + Recovery = new PlaybackRecovery(this, System.IO.Path.Combine(GetPropertyString("config-dir"), "continue.json")); + if (formHandle != IntPtr.Zero) TaskHelp.Run(EventLoop); @@ -389,6 +396,9 @@ protected override void OnFileLoaded() { Duration = TimeSpan.FromSeconds(GetPropertyDouble("duration")); + if (GetPropertyBool("demuxer-via-network")) + SetPropertyString("file-local-options/keep-open", "always"); + if (App.StartSize == "video") WasInitialSizeSet = false; diff --git a/src/Tools/build-portable.ps1 b/src/Tools/build-portable.ps1 new file mode 100644 index 00000000..48bc9dc0 --- /dev/null +++ b/src/Tools/build-portable.ps1 @@ -0,0 +1,69 @@ +<# Builds the Windows x64 portable package, including .NET and native tools. #> +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$root = (Resolve-Path "$PSScriptRoot/../..").Path +$output = Join-Path $root 'artifacts/mpv.net-win-x64' +$downloads = Join-Path $root 'artifacts/dependencies' +$sevenZip = 'C:/Program Files/7-Zip/7z.exe' +New-Item $output, $downloads -ItemType Directory -Force | Out-Null + +function Download($url, $name, $sha256) { + $file = Join-Path $downloads $name + Invoke-WebRequest $url -OutFile $file -MaximumRetryCount 3 -RetryIntervalSec 3 + if ((Get-FileHash $file -Algorithm SHA256).Hash -ne $sha256) { + throw "Checksum mismatch: $name" + } + return $file +} + +function Extract($archive, $directory) { + & $sevenZip x -y $archive "-o$directory" | Out-Null + if ($LASTEXITCODE) { throw "Failed to extract $archive" } +} + +dotnet publish "$root/src/MpvNet.Windows/MpvNet.Windows.csproj" ` + --configuration Release --runtime win-x64 --self-contained true --output $output +if ($LASTEXITCODE) { throw 'Windows publish failed.' } + +# Pin and verify the native inputs so rebuilding a commit uses the same tools. +$mpvRelease = 'https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/20261019' +$mpv = Download "$mpvRelease/mpv-dev-x86_64-20261019-git-6a262d11f6.7z" 'libmpv.7z' ` + 'ca8604754170db6f1b23139d8449782b1ae294a883211131954d30085985f77b' +$ffmpeg = Download "$mpvRelease/ffmpeg-x86_64-git-c2bbf2b83.7z" 'ffmpeg.7z' ` + '8888676c10ffe70406ead843f84ca04ce3243ba6faf500c77c15748d9dcc4952' +$mediaInfo = Download 'https://mediaarea.net/download/binary/libmediainfo0/26.10/MediaInfo_DLL_26.10_Windows_x64_WithoutInstaller.zip' 'mediainfo.zip' ` + '4efe172df014693732e8b64a004cf382b8599eaf86be26a9b38c0d2993e60e1d' +$launcher = Download 'https://github.com/mpvnet-player/file-host/releases/download/tag/mpvnet.com.txt' 'mpvnet.com' ` + 'd4b0a80779dc775fb8817afa128a4ddcfe3bd07bca98a9d0c49ba44daf5cb5e3' + +Extract $mpv "$downloads/mpv" +Extract $ffmpeg "$downloads/ffmpeg" +Extract $mediaInfo "$downloads/mediainfo" +Copy-Item "$downloads/mpv/libmpv-2.dll", "$downloads/ffmpeg/ffmpeg.exe", ` + "$downloads/mediainfo/MediaInfo.dll", $launcher $output +Copy-Item "$root/License.txt" $output +New-Item "$output/licenses" -ItemType Directory -Force | Out-Null +Copy-Item "$downloads/mediainfo/Developers/License.html" "$output/licenses/MediaInfo.html" +Get-ChildItem "$downloads/mpv", "$downloads/ffmpeg" -Recurse -File | + Where-Object { $_.Name -match '^(LICENSE|COPYING)' } | + ForEach-Object { Copy-Item $_.FullName (Join-Path "$output/licenses" ($_.Directory.Name + '-' + $_.Name)) } + +foreach ($name in @('mpvnet.exe', 'mpvnet.com', 'libmpv-2.dll', 'MediaInfo.dll', 'ffmpeg.exe')) { + $file = Join-Path $output $name + if (-not (Test-Path $file) -or (Get-Item $file).Length -eq 0) { + throw "Required package file is missing: $name" + } +} + +$commit = git -C $root rev-parse HEAD +@" +mpv.net Windows x64 portable build +Commit: $commit +Runtime: .NET 10, self-contained (no separate .NET installation required) +libmpv and FFmpeg: $mpvRelease +Native build sources: https://github.com/shinchiro/mpv-winbuild-cmake +MediaInfo 26.10: https://mediaarea.net/en/MediaInfo +Start: extract the entire archive, then run mpvnet.exe on Windows 10/11 x64. +Website streams that use yt-dlp require yt-dlp.exe beside mpvnet.exe or on PATH. +"@ | Set-Content "$output/build-info.txt" -Encoding utf8 diff --git a/src/Tools/release-mpv.net.ps1 b/src/Tools/release-mpv.net.ps1 index 8e71d68a..550aa9c5 100644 --- a/src/Tools/release-mpv.net.ps1 +++ b/src/Tools/release-mpv.net.ps1 @@ -77,7 +77,7 @@ Copy-Item ($PublishDir64 + '*') $OutputDir64 Copy-Item ($PublishDirARM64 + '*') $OutputDirARM64 $BinDirX64 = Test (Join-Path $SourceDir 'MpvNet.Windows\bin\Debug\') $BinDirARM64 = Test (Join-Path $SourceDir 'MpvNet.Windows\bin\Debug\win-arm64\') -$ExtraFiles = 'mpvnet.com', 'libmpv-2.dll', 'MediaInfo.dll' +$ExtraFiles = 'mpvnet.com', 'libmpv-2.dll', 'MediaInfo.dll', 'ffmpeg.exe' $ExtraFiles | ForEach-Object { Copy-Item ($BinDirX64 + $_) ($OutputDir64 + $_) } $ExtraFiles | ForEach-Object { Copy-Item ($BinDirARM64 + $_) ($OutputDirARM64 + $_) } $LocaleDir = Test (Join-Path $SourceDir 'MpvNet.Windows\bin\Debug\Locale\') diff --git a/src/Tools/update-mpv.ps1 b/src/Tools/update-mpv.ps1 index b77e49e4..1570cf0b 100644 --- a/src/Tools/update-mpv.ps1 +++ b/src/Tools/update-mpv.ps1 @@ -1,7 +1,7 @@ <# -Updates mpv (x64) and libmpv (x64 , ARM64). +Updates mpv (x64), libmpv and the FFmpeg export tool (x64, ARM64). Files are downloaded from: https://github.com/shinchiro/mpv-winbuild-cmake/releases @@ -45,11 +45,26 @@ function Download($apiURL, $pattern) { function Unpack($archieveFile, $outputRootDir) { $outputDir = Join-Path $outputRootDir $archieveFile.BaseName if (Test-Path $outputDir) { Remove-Item $outputDir -Recurse } - $process = Start-Process (Test $7ZipPath) @('x', $archieveFile.FullName, "-o$outputDir") -NoNewWindow -Wait - if ($process.ExitCode) { throw $process.ExitCode } + & (Test $7ZipPath) x $archieveFile.FullName "-o$outputDir" | Out-Null + if ($LASTEXITCODE) { throw $LASTEXITCODE } return Test $outputDir } +# FFmpeg is used for complete-stream validation and lossless A/V muxing. +function UpdateFFmpeg($destination, $architecture) { + $apiURL = "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest" + $archiveFile = Get-Item (Download $apiURL "^ffmpeg-$architecture-git-[0-9a-z]+\.7z$") + $archiveDir = Unpack $archiveFile $env:TEMP + try { + $executable = @(Get-ChildItem $archiveDir -Recurse -Filter 'ffmpeg.exe') + if ($executable.Count -ne 1) { throw 'Expected one FFmpeg executable in archive' } + Copy-Item $executable[0].FullName (Join-Path $destination 'ffmpeg.exe') -Force + } finally { + Remove-Item $archiveFile.FullName + Remove-Item $archiveDir -Recurse + } +} + # Update mpv x64 if (Test-Path (Join-Path $MpvDirX64 'mpv.exe')) { @@ -71,6 +86,7 @@ if (Test-Path (Join-Path $LibmpvDirX64 'libmpv-2.dll')) { $archiveFile = Get-Item (Download $apiURL "mpv-dev-x86_64-[0-9]{8}-git-[0-9a-z]+\.7z") $archiveDir = Unpack $archiveFile $env:TEMP Copy-Item $archiveDir\libmpv-2.dll $LibmpvDirX64 -Force + UpdateFFmpeg $LibmpvDirX64 "x86_64" Remove-Item $archiveFile.FullName Remove-Item $archiveDir -Recurse } else { @@ -84,6 +100,7 @@ if (Test-Path (Join-Path $LibmpvDirARM64 'libmpv-2.dll')) { $archiveFile = Get-Item (Download $apiURL "mpv-dev-aarch64-[0-9]{8}-git-[0-9a-z]+\.7z") $archiveDir = Unpack $archiveFile $env:TEMP Copy-Item $archiveDir\libmpv-2.dll $LibmpvDirARM64 -Force + UpdateFFmpeg $LibmpvDirARM64 "aarch64" Remove-Item $archiveFile.FullName Remove-Item $archiveDir -Recurse } else { diff --git a/tests/BufferedVideo/BufferedVideo.csproj b/tests/BufferedVideo/BufferedVideo.csproj new file mode 100644 index 00000000..c354d6c3 --- /dev/null +++ b/tests/BufferedVideo/BufferedVideo.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tests/BufferedVideo/Program.cs b/tests/BufferedVideo/Program.cs new file mode 100644 index 00000000..2368784d --- /dev/null +++ b/tests/BufferedVideo/Program.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using MpvNet; +using static MpvNet.Global; +using static MpvNet.Native.LibMpv; + +Translator.Current = new IdentityTranslator(); + +void Check(bool condition, string message) +{ + if (!condition) + throw new Exception(message); + Console.WriteLine("PASS: " + message); +} + +Check(BufferedVideo.GetCompleteRange("") == null, "No cache is rejected"); +Check(BufferedVideo.GetCompleteRange("{}") == null, "Unavailable cache is rejected"); +Check(BufferedVideo.GetCompleteRange(""" + {"bof-cached":false,"eof-cached":true,"seekable-ranges":[{"start":1,"end":10}]} + """) == null, "Discarded beginning is rejected"); +Check(BufferedVideo.GetCompleteRange(""" + {"bof-cached":true,"eof-cached":false,"seekable-ranges":[{"start":0,"end":10}]} + """) == null, "Unbuffered ending is rejected"); +Check(BufferedVideo.GetCompleteRange(""" + {"bof-cached":true,"eof-cached":true,"seekable-ranges":[{"start":0,"end":2},{"start":4,"end":10}]} + """) == null, "Seek gaps are rejected"); +Check(BufferedVideo.GetCompleteRange(""" + {"bof-cached":true,"eof-cached":true,"seekable-ranges":[]} + """) == null, "Empty ranges are rejected"); +Check(BufferedVideo.GetCompleteRange(""" + {"bof-cached":true,"eof-cached":true,"seekable-ranges":[{"start":0,"end":0}]} + """) == null, "Empty duration is rejected"); +Check(BufferedVideo.GetCompleteRange(""" + {"bof-cached":true,"eof-cached":true,"seekable-ranges":[{"start":42.5,"end":46.75}]} + """) == (42.5, 46.75), "Complete non-zero timestamp range is accepted"); +Check(InputHelp.GetDefaults().Any(binding => binding.Command == "script-message-to mpvnet save-buffered-video"), + "Default context menu includes Save Buffered Video"); +// Optional end-to-end test: serve a short video over HTTP and pass its URL and an output directory. +if (args.Length == 0) + return; + +string output = Path.GetFullPath(args[1]); +Directory.CreateDirectory(output); +string config = Path.Combine(output, "config"); +Directory.CreateDirectory(config); +Environment.SetEnvironmentVariable("MPVNET_HOME", config); +File.WriteAllText(Path.Combine(config, "mpv.conf"), "vo=null\nao=null\nhwdec=no\n"); + +string? native = Environment.GetEnvironmentVariable("MPVNET_TEST_LIBMPV"); +if (native != null) + NativeLibrary.SetDllImportResolver(typeof(MpvClient).Assembly, (name, assembly, path) => + name == "libmpv-2.dll" ? NativeLibrary.Load(native) : IntPtr.Zero); + +Player.Init(IntPtr.Zero, false); +var loop = Task.Run(Player.EventLoop); + +try +{ + Check(Player.GetPropertyBool("cache-on-disk"), "Disk caching is enabled by default"); + Check(Player.GetPropertyLong("demuxer-max-back-bytes") == 512L * 1024 * 1024, + "Back buffer retains 512 MiB of packet metadata"); + + Player.CommandV("loadfile", args[0]); + DateTime deadline = DateTime.UtcNow.AddSeconds(20); + while (!Player.GetPropertyBool("eof-reached") && DateTime.UtcNow < deadline) + await Task.Delay(50); + + Check(Player.GetPropertyBool("eof-reached"), "Playback reaches EOF without unloading the video"); + Check(Player.GetPropertyString("keep-open") == "always", "Network video stays open at EOF"); + Check(BufferedVideo.GetCompleteRange(Player.GetPropertyString("demuxer-cache-state")) != null, + "The complete watched network video remains cached"); + if (Environment.GetEnvironmentVariable("MPVNET_TEST_OFFLINE") == "1") + Check(Console.ReadLine() == "offline", "HTTP server is offline before saving"); + + long generation = Player.PlaybackGeneration; + string destination = Path.Combine(output, "saved فیلم with spaces.mkv"); + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + await BufferedVideo.SaveAsync(Player, destination, generation).WaitAsync(TimeSpan.FromSeconds(10)); + Check(new FileInfo(destination).Length > 1000, "Cached video is exported using locale-independent timestamps"); + + string protectedFile = Path.Combine(output, "existing.mkv"); + File.WriteAllText(protectedFile, "existing destination"); + try + { + await BufferedVideo.SaveAsync(Player, protectedFile, generation - 1); + throw new Exception("Expected changed-file rejection"); + } + catch (InvalidOperationException) { } + Check(File.ReadAllText(protectedFile) == "existing destination", "Changing files preserves the destination"); + + string invalidFormat = Path.Combine(output, "existing.unsupported-format"); + File.WriteAllText(invalidFormat, "existing destination"); + try + { + await BufferedVideo.SaveAsync(Player, invalidFormat, generation); + throw new Exception("Expected muxer failure"); + } + catch (IOException) { } + Check(File.ReadAllText(invalidFormat) == "existing destination", "Muxer failure preserves the destination"); + Check(!Directory.GetFiles(output, "*.partial.*").Any(), "Failed exports leave no temporary files"); + + var invalidCommand = await Player.CommandVAsync("not-a-real-command").WaitAsync(TimeSpan.FromSeconds(5)); + Check(invalidCommand < 0, "Asynchronous command errors reach the caller"); + + Player.CommandV("stop"); + deadline = DateTime.UtcNow.AddSeconds(5); + while (Player.GetPropertyString("path") != "" && DateTime.UtcNow < deadline) + await Task.Delay(50); + Check(Player.PlaybackGeneration != generation, "Stopping invalidates an open save dialog"); + try + { + await BufferedVideo.SaveAsync(Player, protectedFile, Player.PlaybackGeneration); + throw new Exception("Expected stopped-file rejection"); + } + catch (InvalidOperationException) { } + Check(File.ReadAllText(protectedFile) == "existing destination", "Stopped playback preserves the destination"); + + Check(Player.GetPropertyString("keep-open") == "no", "Network keep-open setting is restored after stop"); +} +finally +{ + Player.CommandV("quit"); + await loop.WaitAsync(TimeSpan.FromSeconds(5)); + Player.Destroy(); +} + +class IdentityTranslator : ITranslator +{ + public string Gettext(string text) => text; + public string GetParticularString(string context, string text) => text; +} diff --git a/tests/BufferedVideo/README.md b/tests/BufferedVideo/README.md new file mode 100644 index 00000000..4a28d7f2 --- /dev/null +++ b/tests/BufferedVideo/README.md @@ -0,0 +1,31 @@ +# Buffered video regression checks + +Requires .NET SDK 10. The console checks use the production cache validation code +and verify the default context-menu binding without a test framework: + +```sh +dotnet run --project tests/BufferedVideo/BufferedVideo.csproj +``` + +For the integration test, also install `ffmpeg`, `ffprobe`, Python 3, and libmpv. +On Windows, point `MPVNET_TEST_LIBMPV` at `libmpv-2.dll`. On Linux, point it at +`libmpv.so.2` (and set `LD_LIBRARY_PATH` if its dependencies are not on the system +library path). `DOTNET` can specify a non-default dotnet executable. + +```sh +MPVNET_TEST_LIBMPV=/absolute/path/libmpv.so.2 python3 tests/BufferedVideo/run-integration.py +``` + +The runner generates four-second H.264/AAC MP4 and MPEG-TS fixtures, serves each +over localhost, and exercises `MainPlayer` and `BufferedVideo.SaveAsync` with real +libmpv. It shuts down HTTP and waits for an explicit handshake before exporting. +FFmpeg then verifies every decoded video frame and the AAC payloads (allowing the +initial encoder priming packet to be omitted by mpv). It also checks duration and +codecs, localized filenames/timestamps, changed playback, stopped playback, muxer +errors, destination preservation, temporary-file cleanup, and network keep-open +restoration. All fixtures and exports are temporary. + +The Windows GUI still needs a manual check: stream a video to EOF, right-click, +choose **File > Save Buffered Video...**, cancel once, then save and play the MKV. +Also check the overwrite prompt and a separate selected audio track. See +`tests/StreamCompletion` for automatic completion and audio-switch regression tests. diff --git a/tests/BufferedVideo/run-integration.py b/tests/BufferedVideo/run-integration.py new file mode 100644 index 00000000..54deed40 --- /dev/null +++ b/tests/BufferedVideo/run-integration.py @@ -0,0 +1,112 @@ +"""Run with a .NET 10 SDK, ffmpeg, ffprobe, and libmpv (see README.md).""" +import functools +import http.server +import json +import os +from pathlib import Path +import subprocess +import tempfile +import threading + +project = Path(__file__).resolve().parent / "BufferedVideo.csproj" +dotnet = os.environ.get("DOTNET", "dotnet") +subprocess.run([dotnet, "build", str(project), "--nologo", "-v:q"], check=True) + + +def probe(path): + return json.loads(subprocess.check_output([ + "ffprobe", "-v", "error", + "-show_entries", "stream=codec_type,codec_name:format=duration", + "-of", "json", str(path), + ])) + + +def video_frames(path): + data = subprocess.check_output([ + "ffmpeg", "-v", "error", "-i", str(path), "-map", "0:v:0", "-f", "framemd5", "-", + ]) + return [line.split(b",")[-1].strip() for line in data.splitlines() if not line.startswith(b"#")] + + +def audio_packets(path): + # Normalize AAC transport headers, which change when TS is remuxed to MKV. + data = subprocess.check_output([ + "ffmpeg", "-v", "error", "-i", str(path), "-map", "0:a:0", "-c:a", "copy", "-f", "adts", "-", + ]) + packets = [] + while data: + length = ((data[3] & 3) << 11) | (data[4] << 3) | (data[5] >> 5) + header = 7 if data[1] & 1 else 9 + assert header < length <= len(data), "Invalid ADTS frame" + packets.append(data[header:length]) + data = data[length:] + return packets + + +with tempfile.TemporaryDirectory(prefix="mpvnet-cache-test-") as directory: + root = Path(directory) + source = root / "source.mp4" + subprocess.run([ + "ffmpeg", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=24", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "4", + "-c:v", "libx264", "-preset", "ultrafast", "-g", "24", "-c:a", "aac", + "-movflags", "+faststart", str(source), + ], check=True) + transport = root / "source.ts" + subprocess.run([ + "ffmpeg", "-v", "error", "-i", str(source), "-c", "copy", "-f", "mpegts", str(transport), + ], check=True) + + for fixture in [source, transport]: + requests = [] + + class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + requests.append(self.path) + super().do_GET() + + def log_message(self, *args): + pass + + handler = functools.partial(Handler, directory=str(root)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + output = root / fixture.suffix[1:] + process = subprocess.Popen([ + dotnet, "run", "--no-build", "--project", str(project), "--", + f"http://127.0.0.1:{server.server_port}/{fixture.name}", str(output), + ], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + env={**os.environ, "MPVNET_TEST_OFFLINE": "1"}) + offline = False + try: + for line in process.stdout: + print(line.rstrip(), flush=True) + if "PASS: The complete watched network video remains cached" in line: + server.shutdown() + server.server_close() + thread.join() + offline = True + request_count = len(requests) + process.stdin.write("offline\n") + process.stdin.flush() + assert process.wait(timeout=10) == 0, "C# integration checks failed" + assert offline and len(requests) == request_count, "Export must work after HTTP is shut down" + finally: + if process.poll() is None: + process.kill() + process.wait() + if not offline: + server.shutdown() + server.server_close() + thread.join() + + saved = output / "saved فیلم with spaces.mkv" + original, exported = probe(fixture), probe(saved) + assert original["streams"] == exported["streams"], "Audio/video codecs must be preserved" + assert abs(float(original["format"]["duration"]) - float(exported["format"]["duration"])) < 0.1 + assert video_frames(fixture) == video_frames(saved), "Every decoded video frame must match" + before, after = audio_packets(fixture), audio_packets(saved) + # mpv's demuxer can omit this fixture's initial AAC priming packet. + assert after == before or after == before[1:], "AAC payloads must match without re-encoding" + print(f"PASS: {fixture.suffix} video frames and AAC payloads preserved; export succeeded with HTTP offline") diff --git a/tests/PlaybackRecovery/PlaybackRecovery.csproj b/tests/PlaybackRecovery/PlaybackRecovery.csproj new file mode 100644 index 00000000..c354d6c3 --- /dev/null +++ b/tests/PlaybackRecovery/PlaybackRecovery.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/tests/PlaybackRecovery/Program.cs b/tests/PlaybackRecovery/Program.cs new file mode 100644 index 00000000..8f0e11dc --- /dev/null +++ b/tests/PlaybackRecovery/Program.cs @@ -0,0 +1,178 @@ +using System.Globalization; +using System.Runtime.InteropServices; +using MpvNet; +using static MpvNet.Global; + +Translator.Current = new IdentityTranslator(); + +void Check(bool condition, string message) +{ + if (!condition) + throw new Exception(message); + Console.WriteLine("PASS: " + message); +} + +async Task Until(Func condition) +{ + DateTime deadline = DateTime.UtcNow.AddSeconds(15); + while (!condition() && DateTime.UtcNow < deadline) + await Task.Delay(25); + if (!condition()) + Console.WriteLine($"STATE: time={Player.GetPropertyString("time-pos")}, pause={Player.GetPropertyString("pause")}, seeking={Player.GetPropertyString("seeking")}, eof={Player.GetPropertyString("eof-reached")}, buffering={Player.GetPropertyString("paused-for-cache")}, generation={Player.PlaybackGeneration}"); + Check(condition(), "Wait completed"); +} + +Check(InputHelp.GetDefaults().First().Command == InputHelp.ContinueCommand, "Continue is the first root menu item"); +var legacy = new InputConf("") { Content = "o ignore #menu: File > Open\nCtrl+c script-message-to mpvnet continue #menu: Playback > Continue\n" }; +var bindings = legacy.GetBindings().menuBindings; +Check(bindings.First().Comment == "Continue" && bindings.First().Input == "Ctrl+c" && + bindings.Count(binding => binding.Command == InputHelp.ContinueCommand) == 1, + "Legacy menu places Continue first without losing its key binding"); + +if (args.Length == 0) +{ + string directory = Path.Combine(Path.GetTempPath(), "mpvnet-recovery-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + string checkpoint = Path.Combine(directory, "continue.json"); + try + { + var first = new PlaybackCheckpoint { Path = "https://example.test/فیلم?token=a,b", Position = 12.125 }; + first.Options["http-header-fields"] = "X-Title: فارسی, X-Token: a%b"; + PlaybackRecovery.WriteCheckpoint(checkpoint, first); + Check(PlaybackRecovery.ReadCheckpoint(checkpoint)?.Options["http-header-fields"] == first.Options["http-header-fields"], + "Unicode, punctuation and request settings round-trip"); + PlaybackRecovery.WriteCheckpoint(checkpoint, new PlaybackCheckpoint { Path = first.Path, Position = 15.25 }); + File.WriteAllText(checkpoint, "{interrupted"); + Check(PlaybackRecovery.ReadCheckpoint(checkpoint)?.Position == 12.125, "Corrupt checkpoint falls back to the previous complete file"); + File.WriteAllText(checkpoint + ".bak", "null"); + Check(PlaybackRecovery.ReadCheckpoint(checkpoint) == null, "Invalid primary and backup are rejected safely"); + File.Delete(checkpoint + ".bak"); + File.Delete(checkpoint); + PlaybackRecovery.WriteCheckpoint(checkpoint, first); + byte[] intact = File.ReadAllBytes(checkpoint); + Directory.CreateDirectory(checkpoint + ".bak"); + try + { + PlaybackRecovery.WriteCheckpoint(checkpoint, new PlaybackCheckpoint { Path = first.Path, Position = 20 }); + throw new Exception("Expected checkpoint replacement to fail"); + } + catch (IOException) { } + Check(File.ReadAllBytes(checkpoint).SequenceEqual(intact), "Failed replacement preserves the previous checkpoint"); + Check(!Directory.GetFiles(directory, "*.tmp").Any(), "Atomic writes leave no temporary files"); + } + finally { Directory.Delete(directory, true); } + return; +} + +string mode = args[0]; +string config = Path.GetFullPath(args[1]); +Directory.CreateDirectory(config); +Environment.SetEnvironmentVariable("MPVNET_HOME", config); +string? native = Environment.GetEnvironmentVariable("MPVNET_TEST_LIBMPV"); +if (native != null) + NativeLibrary.SetDllImportResolver(typeof(MpvClient).Assembly, (name, assembly, path) => + name == "libmpv-2.dll" ? NativeLibrary.Load(native) : IntPtr.Zero); + +Player.Init(IntPtr.Zero, false); +var loop = Task.Run(Player.EventLoop); +long restartedGeneration = 0; +Player.PlaybackRestart += () => Interlocked.Exchange(ref restartedGeneration, Player.PlaybackGeneration); +var failure = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); +Player.Recovery.Failed += message => { Console.WriteLine("RECOVERY: " + message); failure.TrySetResult(message); }; +Player.LogMessage += (level, message) => { + if (level <= MpvNet.Native.LibMpv.mpv_log_level.MPV_LOG_LEVEL_WARN) + Console.WriteLine("MPV: " + message.Trim()); +}; + +try +{ + if (mode is "record" or "disconnect") + { + Player.SetPropertyString("http-header-fields", "X-Recovery: checkpoint-test"); + Player.CommandV("loadfile", args[2]); + await Until(() => Player.GetPropertyDouble("time-pos") > 0.1); + Player.SetPropertyBool("pause", true); + Player.CommandV("write-watch-later-config"); // Deliberately stale engine resume point. + Player.CommandV("sub-add", args[3]); + Player.SetPropertyDouble("speed", 1.25); + Player.SetPropertyDouble("volume", 37); + Player.SetPropertyBool("mute", true); + Player.SetPropertyDouble("audio-delay", 0.25); + Player.SetPropertyDouble("sub-delay", -0.4); + Player.SetPropertyDouble("video-zoom", 0.25); + Player.CommandV("seek", "9.125", "absolute+exact"); + await Until(() => Math.Abs(Player.GetPropertyDouble("time-pos") - 9.125) < 0.1 && Player.Recovery.SaveCheckpoint(true)); + Check(!failure.Task.IsCompleted, "Checkpoint writes succeed during playback"); + Check(PlaybackRecovery.ReadCheckpoint(Path.Combine(config, "continue.json"))!.ExternalTracks.Any(), "External subtitles are checkpointed"); + if (mode == "record") + { + Console.WriteLine("CHECKPOINT_READY"); + await Task.Delay(Timeout.Infinite); // Runner kills this process without quit/finally. + } + else + { + Console.WriteLine("DISCONNECT_READY"); + Check(Console.ReadLine() == "offline", "Server is disconnected during active playback"); + Player.CommandV("drop-buffers"); // Force a new range read instead of consuming downloaded packets. + Player.SetPropertyBool("pause", false); + } + } + if (mode != "record") + { + string filename = Path.Combine(config, "continue.json"); + var saved = PlaybackRecovery.ReadCheckpoint(filename)!; + byte[] previous = File.ReadAllBytes(filename); + if (mode == "missing") + { + await Player.Recovery.ContinueAsync(); + await failure.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Check(Player.GetPropertyBool("pause"), "Incomplete external-track recovery pauses playback"); + Check(File.ReadAllBytes(filename).SequenceEqual(previous), "Missing external tracks preserve the checkpoint"); + return; + } + if (mode is "retry" or "disconnect") + { + await Player.Recovery.ContinueAsync(); + previous = File.ReadAllBytes(filename); + saved = PlaybackRecovery.ReadCheckpoint(filename)!; + await failure.Task.WaitAsync(TimeSpan.FromSeconds(15)); + Check(File.ReadAllBytes(filename).SequenceEqual(previous), "Failed reconnect preserves the durable checkpoint"); + Console.WriteLine("RETRY_FAILED"); + Check(Console.ReadLine() == "online", "Network is restored before retry"); + } + + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + await Player.Recovery.ContinueAsync(); + await Until(() => Player.GetPropertyDouble("time-pos") >= saved.Position - 0.05 && + Interlocked.Read(ref restartedGeneration) == Player.PlaybackGeneration && !Player.GetPropertyBool("seeking")); + Player.SetPropertyBool("pause", true); + Check(Player.GetPropertyDouble("time-pos") < saved.Position + 0.5, "Resume starts at the fractional saved position"); + Check(Player.GetPropertyString("path") == saved.Path, "Original URL is reopened"); + Check(Player.GetPropertyDouble("speed") == 1.25 && Player.GetPropertyDouble("volume") == 37 && + Player.GetPropertyBool("mute"), "Speed, volume and mute are restored after a crash"); + double audioDelay = Player.GetPropertyDouble("audio-delay"); + double subDelay = Player.GetPropertyDouble("sub-delay"); + double zoom = Player.GetPropertyDouble("video-zoom"); + Check(Math.Abs(audioDelay - 0.25) < 0.000001 && Math.Abs(subDelay + 0.4) < 0.000001 && + Math.Abs(zoom - 0.25) < 0.000001, $"Sync delays and video zoom are restored ({audioDelay}, {subDelay}, {zoom})"); + Check(Player.GetPropertyString("http-header-fields").Contains("X-Recovery: checkpoint-test"), "Request headers are restored before reconnecting"); + Check(Player.GetPropertyInt("sid") > 0 && Player.GetPropertyString("track-list").Contains("external-filename"), + "External subtitles and their selected track are restored"); + long generation = Player.PlaybackGeneration; + await Player.Recovery.ContinueAsync(); + Check(!Player.GetPropertyBool("pause") && Player.PlaybackGeneration == generation, + "Continue unpauses healthy playback without discarding its buffer"); + } +} +finally +{ + Player.CommandV("quit"); + await loop.WaitAsync(TimeSpan.FromSeconds(5)); + Player.Destroy(); +} + +class IdentityTranslator : ITranslator +{ + public string Gettext(string text) => text; + public string GetParticularString(string context, string text) => text; +} diff --git a/tests/PlaybackRecovery/README.md b/tests/PlaybackRecovery/README.md new file mode 100644 index 00000000..7570fae0 --- /dev/null +++ b/tests/PlaybackRecovery/README.md @@ -0,0 +1,39 @@ +# Continue regression checks + +Requires .NET SDK 10: + +```sh +dotnet run --project tests/PlaybackRecovery/PlaybackRecovery.csproj +``` + +This checks the first root-menu entry, legacy menu/key preservation, atomic +checkpoint replacement, backup recovery, corrupted state rejection, Unicode and +HTTP settings, and cleanup after a failed write. + +The integration runner also requires FFmpeg and real libmpv. Set `DOTNET` to select +a custom SDK executable and `MPVNET_TEST_LIBMPV` to the absolute path of +`libmpv-2.dll` (Windows) or `libmpv.so.2` (Linux). Linux dependencies may additionally +need `LD_LIBRARY_PATH`. + +```sh +MPVNET_TEST_LIBMPV=/absolute/path/libmpv.so.2 python3 tests/PlaybackRecovery/run-integration.py +``` + +The runner generates an H.264/AAC network video and an external subtitle file. +It kills the original player process immediately after a durable checkpoint, +without running quit/shutdown handlers. A fresh process must restore the fractional +position, speed, volume/mute, audio/subtitle delays, zoom, and selected external +subtitles with native watch-later resume disabled. The server requires a custom +HTTP header, verifying that connection settings are restored before loading. + +It also tests a failed connection followed by a successful retry, and a network +interruption within the same process: after seeking and checkpointing, HTTP is +disabled, read-ahead is limited, and mpv's buffers are dropped to force another range read. The checkpoint +must survive the read/connection error and restore correctly when HTTP returns. +Missing external subtitles must pause playback and keep the checkpoint intact. +All fixtures and configuration directories are temporary. This simulates abrupt +process death, not a physical power cut or a Windows GUI session. + +Manual Windows check: verify **Continue** is first in the root context menu, pause +and Continue, interrupt a stream, restore connectivity and Continue, then end the +player through Task Manager and launch it without a file before clicking Continue. diff --git a/tests/PlaybackRecovery/run-integration.py b/tests/PlaybackRecovery/run-integration.py new file mode 100644 index 00000000..dc8c5b67 --- /dev/null +++ b/tests/PlaybackRecovery/run-integration.py @@ -0,0 +1,134 @@ +"""Crash/restart and offline/retry tests with real libmpv; see README.md.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import threading +import http.server +import time + +project = Path(__file__).resolve().parent / "PlaybackRecovery.csproj" +dotnet = os.environ.get("DOTNET", "dotnet") +subprocess.run([dotnet, "build", str(project), "--nologo", "-v:q"], check=True) + +with tempfile.TemporaryDirectory(prefix="mpvnet-continue-test-") as directory: + root = Path(directory) + source = root / "source.mp4" + subtitle = root / "captions فارسی.srt" + subtitle.write_text("1\n00:00:00,000 --> 00:00:29,000\nRecovery subtitle\n", encoding="utf-8") + subprocess.run([ + "ffmpeg", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=24", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "30", + "-c:v", "libx264", "-preset", "ultrafast", "-g", "24", "-c:a", "aac", + "-movflags", "+faststart", str(source), + ], check=True) + online = True + requests = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append((online, self.headers.get("X-Recovery"), self.path)) + if not online: + self.send_error(503) + return + if self.headers.get("X-Recovery") != "checkpoint-test": + self.send_error(403) + return + data = source.read_bytes() + start, end = 0, len(data) - 1 + partial = self.headers.get("Range", "") + if partial: + first, last = partial.removeprefix("bytes=").split("-", 1) + start = int(first) + end = int(last) if last else end + self.send_response(206) + self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}") + else: + self.send_response(200) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Type", "video/mp4") + self.send_header("Content-Length", str(end - start + 1)) + self.end_headers() + try: + for offset in range(start, end + 1, 4096): + if not online: + return + self.wfile.write(data[offset:min(offset + 4096, end + 1)]) + time.sleep(0.02) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + processes = [] + + def launch(mode, config, *extra): + process = subprocess.Popen([ + dotnet, str(project.parent / "bin/Debug/net10.0/PlaybackRecovery.dll"), + mode, str(config), *map(str, extra), + ], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + processes.append(process) + watchdog = threading.Timer(45, process.kill) + watchdog.daemon = True + watchdog.start() + return process + + try: + config = root / "record" + config.mkdir() + (config / "mpv.conf").write_text("vo=null\nao=null\nhwdec=no\nresume-playback=no\nnetwork-timeout=2\n") + url = f"http://127.0.0.1:{server.server_port}/source.mp4?token=a,b&title=movie" + recorder = launch("record", config, url, subtitle) + ready = False + for line in recorder.stdout: + print(line.rstrip(), flush=True) + if "CHECKPOINT_READY" in line: + ready = True + recorder.kill() + recorder.wait(timeout=5) + break + assert ready, "Recorder failed before its durable checkpoint" + state = json.loads((config / "continue.json").read_text()) + assert abs(state["Position"] - 9.125) < 0.1 + print("PASS: Original process killed without quit or shutdown handlers", flush=True) + for mode in os.environ.get("RECOVERY_TEST_MODES", "restore,retry,disconnect,missing").split(","): + restored_config = root / mode + shutil.copytree(config, restored_config) + if mode == "disconnect": + with (restored_config / "mpv.conf").open("a") as options: + options.write("cache=no\ndemuxer-readahead-secs=1\ndemuxer-max-bytes=1MiB\n") + if mode == "missing": + broken = json.loads((restored_config / "continue.json").read_text()) + broken["ExternalTracks"][0]["Filename"] = str(root / "missing-subtitle.srt") + (restored_config / "continue.json").write_text(json.dumps(broken)) + online = mode != "retry" + first_request = len(requests) + process = launch(mode, restored_config, url, subtitle) if mode == "disconnect" else launch(mode, restored_config) + for line in process.stdout: + print(line.rstrip(), flush=True) + if "DISCONNECT_READY" in line: + online = False + process.stdin.write("offline\n") + process.stdin.flush() + if "RETRY_FAILED" in line: + online = True + process.stdin.write("online\n") + process.stdin.flush() + assert process.wait(timeout=5) == 0, f"{mode} checks failed" + assert any(entry[0] and entry[1] == "checkpoint-test" and entry[2] == "/source.mp4?token=a,b&title=movie" + for entry in requests[first_request:]), "Original URL and headers must be used to reconnect" + print("PASS: Crash restart and failed-network retry restore position/settings using the original URL") + finally: + for process in processes: + if process.poll() is None: + process.kill() + process.wait() + server.shutdown() + server.server_close() + thread.join() diff --git a/tests/StreamCompletion/Program.cs b/tests/StreamCompletion/Program.cs new file mode 100644 index 00000000..c01ebf86 --- /dev/null +++ b/tests/StreamCompletion/Program.cs @@ -0,0 +1,155 @@ +using System.Runtime.InteropServices; +using MpvNet; +using static MpvNet.Global; +using static MpvNet.Native.LibMpv; + +Translator.Current = new IdentityTranslator(); +string mode = args[0], url = args[1], output = args[2]; +Directory.CreateDirectory(output); +string config = Path.Combine(output, "config"); +Directory.CreateDirectory(config); +Environment.SetEnvironmentVariable("MPVNET_HOME", config); +File.WriteAllText(Path.Combine(config, "mpv.conf"), "vo=null\nao=null\nhwdec=no\nnetwork-timeout=2\nhttp-header-fields=X-Export: selected-audio\n"); +string? native = Environment.GetEnvironmentVariable("MPVNET_TEST_LIBMPV"); +if (native != null) + NativeLibrary.SetDllImportResolver(typeof(MpvClient).Assembly, (name, _, _) => + name == "libmpv-2.dll" ? NativeLibrary.Load(native) : IntPtr.Zero); +Player.Init(IntPtr.Zero, false); +var loop = Task.Run(Player.EventLoop); +void Check(bool value, string message) +{ + if (!value) throw new Exception(message); + Console.WriteLine("PASS: " + message); +} +async Task WaitFor(Func condition) +{ + var deadline = DateTime.UtcNow.AddSeconds(20); + while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); + Check(condition(), "Playback state ready"); +} +try +{ + string source = url + (mode is "external" or "short-audio" or "corrupt" or "external-video" ? "/video.mp4" : "/dual.mp4"); + if (mode == "short-internal") source = url + "/short-dual.mp4"; + if (mode == "hls") source = url + "/master.m3u8"; + if (mode == "edl") + { + string Escape(string value) => "%" + System.Text.Encoding.UTF8.GetByteCount(value) + "%" + value; + source = "edl://!new_stream;!no_clip;" + Escape(url + "/video.mp4") + + ";!new_stream;!no_clip;" + Escape(url + "/audio1.m4a") + + ";!new_stream;!no_clip;" + Escape(url + "/audio2.m4a"); + } + Player.CommandV("loadfile", source); + await WaitFor(() => Player.GetPropertyString("path") != "" && Player.GetPropertyDouble("duration") > 0); + if (mode is "external" or "short-audio" or "corrupt" or "external-video") + { + Player.CommandV("audio-add", url + "/audio1.m4a", "select"); + await WaitFor(() => Player.GetPropertyInt("aid") == 1); + await WaitFor(() => Player.GetPropertyBool("eof-reached")); + if (mode == "external-video") + { + Player.CommandV("video-add", url + "/other.mp4", "select"); + await WaitFor(() => Player.GetPropertyInt("vid") == 2); + } + Player.CommandV("audio-add", url + (mode == "short-audio" ? "/short.m4a" : "/audio2.m4a"), "select"); + await WaitFor(() => Player.GetPropertyInt("aid") == 2); + } + else + { + Player.SetPropertyBool("pause", true); + Player.CommandV("seek", "2", "absolute+exact"); + await WaitFor(() => !Player.GetPropertyBool("seeking")); + Player.SetPropertyInt("aid", 2); + await Task.Delay(150); + if (mode == "holes") + { + Player.CommandV("drop-buffers"); + Player.SetPropertyLong("demuxer-max-back-bytes", 0); + Player.CommandV("seek", "3", "absolute+exact"); + await WaitFor(() => !Player.GetPropertyBool("seeking")); + } + } + Player.SetPropertyBool("pause", true); + if (mode == "holes") Check(BufferedVideo.GetCompleteRange(Player.GetPropertyString("demuxer-cache-state")) == null, + "Missing cache ranges require completion"); + if (mode is "delay" or "negative-delay") Player.SetPropertyDouble("audio-delay", mode == "delay" ? 0.37 : -0.37); + var selection = BufferedVideo.Capture(Player, Player.PlaybackGeneration); + Check(selection.Tracks.Single(t => t.Type == "audio").Source == (mode is "external" or "corrupt" or "external-video" ? url + "/audio2.m4a" : mode == "short-audio" ? url + "/short.m4a" : source), + "Selected audio source captured"); + double position = Player.GetPropertyDouble("time-pos"); + if (mode is not ("external" or "short-audio" or "corrupt" or "external-video")) Player.SetPropertyInt("aid", 1); + if (mode == "source-change") + { + Player.CommandV("loadfile", url + "/other.mp4"); + await WaitFor(() => Player.PlaybackGeneration != selection.Generation && + Player.GetPropertyString("path") == url + "/other.mp4" && Player.GetPropertyDouble("duration") > 0); + Player.SetPropertyBool("pause", true); + position = Player.GetPropertyDouble("time-pos"); + } + int foregroundAudio = Player.GetPropertyInt("aid"); + if (mode == "watch-later") + { + string bookmarks = Path.Combine(config, "bookmarks"); + Directory.CreateDirectory(bookmarks); + Player.SetPropertyString("watch-later-directory", bookmarks); + Player.CommandV("write-watch-later-config"); + Check(Directory.GetFiles(bookmarks).Length > 0, "Native resume bookmark exists before download"); + selection.Options["watch-later-directory"] = bookmarks; + } + string destination = Path.Combine(output, "saved.mkv"); + File.WriteAllText(destination, "existing destination"); + using var canceled = new CancellationTokenSource(); + Task shutdown = Task.CompletedTask; + var progress = new InlineProgress(message => { + if (!message.StartsWith("Downloading")) return; + if (mode == "cancel") canceled.CancelAfter(100); + if (mode == "shutdown") shutdown = Task.Run(async () => { + await Task.Delay(100); + Player.CommandV("quit"); + }); + }); + if (mode is "failure" or "truncated" or "retry" or "corrupt") + { + Console.WriteLine("DOWNLOAD_READY"); + Check(Console.ReadLine() == "ready", "Server failure enabled"); + } + try + { + await BufferedVideo.SaveAsync(Player, destination, selection, canceled.Token, progress).WaitAsync(TimeSpan.FromSeconds(35)); + Check(mode is not ("cancel" or "shutdown" or "failure" or "truncated"), "Expected download success"); + Check(new FileInfo(destination).Length > 1000, "Complete video and pinned selected audio saved"); + } + catch (OperationCanceledException) when (mode is "cancel" or "shutdown") + { + Check(File.ReadAllText(destination) == "existing destination", "Cancellation preserves the destination"); + } + catch (IOException error) when (mode is "failure" or "truncated") + { + Console.WriteLine("EXPECTED SOURCE ERROR: " + error.Message); + Check(File.ReadAllText(destination) == "existing destination", "Failed or truncated download preserves the destination"); + } + await shutdown; + if (mode != "shutdown") + { + Check(Player.GetPropertyInt("aid") == foregroundAudio, "Export leaves foreground audio selection alone"); + Check(Math.Abs(Player.GetPropertyDouble("time-pos") - position) < 0.2, "Export leaves playback position alone"); + Check(Player.GetPropertyBool("pause"), "Export leaves pause state alone"); + } + Check(!Directory.GetDirectories(output, ".mpvnet-save-*").Any(), "Export temporary files cleaned up"); +} +finally +{ + if (Player.IsQuitNeeded) Player.CommandV("quit"); + await loop.WaitAsync(TimeSpan.FromSeconds(5)); + Player.Destroy(); +} +class IdentityTranslator : ITranslator +{ + public string Gettext(string text) => text; + public string GetParticularString(string context, string text) => text; +} + +class InlineProgress(Action report) : IProgress +{ + public void Report(string message) => report(message); +} diff --git a/tests/StreamCompletion/README.md b/tests/StreamCompletion/README.md new file mode 100644 index 00000000..ab404966 --- /dev/null +++ b/tests/StreamCompletion/README.md @@ -0,0 +1,41 @@ +# Complete selected-stream export checks + +Requires .NET SDK 10, Python 3, FFmpeg, FFprobe (test assertions only), and real +libmpv. `MPVNET_TEST_LIBMPV` selects the absolute library path; `DOTNET` selects a +custom SDK. Set `LD_LIBRARY_PATH` on Linux if native dependencies need it. + +```sh +MPVNET_TEST_LIBMPV=/absolute/path/libmpv.so.2 python3 tests/StreamCompletion/run-integration.py +``` + +The runner generates H.264 video and two distinct AAC tones. Its HTTP server +requires an authentication header for every request and supports byte ranges. +It exercises the production export with: + +- Separately selected external video and audio, including remapping foreground track IDs. +- A fully cached video and separately selected audio 2, verifying that video is + not downloaded again and every selected audio packet is preserved. +- Separate and internal audio tracks shorter than the video, preserving their actual duration. +- Internal audio switching midway through playback, with the first part missing + from the newly selected track's buffer. +- A multi-stream EDL like yt-dlp's separate video/audio representation. +- An HLS master playlist with two alternate audio renditions and separate segments. +- Existing native watch-later bookmarks, which must not truncate background downloads. +- Dropped/evicted cache ranges that must be filled automatically. +- Positive and negative selected audio delays, checking A/V timestamps. +- A foreground track or source switch after selection capture, without changing the export. +- Corrupt cached H.264 slices, verifying the fixture really reports decoding errors + (even when FFmpeg exits zero), then repairing the video from a healthy source. +- Actual HTTP failure followed by automatic retry and recovery. +- Cancellation during an active download, parent-player shutdown, persistent HTTP failure and truncated input, preserving an existing + destination and cleaning temporary export files. + +All decoded video frames and every AAC packet are compared with the original +selected source; the first AAC encoder priming packet may be omitted by mpv. +Foreground position, pause state and track selection must remain unchanged. +Fixtures, exports and native player processes are temporary. `COMPLETION_TEST_MODES` +can select a comma-separated subset of the scenario names in the runner. + +The Windows Save dialog, packaged FFmpeg executable, and a real service requiring +expiring credentials still need manual testing on Windows. This suite uses a +controlled local HTTP origin, not a live streaming account. diff --git a/tests/StreamCompletion/StreamCompletion.csproj b/tests/StreamCompletion/StreamCompletion.csproj new file mode 100644 index 00000000..25665f51 --- /dev/null +++ b/tests/StreamCompletion/StreamCompletion.csproj @@ -0,0 +1,7 @@ + + + Exenet10.0 + enableenable + + + diff --git a/tests/StreamCompletion/run-integration.py b/tests/StreamCompletion/run-integration.py new file mode 100644 index 00000000..0af40da2 --- /dev/null +++ b/tests/StreamCompletion/run-integration.py @@ -0,0 +1,174 @@ +"""Selected external/internal audio, missing buffers, cancellation and failed-source tests.""" +import http.server +import json +import os +from pathlib import Path +import subprocess +import tempfile +import threading +import time + +project = Path(__file__).resolve().parent / "StreamCompletion.csproj" +dotnet = os.environ.get("DOTNET", "dotnet") +subprocess.run([dotnet, "build", str(project), "--nologo", "-v:q"], check=True) + + +def run(*args): + return subprocess.check_output(["ffmpeg", "-v", "error", *map(str, args)]) + + +def frames(path): + return [line.split(b",")[-1].strip() for line in run("-i", path, "-map", "0:v:0", "-f", "framemd5", "-").splitlines() + if not line.startswith(b"#")] + + +def audio(path, index=0): + data = run("-i", path, "-map", f"0:a:{index}", "-c", "copy", "-f", "adts", "-") + packets = [] + while data: + length = ((data[3] & 3) << 11) | (data[4] << 3) | (data[5] >> 5) + assert 7 < length <= len(data) + packets.append(data[7:length]) + data = data[length:] + return packets + + +with tempfile.TemporaryDirectory(prefix="mpvnet-completion-") as directory: + root = Path(directory) + run("-f", "lavfi", "-i", "testsrc2=size=160x90:rate=24", "-f", "lavfi", "-i", + "sine=frequency=440:sample_rate=48000", "-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000", + "-t", "4", "-map", "0:v", "-map", "1:a", "-map", "2:a", "-c:v", "libx264", "-preset", "ultrafast", + "-g", "24", "-c:a", "aac", "-movflags", "+faststart", root / "dual.mp4") + run("-i", root / "dual.mp4", "-map", "0:v:0", "-c", "copy", "-movflags", "+faststart", root / "video.mp4") + for index in [1, 2]: + run("-i", root / "dual.mp4", "-map", f"0:a:{index-1}", "-c", "copy", "-movflags", "+faststart", root / f"audio{index}.m4a") + run("-i", root / "audio2.m4a", "-t", "3", "-c", "copy", root / "short.m4a") + run("-i", root / "dual.mp4", "-map", "0:v", "-map", "0:a:0", "-map", "0:a:1", "-c", "copy", + "-f", "hls", "-hls_time", "1", "-hls_playlist_type", "vod", "-master_pl_name", "master.m3u8", + "-var_stream_map", "v:0,agroup:aud a:0,agroup:aud,name:one,language:eng,default:yes a:1,agroup:aud,name:two,language:fas", + "-hls_segment_filename", root / "segment_%v_%03d.ts", root / "stream_%v.m3u8") + run("-i", root / "dual.mp4", "-i", root / "short.m4a", "-map", "0:v", "-map", "0:a:0", "-map", "1:a:0", + "-c", "copy", "-movflags", "+faststart", root / "short-dual.mp4") + run("-f", "lavfi", "-i", "color=c=red:size=160x90:rate=24", "-t", "4", "-c:v", "libx264", + "-preset", "ultrafast", "-movflags", "+faststart", root / "other.mp4") + packet_info = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_packets", "-show_entries", "packet=pos,size,pts_time", "-of", "json", str(root / "video.mp4")])) + packet = next(p for p in packet_info["packets"] if float(p["pts_time"]) >= 2) + damaged = bytearray((root / "video.mp4").read_bytes()) + offset, end = int(packet["pos"]), int(packet["pos"]) + int(packet["size"]) + while offset + 5 < end: + length = int.from_bytes(damaged[offset:offset+4], "big") + kind = damaged[offset+4] & 31 + if kind in [1, 5] and length > 100: + damaged[offset+5:offset+4+length] = b"\x00" * (length-1) + break + offset += length + 4 + else: + raise AssertionError("Expected a slice NAL in the fixture") + bad = root / "damaged.mp4" + bad.write_bytes(damaged) + check_damage = subprocess.run(["ffmpeg", "-v", "error", "-xerror", "-err_detect", "explode", "-i", str(bad), "-f", "null", "-"], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + assert check_damage.returncode != 0 or check_damage.stderr.strip(), "Fixture corruption must cause a real decoding failure" + original_frames = frames(root / "dual.mp4") + expected_audio = audio(root / "dual.mp4", 1) + repaired = False + online = True + truncate = False + slow = False + requests = [] + failures = [] + + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + requests.append((self.path, self.headers.get("X-Export"))) + if not online: + failures.append(self.path) + if mode == "retry": + def reconnect(): + global online + online = True + threading.Timer(0.1, reconnect).start() + self.send_error(503) + return + if self.headers.get("X-Export") != "selected-audio": + self.send_error(403) + return + data = bytes(damaged) if mode == "corrupt" and not repaired and self.path == "/video.mp4" else (root / self.path.removeprefix("/")).read_bytes() + start, end = 0, len(data) - 1 + partial = self.headers.get("Range", "") + if partial: + first, last = partial.removeprefix("bytes=").split("-", 1) + start, end = int(first), int(last) if last else end + self.send_response(206) + self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}") + else: + self.send_response(200) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Type", "video/mp4") + self.send_header("Content-Length", str(end - start + 1)) + self.end_headers() + try: + for offset in range(start, end + 1, 4096): + if truncate and offset > len(data) // 2: + return + self.wfile.write(data[offset:min(offset + 4096, end + 1)]) + if slow: + time.sleep(0.05) + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + for mode in os.environ.get("COMPLETION_TEST_MODES", "external,external-video,short-audio,short-internal,corrupt,internal,source-change,edl,hls,watch-later,holes,delay,negative-delay,cancel,shutdown,retry,failure,truncated").split(","): + repaired = False + online, truncate, slow = True, False, mode in ["cancel", "shutdown"] + output = root / mode + process = subprocess.Popen([dotnet, str(project.parent / "bin/Debug/net10.0/StreamCompletion.dll"), + mode, f"http://127.0.0.1:{server.server_port}", str(output)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + watchdog = threading.Timer(45, process.kill) + watchdog.start() + start = len(requests) + failed_before = len(failures) + try: + for line in process.stdout: + print(mode + ": " + line.rstrip(), flush=True) + if "DOWNLOAD_READY" in line: + repaired = mode == "corrupt" + online, truncate = mode not in ["failure", "retry"], mode == "truncated" + process.stdin.write("ready\n") + process.stdin.flush() + assert process.wait(timeout=5) == 0, f"{mode} failed" + finally: + watchdog.cancel() + if process.poll() is None: + process.kill() + process.wait() + assert all(header == "selected-audio" for _, header in requests[start:]), "Every download must keep authentication headers" + if mode == "retry": + assert len(failures) > failed_before, "Retry must recover from an actual failed HTTP request" + if mode == "corrupt": + assert sum(path == "/video.mp4" for path, _ in requests[start:]) > 1, f"Corrupt cached video must be repaired from the source: {requests[start:]}" + if mode == "external": + assert sum(path == "/video.mp4" for path, _ in requests[start:]) == 1, "Complete cached video must not be downloaded again" + if mode in ["cancel", "shutdown", "failure", "truncated"]: + continue + saved = output / "saved.mkv" + metadata = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-show_streams", "-of", "json", str(saved)])) + assert [s["codec_type"] for s in metadata["streams"]] == ["video", "audio"] + if mode in ["delay", "negative-delay"]: + starts = [float(s["start_time"]) for s in metadata["streams"]] + assert abs(starts[1] - starts[0] - (0.37 if mode == "delay" else -0.37)) < 0.05, "Selected audio delay must be preserved" + assert frames(saved) == (frames(root / "other.mp4") if mode == "external-video" else original_frames), "All video frames must match, including the missing prefix" + packets = audio(saved) + selected_audio = audio(root / "short.m4a") if mode in ["short-audio", "short-internal"] else expected_audio + assert packets == selected_audio or packets == selected_audio[1:], "Every audio packet must come from selected audio 2" + print(f"PASS: {mode}: complete video frames and audio 2 packets match the source", flush=True) + finally: + server.shutdown() + server.server_close()