2019-05-17 20:22:01 +02:00
"""
Implementation of Subsonic APIs : http : // www . subsonic . org / pages / api . jsp
"""
2020-01-22 18:42:29 +01:00
import Humanize : datasize
2019-05-17 20:22:01 +02:00
" Subsonic API compatible version "
2020-01-22 18:42:29 +01:00
const apiversion = " 4.5 "
2019-05-17 20:22:01 +02:00
const domain = " nixo.xyz "
2019-05-21 18:43:33 +02:00
const ffmpeg_threads = 0
2019-05-17 20:22:01 +02:00
"""
Helper function that prepares a subsonic response .
"""
2020-01-22 18:42:29 +01:00
function subsonic ( ; version = apiversion , status = " ok " )
2019-05-17 20:22:01 +02:00
xdoc = XMLDocument ( )
xroot = create_root ( xdoc , " subsonic-response " )
set_attribute ( xroot , " xmlns " , " http://subsonic.org/restapi " )
set_attribute ( xroot , " status " , status )
set_attribute ( xroot , " version " , version )
( xdoc , xroot )
end
" Wrap a block inside the subsonic response to ease the free() of the XML "
macro subsonic ( block )
return quote
( xdoc , xroot ) = subsonic ( version = $ apiversion )
$ block
doc_str = string ( xdoc )
free ( xdoc )
return doc_str
end
end
function subsonic_return ( doc )
doc_str = string ( doc )
free ( doc )
doc_str
end
" Used to test connectivity with the server. Takes no extra parameters. "
ping ( ) = @subsonic ( nothing )
" Get details about the software license. Takes no extra parameters. "
function getLicense ( )
@subsonic begin
set_attributes ( new_child ( xroot , " license " ) ,
[
( " valid " , " true " ) ,
( " email " , string ( " admin@ " , domain ) ) ,
( " licenseExpires " , " never " ) ,
( " info " , " This is juliaSonic, licensed under GPLv3+ " )
] )
end
end
" Returns all configured top-level music folders. Takes no extra parameters. "
function getMusicFolders ( )
@subsonic begin
folders = new_child ( xroot , " musicFolders " )
folder = new_child ( folders , " musicFolder " )
set_attributes ( folder , [ ( " id " , " 1 " ) ,
( " name " , " Music " ) ] )
end
end
2019-05-19 18:28:43 +02:00
# Implement:
# getIndexes
2019-05-21 11:05:53 +02:00
function getMusicDirectory ( req )
query = HTTP . URIs . queryparams ( req [ :query ] )
id = get ( query , " id " , " " )
isempty ( id ) && return missing_parameter ( )
2020-01-22 18:42:29 +01:00
( xdoc , xroot ) = subsonic ( )
2019-05-21 11:05:53 +02:00
directory = new_child ( xroot , " directory " )
# We simulate directory listing. Root directory has id ==
# 1. Other directories are identified by uuids
2019-05-21 15:15:23 +02:00
artists = Beets . artists ( )
2019-05-21 11:05:53 +02:00
## 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 )
2019-05-21 15:13:08 +02:00
albums = Beets . albums ;
2019-05-21 11:05:53 +02:00
if artistmatch != nothing
artist = artists [ artistmatch ]
set_attributes ( directory ,
[ ( " id " , artist . uuid ) ,
# Always under /Music, id = 1
( " parent " , " 1 " ) ,
( " name " , artist . name ) ,
( " starred " , " 2013-11-02T12:30:00 " )
] )
# List albums
content = new_child ( directory , " child " )
for albumn in findall ( alb -> alb . artist == artist , albums )
album = albums [ albumn ]
set_attributes ( content , [
( " id " , album . uuid ) ,
( " parent " , album . artist . uuid ) ,
# ("artistId", album.artist.uuid),
( " title " , album . title ) ,
( " artist " , album . artist . name ) ,
( " isDir " , " true " ) ,
( " coverArt " , album . uuid ) ,
] )
end
elseif false
content = new_child ( directory , " child " )
# List album content (songs)
set_attributes ( content , [
( " id " , " FIXME " ) ,
( " parent " , " PARENT:ID " ) ,
( " title " , " FIXME " ) ,
( " isDir " , " false " ) ,
( " album " , " FIXME " ) ,
( " artist " , " FIXME " ) ,
( " track " , " FIXME " ) ,
( " year " , " FIXME " ) ,
( " genre " , " FIXME " ) ,
( " coverArt " , " FIXME " ) ,
( " size " , " FIXME " ) ,
# FIXME
( " contentType " , " audio/mpeg " ) ,
( " suffix " , " FIXME " ) ,
( " duration " , " FIXME " ) ,
( " bitrate " , " FIXME " ) ,
( " path " , " FIXME " ) ,
] )
else
return not_found ( )
end
end
doc_str = string ( xdoc )
free ( xdoc )
return doc_str
end
2019-05-19 18:28:43 +02:00
2019-05-17 20:22:01 +02:00
" Returns all genres. "
function getGenres ( )
2019-05-19 18:28:43 +02:00
( xdoc , xroot ) = subsonic ( )
songs = Beets . songs ( ) ;
res = Dict { String , Int } ( )
genrelist = strip . ( sort ( filter! ( ! isempty , Beets . genre . ( Beets . albums ) ) ) )
for genre in genrelist
t = get ( res , genre , 0 )
res [ genre ] = t + 1
end
genres = new_child ( xroot , " genres " )
for k in keys ( res )
genre = new_child ( genres , " genre " )
set_attributes ( genre , [
( " songCount " , string ( res [ k ] ) ) ,
# FIXME
( " albumCount " , string ( count ( genrelist .== k ) ) ) ,
] )
add_text ( genre , k )
2019-05-17 20:22:01 +02:00
end
2019-05-19 18:28:43 +02:00
return subsonic_return ( xdoc )
2019-05-17 20:22:01 +02:00
end
" Similar to getIndexes, but organizes music according to ID3 tags. "
function getArtists ( )
2019-05-19 18:28:43 +02:00
( xdoc , xroot ) = subsonic ( )
2019-05-17 20:22:01 +02:00
indexes = new_child ( xroot , " artists " )
set_attribute ( indexes , " ignoredArticles " , " " )
2019-05-21 15:13:08 +02:00
artists = sort ( Beets . artists ( ) , by = a -> a . name )
2019-05-19 18:28:43 +02:00
firstletters = unique ( first . ( filter ( ! isempty , Beets . name . ( artists ) ) ) .|> uppercase )
for index in string . ( firstletters )
2019-05-17 20:22:01 +02:00
indexXML = new_child ( indexes , " index " )
2019-05-19 18:28:43 +02:00
set_attribute ( indexXML , " name " , index )
2019-05-21 11:05:53 +02:00
for artist in filter ( x -> startswith ( x . name , index ) , artists )
2019-05-19 18:28:43 +02:00
artistXML = push! ( indexXML , artist )
2019-05-17 20:22:01 +02:00
end
end
2019-05-19 18:28:43 +02:00
return subsonic_return ( xdoc )
2019-05-17 20:22:01 +02:00
end
""" Returns details for an artist, including a list of albums.
This method organizes music according to ID3 tags . """
function getArtist ( id :: String )
2019-05-21 11:05:53 +02:00
artists = Beets . artists ( )
2019-05-19 18:28:43 +02:00
matching = findfirst ( a -> a . uuid == id , artists )
matching === nothing && return not_found ( " id " )
artist = artists [ matching ]
2019-05-17 20:22:01 +02:00
# Create the response
( xdoc , xroot ) = subsonic ( )
2019-05-19 18:28:43 +02:00
artistXML = push! ( xroot , artist )
2019-05-27 10:09:06 +02:00
for album in sort ( Beets . album ( artist ) )
2019-05-19 18:28:43 +02:00
push! ( artistXML , album )
2019-05-17 20:22:01 +02:00
end
return subsonic_return ( xdoc )
end
function getArtist ( req :: Dict )
query = HTTP . URIs . queryparams ( req [ :query ] )
artistid = get ( query , " id " , " " )
isempty ( artistid ) && return missing_parameter ( " id " )
return getArtist ( string ( artistid ) )
end
function getAlbum ( req :: Dict )
query = HTTP . URIs . queryparams ( req [ :query ] )
albumid = get ( query , " id " , " " )
isempty ( albumid ) && return missing_parameter ( " id " )
return getAlbum ( albumid )
end
2019-05-19 18:28:43 +02:00
function getAlbum ( albumid )
album = Beets . album ( string ( albumid ) )
album === nothing && return not_found ( " album " )
( xdoc , xroot ) = subsonic ( )
# push!(albumXML, [(s, album) for s in album.songs])
append! ( xroot , album )
return subsonic_return ( xdoc )
end
2020-01-22 18:42:29 +01:00
# TODO: add getStarred2
2019-05-17 20:22:01 +02:00
function getAlbumList ( req :: Dict )
query = HTTP . URIs . queryparams ( req [ :query ] )
albumtype = get ( query , " type " , " " )
isempty ( albumtype ) && return missing_parameter ( " type " )
2020-01-22 18:42:29 +01:00
# 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
2019-05-17 20:22:01 +02:00
end
2020-01-22 18:42:29 +01:00
( xdoc , xroot ) = subsonic ( )
list = new_child ( xroot , " albumList " )
push! . ( Ref ( list ) , albums )
return subsonic_return ( xdoc )
2019-05-17 20:22:01 +02:00
end
2020-01-22 18:42:29 +01:00
getAlbumList2 ( req :: Dict ) = getAlbumList ( req )
2019-05-17 20:22:01 +02:00
function getSong ( req )
query = HTTP . URIs . queryparams ( req [ :query ] )
id = get ( query , " id " , " " )
isempty ( id ) && return missing_parameter ( )
2019-05-21 15:13:08 +02:00
matching = [ album for album in Beets . albums
2019-05-17 20:22:01 +02:00
if any ( getfield . ( album . songs , :uuid ) .== id ) ]
length ( matching ) == 0 && return not_found ( " song " )
( xdoc , xroot ) = subsonic ( )
push! ( xroot , ( first ( filter ( s -> s . uuid == id , first ( matching ) . songs ) ) ,
first ( matching ) ) )
return subsonic_return ( xdoc )
end
2020-01-22 18:42:29 +01:00
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 , '+' => ' ' )
2019-05-17 20:22:01 +02:00
import Random
function getRandomSongs ( ; size = 10 ,
genre :: Union { Missing , String } = missing ,
fromYear :: Union { Missing , Int } = missing ,
toYear :: Union { Missing , Int } = missing ,
musicFolderId :: Union { Missing , String } = missing )
2019-05-19 18:28:43 +02:00
songs = Beets . songs ( ) ;
2019-05-17 20:22:01 +02:00
# Filter
2020-01-22 18:42:29 +01:00
genre = ismissing ( genre ) ? missing : strip ( unescape ( genre ) )
! ismissing ( genre ) && filter! ( x -> strip ( x . genre ) == genre , songs )
2019-05-17 20:22:01 +02:00
! ismissing ( fromYear ) && filter! ( x -> x . year > fromYear , songs )
! ismissing ( toYear ) && filter! ( x -> x . year < toYear , songs )
2020-01-22 18:42:29 +01:00
# Randomize
songs = songs [ Random . randperm ( length ( songs ) ) ] ;
2019-05-17 20:22:01 +02:00
# Take size
songs = length ( songs ) > size ? songs [ 1 : size ] : songs ;
# Create output
( xdoc , xroot ) = subsonic ( )
list = new_child ( xroot , " randomSongs " )
for song in songs
2020-01-22 18:42:29 +01:00
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 )
2019-05-17 20:22:01 +02:00
end
return subsonic_return ( xdoc )
end
function getRandomSongs ( req )
query = HTTP . URIs . queryparams ( req [ :query ] )
2020-01-22 18:42:29 +01:00
# @show query
2019-05-17 20:22:01 +02:00
# name Required Default Notes
# size No 10 The maximum number of songs to return. Max 500.
2020-01-22 18:42:29 +01:00
size = min ( 500 , parse ( Int , get ( query , " size " , " 10 " ) ) )
2019-05-17 20:22:01 +02:00
# genre No Only returns songs belonging to this genre.
genre = get ( query , " genre " , missing )
# fromYear No Only return songs published after or in this year.
# toYear No Only return songs published before or in this year.
fy = get ( query , " fromYear " , missing )
fromY = ismissing ( fy ) ? missing : parse ( Int , fy )
ty = get ( query , " toYear " , missing )
toY = ismissing ( ty ) ? missing : parse ( Int , ty )
getRandomSongs ( size = size ,
genre = ismissing ( genre ) ? missing : string ( genre ) ,
fromYear = fromY , toYear = toY )
end
2020-01-22 18:42:29 +01:00
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
2019-05-17 20:22:01 +02:00
2020-01-22 18:42:29 +01:00
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
2019-05-17 20:22:01 +02:00
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
2020-01-22 18:42:29 +01:00
function makequery ( q :: AbstractString )
# nq = replace(lowercase(q), "*" => ".*")
nq = lowercase ( q )
nq = string ( " .* " , nq , " .* " )
2019-05-17 20:22:01 +02:00
return Regex ( nq )
end
2020-01-22 18:42:29 +01:00
# 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
2019-05-27 10:09:06 +02:00
function search3 ( req ; dlalbum = println , dlall = println )
2019-05-17 20:22:01 +02:00
query = HTTP . URIs . queryparams ( req [ :query ] )
q = get ( query , " query " , " " )
2020-01-22 18:42:29 +01:00
q = isempty ( q ) ? get ( query , " any " , " " ) : q
2019-05-17 20:22:01 +02:00
isempty ( q ) && return missing_parameter ( " query " )
2020-01-22 18:42:29 +01:00
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
2019-05-27 10:09:06 +02:00
@info " Downloading single album "
dlalbum ( string ( strip ( q [ 1 : end - 2 ] ) ) )
return @subsonic ( nothing )
2020-01-22 18:42:29 +01:00
elseif length ( q ) > 2 && q [ end - 1 : end ] == " !d " && req [ :login ] [ :user ] . upload
2019-05-27 10:09:06 +02:00
@info " Downloading discography! "
dlall ( string ( strip ( q [ 1 : end - 2 ] ) ) )
return @subsonic ( nothing )
2019-05-21 16:43:27 +02:00
( xdoc , xroot ) = subsonic ( )
results = new_child ( xroot , " searchResult3 " )
# TODO: Push the results so that we can see what download just started
# push!(results, list)
return subsonic_return ( xdoc )
2019-05-17 20:22:01 +02:00
end
songCount = parse ( Int , get ( query , " songCount " , " 20 " ) )
# TODO:
# artistCount No 20 Maximum number of artists to return.
# artistOffset No 0 Search result offset for artists. Used for paging.
# albumCount No 20 Maximum number of albums to return.
# albumOffset No 0 Search result offset for albums. Used for paging.
# songOffset No 0 Search result offset for songs. Used for paging.
# musicFolderId No (Since 1.12.0) Only return results from music folder with the given ID. See getMusicFolders.
( xdoc , xroot ) = subsonic ( )
results = new_child ( xroot , " searchResult3 " )
2020-01-22 18:42:29 +01:00
k = map ( x -> makequery ( x ) , split . ( unescape ( string ( q ) ) , ' ' ) )
# @show k
2019-05-21 15:13:08 +02:00
matchingartists = Beets . artists ( )
2020-01-22 18:42:29 +01:00
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
2019-05-17 20:22:01 +02:00
# Artists
push! . ( Ref ( results ) , matchingartists )
# # Albums
push! . ( Ref ( results ) , matchingalbums )
# Songs
push! ( results , matchingsongs )
return subsonic_return ( xdoc )
end
" Create (or update) a playlist " # WTF create can update?
function createPlaylist ( req )
global user_playlists
#=
Parameter Required Default Comment
playlistId Yes ( if updating ) The playlist ID .
name Yes ( if creating ) The human - readable name of the playlist .
songId No ID of a song in the playlist . Use one songId parameter for each song in the playlist .
= #
query = HTTP . URIs . queryparams ( req [ :query ] )
playlistId = get ( query , " playlistId " , " " )
name = get ( query , " name " , " " )
songId = get ( query , " songId " , " " )
# Check required params
isempty ( songId ) && return missing_parameter ( " songId " )
2019-05-19 18:28:43 +02:00
songs = Beets . songs ( ) ;
2019-05-17 20:22:01 +02:00
songn = findfirst ( s -> s . uuid == songId , songs )
songn === nothing && return not_found ( " songId " )
song = songs [ songn ]
if ! isempty ( playlistId )
if playlistId in keys ( user_playlists )
push! ( playlist , song )
else
return not_found ( " playlistId " )
end
elseif ! isempty ( name )
2019-05-19 23:27:44 +02:00
playlist = Playlist ( req [ :login ] [ :user ] . name ,
2019-05-21 11:05:53 +02:00
name = name # cover = ???
2019-05-19 23:27:44 +02:00
)
2019-05-17 20:22:01 +02:00
push! ( playlist , song )
else
return missing_parameter ( " either name or playlistId " )
end
push! ( user_playlists , playlist )
# Return the playlist
( xdoc , xroot ) = subsonic ( )
push! ( xroot , playlist )
2019-05-19 22:24:18 +02:00
saveplaylists ( )
2019-05-17 20:22:01 +02:00
return subsonic_return ( xdoc )
end
" Returns all playlists a user is allowed to play. "
function getPlaylists ( req )
global user_playlists
# FIXME: add support for admin (ask other user's playlists, v 1.8.0)
2020-01-22 18:42:29 +01:00
( xdoc , xroot ) = subsonic ( )
2019-05-17 20:22:01 +02:00
playlistsXML = new_child ( xroot , " playlists " )
2019-05-19 22:24:18 +02:00
for playlist in sort ( filter ( p -> canread ( req [ :login ] [ :user ] , p ) ,
user_playlists ) ,
by = p -> p . name )
2019-05-17 20:22:01 +02:00
push! ( playlistsXML , playlist )
end
return subsonic_return ( xdoc )
end
2019-05-19 23:27:44 +02:00
import Base . get
2020-01-22 18:42:29 +01:00
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 }
2019-05-19 23:27:44 +02:00
global user_playlists
2019-05-21 11:05:53 +02:00
playlists = filter ( p -> canread ( u , p ) , user_playlists )
m = findfirst ( p -> p . uuid == id , playlists )
2020-01-22 18:42:29 +01:00
m === nothing ? nothing : playlists [ m ]
2019-05-19 23:27:44 +02:00
end
2019-05-17 20:22:01 +02:00
" Returns a listing of files in a saved playlist. "
function getPlaylist ( req )
global user_playlists
query = HTTP . URIs . queryparams ( req [ :query ] )
id = get ( query , " id " , " " )
isempty ( id ) && return missing_parameter ( " id " )
2019-05-21 11:05:53 +02:00
playlist = get ( Playlist , req [ :login ] [ :user ] , id )
2020-01-22 18:42:29 +01:00
playlist === nothing && return not_found ( " id " )
2019-05-17 20:22:01 +02:00
( xdoc , xroot ) = subsonic ( )
2019-05-21 11:05:53 +02:00
append! ( xroot , playlist )
2019-05-17 20:22:01 +02:00
return subsonic_return ( xroot )
end
" Updates a playlist. Only the owner of a playlist is allowed to update it. "
function updatePlaylist ( req )
global user_playlists
query = HTTP . URIs . queryparams ( req [ :query ] )
playlistId = get ( query , " playlistId " , " " )
isempty ( playlistId ) && return missing_parameter ( " playlistId " )
2019-05-21 11:05:53 +02:00
playlist = get ( Playlist , req [ :login ] [ :user ] , playlistId )
2020-01-22 18:42:29 +01:00
playlist === nothing && return not_found ( " playlistId " )
2019-05-19 23:27:44 +02:00
# Check ownership (if not allowed, should not even reach this (canread is false))
2020-01-22 18:42:29 +01:00
canedit ( req [ :login ] [ :user ] , playlist ) || return unauthorized ( )
2019-05-21 11:05:53 +02:00
# 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
2020-01-22 18:42:29 +01:00
canmakepublic ( req [ :login ] [ :user ] ) || return unauthorized ( )
2019-05-21 11:05:53 +02:00
playlist . public = true
else
playlist . public = false
end
2019-05-17 20:22:01 +02:00
playlist . name = get ( query , " name " , playlist . name )
2020-01-22 18:42:29 +01:00
playlist . comment = unescape ( get ( query , " comment " , playlist . comment ) )
2019-05-17 20:22:01 +02:00
songIdAdd = get ( query , " songIdToAdd " , " " )
# WTF by the index!?
IndexToRemove = get ( query , " songIndexToRemove " , " " )
songIndexToRemove = isempty ( IndexToRemove ) ? - 1 : parse ( Int , IndexToRemove ) + 1
# TODO: Support multiple (repeated) parameter
if ! isempty ( songIdAdd )
2019-05-19 18:28:43 +02:00
songs = Beets . songs ( ) ;
2019-05-17 20:22:01 +02:00
songn = findfirst ( s -> s . uuid == songIdAdd , songs )
songn === nothing && return not_found ( " songIdToAdd " )
song = songs [ songn ]
push! ( playlist , song )
end
# TODO: Support multiple (repeated) parameter
if songIndexToRemove > 0 && songIndexToRemove <= length ( playlist . songs )
deleteat! ( playlist . songs , songIndexToRemove )
end
2019-05-19 22:24:18 +02:00
saveplaylists ( )
2019-05-17 20:22:01 +02:00
@subsonic ( nothing )
end
" Deletes a saved playlist. "
function deletePlaylist ( req )
global user_playlists
query = HTTP . URIs . queryparams ( req [ :query ] )
id = get ( query , " id " , " " )
isempty ( id ) && return missing_parameter ( " id " )
2020-01-22 18:42:29 +01:00
m = findfirst ( p -> p . uuid == PlaylistUUID ( id ) , user_playlists )
2019-05-19 23:27:44 +02:00
m === nothing && return not_found ( " id " )
if ! canedit ( req [ :login ] [ :user ] , user_playlists [ m ] )
2020-01-22 18:42:29 +01:00
return unauthorized ( )
2019-05-19 23:27:44 +02:00
end
deleteat! ( user_playlists , m )
2019-05-19 22:24:18 +02:00
saveplaylists ( )
2019-05-17 20:22:01 +02:00
@subsonic ( nothing )
end
2020-01-22 18:42:29 +01:00
function scrobble ( req )
# @show req
@subsonic ( nothing )
end
2019-05-17 20:22:01 +02:00
function getUser ( req )
2020-01-22 18:42:29 +01:00
global users
m = findfirst ( x -> x . name == req [ :login ] [ :name ] , users )
m === nothing && return not_found ( )
2019-05-17 20:22:01 +02:00
( xdoc , xroot ) = subsonic ( )
2020-01-22 18:42:29 +01:00
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
2019-05-17 20:22:01 +02:00
return subsonic_return ( xroot )
end
2019-05-27 10:09:06 +02:00
const cover_cache = Dict { String , Vector { UInt8 } } ( )
const song_cache = Dict { Tuple { String , Int , String } , Vector { UInt8 } } ( )
2019-05-17 20:22:01 +02:00
# Media retriveal
" Returns a cover art image. "
2020-01-22 18:42:29 +01:00
function getCoverArt ( req :: Dict , config )
2019-05-27 10:09:06 +02:00
global cover_cache
2019-05-17 20:22:01 +02:00
query = HTTP . URIs . queryparams ( req [ :query ] )
2020-01-22 18:42:29 +01:00
px = get ( query , " size " , " " )
px = isempty ( px ) ? config [ :cover ] [ :size ] : parse ( Int , px )
2019-05-17 20:22:01 +02:00
id = get ( query , " id " , " " )
isempty ( id ) && return missing_parameter ( " id " )
2019-05-19 18:28:43 +02:00
n = findfirst ( a -> a . uuid == id , Beets . albums )
2019-05-27 10:09:06 +02:00
2020-01-22 18:42:29 +01:00
n === nothing && return not_found ( " id " )
isempty ( Beets . albums [ n ] . cover ) && return not_found ( " album has no cover art " )
2019-05-27 10:09:06 +02:00
if Beets . albums [ n ] . cover in keys ( cover_cache )
data = cover_cache [ Beets . albums [ n ] . cover ]
else
io = IOBuffer ( )
2020-01-22 18:42:29 +01:00
p = run ( pipeline ( ` convert $ ( Beets . albums [ n ] . cover ) -resize $ ( px ) x $ ( px ) png:- `
, stderr = devnull , stdout = io ) , wait = true )
2019-05-27 10:09:06 +02:00
data = take! ( io )
cover_cache [ Beets . albums [ n ] . cover ] = data
end
headers = Dict { String , String } ( )
2019-05-19 18:28:43 +02:00
headers = Dict { String , String } ( )
2019-05-27 10:09:06 +02:00
suffix = " png "
mime = suffix in keys ( Mux . mimetypes ) ? Mux . mimetypes [ suffix ] : " application/octet-stream "
headers [ " Content-Type " ] = mime
headers [ " Content-Length " ] = string ( length ( data ) )
return Dict ( :body => data ,
:headers => headers ,
:file => join ( [ split ( basename ( Beets . albums [ n ] . cover ) , '.' ) [ 1 : end - 1 ] , " .png " ] , " " ) )
2019-05-17 20:22:01 +02:00
end
2020-01-22 18:42:29 +01:00
function giveconverted ( file , bitrate , format ; stream = false )
2019-05-27 10:09:06 +02:00
global song_cache
k = ( file , bitrate , format )
if k in keys ( song_cache )
@info " Using cached "
data = song_cache [ k ]
else
2020-01-22 18:42:29 +01:00
let cache_size = Base . summarysize ( song_cache )
@info " Adding song ( $file ) to cache, cache size is $ ( datasize ( cache_size ) ) "
end
2019-05-27 10:09:06 +02:00
iodata = convert ( file , bitrate = bitrate , format = format )
data = take! ( iodata )
try
song_cache [ k ] = data
catch e
@warn e
end
end
2019-05-21 16:43:27 +02:00
headers = Dict { String , String } ( )
suffix = format
2020-01-22 18:42:29 +01:00
if stream
mime = " application/octet-stream . $ ( format ) "
else
mime = suffix in keys ( Mux . mimetypes ) ? Mux . mimetypes [ suffix ] :
2019-05-21 19:21:49 +02:00
" application/octet-stream "
2020-01-22 18:42:29 +01:00
end
2019-05-21 16:43:27 +02:00
headers [ " Content-Type " ] = mime
2019-05-21 19:40:34 +02:00
headers [ " Content-Length " ] = string ( length ( data ) )
2019-05-27 10:09:06 +02:00
# headers["Transfer-Encoding"] = "chunked"
2019-05-21 19:40:34 +02:00
return Dict ( :body => data ,
2019-05-21 19:21:49 +02:00
:headers => headers ,
2019-05-21 19:25:10 +02:00
:file => join ( [ split ( basename ( file ) , '.' ) [ 1 : end - 1 ] ,
2019-05-21 19:23:53 +02:00
" .oga " ] , " " ) )
2019-05-21 16:43:27 +02:00
end
function convert ( infile ; bitrate = 64 , format = " oga " )
2019-05-21 18:39:47 +02:00
global ffmpeg_threads
2019-05-21 19:01:53 +02:00
io = IOBuffer ( )
2020-01-22 18:42:29 +01:00
# 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 ` ,
2019-05-27 10:09:06 +02:00
stderr = devnull , stdout = io ) , wait = true )
2019-05-21 19:14:21 +02:00
io
2019-05-21 16:43:27 +02:00
end
2019-05-21 11:05:53 +02:00
canstream ( u :: User ) = u . stream
2019-05-17 20:22:01 +02:00
" Streams a given media file. "
function stream ( req :: Dict )
query = HTTP . URIs . queryparams ( req [ :query ] )
2020-01-22 18:42:29 +01:00
canstream ( req [ :login ] [ :user ] ) || return unauthorized ( )
2019-05-21 16:43:27 +02:00
2019-05-17 20:22:01 +02:00
id = get ( query , " id " , " " )
isempty ( id ) && return missing_parameter ( " id " )
2019-05-21 16:43:27 +02:00
bitrate = try parse ( Int , get ( query , " maxBitRate " , " 0 " ) )
catch e
isa ( e , ArgumentError ) && 0
@error e
end
2019-05-21 18:31:56 +02:00
# Ogg is not compativle with lower bitrates. Use something else?
2019-05-21 19:15:49 +02:00
bitrate = ( bitrate != 0 && bitrate < 64 ) ? 64 : bitrate
2019-05-21 16:43:27 +02:00
format = get ( query , " format " , " oga " )
2019-05-19 18:28:43 +02:00
songs = Beets . songs ( )
2019-05-17 20:22:01 +02:00
m = findfirst ( x -> ( x . uuid == id ) , songs )
m === nothing && return not_found ( " id " )
2019-05-19 18:28:43 +02:00
2019-05-21 18:23:33 +02:00
output = ( bitrate == 0 ) ? sendfile ( songs [ m ] . path ) :
giveconverted ( songs [ m ] . path , bitrate , format )
2019-05-21 16:43:27 +02:00
return output
2019-05-19 18:28:43 +02:00
end
function sendfile ( path ; suffix = nothing )
isfile ( path ) || return Dict { String , String } ( :body => " Not Found " )
2020-01-22 18:42:29 +01:00
suffix = suffix === nothing ? lowercase ( split ( path , '.' ) [ end ] ) : suffix
2019-05-19 18:28:43 +02:00
headers = Dict { String , String } ( )
2019-05-21 19:21:49 +02:00
mime = suffix in keys ( Mux . mimetypes ) ? Mux . mimetypes [ suffix ] :
" application/octet-stream "
2019-05-21 11:05:53 +02:00
headers [ " Content-Type " ] = mime
2019-05-19 18:28:43 +02:00
headers [ " Content-Length " ] = string ( filesize ( path ) )
return Dict ( :body => read ( path ) ,
2019-05-21 19:25:10 +02:00
:file => basename ( path ) ,
2019-05-19 18:28:43 +02:00
:headers => headers )
2019-05-17 20:22:01 +02:00
end
2019-05-19 22:24:18 +02:00
2020-01-22 18:42:29 +01:00
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
2019-05-21 11:05:53 +02:00
canmakepublic ( u :: User ) = u . playlist
2020-01-22 18:42:29 +01:00
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