""" Implementation of Subsonic APIs: http://www.subsonic.org/pages/api.jsp """ import Humanize: datasize "Subsonic API compatible version" const apiversion = "4.5" const domain = "nixo.xyz" const ffmpeg_threads = 0 """ Helper function that prepares a subsonic response. """ function subsonic(; version = apiversion, 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() 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 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 # TODO: add getStarred2 function getAlbumList(req::Dict) query = HTTP.URIs.queryparams(req[:query]) albumtype = get(query, "type", "") isempty(albumtype) && return missing_parameter("type") # The number of albums to return. Max 500. size = abs(min(parse(Int, get(query, "size", "500")), 500)) offset = parse(Int, get(query, "offset", "0")) offset = min(max(abs(offset), 0) + 1, length(Beets.albums)) tot = length(Beets.albums) # Sort # random, newest, highest, frequent, recent # alphabeticalByName, alphabeticalByArtist # starred, byYear, byGenre asked_range = offset:min(tot,min(offset+size,tot)) if albumtype == "newest" albums = sort!(Beets.albums, by = x -> x.added, rev = true) albums = albums[asked_range] elseif albumtype == "random" albums = Beets.albums[Random.randperm(length(Beets.albums))][asked_range] elseif albumtype == "alphabeticalByName" albums = sort!(Beets.albums, by = x -> x.title) albums = albums[asked_range] elseif albumtype == "byGenre" # Required genre = get(query, "genre", missing) isempty(genre) && return missing_parameter("genre") genre = strip(genre) albums = filter(x -> begin length(x.songs) > 0 ? any(strip.(getfield.(x.songs,:genre)) .== genre) : false end, Beets.albums) # @show length(albums) else albums = Beets.albums end (xdoc, xroot) = subsonic() list = new_child(xroot, "albumList") push!.(Ref(list), albums) return subsonic_return(xdoc) end getAlbumList2(req::Dict) = getAlbumList(req) 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 function getLyrics(req) # I really hate those API # artist No The artist name. # title No The song title. # So one can look for a lyric without artist and title? what if we have many results? # Why was not ok to use the id? query = HTTP.URIs.queryparams(req[:query]) artist = unescape(get(query, "artist", "")) title = unescape(get(query, "title", "")) if isempty(artist) && isempty(album) return missing_parameter("At least title or artist is required") end if isempty(title) # Should I return all lyrics for this artist? I refuse to do so return missing_parameter("give me a title too, please") end artists = Beets.artists() n = findall(x -> x.artist.name == artist, Beets.albums) for a in n for s in Beets.albums[a].songs if s.title == title (xdoc, xroot) = subsonic() el = new_child(xroot, "lyrics") set_attribute(el, "title", title) set_attribute(el, "artist", artist) set_content(el, s.lyrics) return subsonic_return(xdoc) end end end return not_found() end unescape(s::AbstractString) = replace(s, '+' => ' ') 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(); # Filter genre = ismissing(genre) ? missing : strip(unescape(genre)) !ismissing(genre) && filter!(x -> strip(x.genre) == genre, songs) !ismissing(fromYear) && filter!(x -> x.year > fromYear, songs) !ismissing(toYear) && filter!(x -> x.year < toYear, songs) # Randomize songs = songs[Random.randperm(length(songs))]; # Take size songs = length(songs) > size ? songs[1:size] : songs; # Create output (xdoc, xroot) = subsonic() list = new_child(xroot, "randomSongs") for song in songs x = push!(list, song) alb = Beets.album(song) set_attribute(x, "artist", alb.artist.name) set_attribute(x, "coverArt", alb.uuid) set_attribute(x, "album", alb.title) end return subsonic_return(xdoc) end function getRandomSongs(req) query = HTTP.URIs.queryparams(req[:query]) # @show query # name Required Default Notes # size No 10 The maximum number of songs to return. Max 500. size = min(500,parse(Int, get(query, "size", "10"))) # 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 getStarred(req; n = "") global user_stars starred = get(user_stars, req[:login][:name], Star[]) (xdoc, xroot) = subsonic() results = new_child(xroot, "starred$n") for el in starred x = push!(results, el.item) set_attribute(x, "starred", el.starred) if isa(el.item, Beets.Song) alb = Beets.album(el.item) set_attribute(x, "artist", alb.artist.name) set_attribute(x, "coverArt", alb.uuid) set_attribute(x, "album", alb.title) end end return subsonic_return(xdoc) end getStarred2(req) = getStarred(req, n = "2") function getids(query) res = Dict{Symbol,Union{Nothing,AbstractString}}( :id => "", :albumId => "", :artistId => "") res[:id] = get(query, "id", nothing) res[:id] != nothing && return res res[:albumId] = get(query, "albumId", nothing) res[:albumId] != nothing && return res res[:artistId] = get(query, "artistId", nothing) return res end function getanybyid(ids) res = nothing if ids[:id] !== nothing res = Beets.songbyid(ids[:id]) res !== nothing && return res end if ids[:albumId] !== nothing albums = Beets.albums m = findfirst(x -> (x.uuid == ids[:albumId]), albums) if m !== nothing res = albums[m] return res end end if ids[:artistId] !== nothing artists = Beets.artists() m = findfirst(x -> (x.uuid == ids[:artistId]), artists) if m !== nothing res = artists[m] return res end end nothing end function star(req) global user_stars query = HTTP.URIs.queryparams(req[:query]) ids = getids(query) any(values(ids) .!= nothing) || return missing_parameter("either id, artistId or albumId are required") item = getanybyid(ids) item == false && return not_found() starred = get(user_stars, req[:login][:name], Star[]) m = findfirst(x -> x.item == item, starred) # Alredy added m != nothing && return @subsonic(nothing) push!(starred, Star(item, Dates.now())) user_stars[req[:login][:name]] = starred savestarred() @subsonic(nothing) end function unstar(req) global user_stars query = HTTP.URIs.queryparams(req[:query]) ids = getids(query) any(values(ids) .!= nothing) || return missing_parameter("either id, artistId or albumId are required") item = getanybyid(ids) item == false && not_found() starred = get(user_stars, req[:login][:name], Star[]) # @show length(starred[1].item.uuid) m = findfirst(s -> s.item.uuid == item.uuid, starred) m === nothing && return not_found() deleteat!(starred, m) user_stars[req[:login][:name]] = starred savestarred() @subsonic(nothing) end function setRating(req) global user_ratings query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", nothing) id === nothing && return not_found("id") rating = get(query, "rating", "") isempty(rating) && return missing_parameter("rating") rating = parse(Int, rating) item = Beets.songbyid(id) if item === nothing item = Beets.album(id) end if item === nothing item = Beets.artistbyid(id) end item === nothing && return not_found("id") ratings = get(user_ratings, req[:login][:name], Rating[]) if rating == 0 @info "Removing" # remove m = findfirst(x -> x.item.uuid == item.uuid, ratings) m === nothing && return not_found("id") deleteat!(ratings, m) else rating = max(min(rating, 5), 1) # update or add m = findfirst(x -> x.item == item, ratings) if m === nothing push!(ratings, Rating(item, rating)) else ratings[m].rating = rating end end user_ratings[req[:login][:name]] = ratings saveratings() @subsonic(nothing) end function extend(d::Dict, el::Pair) if el[1] in keys(d) push!(d[el[1]], el[2]) else d[el[1]] = [el[2]] end d end function savePlayQueue(req) @info "savePlayQueue" global user_playqueue if ! (:data in keys(req)) return @subsonic(nothing) end data = String(req[:data]) req[:data] = Dict{String,Any}() x = map(x -> let d = split(x, '='); (d[1] => d[2]) end, split(data, '&')) map(x -> extend(req[:data], x), x) # @show req[:data] user_playqueue[req[:login][:name]] = string.(req[:data]["id"]) if "current" in keys(req[:data]) # TODO: increase play count? end return @subsonic(nothing) end function getPlayQueue(req) @info "getPlayQueue" global user_playqueue (req[:login][:name] in keys(user_playqueue)) || return @subsonic(nothing) (xdoc, xroot) = subsonic() queue = new_child(xroot, "playQueue") # current="133" position="45000" username="admin" changed="2015-02-18T15:22:22.825Z" changedBy="android" set_attributes(queue, [("current", "0"), ("position", "0"), ("username", req[:login][:name]), ("changed", "0"), ("changedBy", "FIXME") ]) for id in user_playqueue[req[:login][:name]] # push item end return subsonic_return(xdoc) end function debug(req) if :data in keys(req) req[:data] = String(req[:data]) end # @show req return JlSonic.@subsonic(nothing) 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::AbstractString) # nq = replace(lowercase(q), "*" => ".*") nq = lowercase(q) nq = string(".*", nq, ".*") return Regex(nq) end # Deprecated but used by dsub function search(req; dlalbum = println, dlall = println) query = HTTP.URIs.queryparams(req[:query]) artist = get(query, "artist", "") album= get(query, "album", "") title = get(query, "title", "") any = get(query, "any", "") count = get(query, "count", "20") # Used by dsub, but not in the specifications songCount = get(query, "songCount", "20") offset = get(query, "offset", "0") newerThan = get(query, "newerThan", "") end function search3(req; dlalbum = println, dlall = println) query = HTTP.URIs.queryparams(req[:query]) q = get(query, "query", "") q = isempty(q) ? get(query, "any", "") : q isempty(q) && return missing_parameter("query") length(q) < 3 && return missing_parameter("query should be at least 3 char long") if length(q) > 2 && q[end-1:end] == "!a" && req[:login][:user].upload @info "Downloading single album" dlalbum(string(strip(q[1:end-2]))) return @subsonic(nothing) elseif length(q) > 2 && q[end-1:end] == "!d" && req[:login][:user].upload @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 = map(x -> makequery(x), split.(unescape(string(q)), ' ')) # @show k matchingartists = Beets.artists() filter!(a -> all(Base.match.(k, lowercase(a.name)) .!== nothing), matchingartists) matchingalbums = deepcopy(Beets.albums) filter!(a -> all(Base.match.(k, lowercase(a.title)) .!== nothing), matchingalbums) matchingsongs = Beets.songs() filter!(s -> all(Base.match.(k, lowercase(s.title)) .!== nothing), matchingsongs) matchingsongs = length(matchingsongs) > songCount ? matchingsongs[1:songCount] : matchingsongs # 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) 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() 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 get(::Type{Playlist}, u::User, id::AbstractString)::Union{Nothing,Playlist} = get(Playlist, u, PlaylistUUID(id)) function get(::Type{Playlist}, u::User, id::PlaylistUUID)::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 unauthorized() # 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 unauthorized() playlist.public = true else playlist.public = false end playlist.name = get(query, "name", playlist.name) playlist.comment = unescape(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 == PlaylistUUID(id), user_playlists) m === nothing && return not_found("id") if !canedit(req[:login][:user], user_playlists[m]) return unauthorized() end deleteat!(user_playlists, m) saveplaylists() @subsonic(nothing) end function scrobble(req) # @show req @subsonic(nothing) end function getUser(req) global users m = findfirst(x -> x.name == req[:login][:name], users) m === nothing && return not_found() (xdoc, xroot) = subsonic() push!(xroot, users[m]) return subsonic_return(xroot) end function getUsers(req) req[:login][:user].admin || return unauthorized() global users (xdoc, xroot) = subsonic() usersXML = new_child(xroot, "users") for user in users push!(usersXML, user) end 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, config) global cover_cache query = HTTP.URIs.queryparams(req[:query]) px = get(query, "size", "") px = isempty(px) ? config[:cover][:size] : parse(Int, px) id = get(query, "id", "") isempty(id) && return missing_parameter("id") n = findfirst(a -> a.uuid == id, Beets.albums) n === nothing && return not_found("id") isempty(Beets.albums[n].cover) && return not_found("album has no cover art") 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 $(px)x$(px) 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; stream = false) global song_cache k = (file, bitrate, format) if k in keys(song_cache) @info "Using cached" data = song_cache[k] else let cache_size = Base.summarysize(song_cache) @info "Adding song ($file) to cache, cache size is $(datasize(cache_size))" end 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 if stream mime = "application/octet-stream .$(format)" else mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : "application/octet-stream" end 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() # ar is required to convert 192kHz audio files to ogg p = run(pipeline(`ffmpeg -i $infile -y -c:a libvorbis -b:a $(bitrate)k -threads $(ffmpeg_threads) -ar 44100 -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) query = HTTP.URIs.queryparams(req[:query]) canstream(req[:login][:user]) || return unauthorized() 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 canedit(u::User, p::Playlist) = (p.owner == u.name) || u.admin canmakepublic(u::User) = u.playlist function getShares(req) global shared share = filter(s -> s.username == req[:login][:name], shared) (xdoc, xroot) = subsonic() s = new_child(xroot, "shares") # @show share try push!.(Ref(s), share) catch x @show x end @show xroot return subsonic_return(xroot) end # createShare # id Yes ID of a song, album or video to share. Use one id parameter for each entry to share. # description No A user-defined description that will be displayed to people visiting the shared media. # expires No The time at which the share expires. Given as milliseconds since 1970. # http://www.subsonic.org/pages/inc/api/examples/shares_example_1.xml function createShare(req) global shared # check permissions req[:login][:user].share || return unauthorized() query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", "") isempty(id) && return missing_parameter("id") description = unescape(get(query, "description", "You have been shared this file")) expires = get(query, "expires", "") e = if isempty(expires) Dates.now() + Dates.Day(7) else try Dates.unix2datetime(parse(Int, expires) / 1000) catch x @show isa(x, ArgumentError) @show x return missing_parameter("id") end end item = getanybyid(Dict(:id => id, :artistId => id, :albumId => id)) item === nothing && not_found() newshare = Share(item, req[:login][:name], description, expires = e) push!(shared, newshare) saveshared() (xdoc, xroot) = subsonic() s = new_child(xroot, "shares") push!(s, newshare) return subsonic_return(xroot) end expired(s::Share) = Dates.now() > s.expires function updateShare(req) global shared query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", "") isempty(id) && return missing_parameter("id") id = try parse(UInt32, id) catch e @show isa(e, ArgumentError) @show e return missing_parameter("id") end m = findfirst(x -> x.id == id, shared) m === nothing && return not_found("id") shared[m].username == req[:login][:name] || return unauthorized() shared[m].description = get(query, "description", shared[m].description) expires = get(query, "expires", "") e = if isempty(expires) Dates.now() + Dates.Day(7) else try Dates.unix2datetime(parse(Int, expires) / 1000) catch x @show isa(x, ArgumentError) @show x return missing_parameter("id") end end shared[m].expires = e if expired(shared[m]) deleteat!(shared, m) saveshared() @subsonic(nothing) end saveshared() (xdoc, xroot) = subsonic() s = new_child(xroot, "shares") push!(s, shared[m]) return subsonic_return(xroot) end function deleteShare(req) global shared query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", "") isempty(id) && return missing_parameter("id") id = parse(UInt32, id) m = findfirst(x -> x.id == id, shared) m === nothing && return not_found("id") shared[m].username == req[:login][:name] || return unauthorized() # Delete it deleteat!(shared, m) saveshared() @subsonic(nothing) end function updateUser(req) @show req # username Yes The name of the user. # password No The password of the user, either in clear text of hex-encoded (see above). # email No The email address of the user. # ldapAuthenticated No Whether the user is authenicated in LDAP. # adminRole No Whether the user is administrator. # settingsRole No Whether the user is allowed to change personal settings and password. # streamRole No Whether the user is allowed to play files. # jukeboxRole No Whether the user is allowed to play files in jukebox mode. # downloadRole No Whether the user is allowed to download files. # uploadRole No Whether the user is allowed to upload files. # coverArtRole No Whether the user is allowed to change cover art and tags. # commentRole No Whether the user is allowed to create and edit comments and ratings. # podcastRole No Whether the user is allowed to administrate Podcasts. # shareRole No Whether the user is allowed to share files with anyone. # videoConversionRole No false (Since 1.15.0) Whether the user is allowed to start video conversions. # musicFolderId No (Since 1.12.0) IDs of the music folders the user is allowed access to. Include the parameter once for each folder. # maxBitRate No (Since 1.13.0) The maximum bit rate (in Kbps) for the user. Audio streams of higher bit rates are automatically downsampled to this bit rate. Legal values: 0 (no limit), 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320. @subsonic(nothing) end