Current section
Files
Jump to
Current section
Files
lib/mpdex.ex
defmodule Mpdex do
@moduledoc """
Elixir client for Music Player Daemon (MPD).
Mpdex can be used to send commands to MPD, parse responses,
manipulate play queue and playlists as well as to receive
notifications about status and play queue changes.
"""
use GenServer
require Logger
# Client
@doc """
Mpdex GenServer start_link options
* `:host` - host IP where MPD is running (default 127.0.0.1)
* `:port` - integer port number on which MPD listenes for connections (default 6600)
* `:name` - GenServer name
## Examples
{:ok, mpd} = Mpdex.start_link(host: "10.0.0.1", port: 6600)
"""
def start_link(args) do
options = Keyword.take(args, [:name])
host = Keyword.get(args, :host, "127.0.0.1")
port = Keyword.get(args, :port, 6600)
GenServer.start_link(__MODULE__, %{host: host, port: port}, options)
end
@doc """
Subscribes client process to MPD notifications
"""
def subscribe() do
Logger.info("Subscribing client to 'player' notifications")
Registry.register(Mpdex.Registry, "player", [])
end
# Playlists
@doc """
Gets saved playlists.
## Examples
Mpdex.playlists(mpd)
{:ok,
[
%{last_modified: ~U[2022-08-10 11:52:13Z], playlist: "Radio"},
%{last_modified: ~U[2022-08-14 11:42:29Z], playlist: "Classic"}
]}
"""
def playlists(mpd) do
GenServer.call(mpd, :playlists)
end
@doc """
Gets the content of the playlist.
## Examples
Mpdex.get(mpd, "Radio")
{:ok,
[
%{
file: "https://naxidigital-classic128ssl.streaming.rs:8032#Naxi Classic",
metadata: %{
album: "Unknown",
artist: "Unknown",
time: 0,
title: "https://naxidigital-classic128ssl.streaming.rs:8032#Naxi Classic",
undefined: ""
}
},
%{
file: "https://naxidigital-cafe128ssl.streaming.rs:8022#Naxi Cafe Radio",
metadata: %{
album: "Unknown",
artist: "Unknown",
time: 0,
title: "https://naxidigital-cafe128ssl.streaming.rs:8022#Naxi Cafe Radio",
undefined: ""
}
}
]}
"""
def get(mpd, list_name) do
GenServer.call(mpd, {:list, list_name})
end
@doc """
Loads the whole playlist into the play queue.
"""
def load(mpd, list_name) do
GenServer.call(mpd, {:load, list_name})
end
@doc """
Adds URI to the playlist. Automatically creates playlist if it does
not exist.
"""
def add_to_list(mpd, list_name, uri) do
GenServer.call(mpd, {:add_to_list, list_name, uri})
end
@doc """
Clears the playlist.
"""
def clear_list(mpd, list_name) do
GenServer.call(mpd, {:clear_list, list_name})
end
@doc """
Deletes song at the given position from the playlist.
"""
def delete_song_at(mpd, list_name, position) do
GenServer.call(mpd, {:delete_at, list_name, position})
end
@doc """
Moves song in the playlist from the position `from` to the position
`to`.
"""
def move_song_in_playlist(mpd, list_name, from, to) do
GenServer.call(mpd, {:move_song_in_list, list_name, from, to})
end
@doc """
Saves the current play queue to the new playlist.
"""
def save_queue_to_playlist(mpd, list_name) do
GenServer.call(mpd, {:save_queue_to_list, list_name})
end
@doc """
Renames the playlist `list_name` to the `new_name`.
"""
def rename(mpd, list_name, new_name) do
GenServer.call(mpd, {:rename_list, list_name, new_name})
end
@doc """
Deletes playlist.
"""
def delete(mpd, list_name) do
GenServer.call(mpd, {:delete_list, list_name})
end
# Play queue
@doc """
Returns content of the queue.
## Examples
Mpdex.queue(mpd)
{:ok,
[
%{
file: "https://naxidigital-cafe128ssl.streaming.rs:8022#Naxi Cafe Radio",
metadata: %{
album: "Unknown",
artist: "Unknown",
id: "1",
name: "NAXI CAFE RADIO (NAXI,Belgrade,Serbia, NAXI,Beograd,Srbija) - 128k",
position: 0,
time: 0,
title: "https://naxidigital-cafe128ssl.streaming.rs:8022#Naxi Cafe Radio",
undefined: ""
}
},
%{
file: "Classic/radetzky-march.mp3",
metadata: %{
album: "The Wedding Collection 1",
artist: "Various",
duration: 178.04,
genre: "Blues",
id: "2",
last_modified: ~U[2012-01-29 12:56:50Z],
position: 1,
time: 178,
title: "Radetzky March op. 228/Johann",
undefined: ["OK", ""]
}
}
]}
"""
def queue(mpd) do
GenServer.call(mpd, :queue)
end
@doc """
Adds URI to the queue. If URI is directory it will be added
recursively. Otherwise single file or URL is added.
"""
def add_to_queue(mpd, uri) do
GenServer.call(mpd, {:add_to_queue, uri})
end
@doc """
Clears the queue.
"""
def clear(mpd) do
GenServer.call(mpd, :clear_queue)
end
@doc """
Deletes song or range of songs from the queue.
It accepts following options:
* `:start` - start position
* `:end` - end position (exclusive)
If both arguments are given all songs in the range will be
removed, otherwise removes song on the position `:start`.
## Examples
# deletes song on position 1
Mpdex.remove_from_queue(mpd, start: 1)
{:ok, "OK\\n"}
# deletes songs 0, 1 and 2
Mpdex.remove_from_queue(mpd, start: 0, end: 3)
{:ok, "OK\\n"}
"""
def remove_from_queue(mpd, options) do
GenServer.call(mpd, {:remove_from_queue, options})
end
@doc """
Moves a song or range of songs to the given position.
It accepts following options:
* `:start` - start position
* `:end` - end position (song on `end` position is excluded)
* `:to` - position to which songs will be moved
If both arguments are given all songs in the range will be
moved, otherwise moves song on the position `:start`.
## Examples
Mpdex.move_song_in_queue(mpd, start: 1, to: 0)
{:ok, "OK\\n"}
Mpdex.move_song_in_queue(mpd, start: 0, end: 3, to: 5)
{:ok, "OK\\n"}
"""
def move_song_in_queue(mpd, options) do
GenServer.call(mpd, {:move_song_in_queue, options})
end
@doc """
Shuffles the queue.
It accepts the following options:
* `:start` - start of range to be shuffled
* `:end` - end of range to be shuffled
Without options shuffles entire queue.
"""
def shuffle_queue(mpd, options) do
GenServer.call(mpd, {:shuffle, options})
end
# Status
@doc """
Returns statistics.
## Examples
Mpdex.statistics(mpd)
%{
albums: "78",
artists: "216",
db_playtime: "291742",
db_update: "1660465475",
playtime: "20034",
songs: "1140",
uptime: "25174"
}
"""
def statistics(mpd) do
GenServer.call(mpd, :statistics)
end
@doc """
Returns current playback status.
## Examples
Mpdex.status(mpd)
%{
audio: [samplerate: "44100", bits: "24", channels: "2"],
bitrate: "128",
consume: "0",
elapsed: "20109.183",
mixrampdb: "0.000000",
nextsong: "1",
nextsongid: "2",
playlist: "4",
playlistlength: "2",
random: "0",
repeat: "0",
single: "0",
song: "0",
songid: "1",
state: "play",
time: "20109:0",
volume: "16"
}
"""
def status(mpd) do
GenServer.call(mpd, :status)
end
# Playback
@doc """
Sets crossfading between songs.
"""
def crossfade(mpd, seconds) do
GenServer.call(mpd, {:crossface, seconds})
end
@doc """
Plays next song in the queue.
"""
def next(mpd) do
GenServer.call(mpd, :next)
end
@doc """
Pauses playback.
"""
def pause(mpd) do
GenServer.call(mpd, :pause)
end
@doc """
Resumes playback.
"""
def resume(mpd) do
GenServer.call(mpd, :resume)
end
@doc """
Begins playing the playlist at song position or with song ID.
## Examples
Mpdex.play(:position, 2)
Mpdex.play(:id, 23)
"""
def play(mpd, what, val) when :potition == what or :id == what do
GenServer.call(mpd, {:play, what, val})
end
@doc """
Plays previous song in the queue.
"""
def previous(mpd) do
GenServer.call(mpd, :previous)
end
@doc """
Turns random off.
"""
def random_off(mpd) do
GenServer.call(mpd, :random_off)
end
@doc """
Turns random on.
"""
def random_on(mpd) do
GenServer.call(mpd, :random_on)
end
@doc """
Turns repeat off.
"""
def repeat_off(mpd) do
GenServer.call(mpd, :repeat_off)
end
@doc """
Turns repeat on.
"""
def repeat_on(mpd) do
GenServer.call(mpd, :repeat_on)
end
@doc """
Seeks current song to the position time (in seconds; fractions
allowed).
"""
def seek(mpd, time) do
GenServer.call(mpd, {:seek, time})
end
@doc """
Seeks forward current song to the position time (in seconds;
fractions allowed) relative to the current position.
"""
def forward(mpd, time) do
GenServer.call(mpd, {:forward, time})
end
@doc """
Seeks backward current song to the position time (in seconds;
fractions allowed) relative to current position.
"""
def backward(mpd, time) do
GenServer.call(mpd, {:backward, time})
end
@doc """
Stops playing
"""
def stop(mpd) do
GenServer.call(mpd, :stop)
end
@doc """
Sets playback volume (from 0 to 100).
"""
def volume(mpd, vol) do
GenServer.call(mpd, {:volume, vol})
end
@doc """
Returns a list of music DB entries in the given URI.
"""
def list_db(mpd, uri \\ "") do
GenServer.call(mpd, {:listfiles, uri})
end
@doc """
Updates MPD's music DB from the given URI.
"""
def update_db(mpd, uri \\ "") do
GenServer.call(mpd, {:update, uri})
end
# Server
@impl GenServer
def init(%{host: host, port: port}) do
Application.put_env(:mpdex, :host, host)
Application.put_env(:mpdex, :port, port)
case connect(host, port) do
{:ok, socket, version} ->
:inet.setopts(socket, active: true)
:gen_tcp.send(socket, "idle\n")
{:ok,
%{
status: :connected,
host: host,
port: port,
socket: socket,
mpd_version: version
}}
{:error, error} ->
{:ok, %{status: :disconnected, host: host, port: port, socket: nil, error: error}}
end
end
@impl GenServer
def handle_call(mpd_cmd, _from, state) do
res =
case mpd_cmd do
:playlists ->
Mpdex.Playlists.list()
{:list, list_name} ->
Mpdex.Playlists.get(list_name)
{:load, list_name} ->
Mpdex.Playlists.load(list_name)
{:add_to_list, list, uri} ->
Mpdex.Playlists.add_to_list(list, uri)
{:clear_list, list_name} ->
Mpdex.Playlists.clear(list_name)
{:delete_at, list_name, position} ->
Mpdex.Playlists.delete_song_at(list_name, position)
{:move_song_in_list, list_name, from, to} ->
Mpdex.Playlists.move_song(list_name, from, to)
{:save_queue_to_list, list_name} ->
Mpdex.Playlists.save_queue_to_list(list_name)
{:rename_list, list_name, new_name} ->
Mpdex.Playlists.rename(list_name, new_name)
{:delete_list, list_name} ->
Mpdex.Playlists.delete(list_name)
:queue ->
Mpdex.Queue.list()
{:add_to_queue, uri} ->
Mpdex.Queue.add(uri)
:clear_queue ->
Mpdex.Queue.clear()
{:remove_from_queue, options} ->
Mpdex.Queue.delete(options)
{:move_song_in_queue, options} ->
Mpdex.Queue.move(options)
{:shuffle, options} ->
Mpdex.Queue.shuffle(options)
:statistics ->
Mpdex.Status.statistics()
:status ->
Mpdex.Status.status()
{:crossfade, seconds} ->
Mpdex.Playback.crossfade(seconds)
:next ->
Mpdex.Playback.next()
:pause ->
Mpdex.Playback.pause()
:resume ->
Mpdex.Playback.resume()
{:play, what, val} ->
Mpdex.Playback.play(what, val)
:previous ->
Mpdex.Playback.previous()
:random_off ->
Mpdex.Playback.random_off()
:random_on ->
Mpdex.Playback.random_on()
:repeat_off ->
Mpdex.Playback.repeat_off()
:repeat_on ->
Mpdex.Playback.repeat_on()
{:seek, time} ->
Mpdex.Playback.seek(time)
{:forward, time} ->
Mpdex.Playback.forward(time)
{:backward, time} ->
Mpdex.Playback.backward(time)
:stop ->
Mpdex.Playback.stop()
{:volume, volume} ->
Mpdex.Playback.volume(volume)
{:listfiles, uri} ->
Mpdex.MusicDb.list(uri)
{:update, uri} ->
Mpdex.MusicDb.update(uri)
end
{:reply, res, state}
end
@impl GenServer
def handle_info({:tcp, socket, data}, state) do
Logger.info("Received: #{:binary.list_to_bin(data)}")
:binary.list_to_bin(data)
|> String.split("\n")
|> Enum.reduce([], fn chg_info, acc ->
case chg_info do
"changed: mixer" ->
if false == Enum.member?(acc, :status) do
[:status | acc]
else
acc
end
"changed: player" ->
if false == Enum.member?(acc, :status) do
[:status | acc]
else
acc
end
"changed: playlist" ->
if false == Enum.member?(acc, :queue) do
[:queue | acc]
else
acc
end
"OK" ->
acc
_other ->
acc
end
end)
|> Enum.each(fn what ->
case what do
:status ->
status = Mpdex.Status.status()
Logger.info("Dispatching status: #{inspect(status)}")
Registry.dispatch(Mpdex.Registry, "player", fn entries ->
for {pid, _} <- entries, do: send(pid, {:status, status})
end)
:queue ->
case Mpdex.Queue.list() do
{:ok, queue} ->
Logger.info("Dispatching queue: #{inspect(queue)}")
Registry.dispatch(Mpdex.Registry, "player", fn entries ->
for {pid, _} <- entries, do: send(pid, {:queue, queue})
end)
{:error, _} ->
Logger.info("Error fetching queue")
end
end
end)
Logger.info("Sending 'idle' command")
:gen_tcp.send(socket, "idle\n")
{:noreply, state}
end
@impl GenServer
def handle_info({:tcp_closed, socket}, state) do
Logger.info("TCP socket closed. Reconnecting...")
:gen_tcp.shutdown(socket, :read_write)
state =
case connect(state.host, state.port) do
{:ok, socket, _version} ->
:inet.setopts(socket, active: true)
:gen_tcp.send(socket, "idle\n")
Logger.info("Setting new socket into the state.")
Map.put(state, :socket, socket)
{:error, error} ->
Logger.error("Error connecting: #{inspect(error)}")
Map.merge(state, %{
status: :disconnected,
socket: nil,
error: error
})
end
{:noreply, state}
end
@impl GenServer
def handle_info({:tcp_error, _socket, reason}, state) do
Logger.error("TCP error: #{inspect(reason)}")
{:noreply, state}
end
@impl GenServer
def handle_info(:reset_connection, state) do
Logger.info("Resetting TCP connection")
:gen_tcp.shutdown(Map.get(state, :socket), :read_write)
{:noreply, state}
end
defp connect(host, port) when is_binary(host) and is_integer(port) do
Logger.info("Connecting to #{host}:#{port}")
with {:ok, socket} <- :gen_tcp.connect(String.to_charlist(host), port, active: false),
{:ok, resp} <- :gen_tcp.recv(socket, 0) do
case to_string(resp) do
<<"OK MPD ", version::binary>> ->
{:ok, socket, String.trim(version)}
_ ->
{:error, nil}
end
else
err -> {:error, err}
end
end
end