defmodule Snapcast.LocalClient do @moduledoc """ Supervises a local `snapclient` process connected to the embedded Snap server. The Homebrew/macOS snapclient daemon mode wants a system pid directory, so the client is run in the foreground as an Erlang port. If the process exits, this GenServer starts it again after a short delay. """ use GenServer require Logger @restart_ms 2_000 def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @impl true def init(_opts) do Process.flag(:trap_exit, true) {:ok, %{port: nil}, {:continue, :start_snapclient}} end @impl true def handle_continue(:start_snapclient, state) do {:noreply, start_snapclient(state)} end @impl true def handle_info(:restart_snapclient, state) do {:noreply, start_snapclient(state)} end def handle_info({port, {:exit_status, status}}, %{port: port} = state) do Logger.warning("local snapclient exited with status #{inspect(status)}; restarting") schedule_restart() {:noreply, %{state | port: nil}} end def handle_info({port, {:data, data}}, %{port: port} = state) do log_snapclient_output(data) {:noreply, state} end def handle_info(_msg, state), do: {:noreply, state} @impl true def terminate(_reason, %{port: port}) when is_port(port) do Port.close(port) :ok rescue _error -> :ok end def terminate(_reason, _state), do: :ok defp start_snapclient(%{port: port} = state) when is_port(port), do: state defp start_snapclient(state) do case Snapcast.snapclient_path() do path when is_binary(path) -> args = snapclient_args() stop_stale_snapclients(path, args) Logger.info("starting local snapclient: #{path} #{Enum.join(args, " ")}") port = Port.open({:spawn_executable, path}, [ :binary, :exit_status, :stderr_to_stdout, args: args ]) %{state | port: port} _missing -> Logger.warning("local snapclient is enabled but no snapclient executable was found") schedule_restart() state end end defp snapclient_args do [ "--hostID", Snapcast.local_client_id(), "--logsink", "stdout", "--logfilter", Snapcast.local_client_logfilter(), Snapcast.local_client_url() ] end defp stop_stale_snapclients(path, args) do with ps when is_binary(ps) <- System.find_executable("ps"), kill when is_binary(kill) <- System.find_executable("kill"), {output, 0} <- System.cmd(ps, ["-axo", "pid=,command="]) do output |> String.split("\n", trim: true) |> Enum.flat_map(&stale_snapclient_pid(&1, path, args)) |> Enum.each(fn pid -> Logger.info("stopping stale local snapclient pid #{pid}") System.cmd(kill, ["-TERM", pid]) end) else _missing_or_failed -> :ok end end defp stale_snapclient_pid(line, path, args) do case Regex.run(~r/^\s*(\d+)\s+(.+)$/, line) do [_, pid, command] -> if stale_snapclient_command?(command, path, args), do: [pid], else: [] _no_match -> [] end end defp stale_snapclient_command?(command, path, args) do String.starts_with?(command, path) and Enum.all?(args, &String.contains?(command, &1)) end defp schedule_restart, do: Process.send_after(self(), :restart_snapclient, @restart_ms) defp log_snapclient_output(data) do data |> to_string() |> String.split(["\r", "\n"], trim: true) |> Enum.each(&Logger.debug("snapclient: #{&1}")) end end