Current section
Files
Jump to
Current section
Files
lib/snapcast/local_client.ex
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