Files
vibesonic/internal/subsonic/browsing.go
T

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
}