From bc5eba3288e07adcaab95dbcc5a422e47b68cc91 Mon Sep 17 00:00:00 2001 From: Dariush vesal Date: Mon, 19 Oct 2026 17:14:08 +0330 Subject: [PATCH 1/5] Add context menu action to save fully buffered network videos --- docs/manual.md | 32 +++++ src/MpvNet.Windows/GuiCommand.cs | 67 +++++++++ src/MpvNet.Windows/Resources/editor_conf.txt | 16 +++ src/MpvNet/BufferedVideo.cs | 78 +++++++++++ src/MpvNet/InputHelp.cs | 1 + src/MpvNet/MpvClient.cs | 41 +++++- src/MpvNet/Native/LibMpv.cs | 3 + src/MpvNet/Player.cs | 7 + tests/BufferedVideo/BufferedVideo.csproj | 11 ++ tests/BufferedVideo/Program.cs | 138 +++++++++++++++++++ tests/BufferedVideo/README.md | 30 ++++ tests/BufferedVideo/run-integration.py | 112 +++++++++++++++ 12 files changed, 533 insertions(+), 3 deletions(-) create mode 100644 src/MpvNet/BufferedVideo.cs create mode 100644 tests/BufferedVideo/BufferedVideo.csproj create mode 100644 tests/BufferedVideo/Program.cs create mode 100644 tests/BufferedVideo/README.md create mode 100644 tests/BufferedVideo/run-integration.py diff --git a/docs/manual.md b/docs/manual.md index b3ba24e7..eaca9943 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -13,6 +13,7 @@ Table of contents * [Support](#support) * [Config Folder](#config-folder) * [Input and context menu](#input-and-context-menu) +* [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 +133,37 @@ The most important files and folders in the config folder are: - `script-opts` folder containing user scripts configuration files. +Saving a buffered video +----------------------- + +Use `File > Save Buffered Video...` in the right-click context menu to save a fully +buffered network video as a Matroska (`.mkv`) file without downloading it again. +Choose a destination in the Save dialog. An existing destination is replaced only +after the export succeeds. The menu command is also available as +`script-message-to mpvnet save-buffered-video` for custom bindings and menus. + +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 buffer; it does not survive +restarting the player. + +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`; disabling caching or reducing the limits may prevent saving +the whole video. + +Saving requires a single continuous cached range containing both the beginning and +end. If the cache is incomplete, watch/buffer the missing parts first. Videos using +selected external audio/video streams (such as some adaptive streaming URLs) are +not supported, because mpv's cache export only writes the main demuxer. Export +remuxes buffered packets without re-encoding; it does not reproduce the original +container byte-for-byte. mpv's experimental `dump-cache` can temporarily stall +playback for a large export, and some source codecs/containers may fail to remux. + + Input and context menu ---------------------- diff --git a/src/MpvNet.Windows/GuiCommand.cs b/src/MpvNet.Windows/GuiCommand.cs index 6f80ac8b..5e3b804a 100644 --- a/src/MpvNet.Windows/GuiCommand.cs +++ b/src/MpvNet.Windows/GuiCommand.cs @@ -20,6 +20,7 @@ namespace MpvNet; public class GuiCommand { Dictionary>>? _commands; + bool _savingBufferedVideo; public event Action? ScaleWindow; public event Action? MoveWindow; @@ -40,6 +41,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 +78,71 @@ void ShowDialog(Type winType) win?.ShowDialog(); } + 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; + } + + if (BufferedVideo.HasExternalMedia(Player.GetPropertyString("track-list"))) + { + Msg.ShowInfo(_("Saving videos with separate external audio or video streams is not supported.")); + return; + } + + if (BufferedVideo.GetCompleteRange(Player.GetPropertyString("demuxer-cache-state")) == null) + { + Msg.ShowInfo(_("The complete video is not buffered. Let it buffer from beginning to end before saving.")); + return; + } + + 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"); + await BufferedVideo.SaveAsync(Player, dialog.FileName, generation); + Msg.ShowInfo(_("Buffered video saved.") + BR2 + dialog.FileName); + } + catch (OperationCanceledException) + { + // libmpv cancels pending commands during shutdown. + } + catch (Exception ex) + { + Msg.ShowError(ex.Message); + } + finally + { + _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/BufferedVideo.cs b/src/MpvNet/BufferedVideo.cs new file mode 100644 index 00000000..a1a23b41 --- /dev/null +++ b/src/MpvNet/BufferedVideo.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Text.Json; +using System.Threading.Tasks; + +using static MpvNet.Native.LibMpv; + +namespace MpvNet; + +public static class BufferedVideo +{ + public static bool HasExternalMedia(string json) + { + using var tracks = JsonDocument.Parse(json); + return tracks.RootElement.EnumerateArray().Any(track => + track.TryGetProperty("external", out var external) && external.GetBoolean() && + track.GetProperty("selected").GetBoolean() && + track.GetProperty("type").GetString() is "audio" or "video"); + } + + 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 (!state.TryGetProperty("bof-cached", out var bof) || !bof.GetBoolean() || + !state.TryGetProperty("eof-cached", out var eof) || !eof.GetBoolean() || + !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 async Task SaveAsync(MpvClient player, string filename, long playbackGeneration) + { + // A sibling temporary file keeps an existing destination intact if mpv fails or playback changes. + string temporary = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(filename)!, + Guid.NewGuid().ToString("N") + ".partial" + System.IO.Path.GetExtension(filename)); + + try + { + if (player.PlaybackGeneration != playbackGeneration) + throw new InvalidOperationException(_("The playing file changed. Please try again.")); + + var range = GetCompleteRange(player.GetPropertyString("demuxer-cache-state")); + + if (range == null) + throw new InvalidOperationException(_("The complete video is not buffered. Let it buffer from beginning to end before saving.")); + + if (HasExternalMedia(player.GetPropertyString("track-list"))) + throw new InvalidOperationException(_("Saving videos with separate external audio or video streams is not supported.")); + + mpv_error error = await player.CommandVAsync("dump-cache", + range.Value.Start.ToString("R", CultureInfo.InvariantCulture), + (range.Value.End + 1).ToString("R", CultureInfo.InvariantCulture), temporary); + + if (error < 0) + throw new IOException(_("Could not save the buffered video.") + BR + GetError(error)); + + if (player.PlaybackGeneration != playbackGeneration) + throw new InvalidOperationException(_("The playing file changed. Please try again.")); + + if (!File.Exists(temporary) || new FileInfo(temporary).Length == 0) + throw new IOException(_("No video data was saved.")); + + File.Move(temporary, filename, true); + } + finally + { + if (File.Exists(temporary)) + File.Delete(temporary); + } + } +} diff --git a/src/MpvNet/InputHelp.cs b/src/MpvNet/InputHelp.cs index 0da86c23..1a8b15ef 100644 --- a/src/MpvNet/InputHelp.cs +++ b/src/MpvNet/InputHelp.cs @@ -11,6 +11,7 @@ public static List GetDefaults() 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/MpvClient.cs b/src/MpvNet/MpvClient.cs index a4890a48..c2a14272 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) 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/Player.cs b/src/MpvNet/Player.cs index 25310545..62bb3f52 100644 --- a/src/MpvNet/Player.cs +++ b/src/MpvNet/Player.cs @@ -114,6 +114,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(); @@ -389,6 +393,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/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..c5d66e10 --- /dev/null +++ b/tests/BufferedVideo/Program.cs @@ -0,0 +1,138 @@ +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"); +Check(BufferedVideo.HasExternalMedia(""" + [{"type":"audio","external":true,"selected":true}] + """), "Separate selected audio is rejected"); +Check(!BufferedVideo.HasExternalMedia(""" + [{"type":"sub","external":true,"selected":true},{"type":"audio","external":true,"selected":false}] + """), "External subtitles and unselected audio do not block export"); + +// 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 incomplete-cache rejection"); + } + catch (InvalidOperationException) { } + Check(File.ReadAllText(protectedFile) == "existing destination", "Incomplete cache 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..9e68ce2a --- /dev/null +++ b/tests/BufferedVideo/README.md @@ -0,0 +1,30 @@ +# 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, incomplete caches, 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 the unsupported separate-audio message. 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") From 69a718e05cf88d475e200072cb0deb8af01ce65f Mon Sep 17 00:00:00 2001 From: Dariush vesal Date: Mon, 19 Oct 2026 17:36:47 +0330 Subject: [PATCH 2/5] Persist playback checkpoints and add Continue first in context menu --- docs/manual.md | 35 ++ src/MpvNet.Windows/GuiCommand.cs | 17 + src/MpvNet.Windows/WinForms/MainForm.cs | 1 + src/MpvNet/InputConf.cs | 8 + src/MpvNet/InputHelp.cs | 6 + src/MpvNet/MpvClient.cs | 16 + src/MpvNet/PlaybackRecovery.cs | 308 ++++++++++++++++++ src/MpvNet/Player.cs | 3 + .../PlaybackRecovery/PlaybackRecovery.csproj | 11 + tests/PlaybackRecovery/Program.cs | 178 ++++++++++ tests/PlaybackRecovery/README.md | 39 +++ tests/PlaybackRecovery/run-integration.py | 134 ++++++++ 12 files changed, 756 insertions(+) create mode 100644 src/MpvNet/PlaybackRecovery.cs create mode 100644 tests/PlaybackRecovery/PlaybackRecovery.csproj create mode 100644 tests/PlaybackRecovery/Program.cs create mode 100644 tests/PlaybackRecovery/README.md create mode 100644 tests/PlaybackRecovery/run-integration.py diff --git a/docs/manual.md b/docs/manual.md index eaca9943..df7d22ec 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -13,6 +13,7 @@ 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) @@ -133,6 +134,40 @@ 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 ----------------------- diff --git a/src/MpvNet.Windows/GuiCommand.cs b/src/MpvNet.Windows/GuiCommand.cs index 5e3b804a..59d80fc6 100644 --- a/src/MpvNet.Windows/GuiCommand.cs +++ b/src/MpvNet.Windows/GuiCommand.cs @@ -32,6 +32,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, @@ -78,6 +79,22 @@ 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); + } + } + async void SaveBufferedVideo() { if (_savingBufferedVideo) diff --git a/src/MpvNet.Windows/WinForms/MainForm.cs b/src/MpvNet.Windows/WinForms/MainForm.cs index 7a12acb6..c7e55342 100644 --- a/src/MpvNet.Windows/WinForms/MainForm.cs +++ b/src/MpvNet.Windows/WinForms/MainForm.cs @@ -69,6 +69,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 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 1a8b15ef..5cce6dae 100644 --- a/src/MpvNet/InputHelp.cs +++ b/src/MpvNet/InputHelp.cs @@ -5,10 +5,16 @@ 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")); diff --git a/src/MpvNet/MpvClient.cs b/src/MpvNet/MpvClient.cs index c2a14272..7923aba1 100644 --- a/src/MpvNet/MpvClient.cs +++ b/src/MpvNet/MpvClient.cs @@ -428,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/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 62bb3f52..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; @@ -155,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); 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() From 190a2f814e378c32517cdb283a43bb0e2234bf3e Mon Sep 17 00:00:00 2001 From: Dariush vesal Date: Mon, 19 Oct 2026 18:15:55 +0330 Subject: [PATCH 3/5] Save complete streams with the selected audio --- docs/manual.md | 54 ++-- src/MpvNet.Windows/GuiCommand.cs | 39 ++- src/MpvNet.Windows/WinForms/MainForm.cs | 16 +- src/MpvNet/BufferedVideo.cs | 268 +++++++++++++++--- src/MpvNet/MediaExportTools.cs | 125 ++++++++ src/Tools/release-mpv.net.ps1 | 2 +- src/Tools/update-mpv.ps1 | 23 +- tests/BufferedVideo/Program.cs | 11 +- tests/BufferedVideo/README.md | 5 +- tests/StreamCompletion/Program.cs | 155 ++++++++++ tests/StreamCompletion/README.md | 41 +++ .../StreamCompletion/StreamCompletion.csproj | 7 + tests/StreamCompletion/run-integration.py | 174 ++++++++++++ 13 files changed, 831 insertions(+), 89 deletions(-) create mode 100644 src/MpvNet/MediaExportTools.cs create mode 100644 tests/StreamCompletion/Program.cs create mode 100644 tests/StreamCompletion/README.md create mode 100644 tests/StreamCompletion/StreamCompletion.csproj create mode 100644 tests/StreamCompletion/run-integration.py diff --git a/docs/manual.md b/docs/manual.md index df7d22ec..8067a433 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -171,32 +171,54 @@ 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 fully -buffered network video as a Matroska (`.mkv`) file without downloading it again. -Choose a destination in the Save dialog. An existing destination is replaced only -after the export succeeds. The menu command is also available as +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 buffer; it does not survive -restarting the player. +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`; disabling caching or reducing the limits may prevent saving -the whole video. - -Saving requires a single continuous cached range containing both the beginning and -end. If the cache is incomplete, watch/buffer the missing parts first. Videos using -selected external audio/video streams (such as some adaptive streaming URLs) are -not supported, because mpv's cache export only writes the main demuxer. Export -remuxes buffered packets without re-encoding; it does not reproduce the original -container byte-for-byte. mpv's experimental `dump-cache` can temporarily stall -playback for a large export, and some source codecs/containers may fail to remux. +`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 59d80fc6..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; @@ -21,6 +23,8 @@ public class GuiCommand { Dictionary>>? _commands; bool _savingBufferedVideo; + CancellationTokenSource? _saveCancellation; + Task? _saveTask; public event Action? ScaleWindow; public event Action? MoveWindow; @@ -95,6 +99,12 @@ async void ContinuePlayback() } } + public Task CancelVideoSaveAsync() + { + _saveCancellation?.Cancel(); + return _saveTask ?? Task.CompletedTask; + } + async void SaveBufferedVideo() { if (_savingBufferedVideo) @@ -115,17 +125,7 @@ async void SaveBufferedVideo() return; } - if (BufferedVideo.HasExternalMedia(Player.GetPropertyString("track-list"))) - { - Msg.ShowInfo(_("Saving videos with separate external audio or video streams is not supported.")); - return; - } - - if (BufferedVideo.GetCompleteRange(Player.GetPropertyString("demuxer-cache-state")) == null) - { - Msg.ShowInfo(_("The complete video is not buffered. Let it buffer from beginning to end before saving.")); - return; - } + var selection = BufferedVideo.Capture(Player, generation); string title = Player.GetPropertyString("media-title"); title = string.Concat(title.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); @@ -143,8 +143,15 @@ async void SaveBufferedVideo() return; Player.CommandV("show-text", _("Saving buffered video..."), "5000"); - await BufferedVideo.SaveAsync(Player, dialog.FileName, generation); - Msg.ShowInfo(_("Buffered video saved.") + BR2 + dialog.FileName); + _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) { @@ -152,10 +159,14 @@ async void SaveBufferedVideo() } catch (Exception ex) { - Msg.ShowError(ex.Message); + 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; } } diff --git a/src/MpvNet.Windows/WinForms/MainForm.cs b/src/MpvNet.Windows/WinForms/MainForm.cs index c7e55342..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; @@ -1422,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 index a1a23b41..ddc2cd4f 100644 --- a/src/MpvNet/BufferedVideo.cs +++ b/src/MpvNet/BufferedVideo.cs @@ -1,78 +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 { - public static bool HasExternalMedia(string json) + 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) { - using var tracks = JsonDocument.Parse(json); - return tracks.RootElement.EnumerateArray().Any(track => - track.TryGetProperty("external", out var external) && external.GetBoolean() && - track.GetProperty("selected").GetBoolean() && - track.GetProperty("type").GetString() is "audio" or "video"); + 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; - + if (string.IsNullOrEmpty(json)) return null; using var document = JsonDocument.Parse(json); var state = document.RootElement; - - if (!state.TryGetProperty("bof-cached", out var bof) || !bof.GetBoolean() || - !state.TryGetProperty("eof-cached", out var eof) || !eof.GetBoolean() || - !state.TryGetProperty("seekable-ranges", out var ranges) || ranges.GetArrayLength() != 1) - return null; - + 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 async Task SaveAsync(MpvClient player, string filename, long playbackGeneration) - { - // A sibling temporary file keeps an existing destination intact if mpv fails or playback changes. - string temporary = System.IO.Path.Combine(System.IO.Path.GetDirectoryName(filename)!, - Guid.NewGuid().ToString("N") + ".partial" + System.IO.Path.GetExtension(filename)); + 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 { - if (player.PlaybackGeneration != playbackGeneration) - throw new InvalidOperationException(_("The playing file changed. Please try again.")); - - var range = GetCompleteRange(player.GetPropertyString("demuxer-cache-state")); - - if (range == null) - throw new InvalidOperationException(_("The complete video is not buffered. Let it buffer from beginning to end before saving.")); - - if (HasExternalMedia(player.GetPropertyString("track-list"))) - throw new InvalidOperationException(_("Saving videos with separate external audio or video streams is not supported.")); + 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); + } - mpv_error error = await player.CommandVAsync("dump-cache", - range.Value.Start.ToString("R", CultureInfo.InvariantCulture), - (range.Value.End + 1).ToString("R", CultureInfo.InvariantCulture), temporary); + 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(); - if (error < 0) - throw new IOException(_("Could not save the buffered video.") + BR + GetError(error)); - - if (player.PlaybackGeneration != playbackGeneration) - throw new InvalidOperationException(_("The playing file changed. Please try again.")); + 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); + } + } - if (!File.Exists(temporary) || new FileInfo(temporary).Length == 0) - throw new IOException(_("No video data was saved.")); + internal static string Number(double value) => value.ToString("R", CultureInfo.InvariantCulture); - File.Move(temporary, filename, true); + 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 (File.Exists(temporary)) - File.Delete(temporary); + if (loop != null) + { + worker.CommandV("quit"); + await loop; + } + mpv_destroy(worker.Handle); } } } 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/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/Program.cs b/tests/BufferedVideo/Program.cs index c5d66e10..2368784d 100644 --- a/tests/BufferedVideo/Program.cs +++ b/tests/BufferedVideo/Program.cs @@ -35,13 +35,6 @@ void Check(bool condition, string message) """) == (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"); -Check(BufferedVideo.HasExternalMedia(""" - [{"type":"audio","external":true,"selected":true}] - """), "Separate selected audio is rejected"); -Check(!BufferedVideo.HasExternalMedia(""" - [{"type":"sub","external":true,"selected":true},{"type":"audio","external":true,"selected":false}] - """), "External subtitles and unselected audio do not block export"); - // Optional end-to-end test: serve a short video over HTTP and pass its URL and an output directory. if (args.Length == 0) return; @@ -117,10 +110,10 @@ void Check(bool condition, string message) try { await BufferedVideo.SaveAsync(Player, protectedFile, Player.PlaybackGeneration); - throw new Exception("Expected incomplete-cache rejection"); + throw new Exception("Expected stopped-file rejection"); } catch (InvalidOperationException) { } - Check(File.ReadAllText(protectedFile) == "existing destination", "Incomplete cache preserves the destination"); + 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"); } diff --git a/tests/BufferedVideo/README.md b/tests/BufferedVideo/README.md index 9e68ce2a..4a28d7f2 100644 --- a/tests/BufferedVideo/README.md +++ b/tests/BufferedVideo/README.md @@ -21,10 +21,11 @@ over localhost, and exercises `MainPlayer` and `BufferedVideo.SaveAsync` with re 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, incomplete caches, muxer +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 the unsupported separate-audio message. +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/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() From 703e8bd1285bbd28846d1dd693cb21d881d841eb Mon Sep 17 00:00:00 2001 From: Dariush vesal Date: Mon, 19 Oct 2026 18:24:48 +0330 Subject: [PATCH 4/5] Build ready-to-run Windows portable packages in GitHub Actions --- .github/workflows/windows.yml | 71 +++++++++++++++++++++++++++++++++++ src/Tools/build-portable.ps1 | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 .github/workflows/windows.yml create mode 100644 src/Tools/build-portable.ps1 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/src/Tools/build-portable.ps1 b/src/Tools/build-portable.ps1 new file mode 100644 index 00000000..1416e762 --- /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" $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 From f35ea5f0d22d45e818245bb8c9eaf10b30442347 Mon Sep 17 00:00:00 2001 From: Dariush vesal Date: Mon, 19 Oct 2026 18:27:40 +0330 Subject: [PATCH 5/5] Use the existing license filename in portable packages --- src/Tools/build-portable.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tools/build-portable.ps1 b/src/Tools/build-portable.ps1 index 1416e762..48bc9dc0 100644 --- a/src/Tools/build-portable.ps1 +++ b/src/Tools/build-portable.ps1 @@ -42,7 +42,7 @@ 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" $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 |