""" 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