diff --git a/JlSonic/JlSonic.jl b/JlSonic/JlSonic.jl index d1145bf..22b95e6 100644 --- a/JlSonic/JlSonic.jl +++ b/JlSonic/JlSonic.jl @@ -11,15 +11,18 @@ using JSON2 # The idea is to sum all the album arts in some way. But it's easier to get one random # using FileIO, Images -const domain = "nixo.xyz" - include("types.jl") export Playlist, Album, Artist +const domain = "nixo.xyz" +const users = User[] +const user_playlists = Vector{Playlist}() + include("api.jl") export ping, getLicense, # Browsing - getMusicFolders, # getIndexes, getMusicDirectory, + getMusicFolders, # getIndexes, + getMusicDirectory, getGenres, getArtists, getArtist, getAlbum, getSong, # Album/song list getAlbumList, getAlbumList2, getRandomSongs, @@ -42,5 +45,6 @@ export auth_failed include("beethelpers.jl") include("beet2xml.jl") +include("login.jl") end # module JlSonic diff --git a/JlSonic/api.jl b/JlSonic/api.jl index 9dac677..a9d2982 100644 --- a/JlSonic/api.jl +++ b/JlSonic/api.jl @@ -63,7 +63,88 @@ end # Implement: # getIndexes -# getMusicDirectory +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") + directory = new_child(xroot, "directory") + # We simulate directory listing. Root directory has id == + # 1. Other directories are identified by uuids + artists = Beets.getartists() + ## 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.getalbums(); + if artistmatch != nothing + @show id + 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() @@ -98,7 +179,7 @@ function getArtists() for index in string.(firstletters) indexXML = new_child(indexes, "index") set_attribute(indexXML, "name", index) - for artist in unique(filter(x -> startswith(x.name, index), artists)) + for artist in filter(x -> startswith(x.name, index), artists) artistXML = push!(indexXML, artist) end end @@ -109,7 +190,7 @@ end This method organizes music according to ID3 tags.""" function getArtist(id::String) - artists = Beets.getartists() + artists = Beets.artists() matching = findfirst(a -> a.uuid == id, artists) matching === nothing && return not_found("id") artist = artists[matching] @@ -264,8 +345,6 @@ function search3(req) 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 @@ -293,10 +372,10 @@ function createPlaylist(req) end elseif !isempty(name) playlist = Playlist(req[:login][:user].name, - name = name, - # cover = ??? + name = name # cover = ??? ) push!(playlist, song) + @show "THERE" else return missing_parameter("either name or playlistId") end @@ -323,11 +402,11 @@ function getPlaylists(req) end import Base.get -function get(::Type{Playlist}, u::User, id::AbstractString) +function get(::Type{Playlist}, u::User, id::AbstractString)::Union{Nothing,Playlist} global user_playlists - findfirst(p -> p.uuid == id, - filter(p -> canread(u, p), - 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." @@ -336,10 +415,10 @@ function getPlaylist(req) query = HTTP.URIs.queryparams(req[:query]) id = get(query, "id", "") isempty(id) && return missing_parameter("id") - m = get(Playlist, req[:login][:user], id) - m == nothing && return not_found("id") + playlist = get(Playlist, req[:login][:user], id) + playlist == nothing && return not_found("id") (xdoc, xroot) = subsonic() - append!(xroot, user_playlists[m]) + append!(xroot, playlist) return subsonic_return(xroot) end @@ -349,16 +428,26 @@ function updatePlaylist(req) query = HTTP.URIs.queryparams(req[:query]) playlistId = get(query, "playlistId", "") isempty(playlistId) && return missing_parameter("playlistId") - m = get(Playlist, req[:login][:user], playlistId) - m == nothing && return not_found("playlistId") - playlist = user_playlists[m] + 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 not_allowed() + canedit(req[:login][:user], playlist) || return unuthorized() + # 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 unuthorized() + playlist.public = true + else + playlist.public = false + end + 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", "") @@ -416,9 +505,11 @@ function getCoverArt(req::Dict) return sendfile(Beets.albums[n].cover) 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 unuthorized() id = get(query, "id", "") isempty(id) && return missing_parameter("id") songs = Beets.songs() @@ -432,35 +523,20 @@ 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}() - headers["Content-Type"] = Mux.mimetypes[suffix] + mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : suffix + headers["Content-Type"] = mime headers["Content-Length"] = string(filesize(path)) return Dict(:body => read(path), :headers => headers) end -function saveplaylists(; file = expanduser("~/.config/beets/playlists.jsonl")) - global user_playlists - open(file, "w") do f - write(f, - join(JSON2.write.(user_playlists), "\n")) - 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 - -function loadplaylists(; file = expanduser("~/.config/beets/playlists.jsonl")) - global user_playlists - isfile(file) || touch(file) - ps = JSON2.readlines(file) - empty!(user_playlists) - for p in ps - # try - pl = JSON2.read(p, Playlist) - push!(user_playlists, pl) - # catch e - # @warn "Failed to read with error $e" - # isa(e, ArgumentError) && continue - # end - end -end - -canread(u::User, p::Playlist) = p.public || p.owner == u.name || u in p.allowed -canedit(u::User, p::Playlist) = p.owner == u.name +canmakepublic(u::User) = u.playlist diff --git a/JlSonic/beet2xml.jl b/JlSonic/beet2xml.jl index b423266..1c161e7 100644 --- a/JlSonic/beet2xml.jl +++ b/JlSonic/beet2xml.jl @@ -1,5 +1,12 @@ +import Dates +ms2string(m::Dates.DateTime) = Dates.format(m, Dates.dateformat"YYYY-mm-ddTHH:MM:SS") + import Base.push! +# Try to fix missing mime types +Mux.mimetypes["alac"] = "audio/x-m4a" +Mux.mimetypes["m4a"] = "audio/x-m4a" + function push!(root::XMLElement, p::Playlist) playlistXML = new_child(root, "playlist") set_attributes(playlistXML, [ @@ -10,7 +17,7 @@ function push!(root::XMLElement, p::Playlist) ("public", string(p.public)), ("songCount", string(length(p.songs))), ("duration", reduce(+, p.songs, init = 0.0) |> floor |> Int |> string), - ("created", "2012-04-17T19:53:44"), + ("created", ms2string(p.created)), ("coverArt", p.cover), ]) playlistXML @@ -20,8 +27,7 @@ function append!(root::XMLElement, p::Playlist) playlistXML = push!(root, p) # Allowed users for al in p.allowed - set_content(new_child(playlistXML, "allowedUser"), - al) + set_content(new_child(playlistXML, "allowedUser"), al) end for song in p.songs entry = new_child(playlistXML, "entry") @@ -40,7 +46,7 @@ function push!(root::XMLElement, album::Beets.Album) ("name", album.title), ("coverArt", album.uuid), ("songCount", string(length(album.songs))), - ("created", "0"), # FIXME + ("created", ms2string(album.added)), ("duration", string(sum([t.length for t in album.songs]) |> floor |> Int)), ("artist", album.artist.name), ("artistId", album.artist.uuid) @@ -78,16 +84,17 @@ end function push!(root::XMLElement, song::Beets.Song) songXML = new_child(root, "song") suffix = lowercase(song.format) + mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : suffix set_attributes(songXML, [ ("id", song.uuid), ("title", song.title), ("isDir", "false"), - ("created", "FIXME"), + ("created", ms2string(song.added)), ("duration", string(floor(song.length) |> Int)), ("bitrate", string(song.bitrate)), ("size", string(filesize(song.path))), ("suffix", suffix), - ("contentType", Mux.mimetypes[suffix]), + ("contentType", mime), ("isVideo", "false"), ("path", relpath(song.path, Beets.musicdir())), ("type", "music") @@ -99,6 +106,7 @@ function push!(root::XMLElement, song_album::Tuple{Beets.Song,Beets.Album}) songXML = new_child(root, "song") song, album = song_album suffix = lowercase(song.format) + mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : suffix set_attributes(songXML, [ ("id", song.uuid), ("parent", album.artist.uuid), # Not clear @@ -107,12 +115,12 @@ function push!(root::XMLElement, song_album::Tuple{Beets.Song,Beets.Album}) ("artist", album.artist.name), ("isDir", "false"), ("coverArt", album.uuid), - ("created", "FIXME"), + ("created", ms2string(album.added)), ("duration", string(floor(song.length) |> Int)), ("bitrate", string(song.bitrate)), ("size", string(filesize(song.path))), ("suffix", suffix), - ("contentType", Mux.mimetypes[suffix]), # mpeg + ("contentType", mime), ("isVideo", "false"), ("path", relpath(song.path, Beets.musicdir())), ("albumId", album.uuid), @@ -124,6 +132,7 @@ end function props(song::Song) suffix = lowercase(song.format) + mime = suffix in keys(Mux.mimetypes) ? Mux.mimetypes[suffix] : suffix [ ("id", song.uuid), # ("parent", album.artist.uuid), # Not clear @@ -132,12 +141,12 @@ function props(song::Song) # ("artist", song.album.artist.name), ("isDir", "false"), ("coverArt", song.uuid), - ("created", "FIXME"), + ("created", ms2string(song.added)), ("duration", string(floor(song.length) |> Int)), ("bitrate", string(song.bitrate)), ("size", string(filesize(song.path))), ("suffix", suffix), - ("contentType", Mux.mimetypes[suffix]), # mpeg + ("contentType", mime), # mpeg ("isVideo", "false"), ("path", relpath(song.path, Beets.musicdir())), # ("albumId", song.album.uuid), diff --git a/JlSonic/beethelpers.jl b/JlSonic/beethelpers.jl index 6ccaa13..0dc9a78 100644 --- a/JlSonic/beethelpers.jl +++ b/JlSonic/beethelpers.jl @@ -1,4 +1,44 @@ -function allsongs() - albums = [album.songs for album in Beets.getalbums()]; - songs = Iterators.flatten(albums) |> collect; +playlistfile(path) = joinpath(path, "playlists.jsonl") + +function saveplaylists(; file = playlistfile(Beets.confdir())) + global user_playlists + open(file, "w") do f + write(f, + join(JSON2.write.(user_playlists), "\n")) + end end + +function loadplaylists(; file = playlistfile(Beets.confdir())) + global user_playlists + isfile(file) || touch(file) + ps = JSON2.readlines(file) + empty!(user_playlists) + for p in ps + try + pl = JSON2.read(p, Playlist) + push!(user_playlists, pl) + catch e + @warn "Failed to read with error $e" + isa(e, ArgumentError) && continue + end + end +end + +function saveusers(file = expanduser("~/.config/beets/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")) + global users + isfile(file) || touch(file) + ps = JSON2.readlines(file) + p = JSON2.read.(ps, JlSonic.User) + empty!(users) + for pl in p + push!(users, pl) + end +end + diff --git a/JlSonic/types.jl b/JlSonic/types.jl index 44176d0..45cfec3 100644 --- a/JlSonic/types.jl +++ b/JlSonic/types.jl @@ -1,5 +1,5 @@ import Random - +import Dates mutable struct User name::String password::String @@ -28,6 +28,7 @@ mutable struct Playlist songs::Vector{Song} cover::String allowed::Vector{String} + created::Dates.DateTime end function Playlist(owner::String @@ -37,8 +38,12 @@ function Playlist(owner::String public = false, songs = Song[], cover = "", - allowed = String[]) - Playlist(uuid, name, comment, owner, public, songs, cover, allowed) + allowed = String[], + creation = Dates.now()) + Playlist(uuid, + name, comment, owner, public, + songs, cover, + allowed, creation) end function User(name::String) diff --git a/airsonic.rest b/airsonic.rest index f2dc521..ee5b9bf 100644 --- a/airsonic.rest +++ b/airsonic.rest @@ -15,8 +15,8 @@ GET :url/getLicense # Returns all configured top-level music folders. Takes no extra parameters. GET :url/getMusicFolders:auth -# getIndexes = Returns an indexed structure of all artists. -# getMusicDirectory = Returns a listing of all files in a music directory. Typically used to get list of albums for an artist, or list of songs for an album. +# +GET :url/getMusicDirectory:auth&id=fab34286-b8e1-4879-bce3-194e1358fbd2 # Returns all genres. GET :url/getGenres:auth @@ -24,26 +24,27 @@ GET :url/getGenres:auth # Similar to getIndexes, but organizes music according to ID3 tags. GET :url/getArtists:auth -# Returns details for an artist, including a list of albums. This method organizes music according to ID3 tags. -GET :url/getArtist:auth&id=14d44067-99c2-4f77-b58b-138f0b6911fa +# Returns details for an artist, including a list of albums. This method organizes music according to ID3 tags.14d44067-99c2-4f77-b58b-138f0b6911fa +GET :url/getArtist:auth&id=ba853904-ae25-4ebb-89d6-c44cfbd71bd2 + # Returns details for an album, including a list of songs. This method organizes music according to ID3 tags. -GET :url/getAlbum:auth&id=d9522a40-887f-4a15-a59f-0d3bccfa908f +GET :url/getAlbum:auth&id=f281e63f-589d-4691-8f13-9906ccc09aa0 # Returns details for a song. -GET :url/getSong:auth&id=df5937fd-d79b-40b5-bf14-8c29c54e1bdb +GET :url/getSong:auth&id=e1ebe027-2e21-45c9-bff8-94ba538f895f # Returns a cover art image. GET :url/getCoverArt:auth&id=7167f941-efef-49dd-a54f-8e2d41e3f4a7 # Stream -GET :url/stream:auth&id=df5937fd-d79b-40b5-bf14-8c29c54e1bdb +GET :url/stream:auth&id=e1ebe027-2e21-45c9-bff8-94ba538f895f # Get playlists GET :url/getPlaylists:auth # Get single playlist -GET :url/getPlaylist:auth&id=a2df9320-4775-40a5-9830-8960f3eb9203 +GET :url/getPlaylist:auth&id=1455e415-8718-4453-a5f5-490a00b62d34 # Get not owned playlist GET :url/getPlaylist:auth&id=799f5074-5db2-4daa-b449-9677d0c7744c @@ -52,7 +53,7 @@ GET :url/getPlaylist:auth&id=799f5074-5db2-4daa-b449-9677d0c7744c GET :url/deletePlaylist:auth&id=799f5074-5db2-4daa-b449-9677d0c7744c # Update not owned playlist -GET :url/updatePlaylist:auth&playlistId=799f5074-5db2-4daa-b449-9677d0c7744c +GET :url/updatePlaylist:auth&playlistId=e39d8798-473e-45a9-8a1f-d5d0485ed274 # Update owned playlist GET :url/updatePlaylist:auth&playlistId=a2df9320-4775-40a5-9830-8960f3eb9203&name=nuovo @@ -60,5 +61,8 @@ GET :url/updatePlaylist:auth&playlistId=a2df9320-4775-40a5-9830-8960f3eb9203&nam # Delete owned playlist GET :url/deletePlaylist:auth&id=a2df9320-4775-40a5-9830-8960f3eb9203 +# Get random songs +GET :url/getRandomSongs:auth + diff --git a/login.jl b/login.jl index 581551d..f6e744d 100644 --- a/login.jl +++ b/login.jl @@ -2,59 +2,28 @@ using MD5 using HTTP using JSON2 -const users = JlSonic.User[] - 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, + req[:login] = Dict(:name => string(get(query, "u", "")), + :token => get(query, "t", ""), + :salt => get(query, "s", ""), + :password => get(query, "p", ""), :login => false) return app(req) end function checkpassword(app, req) - global users - usern = findfirst(u -> u.name == req[:login][:name], users) - usern === nothing && return app(req) - user = users[usern] - req[:login][:user] = user - if !isempty(req[:login][:salt]) - if bytes2hex(MD5.md5(string(user.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])) == - user.password - else - req[:login][:login] = user.password == req[:login][:password] - end + user = JlSonic.checkpass(req[:login][:name], + req[:login][:salt], + req[:login][:token], + req[:login][:password]) + if user == nothing + req[:login][:login] = false + else + req[:login][:login] = true + req[:login][:user] = user end return app(req) end -function saveusers(file = expanduser("~/.config/beets/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")) - global users - isfile(file) || touch(file) - ps = JSON2.readlines(file) - p = JSON2.read.(ps, JlSonic.User) - empty!(users) - append!(users, p) -end - sonic_login = stack(getlogin, checkpassword) diff --git a/router.jl b/router.jl index b7c5841..170f141 100644 --- a/router.jl +++ b/router.jl @@ -9,7 +9,7 @@ restp(p, app...) = branch(req -> restpath!(p, req), app...) dispatch = stack( # Browsing restp("getMusicFolders", _ -> getMusicFolders()), - restp("getMusicDirectory", req -> getmusicdirectory(req)), + restp("getMusicDirectory", req -> getMusicDirectory(req)), restp("getAlbumList", req -> getAlbumList(req)), restp("getGenres", _ -> getGenres()), restp("getArtists", _ -> getArtists()), diff --git a/server.jl b/server.jl index 879880e..801a07a 100644 --- a/server.jl +++ b/server.jl @@ -8,10 +8,10 @@ Beets.update_albums(); push!(LOAD_PATH, realpath("JlSonic")) using JlSonic JlSonic.loadplaylists() +JlSonic.loadusers() include("router.jl") include("login.jl") -loadusers() @app sonic = ( Mux.defaults,