361 lines
9.4 KiB
Go
361 lines
9.4 KiB
Go
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() {
|
|
registerExtension("topSongsByArtistId", 1)
|
|
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
|
|
}
|