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 }