Replacing currently playing files in mpv... or how I spent my Saturday

Turns out that this isn’t a feature that mpv wants to natively support:

And the provided solution is to use the umpv script, but there’s a caveat. It defaults to appending to the mpv playlist instead of replacing the currently playing file.

To get umpv to replace, you just need to change append-play to replace about half way down the script. Doing this the nix way with the correct ports, wrappers, and calling python correctly, etc. turned out to be not so trivial. Here’s what I came up with, but if you know of a better solution please share.

{ pkgs, ... }: 
let
  # Create our custom umpv with replace and correct socket path
  umpv-custom = pkgs.writeScriptBin "umpv" ''
    #!${pkgs.python3}/bin/python3

    """
    This script emulates "unique application" functionality. When starting
    playback with this script, it will try to reuse an already running instance of
    mpv (but only if that was started with umpv). Other mpv instances (not started
    by umpv) are ignored, and the script doesn't know about them.

    This only takes filenames as arguments. Custom options can't be used; the script
    interprets them as filenames. If mpv is already running, the files passed to
    umpv are appended to mpv's internal playlist. If a file does not exist or is
    otherwise not playable, mpv will skip the playlist entry when attempting to
    play it (from the GUI perspective, it's silently ignored).

    If mpv isn't running yet, this script will start mpv and let it control the
    current terminal. It will not write output to stdout/stderr, because this
    will typically just fill ~/.xsession-errors with garbage.

    mpv will terminate if there are no more files to play, and running the umpv
    script after that will start a new mpv instance.

    Note: you can supply custom mpv path and options with the MPV environment
          variable. The environment variable will be split on whitespace, and the
          first item is used as path to mpv binary and the rest is passed as options
          _if_ the script starts mpv. If mpv is not started by the script (i.e. mpv
          is already running), this will be ignored.
    """

    import os
    import shlex
    import socket
    import string
    import subprocess
    import sys
    from collections.abc import Iterable
    from typing import BinaryIO


    def is_url(filename: str) -> bool:
        parts = filename.split("://", 1)
        if len(parts) < 2:
            return False
        # protocol prefix has no special characters => it's an URL
        allowed_symbols = string.ascii_letters + string.digits + "_"
        prefix = parts[0]
        return all(c in allowed_symbols for c in prefix)

    def get_socket_path() -> str:
        # Use the same socket path as configured in mpv
        return "/tmp/mpvsocket"

    def send_files_to_mpv(conn: socket.socket | BinaryIO, files: Iterable[str]) -> None:
        try:
            send = conn.send if isinstance(conn, socket.socket) else conn.write
            for f in files:
                f = f.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
                send(f'raw loadfile "{f}" replace\n'.encode())
        except Exception:
            print("mpv is terminating or the connection was lost.", file=sys.stderr)
            sys.exit(1)

    def start_mpv(files: Iterable[str], socket_path: str) -> None:
        mpv = "${pkgs.mpv}/bin/mpv"  # Use full path to mpv
        mpv_command = shlex.split(os.getenv("MPV", mpv))
        mpv_command.extend([
            "--profile=builtin-pseudo-gui",
            f"--input-ipc-server={socket_path}",
            "--",
        ])
        mpv_command.extend(files)
        subprocess.Popen(mpv_command, start_new_session=True)

    def main() -> None:
        files = list(os.path.abspath(f) if not is_url(f) else f for f in sys.argv[1:])
        socket_path = get_socket_path()

        try:
            if os.name == "nt":
                with open(socket_path, "r+b", buffering=0) as pipe:
                    send_files_to_mpv(pipe, files)
            else:
                with socket.socket(socket.AF_UNIX) as sock:
                    sock.connect(socket_path)
                    send_files_to_mpv(sock, files)
        except (FileNotFoundError, ConnectionRefusedError):
            start_mpv(files, socket_path)

    if __name__ == "__main__":
        main()
  '';
  
  # Create a custom mpv package that replaces umpv
  mpv-with-custom-umpv = pkgs.symlinkJoin {
    name = "mpv-with-custom-umpv";
    paths = [ pkgs.mpv ];
    postBuild = ''
      rm $out/bin/umpv
      ln -s ${umpv-custom}/bin/umpv $out/bin/umpv
    '';
  };
in
{
  programs.mpv = {
    enable = true;
    package = mpv-with-custom-umpv;
    config = {
      input-ipc-server = "/tmp/mpvsocket";
      force-window = "no";
      audio-display = "no";
    };
  };
}

Why not use a .patch file?

I like the idea of a patch file since it would be more targeted and easier to track changes. The problem is that home-manager’s programs.mpv module creates wrappers and the patch doesn’t get through. Here’s what I tried:

umpv-replace.patch

--- a/TOOLS/umpv
+++ b/TOOLS/umpv
@@ -1,4 +1,4 @@
-#!/usr/bin/env python3
+#!@python3@/bin/python3
 
 """
 This script emulates "unique application" functionality. When starting
@@ -64,7 +64,7 @@ def send_files_to_mpv(conn: socket.socket | BinaryIO, files: Iterable[str]) ->
         send = conn.send if isinstance(conn, socket.socket) else conn.write
         for f in files:
             f = f.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
-            send(f'raw loadfile "{f}" append-play\n'.encode())
+            send(f'raw loadfile "{f}" replace\n'.encode())
     except Exception:
         print("mpv is terminating or the connection was lost.", file=sys.stderr)
         sys.exit(1)

default.nix

{ pkgs, ... }: 
let
  # Create a patched mpv package
  mpv-patched = pkgs.mpv.overrideAttrs (oldAttrs: {
    patches = (oldAttrs.patches or []) ++ [
      ./umpv-replace.patch
    ];
    postPatch = (oldAttrs.postPatch or "") + ''
      substituteInPlace TOOLS/umpv \
        --replace "@python3@" "${pkgs.python3}"
    '';
  });
in
{
  programs.mpv = {
    enable = true;
    package = mpv-patched;
    config = {
      # Don't set input-ipc-server - let umpv use its default
      force-window = "no";
      audio-display = "no";
    };
  };
}

mpv is an unusual case with how they wrapped it in nixpkgs; what you’ll want to do is more like this structure (untested):

programs.mpv.package = pkgs.mpv-unwrapped.wrapper {
  mpv = pkgs.mpv-unwrapped.overrideAttrs (oldAttrs: {
    patches = (oldAttrs.patches or []) ++ [
      ./umpv-replace.patch
    ];
  });
};

You also don’t really need these bits of code, because fixup hooks take care of this:

1 Like