allow shares

master
Nicolò Balzarotti 2020-01-22 18:42:29 +01:00
parent a87081746d
commit d180c207d8
9 changed files with 859 additions and 114 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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, "")

View File

@ -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)

110
router.jl
View File

@ -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 = """
<a href="./$(sh.uuid)?stream/$n">stream $(s.title)</a>
<a href="./$(sh.uuid)?dl/$n" download="$(s.title).flac">download $(s.title)</a>
"""
push!(list, el)
end
else
push!(list, """
<a href="./$(sh.uuid)?stream">stream</a>
<a href="./$(sh.uuid)?dl" download="$(sh.item.title).flac">download</a>
""")
end
# application/octet-stream
return """
<html>
<head>
<title>$(sh.description)</title>
</head>
User: $(sh.username) shared <b>$(sh.item.title)</b> with you!<br/>
Description: $(sh.description)<br/>
Expires: $(sh.expires)<br/>
Viewed: $(sh.count) times<br/>
<br/>
$(join(list, "<br/>"))
</html>
"""
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
)

View File

@ -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())),