431 lines
14 KiB
Julia
431 lines
14 KiB
Julia
"""
|
|
Implementation of Subsonic APIs: http://www.subsonic.org/pages/api.jsp
|
|
"""
|
|
|
|
"Subsonic API compatible version"
|
|
const apiversion = "1.16.1"
|
|
const domain = "nixo.xyz"
|
|
|
|
"""
|
|
Helper function that prepares a subsonic response.
|
|
"""
|
|
function subsonic(; version = "1.10.1", status = "ok")
|
|
xdoc = XMLDocument()
|
|
xroot = create_root(xdoc, "subsonic-response")
|
|
set_attribute(xroot, "xmlns", "http://subsonic.org/restapi")
|
|
set_attribute(xroot, "status", status)
|
|
set_attribute(xroot, "version", version)
|
|
(xdoc, xroot)
|
|
end
|
|
|
|
"Wrap a block inside the subsonic response to ease the free() of the XML"
|
|
macro subsonic(block)
|
|
return quote
|
|
(xdoc, xroot) = subsonic(version = $apiversion)
|
|
$block
|
|
doc_str = string(xdoc)
|
|
free(xdoc)
|
|
return doc_str
|
|
end
|
|
end
|
|
|
|
function subsonic_return(doc)
|
|
doc_str = string(doc)
|
|
free(doc)
|
|
doc_str
|
|
end
|
|
|
|
"Used to test connectivity with the server. Takes no extra parameters."
|
|
ping() = @subsonic(nothing)
|
|
|
|
"Get details about the software license. Takes no extra parameters."
|
|
function getLicense()
|
|
@subsonic begin
|
|
set_attributes(new_child(xroot, "license"),
|
|
[
|
|
("valid", "true"),
|
|
("email", string("admin@",domain)),
|
|
("licenseExpires", "never"),
|
|
("info", "This is juliaSonic, licensed under GPLv3+")
|
|
])
|
|
end
|
|
end
|
|
|
|
"Returns all configured top-level music folders. Takes no extra parameters."
|
|
function getMusicFolders()
|
|
@subsonic begin
|
|
folders = new_child(xroot, "musicFolders")
|
|
folder = new_child(folders, "musicFolder")
|
|
set_attributes(folder, [("id", "1"),
|
|
("name", "Music")])
|
|
end
|
|
end
|
|
|
|
"Returns all genres."
|
|
function getGenres()
|
|
@subsonic begin
|
|
songs = allsongs();
|
|
res = Dict{String,Int}()
|
|
for genre in filter!(!isempty, getfield.(songs, :genre))
|
|
t = get(res, genre, 0)
|
|
res[genre] = t+1
|
|
end
|
|
genres = new_child(xroot, "genres")
|
|
for k in keys(res)
|
|
genre = new_child(genres, "genre")
|
|
set_attributes(genre, [
|
|
("songCount", string(res[k])),
|
|
# FIXME
|
|
("albumCount", "0"),
|
|
])
|
|
add_text(genre, k)
|
|
end
|
|
end
|
|
end
|
|
|
|
"Similar to getIndexes, but organizes music according to ID3 tags."
|
|
function getArtists()
|
|
# TODO
|
|
(xdoc, xroot) = subsonic(version = "1.12.0")
|
|
indexes = new_child(xroot, "artists")
|
|
set_attribute(indexes, "ignoredArticles", "")
|
|
beetsdb = Beets.getartists()
|
|
artists = unique(beetsdb)
|
|
# albums = group_albums_as_artists()
|
|
# .|> does not work in a macro. What to do?
|
|
for index in unique(first.(filter(isempty,
|
|
getfield.(artists, Ref(:name)))) .|> uppercase)
|
|
indexXML = new_child(indexes, "index")
|
|
set_attribute(indexXML, "name", string(index))
|
|
for artist in filter(x -> startswith(x.name, string(index)), artists)
|
|
artistXML = new_child(indexXML, "artist")
|
|
set_attributes(artistXML,
|
|
[("name", artist.name),
|
|
("id", artist.uuid),
|
|
("coverArt", ""),
|
|
("albumCount", "")])
|
|
end
|
|
end
|
|
doc_str = string(xdoc)
|
|
free(xdoc)
|
|
return doc_str
|
|
end
|
|
|
|
"""Returns details for an artist, including a list of albums.
|
|
|
|
This method organizes music according to ID3 tags."""
|
|
function getArtist(id::String)
|
|
artists = Beets.getartists()
|
|
matching = artists[getfield.(artists, :uuid) .== id]
|
|
name = length(matching) > 0 ? first(matching).name : ""
|
|
isempty(name) && return not_found()
|
|
# Create the response
|
|
(xdoc, xroot) = subsonic()
|
|
artistXML = new_child(xroot, "artist")
|
|
artist = first(matching)
|
|
artist_albums = [i for i in Beets.getalbums() if i.artist == artist]
|
|
set_attributes(artistXML, [
|
|
("id", artist.uuid),
|
|
("albumCount", string(length(artist_albums))),
|
|
("name", artist.name),
|
|
("coverArt", "false")
|
|
])
|
|
for album in artist_albums
|
|
push!(xroot, album)
|
|
end
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
function getArtist(req::Dict)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
artistid = get(query, "id", "")
|
|
isempty(artistid) && return missing_parameter("id")
|
|
return getArtist(string(artistid))
|
|
end
|
|
|
|
function getAlbum(req::Dict)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
albumid = get(query, "id", "")
|
|
isempty(albumid) && return missing_parameter("id")
|
|
return getAlbum(albumid)
|
|
end
|
|
|
|
function getAlbumList(req::Dict)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
@show query
|
|
albumtype = get(query, "type", "")
|
|
isempty(albumtype) && return missing_parameter("type")
|
|
@subsonic begin
|
|
list = new_child(xroot, "albumList")
|
|
push!.(Ref(list), Beets.getalbums())
|
|
end
|
|
end
|
|
|
|
function getAlbum(albumid)
|
|
matching = [album for album in Beets.getalbums() if album.uuid == albumid]
|
|
if length(matching) < 1
|
|
return not_found("album")
|
|
end
|
|
album = first(matching)
|
|
(xdoc, xroot) = subsonic()
|
|
albumXML = push!(xroot, album)
|
|
push!(albumXML, [(s, album) for s in album.songs])
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
function getSong(req)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
id = get(query, "id", "")
|
|
isempty(id) && return missing_parameter()
|
|
matching = [album for album in Beets.getalbums()
|
|
if any(getfield.(album.songs, :uuid) .== id)]
|
|
length(matching) == 0 && return not_found("song")
|
|
(xdoc, xroot) = subsonic()
|
|
push!(xroot, (first(filter(s -> s.uuid == id, first(matching).songs)),
|
|
first(matching)))
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
import Random
|
|
function getRandomSongs(; size = 10,
|
|
genre::Union{Missing,String} = missing,
|
|
fromYear::Union{Missing,Int} = missing,
|
|
toYear::Union{Missing,Int} = missing,
|
|
musicFolderId::Union{Missing,String} = missing)
|
|
songs = allsongs();
|
|
# Randomize
|
|
songs = songs[Random.randperm(length(songs))];
|
|
# Filter
|
|
!ismissing(genre) && filter!(x -> strip(x.genre) == strip(genre), songs)
|
|
!ismissing(fromYear) && filter!(x -> x.year > fromYear, songs)
|
|
!ismissing(toYear) && filter!(x -> x.year < toYear, songs)
|
|
# Take size
|
|
songs = length(songs) > size ? songs[1:size] : songs;
|
|
# Create output
|
|
(xdoc, xroot) = subsonic()
|
|
list = new_child(xroot, "randomSongs")
|
|
albums = Beets.getalbums();
|
|
for song in songs
|
|
album = filter(x -> song in x.songs, albums) |> first
|
|
push!(list, (song, album))
|
|
end
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
function getRandomSongs(req)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
# name Required Default Notes
|
|
# size No 10 The maximum number of songs to return. Max 500.
|
|
size = parse(Int, get(query, "size", "10"))
|
|
size = size > 500 ? 500 : size
|
|
# genre No Only returns songs belonging to this genre.
|
|
genre = get(query, "genre", missing)
|
|
# fromYear No Only return songs published after or in this year.
|
|
# toYear No Only return songs published before or in this year.
|
|
fy = get(query, "fromYear", missing)
|
|
fromY = ismissing(fy) ? missing : parse(Int, fy)
|
|
ty = get(query, "toYear", missing)
|
|
toY = ismissing(ty) ? missing : parse(Int, ty)
|
|
getRandomSongs(size = size,
|
|
genre = ismissing(genre) ? missing : string(genre),
|
|
fromYear = fromY, toYear = toY)
|
|
end
|
|
|
|
|
|
|
|
function match(q::Regex, album::Beets.Album)
|
|
(match(q, album.title) !== nothing) || (match(q, album.artist.name) !== nothing) ||
|
|
any(map(s -> match(q, s.title) !== nothing, album.songs))
|
|
end
|
|
|
|
function makequery(q::String)
|
|
nq = replace(lowercase(q), "*" => ".*")
|
|
nq = string(".*", nq)
|
|
return Regex(nq)
|
|
end
|
|
|
|
function search3(req)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
q = get(query, "query", "")
|
|
isempty(q) && return missing_parameter("query")
|
|
if length(q) > 1 && q[1] == '!'
|
|
@info "This is the special torrent mode!"
|
|
end
|
|
songCount = parse(Int, get(query, "songCount", "20"))
|
|
# TODO:
|
|
# artistCount No 20 Maximum number of artists to return.
|
|
# artistOffset No 0 Search result offset for artists. Used for paging.
|
|
# albumCount No 20 Maximum number of albums to return.
|
|
# albumOffset No 0 Search result offset for albums. Used for paging.
|
|
# songOffset No 0 Search result offset for songs. Used for paging.
|
|
# musicFolderId No (Since 1.12.0) Only return results from music folder with the given ID. See getMusicFolders.
|
|
(xdoc, xroot) = subsonic()
|
|
results = new_child(xroot, "searchResult3")
|
|
k = makequery(string(q))
|
|
matchingartists = Beets.getartists()
|
|
filter!(a -> Base.match(k, lowercase(a.name)) !== nothing, matchingartists)
|
|
albums = Beets.getalbums();
|
|
matchingalbums = filter(a -> Base.match(k, lowercase(a.title)) !== nothing,
|
|
albums)
|
|
matchingsongs = Tuple{Beets.Song,Beets.Album}[]
|
|
for album in albums, song in album.songs
|
|
if Base.match(k, lowercase(song.title)) !== nothing
|
|
push!(matchingsongs, (song, album))
|
|
end
|
|
end
|
|
# Artists
|
|
push!.(Ref(results), matchingartists)
|
|
# # Albums
|
|
push!.(Ref(results), matchingalbums)
|
|
# Songs
|
|
push!(results, matchingsongs)
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
const user_playlists = Vector{Playlist}()
|
|
|
|
"Create (or update) a playlist" # WTF create can update?
|
|
function createPlaylist(req)
|
|
global user_playlists
|
|
#=
|
|
Parameter Required Default Comment
|
|
playlistId Yes (if updating) The playlist ID.
|
|
name Yes (if creating) The human-readable name of the playlist.
|
|
songId No ID of a song in the playlist. Use one songId parameter for each song in the playlist.
|
|
=#
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
playlistId = get(query, "playlistId", "")
|
|
name = get(query, "name", "")
|
|
songId = get(query, "songId", "")
|
|
# Check required params
|
|
isempty(songId) && return missing_parameter("songId")
|
|
songs = allsongs();
|
|
songn = findfirst(s -> s.uuid == songId, songs)
|
|
songn === nothing && return not_found("songId")
|
|
song = songs[songn]
|
|
if !isempty(playlistId)
|
|
if playlistId in keys(user_playlists)
|
|
push!(playlist, song)
|
|
else
|
|
return not_found("playlistId")
|
|
end
|
|
elseif !isempty(name)
|
|
playlist = Playlist(req[:login][:name], name = name)
|
|
push!(playlist, song)
|
|
else
|
|
return missing_parameter("either name or playlistId")
|
|
end
|
|
push!(user_playlists, playlist)
|
|
# Return the playlist
|
|
(xdoc, xroot) = subsonic()
|
|
push!(xroot, playlist)
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
"Returns all playlists a user is allowed to play."
|
|
function getPlaylists(req)
|
|
global user_playlists
|
|
user = req[:login][:name]
|
|
# FIXME: add support for admin (ask other user's playlists, v 1.8.0)
|
|
(xdoc, xroot) = subsonic(version = "1.0.0")
|
|
playlistsXML = new_child(xroot, "playlists")
|
|
for playlist in filter(p -> p.owner == user, user_playlists)
|
|
push!(playlistsXML, playlist)
|
|
end
|
|
return subsonic_return(xdoc)
|
|
end
|
|
|
|
"Returns a listing of files in a saved playlist."
|
|
function getPlaylist(req)
|
|
global user_playlists
|
|
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
id = get(query, "id", "")
|
|
isempty(id) && return missing_parameter("id")
|
|
|
|
m = findfirst(x -> x.owner == req[:login][:name], user_playlists)
|
|
m == nothing && return not_found("id")
|
|
|
|
(xdoc, xroot) = subsonic()
|
|
append!(xroot, user_playlists[m])
|
|
return subsonic_return(xroot)
|
|
end
|
|
|
|
"Updates a playlist. Only the owner of a playlist is allowed to update it."
|
|
function updatePlaylist(req)
|
|
global user_playlists
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
@show query
|
|
playlistId = get(query, "playlistId", "")
|
|
isempty(playlistId) && return missing_parameter("playlistId")
|
|
# FIXME: check ownership
|
|
pn = findfirst(p -> p.uuid == playlistId,
|
|
user_playlists)
|
|
pn == nothing && return not_found("playlistId")
|
|
playlist = user_playlists[pn]
|
|
playlist.name = get(query, "name", playlist.name)
|
|
playlist.comment = get(query, "comment", playlist.comment)
|
|
# FIXME: use try/catch
|
|
playlist.public = parse(Bool,get(query, "public", string(playlist.public)))
|
|
songIdAdd = get(query, "songIdToAdd", "")
|
|
# WTF by the index!?
|
|
IndexToRemove = get(query, "songIndexToRemove", "")
|
|
songIndexToRemove = isempty(IndexToRemove) ? -1 : parse(Int,IndexToRemove) + 1
|
|
|
|
# TODO: Support multiple (repeated) parameter
|
|
if !isempty(songIdAdd)
|
|
songs = allsongs();
|
|
songn = findfirst(s -> s.uuid == songIdAdd, songs)
|
|
songn === nothing && return not_found("songIdToAdd")
|
|
song = songs[songn]
|
|
push!(playlist, song)
|
|
end
|
|
# TODO: Support multiple (repeated) parameter
|
|
if songIndexToRemove > 0 && songIndexToRemove <= length(playlist.songs)
|
|
deleteat!(playlist.songs, songIndexToRemove)
|
|
end
|
|
@subsonic(nothing)
|
|
end
|
|
|
|
"Deletes a saved playlist."
|
|
function deletePlaylist(req)
|
|
global user_playlists
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
id = get(query, "id", "")
|
|
isempty(id) && return missing_parameter("id")
|
|
# FIXME: check ownership
|
|
filter!(p -> p.uuid != id, user_playlists)
|
|
@subsonic(nothing)
|
|
end
|
|
|
|
function getUser(req)
|
|
(xdoc, xroot) = subsonic()
|
|
push!(xroot, User(req[:login][:name]))
|
|
return subsonic_return(xroot)
|
|
end
|
|
|
|
# Media retriveal
|
|
|
|
"Returns a cover art image."
|
|
function getCoverArt(req::Dict)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
id = get(query, "id", "")
|
|
isempty(id) && return missing_parameter("id")
|
|
albums = Beets.getalbums()
|
|
n = findfirst(a -> album.uuid == id, albums)
|
|
n === nothing && return not_found("id")
|
|
# @show matching.cover
|
|
return Dict(:body => read(albums))
|
|
end
|
|
|
|
"Streams a given media file."
|
|
function stream(req::Dict)
|
|
query = HTTP.URIs.queryparams(req[:query])
|
|
id = get(query, "id", "")
|
|
isempty(id) && return missing_parameter("id")
|
|
songs = allsongs()
|
|
m = findfirst(x -> (x.uuid == id), songs)
|
|
m === nothing && return not_found("id")
|
|
return Dict(:body => read(songs[m].path))
|
|
end
|