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