working
This commit is contained in:
commit
bd2826abde
|
@ -0,0 +1,41 @@
|
|||
__precompile__()
|
||||
module JlSonic
|
||||
|
||||
using Beets
|
||||
using LightXML
|
||||
import UUIDs
|
||||
import HTTP
|
||||
|
||||
const domain = "nixo.xyz"
|
||||
|
||||
include("types.jl")
|
||||
export Playlist, Album, Artist
|
||||
|
||||
include("api.jl")
|
||||
export ping, getLicense,
|
||||
# Browsing
|
||||
getMusicFolders, # getIndexes, getMusicDirectory,
|
||||
getGenres, getArtists, getArtist, getAlbum, getSong,
|
||||
# Album/song list
|
||||
getAlbumList, getAlbumList2, getRandomSongs,
|
||||
getSongsByGenre, getNowPlaying, getStarred, getStarred2,
|
||||
# Searching
|
||||
search3,
|
||||
# Playlists
|
||||
getPlaylists, getPlaylist, createPlaylist,
|
||||
updatePlaylist, deletePlaylist,
|
||||
# Media retrieval
|
||||
stream,
|
||||
# download, hls, getCaptions,
|
||||
getCoverArt, # getLyrics, getAvatar,
|
||||
# User management
|
||||
getUser # getUsers, createUser, updateUser, deleteUser, changePassword
|
||||
|
||||
|
||||
include("errors.jl")
|
||||
export auth_failed
|
||||
|
||||
include("beethelpers.jl")
|
||||
include("beet2xml.jl")
|
||||
|
||||
end # module JlSonic
|
|
@ -0,0 +1,429 @@
|
|||
"""
|
||||
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.(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
|
|
@ -0,0 +1,142 @@
|
|||
import Base.push!
|
||||
|
||||
function push!(root::XMLElement, p::Playlist)
|
||||
playlistXML = new_child(root, "playlist")
|
||||
set_attributes(playlistXML, [
|
||||
("id", p.uuid),
|
||||
("name", p.name),
|
||||
("comment", p.comment),
|
||||
("owner", p.owner),
|
||||
("public", string(p.public)),
|
||||
("songCount", string(length(p.songs))),
|
||||
("duration", reduce(+, p.songs, init = 0.0) |> floor |> string),
|
||||
("created", "FIXME"),
|
||||
("coverArt", p.cover),
|
||||
])
|
||||
playlistXML
|
||||
#=
|
||||
<allowedUser>sindre</allowedUser>
|
||||
<allowedUser>john</allowedUser>
|
||||
=#
|
||||
end
|
||||
|
||||
function append!(root::XMLElement, p::Playlist)
|
||||
playlistXML = push!(root, p)
|
||||
# FIXME: 1. allowed users
|
||||
for song in p.songs
|
||||
entry = new_child(playlistXML, "entry")
|
||||
set_attributes(entry, props(song))
|
||||
end
|
||||
playlistXML
|
||||
end
|
||||
|
||||
push!(p::Playlist, s::Song) = push!(p.songs, s)
|
||||
|
||||
function push!(root::XMLElement, album::Beets.Album)
|
||||
albumXML = new_child(root, "album")
|
||||
set_attributes(albumXML, [
|
||||
("name", album.title),
|
||||
("id", album.uuid),
|
||||
("name", album.artist.name),
|
||||
("artistId", album.artist.uuid),
|
||||
("artist", album.artist.name),
|
||||
# FIXME
|
||||
("created", "0"),
|
||||
("coverArt", album.uuid),
|
||||
("songs", "FIXME"),
|
||||
("songCount", string(length(album.songs))),
|
||||
("duration", string(sum([t.length for t in album.songs])))
|
||||
])
|
||||
albumXML
|
||||
end
|
||||
|
||||
function push!(root::XMLElement, artist::Beets.Artist)
|
||||
artistXML = new_child(root, "artist")
|
||||
set_attributes(artistXML, [
|
||||
("id", artist.uuid),
|
||||
("name", artist.name),
|
||||
("coverArt", artist.uuid),
|
||||
("albumCount", "0")
|
||||
])
|
||||
artistXML
|
||||
end
|
||||
|
||||
function push!(root::XMLElement, song_album::Tuple{Beets.Song,Beets.Album})
|
||||
songXML = new_child(root, "song")
|
||||
song, album = song_album
|
||||
set_attributes(songXML, [
|
||||
("id", song.uuid),
|
||||
# ("parent", album.artist.uuid), # Not clear
|
||||
("title", song.title),
|
||||
("album", album.title),
|
||||
("artist", album.artist.name),
|
||||
("isDir", "false"),
|
||||
("coverArt", album.uuid),
|
||||
("created", "FIXME"),
|
||||
("duration", string(floor(song.length) |> Int)),
|
||||
("bitrate", string(song.bitrate)),
|
||||
("size", string(filesize(song.path))),
|
||||
("suffix", lowercase(song.format)),
|
||||
## FIXME
|
||||
("contentType", "audio/flac"), # mpeg
|
||||
("isVideo", "false"),
|
||||
("path", relpath(song.path, Beets.musicdir())),
|
||||
("albumId", album.uuid),
|
||||
("artistId", album.artist.uuid),
|
||||
("type", "music")
|
||||
])
|
||||
root
|
||||
end
|
||||
|
||||
function props(song::Song)
|
||||
[
|
||||
("id", song.uuid),
|
||||
# ("parent", album.artist.uuid), # Not clear
|
||||
("title", song.title),
|
||||
("album", song.title),
|
||||
# ("artist", song.album.artist.name),
|
||||
("isDir", "false"),
|
||||
("coverArt", song.uuid),
|
||||
("created", "FIXME"),
|
||||
("duration", string(floor(song.length) |> Int)),
|
||||
("bitrate", string(song.bitrate)),
|
||||
("size", string(filesize(song.path))),
|
||||
("suffix", lowercase(song.format)),
|
||||
## FIXME
|
||||
("contentType", "audio/flac"), # mpeg
|
||||
("isVideo", "false"),
|
||||
("path", relpath(song.path, Beets.musicdir())),
|
||||
# ("albumId", song.album.uuid),
|
||||
# ("artistId", song.album.artist.uuid),
|
||||
("type", "music")
|
||||
]
|
||||
end
|
||||
|
||||
function push!(root::XMLElement, songs::Vector{Tuple{Beets.Song,Beets.Album}})
|
||||
for (song, album) in songs
|
||||
songXML = new_child(root, "song")
|
||||
set_attributes(songXML, props(song))
|
||||
end
|
||||
root
|
||||
end
|
||||
|
||||
function push!(root::XMLElement, user::User)
|
||||
userXML = new_child(root, "user")
|
||||
set_attributes(userXML,
|
||||
[
|
||||
("username", user.name)
|
||||
("email", user.email)
|
||||
("adminRole", string(user.admin))
|
||||
("scrobblingEnabled", "false")
|
||||
("settingsRole", string(user.settings))
|
||||
("downloadRole", string(user.download))
|
||||
("uploadRole", string(user.upload))
|
||||
("playlistRole", string(user.playlist))
|
||||
("coverArtRole", string(user.cover))
|
||||
("commentRole", string(user.comment))
|
||||
("podcastRole", "false")
|
||||
("streamRole", string(user.stream))
|
||||
("jukeboxRole", "false")
|
||||
("shareRole", string(user.share))
|
||||
])
|
||||
end
|
|
@ -0,0 +1,4 @@
|
|||
function allsongs()
|
||||
albums = [album.songs for album in Beets.getalbums()];
|
||||
songs = Iterators.flatten(albums) |> collect;
|
||||
end
|
|
@ -0,0 +1,34 @@
|
|||
import Mux
|
||||
# https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
||||
error() = error(0, "A generic error.", 500)
|
||||
missing_parameter() = error(10, "Required parameter is missing.", 400)
|
||||
missing_parameter(n) = error(10, "Required parameter ($n) is missing.", 400)
|
||||
|
||||
upgrade_client() =
|
||||
error(20, "Incompatible Subsonic REST protocol version. Client must upgrade.", 400)
|
||||
upgrade_server() =
|
||||
error(30, "Incompatible Subsonic REST protocol version. Server must upgrade.", 501)
|
||||
|
||||
auth_failed() =
|
||||
error(40, "Wrong username or password.", 401)
|
||||
ldap_unsupported() =
|
||||
error(41, "Token authentication not supported for LDAP users.", 501)
|
||||
unuthorized() =
|
||||
error(50, "User is not authorized for the given operation.", 401)
|
||||
|
||||
not_found() =
|
||||
error(70, "The requested data was not found.", 404)
|
||||
not_found(n) =
|
||||
error(70, "The requested data ($n) was not found.", 404)
|
||||
|
||||
# FIXME: to use the status, we need to take the request dict
|
||||
function error(code, message, error_code)
|
||||
Mux.status(error_code)
|
||||
(xdoc, xroot) = subsonic(version = "1.1.0", status = "failed")
|
||||
er = new_child(xroot, "error")
|
||||
set_attributes(er, [("code", string(code)),
|
||||
("message", string(message))])
|
||||
doc_str = string(xdoc)
|
||||
free(xdoc)
|
||||
return doc_str
|
||||
end
|
|
@ -0,0 +1,41 @@
|
|||
mutable struct Playlist
|
||||
uuid::String
|
||||
name::String
|
||||
comment::String
|
||||
owner::String
|
||||
public::Bool
|
||||
songs::Vector{Song}
|
||||
cover::String
|
||||
end
|
||||
|
||||
mutable struct User
|
||||
name::String
|
||||
email::String
|
||||
# scrobbling::Bool
|
||||
admin::Bool
|
||||
settings::Bool
|
||||
download::Bool
|
||||
upload::Bool
|
||||
cover::Bool
|
||||
playlist::Bool
|
||||
comment::Bool
|
||||
# podcast::Bool
|
||||
stream::Bool
|
||||
# jukebox::Bool
|
||||
share::Bool
|
||||
end
|
||||
|
||||
function Playlist(owner::String
|
||||
; uuid = string(UUIDs.uuid4()),
|
||||
name = "New Playlist",
|
||||
comment = "",
|
||||
public = false,
|
||||
songs = Song[],
|
||||
cover = "",)
|
||||
Playlist(uuid, name, comment, owner, public, songs, cover)
|
||||
end
|
||||
|
||||
function User(name::String)
|
||||
User(name, string(name, "@", domain),
|
||||
false, false, false, false, false, false, false, false, false)
|
||||
end
|
|
@ -0,0 +1,39 @@
|
|||
using MD5
|
||||
using HTTP
|
||||
function getlogin(app, req)
|
||||
query = HTTP.URIs.queryparams(req[:query])
|
||||
username = string(get(query, "u", ""))
|
||||
token = get(query, "t", "")
|
||||
salt = get(query, "s", "")
|
||||
password = get(query, "p", "")
|
||||
req[:login] = Dict(:name => username,
|
||||
:token => token,
|
||||
:salt => salt,
|
||||
:password => password,
|
||||
:login => false)
|
||||
return app(req)
|
||||
end
|
||||
|
||||
function checkpassword(app, req)
|
||||
# FIXME: do not hardcode the password here!
|
||||
password = "test"
|
||||
# @show bytes2hex(MD5.md5(string(password, salt)))
|
||||
# @show req[:login][:token]
|
||||
# @show req[:login][:salt]
|
||||
if !isempty(req[:login][:salt])
|
||||
if bytes2hex(MD5.md5(string(password, req[:login][:salt]))) ==
|
||||
req[:login][:token]
|
||||
req[:login][:login] = true
|
||||
end
|
||||
elseif !isempty(req[:login][:password])
|
||||
if startswith(req[:login][:password], "enc:")
|
||||
req[:login][:login] =
|
||||
String(hex2bytes(split(req[:login][:password], ":")[2])) == password
|
||||
else
|
||||
req[:login][:login] = password == req[:login][:password]
|
||||
end
|
||||
end
|
||||
return app(req)
|
||||
end
|
||||
|
||||
sonic_login = stack(getlogin, checkpassword)
|
|
@ -0,0 +1,36 @@
|
|||
function restpath!(target, req)
|
||||
@show req[:path]
|
||||
length(req[:path]) < 2 && return false
|
||||
return req[:path][1] == "rest" &&
|
||||
startswith(req[:path][2], target)
|
||||
end
|
||||
restp(p, app...) = branch(req -> restpath!(p, req), app...)
|
||||
|
||||
dispatch = stack(
|
||||
# Browsing
|
||||
restp("getMusicFolders", _ -> getMusicFolders()),
|
||||
restp("getMusicDirectory", req -> getmusicdirectory(req)),
|
||||
restp("getAlbumList", req -> getAlbumList(req)),
|
||||
restp("getGenres", _ -> getGenres()),
|
||||
restp("getArtists", _ -> getArtists()),
|
||||
restp("getArtist", r -> getArtist(r)),
|
||||
restp("getAlbum", req -> getAlbum(req)),
|
||||
restp("getSong", req -> getSong(req)),
|
||||
# Album/song list
|
||||
restp("getRandomSongs", req -> getRandomSongs(req)),
|
||||
# Searching
|
||||
restp("search3", req -> search3(req)),
|
||||
# Playlists
|
||||
restp("createPlaylist", req -> createPlaylist(req)),
|
||||
restp("getPlaylists", req -> getPlaylists(req)),
|
||||
restp("getPlaylist", req -> getPlaylist(req)),
|
||||
restp("updatePlaylist", req -> updatePlaylist(req)),
|
||||
restp("deletePlaylist", req -> deletePlaylist(req)),
|
||||
# User management
|
||||
restp("getUser", req -> getUser(req)),
|
||||
# Media retrieval
|
||||
restp("stream", req -> stream(req)),
|
||||
restp("getCoverArt", req -> getCoverArt(req)),
|
||||
# Media library scanning (can be used to check download status!)
|
||||
# getScanStatus startScan
|
||||
)
|
|
@ -0,0 +1,23 @@
|
|||
using Mux
|
||||
using HTTP
|
||||
using Revise
|
||||
|
||||
import Beets
|
||||
push!(LOAD_PATH, realpath("JlSonic"))
|
||||
using JlSonic
|
||||
|
||||
include("router.jl")
|
||||
include("login.jl")
|
||||
@app sonic = (
|
||||
Mux.defaults,
|
||||
restp("ping", _ -> ping()),
|
||||
restp("getLicense", _ -> getLicense()),
|
||||
mux(sonic_login,
|
||||
branch(req -> req[:login][:login],
|
||||
mux(dispatch, Mux.notfound())),
|
||||
respond(auth_failed())),
|
||||
)
|
||||
if !isdefined(Main, :started)
|
||||
serve(sonic)
|
||||
started = true
|
||||
end
|
Loading…
Reference in New Issue