1069 lines
36 KiB
Julia
1069 lines
36 KiB
Julia
"""
|
|
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
|