From d180c207d8ffcd07cf3b67baa10c4b8c4aaeaf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Balzarotti?= Date: Wed, 22 Jan 2020 18:42:29 +0100 Subject: [PATCH] allow shares --- JlSonic/JlSonic.jl | 18 +- JlSonic/api.jl | 567 ++++++++++++++++++++++++++++++++++++----- JlSonic/beet2xml.jl | 66 ++++- JlSonic/beethelpers.jl | 74 +++++- JlSonic/errors.jl | 11 +- JlSonic/types.jl | 68 ++++- consistency.jl | 12 +- router.jl | 110 +++++++- server.jl | 47 ++-- 9 files changed, 859 insertions(+), 114 deletions(-) diff --git a/JlSonic/JlSonic.jl b/JlSonic/JlSonic.jl index 22b95e6..79d2531 100644 --- a/JlSonic/JlSonic.jl +++ b/JlSonic/JlSonic.jl @@ -6,6 +6,7 @@ using LightXML import UUIDs import HTTP using JSON2 +import Dates # # Playlist cover art support # The idea is to sum all the album arts in some way. But it's easier to get one random @@ -17,28 +18,37 @@ export Playlist, Album, Artist const domain = "nixo.xyz" const users = User[] const user_playlists = Vector{Playlist}() +# user => share +const shared = Vector{Share}() + +# map username => (starred element, starred date) +user_stars = Dict{String,Vector{Star}}() +user_ratings = Dict{String, Vector{Rating}}() +user_playqueue = Dict{String, Vector{String}}() include("api.jl") export ping, getLicense, # Browsing getMusicFolders, # getIndexes, - getMusicDirectory, - getGenres, getArtists, getArtist, getAlbum, getSong, + getMusicDirectory, getShares, createShare, updateShare, deleteShare, + getGenres, getArtists, getArtist, getAlbum, getSong, getLyrics, # Album/song list getAlbumList, getAlbumList2, getRandomSongs, getSongsByGenre, getNowPlaying, getStarred, getStarred2, + star, unstar, setRating, # Searching search3, # Playlists getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, + savePlayQueue, # Media retrieval stream, # download, hls, getCaptions, getCoverArt, # getLyrics, getAvatar, + scrobble, # User management - getUser # getUsers, createUser, updateUser, deleteUser, changePassword - + getUser, getUsers , createUser, updateUser, deleteUser, changePassword include("errors.jl") export auth_failed diff --git a/JlSonic/api.jl b/JlSonic/api.jl index 824603d..8be20be 100644 --- a/JlSonic/api.jl +++ b/JlSonic/api.jl @@ -2,14 +2,16 @@ Implementation of Subsonic APIs: http://www.subsonic.org/pages/api.jsp """ +import Humanize: datasize + "Subsonic API compatible version" -const apiversion = "1.16.1" +const apiversion = "4.5" const domain = "nixo.xyz" const ffmpeg_threads = 0 """ Helper function that prepares a subsonic response. """ -function subsonic(; version = "1.10.1", status = "ok") +function subsonic(; version = apiversion, status = "ok") xdoc = XMLDocument() xroot = create_root(xdoc, "subsonic-response") set_attribute(xroot, "xmlns", "http://subsonic.org/restapi") @@ -67,7 +69,7 @@ function getMusicDirectory(req) query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", "") isempty(id) && return missing_parameter() - (xdoc, xroot) = subsonic(version = "1.0.0") + (xdoc, xroot) = subsonic() directory = new_child(xroot, "directory") # We simulate directory listing. Root directory has id == # 1. Other directories are identified by uuids @@ -92,7 +94,6 @@ function getMusicDirectory(req) artistmatch = findfirst(a -> a.uuid == id, artists) albums = Beets.albums; if artistmatch != nothing - @show id artist = artists[artistmatch] set_attributes(directory, [("id", artist.uuid), @@ -226,16 +227,51 @@ function getAlbum(albumid) 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") - @subsonic begin - list = new_child(xroot, "albumList") - push!.(Ref(list), Beets.albums) + # 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", "") @@ -249,6 +285,41 @@ function getSong(req) 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, @@ -256,31 +327,34 @@ function getRandomSongs(; size = 10, toYear::Union{Missing,Int} = missing, musicFolderId::Union{Missing,String} = missing) songs = Beets.songs(); - # Randomize - songs = songs[Random.randperm(length(songs))]; # Filter - !ismissing(genre) && filter!(x -> strip(x.genre) == strip(genre), songs) + 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") - albums = Beets.albums; for song in songs - album = filter(x -> song in x.songs, albums) |> first - push!(list, (song, album)) + 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 = parse(Int, get(query, "size", "10")) - size = size > 500 ? 500 : size + 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. @@ -294,28 +368,234 @@ function getRandomSongs(req) 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::String) - nq = replace(lowercase(q), "*" => ".*") - nq = string(".*", nq) +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") - if req[:login][:user].upload && length(q) > 2 && q[end-1:end] == "!a" + 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 req[:login][:user].upload && length(q) > 2 && q[end-1:end] == "!d" + 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) @@ -335,18 +615,15 @@ function search3(req; dlalbum = println, dlall = println) # 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)) + k = map(x -> makequery(x), split.(unescape(string(q)), ' ')) + # @show k matchingartists = Beets.artists() - filter!(a -> Base.match(k, lowercase(a.name)) !== nothing, matchingartists) - albums = Beets.albums; - matchingalbums = filter(a -> Base.match(k, lowercase(a.title)) !== nothing, - albums) - matchingsongs = Tuple{Beets.Song,Beets.Album}[] - for album in albums, song in album.songs - if Base.match(k, lowercase(song.title)) !== nothing - push!(matchingsongs, (song, album)) - end - end + 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 @@ -386,7 +663,6 @@ function createPlaylist(req) name = name # cover = ??? ) push!(playlist, song) - @show "THERE" else return missing_parameter("either name or playlistId") end @@ -402,7 +678,7 @@ end function getPlaylists(req) global user_playlists # FIXME: add support for admin (ask other user's playlists, v 1.8.0) - (xdoc, xroot) = subsonic(version = "1.0.0") + (xdoc, xroot) = subsonic() playlistsXML = new_child(xroot, "playlists") for playlist in sort(filter(p -> canread(req[:login][:user], p), user_playlists), @@ -413,11 +689,14 @@ function getPlaylists(req) end import Base.get -function get(::Type{Playlist}, u::User, id::AbstractString)::Union{Nothing,Playlist} +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] + m === nothing ? nothing : playlists[m] end "Returns a listing of files in a saved playlist." @@ -427,7 +706,7 @@ function getPlaylist(req) id = get(query, "id", "") isempty(id) && return missing_parameter("id") playlist = get(Playlist, req[:login][:user], id) - playlist == nothing && return not_found("id") + playlist === nothing && return not_found("id") (xdoc, xroot) = subsonic() append!(xroot, playlist) return subsonic_return(xroot) @@ -440,10 +719,10 @@ function updatePlaylist(req) playlistId = get(query, "playlistId", "") isempty(playlistId) && return missing_parameter("playlistId") playlist = get(Playlist, req[:login][:user], playlistId) - playlist == nothing && return not_found("playlistId") + playlist === nothing && return not_found("playlistId") # Check ownership (if not allowed, should not even reach this (canread is false)) - canedit(req[:login][:user], playlist) || return unuthorized() + canedit(req[:login][:user], playlist) || return unauthorized() # Want to make public. Is allowed? wantpublic = try parse(Bool,get(query, "public", string(playlist.public))) @@ -451,14 +730,14 @@ function updatePlaylist(req) isa(e, ArgumentError) ? false : @error e end if wantpublic - canmakepublic(req[:login][:user]) || return unuthorized() + canmakepublic(req[:login][:user]) || return unauthorized() playlist.public = true else playlist.public = false end playlist.name = get(query, "name", playlist.name) - playlist.comment = get(query, "comment", playlist.comment) + playlist.comment = unescape(get(query, "comment", playlist.comment)) songIdAdd = get(query, "songIdToAdd", "") # WTF by the index!? IndexToRemove = get(query, "songIndexToRemove", "") @@ -486,10 +765,10 @@ function deletePlaylist(req) query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", "") isempty(id) && return missing_parameter("id") - m = findfirst(p -> p.uuid == id, user_playlists) + m = findfirst(p -> p.uuid == PlaylistUUID(id), user_playlists) m === nothing && return not_found("id") if !canedit(req[:login][:user], user_playlists[m]) - return unuthorized() + return unauthorized() end deleteat!(user_playlists, m) @@ -498,9 +777,28 @@ function deletePlaylist(req) @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, User(req[:login][:name])) + 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 @@ -509,23 +807,26 @@ const song_cache = Dict{Tuple{String,Int,String}, Vector{UInt8}}() # Media retriveal "Returns a cover art image." -function getCoverArt(req::Dict) +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 || isempty(Beets.albums[n].cover)) && return not_found("id") + 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 200x200 png:-`, - stderr=devnull, stdout=io), wait = true) + 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 @@ -541,14 +842,16 @@ function getCoverArt(req::Dict) :file => join([split(basename(Beets.albums[n].cover), '.')[1:end-1], ".png"],"")) end -function giveconverted(file, bitrate, format) +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 - @info "Adding song to cache" + 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 @@ -560,8 +863,12 @@ function giveconverted(file, bitrate, format) headers = Dict{String,String}() suffix = format - mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : + 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" @@ -574,7 +881,8 @@ end function convert(infile; bitrate = 64, format = "oga") global ffmpeg_threads io = IOBuffer() - p = run(pipeline(`ffmpeg -i $infile -y -c:a libvorbis -b:a $(bitrate)k -threads $(ffmpeg_threads) -f $format pipe:1`, + # 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 @@ -582,10 +890,8 @@ end canstream(u::User) = u.stream "Streams a given media file." function stream(req::Dict) - @show req - query = HTTP.URIs.queryparams(req[:query]) - canstream(req[:login][:user]) || return unuthorized() + canstream(req[:login][:user]) || return unauthorized() id = get(query, "id", "") isempty(id) && return missing_parameter("id") @@ -610,7 +916,7 @@ end function sendfile(path; suffix = nothing) isfile(path) || return Dict{String,String}(:body => "Not Found") - suffix = suffix == nothing ? lowercase(split(path, '.')[end]) : suffix + suffix = suffix === nothing ? lowercase(split(path, '.')[end]) : suffix headers = Dict{String,String}() mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : "application/octet-stream" @@ -621,13 +927,142 @@ function sendfile(path; suffix = nothing) :headers => headers) end -canread(u::User, p::Playlist) = p.public || - (u.admin || p.owner == u.name) || - u in p.allowed -function canedit(u::User, p::Playlist) - @show p.owner - @show u.name - @show u.admin - (p.owner == u.name) || u.admin -end +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 diff --git a/JlSonic/beet2xml.jl b/JlSonic/beet2xml.jl index 0eaa347..83f960c 100644 --- a/JlSonic/beet2xml.jl +++ b/JlSonic/beet2xml.jl @@ -10,43 +10,75 @@ Mux.mimetypes["m4a"] = "audio/x-m4a" function push!(root::XMLElement, p::Playlist) playlistXML = new_child(root, "playlist") set_attributes(playlistXML, [ - ("id", p.uuid), + ("id", convert(String, 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 |> Int |> string), + ("duration", reduce(+, + Beets.songbyid.(p.songs), + init = 0.0) |> floor |> Int |> string), ("created", ms2string(p.created)), ("coverArt", p.cover), ]) playlistXML end +# FIXME: save uuids in playlists and when importing them +# look for the correct song. metadata can change function append!(root::XMLElement, p::Playlist) playlistXML = push!(root, p) # Allowed users for al in p.allowed set_content(new_child(playlistXML, "allowedUser"), al) end - for song in p.songs + @info "OK" + for song in Beets.songbyid.(p.songs) + @show song entry = new_child(playlistXML, "entry") set_attributes(entry, props(song)) + album = Beets.album(song) + set_attribute(entry, "coverArt", album.uuid) try artist = Beets.artist(song) - n = artist == nothing ? artist.name : "" - set_attribute(entry, "artist", "") + n = artist != nothing ? artist.name : "" + set_attribute(entry, "artist", n) catch e @warn e + @show song.uuid end end playlistXML end -push!(p::Playlist, s::Song) = push!(p.songs, s) +# FIXME!! +function push!(root::XMLElement, s::Share) + shareXML = new_child(root, "share") + set_attributes(shareXML, [ + ("id", string(s.id)), + # FIXME: Don't hardcode + ("url", string("https://music.", domain, "/share/", s.uuid)), + ("description", s.description), + ("username", s.username), + ("created", ms2string(s.created)), + ("lastVisited", ismissing(s.lastvisit) ? "" : ms2string(s.lastvisit)), + ("expires", ms2string(s.expires)), + ("visitCount", string(s.count)) + ]) + if isa(s.item, Album) + push!.(Ref(shareXML), s.item.songs; element = "entry") + else + push!(shareXML, s.item; element = "entry") + end + shareXML +end -function push!(root::XMLElement, album::Beets.Album) - albumXML = new_child(root, "album") +push!(p::Playlist, s::Song) = push!(p.songs, SongUUID(s.uuid)) +push!(p::Playlist, s::SongUUID) = push!(p.songs, s) + +function push!(root::XMLElement, album::Beets.Album; element = "album") + albumXML = new_child(root, element) set_attributes(albumXML, [ ("id", album.uuid), ("name", album.title), @@ -91,8 +123,8 @@ function push!(root::XMLElement, artist::Beets.Artist) artistXML end -function push!(root::XMLElement, song::Beets.Song) - songXML = new_child(root, "song") +function push!(root::XMLElement, song::Beets.Song; element = "song") + songXML = new_child(root, element) suffix = lowercase(song.format) mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : suffix set_attributes(songXML, [ @@ -150,7 +182,7 @@ function props(song::Song) ("album", song.title), # ("artist", song.album.artist.name), ("isDir", "false"), - ("coverArt", song.uuid), + # ("coverArt", song.uuid), ("created", ms2string(song.added)), ("duration", string(floor(song.length) |> Int)), ("bitrate", string(song.bitrate)), @@ -165,6 +197,18 @@ function props(song::Song) ] end +function push!(root::XMLElement, songs::Vector{Beets.Song}) + for song in songs + album = Beets.album(song) + songXML = new_child(root, "song") + set_attributes(songXML, props(song)) + set_attribute(songXML, "artistId", album.artist.uuid) + set_attribute(songXML, "albumId", album.uuid) + set_attribute(songXML, "artist", album.artist.name) + set_attribute(songXML, "album", album.title) + end + root +end function push!(root::XMLElement, songs::Vector{Tuple{Beets.Song,Beets.Album}}) for (song, album) in songs diff --git a/JlSonic/beethelpers.jl b/JlSonic/beethelpers.jl index 0dc9a78..e6d6415 100644 --- a/JlSonic/beethelpers.jl +++ b/JlSonic/beethelpers.jl @@ -16,6 +16,14 @@ function loadplaylists(; file = playlistfile(Beets.confdir())) for p in ps try pl = JSON2.read(p, Playlist) + # TODO: Control verbosity + @info "Importing playlist ($(pl.owner)) $(convert(String, pl.uuid)), with $(length(pl.songs)) songs in it" + # Check if song uuid exits, else skip it (and emit a warning) + filteredsongs = filter(x -> Beets.songbyid(x) !== nothing, pl.songs) + if length(filteredsongs) != length(pl.songs) + pl.songs = filteredsongs + @warn "Failed to import some playlist's song" + end push!(user_playlists, pl) catch e @warn "Failed to read with error $e" @@ -24,14 +32,15 @@ function loadplaylists(; file = playlistfile(Beets.confdir())) end end -function saveusers(file = expanduser("~/.config/beets/users.jsonl")) +beetfile(f) = expanduser("~/.config/beets/$f") +function saveusers(file = beetfile("users.jsonl")) global users open(file, "w") do f write(f, join(JSON2.write.(users), "\n")) end end -function loadusers(; file = expanduser("~/.config/beets/users.jsonl")) +function loadusers(; file = beetfile("users.jsonl")) global users isfile(file) || touch(file) ps = JSON2.readlines(file) @@ -42,3 +51,64 @@ function loadusers(; file = expanduser("~/.config/beets/users.jsonl")) end end +function saveratings(; file = beetfile("ratings.json")) + global user_ratings + open(file, "w") do f + write(f, JSON2.write(user_ratings)) + end +end +function savestarred(; file = beetfile("starred.json")) + global user_stars + open(file, "w") do f + write(f, JSON2.write(user_stars)) + end +end + +function loadratings(; file = beetfile("ratings.json")) + global user_ratings + isfile(file) || begin touch(file); return end + content = read(file, String) + isempty(content) && return file + user_ratings = JSON2.read(content, typeof(user_ratings)) +end + +function loadstarred(; file = beetfile("starred.json")) + global user_stars + isfile(file) || begin touch(file); return end + content = read(file, String) + isempty(content) && return user_stars + user_stars = JSON2.read(content, typeof(user_stars)) +end + +function config_create(; + cover_px_size = 250, + disk_cache_size = 5 * 1024 * 1024, + memory_cache_size = 1 * 1024 * 1024, + ) + return Dict(:cover => Dict(:size => cover_px_size), + :cache => Dict( + :disk => Dict( + :size => disk_cache_size, + ), + :memory => Dict( + :size => memory_cache_size, + ) + )) +end + + +# TODO: those functions are perfect for a macro +function saveshared(; file = beetfile("shared.json")) + global shared + open(file, "w") do f + write(f, JSON2.write(shared)) + end +end +function loadshared(; file = beetfile("shared.json")) + global shared + isfile(file) || return + content = read(file, String) + empty!(shared) + push!.(shared, JSON2.read(content, typeof(shared))) +end + diff --git a/JlSonic/errors.jl b/JlSonic/errors.jl index 1e87787..aa218ab 100644 --- a/JlSonic/errors.jl +++ b/JlSonic/errors.jl @@ -8,14 +8,19 @@ 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) +upgrade_server(m) = + error(30, "$m. 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() = +unauthorized() = error(50, "User is not authorized for the given operation.", 401) +unauthorized(m) = + error(50, "User is not authorized for the given operation ($m).", 401) + not_found() = error(70, "The requested data was not found.", 404) not_found(n) = @@ -23,12 +28,12 @@ not_found(n) = # 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 + return Dict(:status => error_code, + :body => doc_str) end diff --git a/JlSonic/types.jl b/JlSonic/types.jl index 45cfec3..fad4c89 100644 --- a/JlSonic/types.jl +++ b/JlSonic/types.jl @@ -18,14 +18,37 @@ mutable struct User share::Bool end -mutable struct Playlist +# FIXME: add uuid format check +abstract type BeetUUID end +struct SongUUID <: BeetUUID uuid::String +end +struct ArtistUUID <: BeetUUID + uuid::String +end +struct AlbumUUID <: BeetUUID + uuid::String +end +struct PlaylistUUID <: BeetUUID + uuid::String +end + +convert(::Type{SongUUID}, s::String) = SongUUID(s) +convert(::Type{ArtistUUID}, s::String) = ArtistUUID(s) +convert(::Type{AlbumUUID}, s::String) = AlbumUUID(s) +convert(::Type{PlaylistUUID}, s::String) = PlaylistUUID(s) +convert(::Type{String}, s::BeetUUID) = s.uuid + +import Beets.songbyid +songbyid(id::SongUUID) = songbyid(convert(String, id)) + +mutable struct Playlist + uuid::PlaylistUUID name::String comment::String owner::String public::Bool - # FIXME: replace with uuids only (and check they exists when importing) - songs::Vector{Song} + songs::Vector{SongUUID} cover::String allowed::Vector{String} created::Dates.DateTime @@ -36,7 +59,7 @@ function Playlist(owner::String name = "New Playlist", comment = "", public = false, - songs = Song[], + songs = SongUUID[], cover = "", allowed = String[], creation = Dates.now()) @@ -51,3 +74,40 @@ function User(name::String) string(name, "@", domain), false, false, false, false, false, false, false, false, false) end + +mutable struct Rating + item::Union{Artist,Album,Song} + rating::Int +end + +mutable struct Star + item::Union{Artist,Album,Song} + starred::Dates.DateTime +end + +mutable struct Share + id::UInt32 + uuid::String + item::BeetUUID + username::String + description::String + created::Dates.DateTime + expires::Dates.DateTime + lastvisit::Union{Missing,Dates.DateTime} + count::Int +end + +function Share(item::BeetUUID, + username::AbstractString, + description::AbstractString; + expires = Dates.now() + Dates.Day(7)) + Share(Random.rand(UInt32), + Random.randstring(8), + item, + username, description, + # FIXME: do not hardcode + Dates.now(), expires, + missing, 0) +end +Share(i::BeetUUID, u::AbstractString, e::Dates.DateTime) = Share(i, u, ""; expires = e) +Share(i::BeetUUID, u::AbstractString) = Share(i, u, "") diff --git a/consistency.jl b/consistency.jl index 068f6be..b2e740e 100644 --- a/consistency.jl +++ b/consistency.jl @@ -25,10 +25,10 @@ end brokenartists(r) = map(x -> Beets.artist(x) , r.broken) |> unique brokenalbums(r) = map(x -> Beets.album(x) , r.broken) |> unique -l() = map(a -> (artist = a.artist.name, title = a.title), brokenalbums(format_check(Beets.songs()))) -m() = map(a -> (artist = a.artist.name, title = a.title), brokenalbums(existing_check(Beets.songs()))) -n() = map(a -> (artist = a.artist.name, title = a.title), brokenalbums(path_check(Beets.songs()))) +l1() = map(a -> (artist = a.artist.name, title = a.title), brokenalbums(format_check(Beets.songs()))) +m1() = map(a -> (artist = a.artist.name, title = a.title), brokenalbums(existing_check(Beets.songs()))) +n1() = map(a -> (artist = a.artist.name, title = a.title), brokenalbums(path_check(Beets.songs()))) -l() |> DataFrame |> d -> FileIO.save("format.csv", d) -m() |> DataFrame |> d -> FileIO.save("missing.csv", d) -n() |> DataFrame |> d -> FileIO.save("wrong_path.csv", d) +l1() |> DataFrame |> d -> FileIO.save("format.csv", d) +m1() |> DataFrame |> d -> FileIO.save("missing.csv", d) +n1() |> DataFrame |> d -> FileIO.save("wrong_path.csv", d) diff --git a/router.jl b/router.jl index 2313e6d..fb75e4f 100644 --- a/router.jl +++ b/router.jl @@ -6,6 +6,12 @@ function restpath!(target, req) end restp(p, app...) = branch(req -> restpath!(p, req), app...) +function share!(req) + return (length(req[:path]) == 2) && + (req[:path][1] == "share") +end +share(app...) = branch(req -> share!(req), app...) + function torrentdl(query::AbstractString) global rpc, me TransmissionRPC.getauth(rpc) @@ -18,7 +24,7 @@ function albumdl(query::AbstractString) global rpc, me TransmissionRPC.getauth(rpc) todl = RuTrackers.search(me, query) - @show todl +# @show todl lossless = RuTrackers.islossless.(todl) discog = RuTrackers.isdiscography.(todl) m = findfirst(lossless .& .!discog) @@ -26,22 +32,118 @@ function albumdl(query::AbstractString) TransmissionRPC.add(rpc, RuTrackers.download(me, todl[m])) end +function showshare(req) + notfound = Dict(:status => 404, :body => "Not found") + length(req[:path]) != 2 && return notfound + el = findfirst(x -> x.uuid == req[:path][2], + JlSonic.shared) + el == nothing && return notfound + sh = JlSonic.shared[el] + if JlSonic.expired(JlSonic.shared[el]) + deleteat!(JlSonic.shared, el) + return notfound + end + sh.lastvisit = Dates.now() + query = split(req[:query], '/') + if query[1] == "dl" + sh.count += 1 + if isa(sh.item, Beets.Song) + return JlSonic.sendfile(sh.item.path) + else + if length(query) == 2 + el = parse(Int, query[2]) + if 0 < el <= length(sh.item.songs) + return JlSonic.sendfile(sh.item.songs[el].path) + end + return JlSonic.upgrade_server("Not found") + else + return JlSonic.upgrade_server("Wrong request?") + end + end + elseif query[1] == "stream" + sh.count += 1 + if isa(sh.item, Beets.Song) + return JlSonic.giveconverted(sh.item.path, 192, "oga"; stream = true) + else + if length(query) == 2 + el = parse(Int, query[2]) + if 0 < el <= length(sh.item.songs) + return JlSonic.giveconverted(sh.item.songs[el].path, 192, "oga"; stream = true) + end + return JlSonic.upgrade_server("Not found") + else + return JlSonic.upgrade_server("Not implemented") + end + end + end + list = [] + if isa(sh.item, Beets.Album) + push!(list, "It's an Album, here's the song list:") + for (n, s) in enumerate(sh.item.songs) + el = """ + stream $(s.title) + download $(s.title) + """ + push!(list, el) + end + else + push!(list, """ + stream + download + """) + end + # application/octet-stream + return """ + + + $(sh.description) + + User: $(sh.username) shared $(sh.item.title) with you!
+ Description: $(sh.description)
+ Expires: $(sh.expires)
+ Viewed: $(sh.count) times
+
+ $(join(list, "
")) + + """ +end + +config = JlSonic.config_create(disk_cache_size = 5 * 1024 * 1024, + memory_cache_size = 5 * 1024 * 1024) + dispatch = stack( # Browsing restp("getMusicFolders", _ -> getMusicFolders()), restp("getMusicDirectory", req -> getMusicDirectory(req)), + restp("getAlbumList2", req -> getAlbumList2(req)), restp("getAlbumList", req -> getAlbumList(req)), restp("getGenres", _ -> getGenres()), - restp("getArtists", _ -> getArtists()), +restp("getArtists", _ -> getArtists()), restp("getArtist", r -> getArtist(r)), restp("getAlbum", req -> getAlbum(req)), restp("getSong", req -> getSong(req)), + restp("getLyrics", req -> getLyrics(req)), + restp("getShares", req -> getShares(req)), + restp("createShare", req -> createShare(req)), + restp("updateShare", req -> updateShare(req)), + restp("deleteShare", req -> deleteShare(req)), # Album/song list restp("getRandomSongs", req -> getRandomSongs(req)), + restp("getStarred2", req -> getStarred2(req)), + restp("getStarred", req -> getStarred(req)), + restp("scrobble", req -> scrobble(req)), + restp("savePlayQueue", req -> savePlayQueue(req)), + restp("getPlayQueue", req -> getPlayQueue(req)), + restp("star", req -> star(req)), + restp("unstar", req -> unstar(req)), + restp("setRating", req -> setRating(req)), # Searching restp("search3", req -> search3(req; dlalbum = albumdl, dlall = torrentdl)), + restp("search", req -> search3(req; + dlalbum = albumdl, + dlall = torrentdl)), # Playlists restp("createPlaylist", req -> createPlaylist(req)), restp("getPlaylists", req -> getPlaylists(req)), @@ -49,10 +151,12 @@ dispatch = stack( restp("updatePlaylist", req -> updatePlaylist(req)), restp("deletePlaylist", req -> deletePlaylist(req)), # User management + restp("getUsers", req -> getUsers(req)), restp("getUser", req -> getUser(req)), + restp("updateUser", req -> updateUser(req)), # Media retrieval restp("stream", req -> stream(req)), - restp("getCoverArt", req -> getCoverArt(req)), + restp("getCoverArt", req -> getCoverArt(req, config)), # Media library scanning (can be used to check download status!) # getScanStatus startScan ) diff --git a/server.jl b/server.jl index 81637be..297a495 100644 --- a/server.jl +++ b/server.jl @@ -12,7 +12,7 @@ import TransmissionRPC import JSON # FIXME: replace with JSON2 serialization me = RuTrackers.RuTracker(read("rutracker.json", String) |> JSON.parse) -rpc = TransmissionRPC.Transmission(TransmissionRPC.Sockets.ip"192.168.1.3") +rpc = TransmissionRPC.Transmission(TransmissionRPC.Sockets.ip"192.168.1.4") Beets.update_albums() @@ -21,6 +21,9 @@ push!(LOAD_PATH, realpath("JlSonic")) using JlSonic JlSonic.loadplaylists() JlSonic.loadusers() +JlSonic.loadratings() +JlSonic.loadstarred() +JlSonic.loadshared() include("router.jl") include("login.jl") @@ -30,25 +33,38 @@ if isdefined(Main, :logfile) && isopen(logfile) close(logfile) end -logfile = open("requests.log", "a") +if !isdefined(:Main, :logfile) + logfile = open("requests.log", "a") +end function logger(app, req) global logfile - if isopen(logfile) + if !isopen(logfile) logfile = open("requests.log", "a") end - write(logfile, string(req, "\n")) - println(string("[", Dates.now(), "] ", req[:method], ": ", req[:path][end])) - #, " - ", req[:headers]["User-Agent"])) - return app(req) + ip = get(Dict(req[:headers]), "X-Real-IP", "0.0.0.0") + pt = length(req[:path]) != 0 ? req[:path][end] : "" + content = if req[:login][:login] + string("[", Dates.now(), "] ", + "(", req[:login][:name], ") ", + req[:method], ": ", pt, + " (", ip, ")") + else + string("[", Dates.now(), "] ", + "(no auth) ", + req[:method], ": ", pt) + end + write(logfile, string(content, "\n")) + @info content + return app(req) end function basiccatch(app, req) - try - app(req) - catch e - showerror(e, catch_backtrace()) - return d(:status => 500, :body => "failed") - end + try + app(req) + catch e + showerror(e, catch_backtrace()) + return d(:status => 500, :body => "failed") + end end defaults = stack(Mux.todict, basiccatch, Mux.splitquery, Mux.toresponse, Mux.assetserver, Mux.pkgfiles) @app sonic = ( @@ -56,8 +72,9 @@ defaults = stack(Mux.todict, basiccatch, Mux.splitquery, Mux.toresponse, Mux.ass # logger, restp("ping", _ -> ping()), restp("getLicense", _ -> getLicense()), - mux(logger, - sonic_login, + share(req -> showshare(req)), + mux(sonic_login, + logger, branch(req -> req[:login][:login], mux(dispatch, Mux.notfound())), respond(auth_failed())),