Subsonic: browsing endpoints (getMusicFolders, getIndexes, getMusicDirectory, getGenres, getArtists/Artist, getAlbum, getSong, getVideos/VideoInfo, getArtistInfo/2, getAlbumInfo/2, getSimilarSongs/2, getTopSongs)
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
package subsonic
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/library"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("getMusicFolders", handleGetMusicFolders)
|
||||
register("getIndexes", handleGetIndexes)
|
||||
register("getMusicDirectory", handleGetMusicDirectory)
|
||||
register("getGenres", handleGetGenres)
|
||||
register("getArtists", handleGetArtists)
|
||||
register("getArtist", handleGetArtist)
|
||||
register("getAlbum", handleGetAlbum)
|
||||
register("getSong", handleGetSong)
|
||||
register("getVideos", handleGetVideos)
|
||||
register("getVideoInfo", handleGetVideoInfo)
|
||||
register("getArtistInfo", handleGetArtistInfo)
|
||||
register("getArtistInfo2", handleGetArtistInfo2)
|
||||
register("getAlbumInfo", handleGetAlbumInfo)
|
||||
register("getAlbumInfo2", handleGetAlbumInfo2)
|
||||
register("getSimilarSongs", handleGetSimilarSongs)
|
||||
register("getSimilarSongs2", handleGetSimilarSongs2)
|
||||
register("getTopSongs", handleGetTopSongs)
|
||||
}
|
||||
|
||||
func handleGetMusicFolders(rc *Context) *apiError {
|
||||
fm, err := loadFolderMap(rc.Server.DB)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
allowed, err := fm.Allowed(rc.Server.DB, rc.User)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &MusicFolders{}
|
||||
for _, f := range allowed {
|
||||
out.MusicFolder = append(out.MusicFolder, MusicFolder{ID: fm.IntID(f.ID), Name: f.Name})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.MusicFolders = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Directory IDs for filesystem-style browsing ---
|
||||
//
|
||||
// A directory (or the folder root) is addressed as "d-" + base64(folderID
|
||||
// + "\x00" + relativePath). Songs keep using their real database ID
|
||||
// (songs.id), so the same identifier works whether a client got there via
|
||||
// getIndexes/getMusicDirectory or getArtists/getAlbum.
|
||||
|
||||
func encodeDirID(folderID, relPath string) string {
|
||||
return "d-" + base64.RawURLEncoding.EncodeToString([]byte(folderID+"\x00"+relPath))
|
||||
}
|
||||
|
||||
func decodeDirID(id string) (folderID, relPath string, ok bool) {
|
||||
if !strings.HasPrefix(id, "d-") {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(id, "d-"))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(string(raw), "\x00", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func indexLetter(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
for _, prefix := range []string{"The ", "A ", "An "} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
name = name[len(prefix):]
|
||||
break
|
||||
}
|
||||
}
|
||||
if name == "" {
|
||||
return "#"
|
||||
}
|
||||
r := []rune(strings.ToUpper(name))[0]
|
||||
if unicode.IsLetter(r) {
|
||||
return string(r)
|
||||
}
|
||||
return "#"
|
||||
}
|
||||
|
||||
func handleGetIndexes(rc *Context) *apiError {
|
||||
db := rc.Server.DB
|
||||
fm, err := loadFolderMap(db)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
folders, aerr := resolveFoldersParam(rc, fm)
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
|
||||
groups := map[string][]Artist{}
|
||||
var loose []Child
|
||||
for _, folder := range folders {
|
||||
entries, lerr := library.ListDirectory(db, folder.ID, "")
|
||||
if lerr != nil {
|
||||
return newError(ErrGeneric, lerr.Error())
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Dir {
|
||||
a := Artist{ID: encodeDirID(folder.ID, e.Name), Name: e.Name}
|
||||
letter := indexLetter(e.Name)
|
||||
groups[letter] = append(groups[letter], a)
|
||||
} else {
|
||||
song, serr := database.GetSongByMediaFileID(db, e.File.ID)
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
loose = append(loose, childFromSong(db, song, e.File, albumOfSong(db, song), rc.User.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var letters []string
|
||||
for l := range groups {
|
||||
letters = append(letters, l)
|
||||
}
|
||||
sort.Strings(letters)
|
||||
|
||||
idx := &Indexes{IgnoredArticles: "The El La Los Las Le Les"}
|
||||
for _, l := range letters {
|
||||
artists := groups[l]
|
||||
sort.Slice(artists, func(i, j int) bool { return strings.ToLower(artists[i].Name) < strings.ToLower(artists[j].Name) })
|
||||
idx.Index = append(idx.Index, Index{Name: l, Artist: artists})
|
||||
}
|
||||
idx.Child = loose
|
||||
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Indexes = idx
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFoldersParam returns the folders a request should operate over:
|
||||
// the single folder named by musicFolderId if present (and permitted),
|
||||
// otherwise every folder the user may see.
|
||||
func resolveFoldersParam(rc *Context, fm *folderMap) ([]*database.MusicFolder, *apiError) {
|
||||
allowed, err := fm.Allowed(rc.Server.DB, rc.User)
|
||||
if err != nil {
|
||||
return nil, newError(ErrGeneric, err.Error())
|
||||
}
|
||||
folderParam := rc.Params.Get("musicFolderId")
|
||||
if folderParam == "" {
|
||||
return allowed, nil
|
||||
}
|
||||
f := fm.ByIntID(folderParam)
|
||||
if f == nil {
|
||||
return nil, errNotFound("Music folder")
|
||||
}
|
||||
for _, a := range allowed {
|
||||
if a.ID == f.ID {
|
||||
return []*database.MusicFolder{f}, nil
|
||||
}
|
||||
}
|
||||
return nil, errUnauthorized("music folder")
|
||||
}
|
||||
|
||||
func handleGetMusicDirectory(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
db := rc.Server.DB
|
||||
|
||||
folderID, relPath, ok := decodeDirID(id)
|
||||
if !ok {
|
||||
return errNotFound("Directory")
|
||||
}
|
||||
folder, ferr := database.GetMusicFolder(db, folderID)
|
||||
if ferr != nil {
|
||||
return errNotFound("Directory")
|
||||
}
|
||||
if canSee, cerr := UserCanSeeFolder(db, rc.User, folder.ID); cerr != nil {
|
||||
return newError(ErrGeneric, cerr.Error())
|
||||
} else if !canSee {
|
||||
return errUnauthorized("music folder")
|
||||
}
|
||||
|
||||
entries, lerr := library.ListDirectory(db, folder.ID, relPath)
|
||||
if lerr != nil {
|
||||
return newError(ErrGeneric, lerr.Error())
|
||||
}
|
||||
|
||||
name := folder.Name
|
||||
if relPath != "" {
|
||||
parts := strings.Split(relPath, "/")
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
dir := &Directory{ID: id, Name: name}
|
||||
for _, e := range entries {
|
||||
if e.Dir {
|
||||
sub := relPath
|
||||
if sub == "" {
|
||||
sub = e.Name
|
||||
} else {
|
||||
sub = sub + "/" + e.Name
|
||||
}
|
||||
dir.Child = append(dir.Child, Child{ID: encodeDirID(folder.ID, sub), Parent: id, IsDir: true, Title: e.Name})
|
||||
continue
|
||||
}
|
||||
song, serr := database.GetSongByMediaFileID(db, e.File.ID)
|
||||
if serr != nil {
|
||||
continue
|
||||
}
|
||||
c := childFromSong(db, song, e.File, albumOfSong(db, song), rc.User.ID)
|
||||
c.Parent = id
|
||||
dir.Child = append(dir.Child, c)
|
||||
}
|
||||
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Directory = dir
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetGenres(rc *Context) *apiError {
|
||||
stats, err := database.ListGenreStats(rc.Server.DB)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &Genres{}
|
||||
for _, g := range stats {
|
||||
out.Genre = append(out.Genre, Genre{Name: g.Name, SongCount: g.SongCount, AlbumCount: g.AlbumCount})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Genres = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetArtists(rc *Context) *apiError {
|
||||
db := rc.Server.DB
|
||||
artists, err := database.ListArtists(db)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
groups := map[string][]ArtistID3{}
|
||||
for _, a := range artists {
|
||||
letter := indexLetter(a.Name)
|
||||
groups[letter] = append(groups[letter], artistToID3(db, a, rc.User.ID))
|
||||
}
|
||||
var letters []string
|
||||
for l := range groups {
|
||||
letters = append(letters, l)
|
||||
}
|
||||
sort.Strings(letters)
|
||||
|
||||
out := &ArtistsID3{IgnoredArticles: "The El La Los Las Le Les"}
|
||||
for _, l := range letters {
|
||||
out.Index = append(out.Index, IndexID3{Name: l, Artist: groups[l]})
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Artists = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetArtist(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
db := rc.Server.DB
|
||||
artist, err := database.GetArtist(db, id)
|
||||
if err != nil {
|
||||
return errNotFound("Artist")
|
||||
}
|
||||
albums, err := database.ListAlbumsByArtist(db, id)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &ArtistWithAlbumsID3{ArtistID3: artistToID3(db, artist, rc.User.ID)}
|
||||
for _, al := range albums {
|
||||
out.Album = append(out.Album, albumToID3(db, al, rc.User.ID))
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Artist = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetAlbum(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
db := rc.Server.DB
|
||||
album, err := database.GetAlbum(db, id)
|
||||
if err != nil {
|
||||
return errNotFound("Album")
|
||||
}
|
||||
songs, err := database.ListSongsByAlbum(db, id)
|
||||
if err != nil {
|
||||
return newError(ErrGeneric, err.Error())
|
||||
}
|
||||
out := &AlbumWithSongsID3{AlbumID3: albumToID3(db, album, rc.User.ID)}
|
||||
for _, s := range songs {
|
||||
mf, merr := database.GetMediaFile(db, s.MediaFileID)
|
||||
if merr != nil {
|
||||
continue
|
||||
}
|
||||
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr == nil && !canSee {
|
||||
continue
|
||||
}
|
||||
out.Song = append(out.Song, childFromSong(db, s, mf, album, rc.User.ID))
|
||||
}
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Album = out
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleGetSong(rc *Context) *apiError {
|
||||
id, aerr := rc.Params.Required("id")
|
||||
if aerr != nil {
|
||||
return aerr
|
||||
}
|
||||
db := rc.Server.DB
|
||||
song, err := database.GetSong(db, id)
|
||||
if err != nil {
|
||||
return errNotFound("Song")
|
||||
}
|
||||
mf, err := database.GetMediaFile(db, song.MediaFileID)
|
||||
if err != nil {
|
||||
return errNotFound("Song")
|
||||
}
|
||||
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil {
|
||||
return newError(ErrGeneric, cerr.Error())
|
||||
} else if !canSee {
|
||||
return errUnauthorized("song")
|
||||
}
|
||||
album := albumOfSong(db, song)
|
||||
c := childFromSong(db, song, mf, album, rc.User.ID)
|
||||
rc.Response = newResponse()
|
||||
rc.Response.Song = &c
|
||||
return nil
|
||||
}
|
||||
|
||||
// albumOfSong loads a song's album, tolerating albumless songs.
|
||||
func albumOfSong(db *sql.DB, song *database.Song) *database.Album {
|
||||
if !song.AlbumID.Valid {
|
||||
return nil
|
||||
}
|
||||
album, err := database.GetAlbum(db, song.AlbumID.String)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return album
|
||||
}
|
||||
Reference in New Issue
Block a user