JlSonic/JlSonic/api.jl

634 lines
21 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"
const ffmpeg_threads = 0
"""
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
# Implement:
# getIndexes
function getMusicDirectory(req)
query = HTTP.URIs.queryparams(req[:query])
id = get(query, "id", "")
isempty(id) && return missing_parameter()
(xdoc, xroot) = subsonic(version = "1.0.0")
directory = new_child(xroot, "directory")
# We simulate directory listing. Root directory has id ==
# 1. Other directories are identified by uuids
artists = Beets.artists()
## Structure is: Music(id=1)/Artist/Album/Song
if id == "1"
# List artists
for artist in artists
content = new_child(directory, "child")
set_attributes(content,
[("id", artist.uuid),
# Always under /Music, id = 1
("parent", "1"),
("name", artist.name),
# ("starred", "FIXME")
])
end
else
# List content
# 1. Search if uuid matches artist
# 2. Else, check if matches albums
artistmatch = findfirst(a -> a.uuid == id, artists)
albums = Beets.albums;
if artistmatch != nothing
@show id
artist = artists[artistmatch]
set_attributes(directory,
[("id", artist.uuid),
# Always under /Music, id = 1
("parent", "1"),
("name", artist.name),
("starred", "2013-11-02T12:30:00")
])
# List albums
content = new_child(directory, "child")
for albumn in findall(alb -> alb.artist == artist, albums)
album = albums[albumn]
set_attributes(content, [
("id", album.uuid),
("parent", album.artist.uuid),
# ("artistId", album.artist.uuid),
("title", album.title),
("artist", album.artist.name),
("isDir", "true"),
("coverArt", album.uuid),
])
end
elseif false
content = new_child(directory, "child")
# List album content (songs)
set_attributes(content, [
("id", "FIXME"),
("parent", "PARENT:ID"),
("title", "FIXME"),
("isDir", "false"),
("album", "FIXME"),
("artist", "FIXME"),
("track", "FIXME"),
("year", "FIXME"),
("genre", "FIXME"),
("coverArt", "FIXME"),
("size", "FIXME"),
# FIXME
("contentType", "audio/mpeg"),
("suffix", "FIXME"),
("duration", "FIXME"),
("bitrate", "FIXME"),
("path", "FIXME"),
])
else
return not_found()
end
end
doc_str = string(xdoc)
free(xdoc)
return doc_str
end
"Returns all genres."
function getGenres()
(xdoc, xroot) = subsonic()
songs = Beets.songs();
res = Dict{String,Int}()
genrelist = strip.(sort(filter!(!isempty, Beets.genre.(Beets.albums))))
for genre in genrelist
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", string(count(genrelist .== k))),
])
add_text(genre, k)
end
return subsonic_return(xdoc)
end
"Similar to getIndexes, but organizes music according to ID3 tags."
function getArtists()
(xdoc, xroot) = subsonic()
indexes = new_child(xroot, "artists")
set_attribute(indexes, "ignoredArticles", "")
artists = sort(Beets.artists(), by = a -> a.name)
firstletters = unique(first.(filter(!isempty, Beets.name.(artists))) .|> uppercase)
for index in string.(firstletters)
indexXML = new_child(indexes, "index")
set_attribute(indexXML, "name", index)
for artist in filter(x -> startswith(x.name, index), artists)
artistXML = push!(indexXML, artist)
end
end
return subsonic_return(xdoc)
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.artists()
matching = findfirst(a -> a.uuid == id, artists)
matching === nothing && return not_found("id")
artist = artists[matching]
# Create the response
(xdoc, xroot) = subsonic()
artistXML = push!(xroot, artist)
for album in sort(Beets.album(artist))
push!(artistXML, 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 getAlbum(albumid)
album = Beets.album(string(albumid))
album === nothing && return not_found("album")
(xdoc, xroot) = subsonic()
# push!(albumXML, [(s, album) for s in album.songs])
append!(xroot, album)
return subsonic_return(xdoc)
end
function getAlbumList(req::Dict)
query = HTTP.URIs.queryparams(req[:query])
albumtype = get(query, "type", "")
isempty(albumtype) && return missing_parameter("type")
@subsonic begin
list = new_child(xroot, "albumList")
push!.(Ref(list), Beets.albums)
end
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.albums
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 = Beets.songs();
# 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.albums;
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; dlalbum = println, dlall = println)
query = HTTP.URIs.queryparams(req[:query])
q = get(query, "query", "")
isempty(q) && return missing_parameter("query")
if req[:login][:user].upload && length(q) > 2 && q[end-1:end] == "!a"
@info "Downloading single album"
dlalbum(string(strip(q[1:end-2])))
return @subsonic(nothing)
elseif req[:login][:user].upload && length(q) > 2 && q[end-1:end] == "!d"
@info "Downloading discography!"
dlall(string(strip(q[1:end-2])))
return @subsonic(nothing)
(xdoc, xroot) = subsonic()
results = new_child(xroot, "searchResult3")
# TODO: Push the results so that we can see what download just started
# push!(results, list)
return subsonic_return(xdoc)
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.artists()
filter!(a -> Base.match(k, lowercase(a.name)) !== nothing, matchingartists)
albums = Beets.albums;
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
"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 = Beets.songs();
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][:user].name,
name = name # cover = ???
)
push!(playlist, song)
@show "THERE"
else
return missing_parameter("either name or playlistId")
end
push!(user_playlists, playlist)
# Return the playlist
(xdoc, xroot) = subsonic()
push!(xroot, playlist)
saveplaylists()
return subsonic_return(xdoc)
end
"Returns all playlists a user is allowed to play."
function getPlaylists(req)
global user_playlists
# 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 sort(filter(p -> canread(req[:login][:user], p),
user_playlists),
by = p -> p.name)
push!(playlistsXML, playlist)
end
return subsonic_return(xdoc)
end
import Base.get
function get(::Type{Playlist}, u::User, id::AbstractString)::Union{Nothing,Playlist}
global user_playlists
playlists = filter(p -> canread(u, p), user_playlists)
m = findfirst(p -> p.uuid == id, playlists)
m == nothing ? nothing : playlists[m]
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")
playlist = get(Playlist, req[:login][:user], id)
playlist == nothing && return not_found("id")
(xdoc, xroot) = subsonic()
append!(xroot, playlist)
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])
playlistId = get(query, "playlistId", "")
isempty(playlistId) && return missing_parameter("playlistId")
playlist = get(Playlist, req[:login][:user], playlistId)
playlist == nothing && return not_found("playlistId")
# Check ownership (if not allowed, should not even reach this (canread is false))
canedit(req[:login][:user], playlist) || return unuthorized()
# Want to make public. Is allowed?
wantpublic = try
parse(Bool,get(query, "public", string(playlist.public)))
catch e
isa(e, ArgumentError) ? false : @error e
end
if wantpublic
canmakepublic(req[:login][:user]) || return unuthorized()
playlist.public = true
else
playlist.public = false
end
playlist.name = get(query, "name", playlist.name)
playlist.comment = get(query, "comment", playlist.comment)
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 = Beets.songs();
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
saveplaylists()
@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")
m = findfirst(p -> p.uuid == id, user_playlists)
m === nothing && return not_found("id")
if !canedit(req[:login][:user], user_playlists[m])
return unuthorized()
end
deleteat!(user_playlists, m)
saveplaylists()
@subsonic(nothing)
end
function getUser(req)
(xdoc, xroot) = subsonic()
push!(xroot, User(req[:login][:name]))
return subsonic_return(xroot)
end
const cover_cache = Dict{String, Vector{UInt8}}()
const song_cache = Dict{Tuple{String,Int,String}, Vector{UInt8}}()
# Media retriveal
"Returns a cover art image."
function getCoverArt(req::Dict)
global cover_cache
query = HTTP.URIs.queryparams(req[:query])
id = get(query, "id", "")
isempty(id) && return missing_parameter("id")
n = findfirst(a -> a.uuid == id, Beets.albums)
(n === nothing || isempty(Beets.albums[n].cover)) && return not_found("id")
if Beets.albums[n].cover in keys(cover_cache)
data = cover_cache[Beets.albums[n].cover]
else
io = IOBuffer()
p = run(pipeline(`convert $(Beets.albums[n].cover) -resize 200x200 png:-`,
stderr=devnull, stdout=io), wait = true)
data = take!(io)
cover_cache[Beets.albums[n].cover] = data
end
headers = Dict{String,String}()
headers = Dict{String,String}()
suffix = "png"
mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : "application/octet-stream"
headers["Content-Type"] = mime
headers["Content-Length"] = string(length(data))
return Dict(:body => data,
:headers => headers,
:file => join([split(basename(Beets.albums[n].cover), '.')[1:end-1], ".png"],""))
end
function giveconverted(file, bitrate, format)
global song_cache
k = (file, bitrate, format)
if k in keys(song_cache)
@info "Using cached"
data = song_cache[k]
else
@info "Adding song to cache"
iodata = convert(file, bitrate = bitrate, format = format)
data = take!(iodata)
try
song_cache[k] = data
catch e
@warn e
end
end
headers = Dict{String,String}()
suffix = format
mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] :
"application/octet-stream"
headers["Content-Type"] = mime
headers["Content-Length"] = string(length(data))
# headers["Transfer-Encoding"] = "chunked"
return Dict(:body => data,
:headers => headers,
:file => join([split(basename(file), '.')[1:end-1],
".oga"],""))
end
function convert(infile; bitrate = 64, format = "oga")
global ffmpeg_threads
io = IOBuffer()
p = run(pipeline(`ffmpeg -i $infile -y -c:a libvorbis -b:a $(bitrate)k -threads $(ffmpeg_threads) -f $format pipe:1`,
stderr=devnull, stdout=io), wait = true)
io
end
canstream(u::User) = u.stream
"Streams a given media file."
function stream(req::Dict)
@show req
query = HTTP.URIs.queryparams(req[:query])
canstream(req[:login][:user]) || return unuthorized()
id = get(query, "id", "")
isempty(id) && return missing_parameter("id")
bitrate = try parse(Int, get(query, "maxBitRate", "0"))
catch e
isa(e, ArgumentError) && 0
@error e
end
# Ogg is not compativle with lower bitrates. Use something else?
bitrate = (bitrate != 0 && bitrate < 64) ? 64 : bitrate
format = get(query, "format", "oga")
songs = Beets.songs()
m = findfirst(x -> (x.uuid == id), songs)
m === nothing && return not_found("id")
output = (bitrate == 0) ? sendfile(songs[m].path) :
giveconverted(songs[m].path, bitrate, format)
return output
end
function sendfile(path; suffix = nothing)
isfile(path) || return Dict{String,String}(:body => "Not Found")
suffix = suffix == nothing ? lowercase(split(path, '.')[end]) : suffix
headers = Dict{String,String}()
mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] :
"application/octet-stream"
headers["Content-Type"] = mime
headers["Content-Length"] = string(filesize(path))
return Dict(:body => read(path),
:file => basename(path),
:headers => headers)
end
canread(u::User, p::Playlist) = p.public ||
(u.admin || p.owner == u.name) ||
u in p.allowed
function canedit(u::User, p::Playlist)
@show p.owner
@show u.name
@show u.admin
(p.owner == u.name) || u.admin
end
canmakepublic(u::User) = u.playlist