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:
2026-08-23 00:06:08 -05:00
parent b5e2bf01e2
commit f40a011c46
9 changed files with 1732 additions and 4 deletions
+359
View File
@@ -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
}
+233
View File
@@ -0,0 +1,233 @@
package subsonic
import (
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
"git.noroutes.net/NoRoutes/vibetunes/internal/streaming"
)
func handleGetVideos(rc *Context) *apiError {
db := rc.Server.DB
files, err := database.ListVideoMediaFiles(db)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &Videos{}
for _, mf := range files {
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee {
continue
}
song, serr := database.GetSongByMediaFileID(db, mf.ID)
if serr != nil {
continue
}
out.Video = append(out.Video, childFromSong(db, song, mf, albumOfSong(db, song), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.Videos = out
return nil
}
// sidecarCaptionExtensions are checked next to a video file for a
// same-basename subtitle track — the common convention most media
// managers and players use (movie.mp4 + movie.srt/.vtt/.ass).
var sidecarCaptionExtensions = []string{".srt", ".vtt", ".ass"}
func findSidecarCaptions(videoAbsPath string) []string {
dir := filepath.Dir(videoAbsPath)
base := strings.TrimSuffix(filepath.Base(videoAbsPath), filepath.Ext(videoAbsPath))
var found []string
for _, ext := range sidecarCaptionExtensions {
p := filepath.Join(dir, base+ext)
if _, err := os.Stat(p); err == nil {
found = append(found, p)
}
}
return found
}
func handleGetVideoInfo(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("Video")
}
mf, err := database.GetMediaFile(db, song.MediaFileID)
if err != nil {
return errNotFound("Video")
}
if canSee, cerr := UserCanSeeMediaFile(db, rc.User, mf); cerr != nil || !canSee {
return errUnauthorized("video")
}
folder, err := database.GetMusicFolder(db, mf.MusicFolderID)
if err != nil {
return newError(ErrGeneric, err.Error())
}
absPath, perr := streaming.ResolvePath(folder.Path, mf.RelativePath)
if perr != nil {
return newError(ErrGeneric, perr.Error())
}
info := &VideoInfo{ID: id, AudioTrack: []AudioTrack{{ID: id, Name: "Default"}}}
for i, capPath := range findSidecarCaptions(absPath) {
info.Captions = append(info.Captions, Captions{ID: id + ":cap:" + strconv.Itoa(i), Name: filepath.Base(capPath)})
}
rc.Response = newResponse()
rc.Response.VideoInfo = info
return nil
}
// --- Artist/album info: VibeSonic has no external metadata provider
// (last.fm etc.) in Phase 2, so these honestly return only what's known
// locally — which for biography/images/similar-artists is nothing —
// rather than fabricating placeholder content. ---
func handleGetArtistInfo(rc *Context) *apiError {
if _, aerr := rc.Params.Required("id"); aerr != nil {
return aerr
}
rc.Response = newResponse()
rc.Response.ArtistInfo = &ArtistInfo{}
return nil
}
func handleGetArtistInfo2(rc *Context) *apiError {
if _, aerr := rc.Params.Required("id"); aerr != nil {
return aerr
}
rc.Response = newResponse()
rc.Response.ArtistInfo2 = &ArtistInfo2{}
return nil
}
func handleGetAlbumInfo(rc *Context) *apiError {
if _, aerr := rc.Params.Required("id"); aerr != nil {
return aerr
}
rc.Response = newResponse()
rc.Response.AlbumInfo = &AlbumInfo{}
return nil
}
func handleGetAlbumInfo2(rc *Context) *apiError {
if _, aerr := rc.Params.Required("id"); aerr != nil {
return aerr
}
rc.Response = newResponse()
rc.Response.AlbumInfo = &AlbumInfo{}
return nil
}
// --- Similar/top songs: a real, local, genre-based heuristic rather
// than a fabricated or hardcoded list. ---
func handleGetSimilarSongs(rc *Context) *apiError {
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
count := rc.Params.Int("count", 50)
db := rc.Server.DB
songs, serr := similarSongsFor(db, id, count)
if serr != nil {
return errNotFound("Item")
}
out := &SimilarSongs{}
for _, s := range songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.SimilarSongs = out
return nil
}
func handleGetSimilarSongs2(rc *Context) *apiError {
id, aerr := rc.Params.Required("id")
if aerr != nil {
return aerr
}
count := rc.Params.Int("count", 50)
db := rc.Server.DB
// id here names an artist (ID3 context).
songs, err := database.SimilarSongsByArtist(db, id, count)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &SimilarSongs2{}
for _, s := range songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.SimilarSongs2 = out
return nil
}
// similarSongsFor resolves id as a song, album, or artist (getSimilarSongs's
// "id" can be any of the three in real Subsonic usage) and finds
// genre-similar songs for it.
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
if song, err := database.GetSong(db, id); err == nil {
return database.SimilarSongsByGenre(db, song.ID, count)
}
if _, err := database.GetAlbum(db, id); err == nil {
songs, err := database.ListSongsByAlbum(db, id)
if err != nil || len(songs) == 0 {
return nil, err
}
return database.SimilarSongsByGenre(db, songs[0].ID, count)
}
if _, err := database.GetArtist(db, id); err == nil {
return database.SimilarSongsByArtist(db, id, count)
}
return nil, database.ErrNotFound
}
func handleGetTopSongs(rc *Context) *apiError {
artistName, aerr := rc.Params.Required("artist")
if aerr != nil {
return aerr
}
count := rc.Params.Int("count", 50)
db := rc.Server.DB
artist, err := database.FindArtistByName(db, artistName)
if err != nil {
rc.Response = newResponse()
rc.Response.TopSongs = &TopSongs{}
return nil
}
songs, err := database.TopSongsByArtist(db, artist.ID, count)
if err != nil {
return newError(ErrGeneric, err.Error())
}
out := &TopSongs{}
for _, s := range songs {
mf, merr := database.GetMediaFile(db, s.MediaFileID)
if merr != nil {
continue
}
out.Song = append(out.Song, childFromSong(db, s, mf, albumOfSong(db, s), rc.User.ID))
}
rc.Response = newResponse()
rc.Response.TopSongs = out
return nil
}
+257
View File
@@ -0,0 +1,257 @@
package subsonic
import (
"context"
"encoding/xml"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"testing"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// seedLibrary scans a small real (ffmpeg-generated) library into s so
// browsing endpoints have real rows to return. Skips the test if
// ffmpeg/ffprobe aren't installed.
func seedLibrary(t *testing.T, s *Server) (folder *database.MusicFolder) {
t.Helper()
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed")
}
dir := t.TempDir()
songDir := filepath.Join(dir, "Test Artist", "Test Album")
if err := os.MkdirAll(songDir, 0o755); err != nil {
t.Fatal(err)
}
for i, title := range []string{"First Song", "Second Song"} {
out := filepath.Join(songDir, title+".mp3")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
cmd := exec.CommandContext(ctx, "ffmpeg",
"-f", "lavfi", "-i", "sine=frequency=440:duration=1",
"-metadata", "title="+title,
"-metadata", "artist=Test Artist",
"-metadata", "album=Test Album",
"-metadata", "genre=Rock",
"-metadata", "track="+strconv.Itoa(i+1),
"-metadata", "date=2021",
"-codec:a", "libmp3lame", "-b:a", "64k",
out)
if combined, err := cmd.CombinedOutput(); err != nil {
cancel()
t.Fatalf("ffmpeg: %v\n%s", err, combined)
}
cancel()
}
f, err := database.CreateMusicFolder(s.DB, "Test Music", dir, "music")
if err != nil {
t.Fatal(err)
}
if err := s.Scanner.ScanFolder(context.Background(), f.ID); err != nil {
t.Fatalf("ScanFolder: %v", err)
}
return f
}
func TestGetMusicFoldersAndGenresAndArtists(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getMusicFolders.view", q)
j := decodeJSON(t, resp)
folders := j["musicFolders"].(map[string]any)["musicFolder"].([]any)
if len(folders) != 1 {
t.Fatalf("musicFolders = %v, want 1", folders)
}
resp = getPath(t, srv.URL, "/rest/getGenres.view", q)
j = decodeJSON(t, resp)
genres := j["genres"].(map[string]any)["genre"].([]any)
if len(genres) != 1 {
t.Fatalf("genres = %v, want 1", genres)
}
resp = getPath(t, srv.URL, "/rest/getArtists.view", q)
j = decodeJSON(t, resp)
indexes := j["artists"].(map[string]any)["index"].([]any)
if len(indexes) == 0 {
t.Fatalf("artists index = %v, want at least 1 letter group", indexes)
}
}
func TestGetArtistAndAlbumAndSong(t *testing.T) {
s, db := newTestServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
srv := httptest.NewServer(s.Router())
defer srv.Close()
artists, err := database.ListArtists(db)
if err != nil || len(artists) == 0 {
t.Fatalf("ListArtists: %v %v", artists, err)
}
artistID := artists[0].ID
q := baseParams("alice", "password123")
q.Set("f", "json")
q.Set("id", artistID)
resp := getPath(t, srv.URL, "/rest/getArtist.view", q)
j := decodeJSON(t, resp)
artist := j["artist"].(map[string]any)
albums := artist["album"].([]any)
if len(albums) != 1 {
t.Fatalf("albums = %v, want 1", albums)
}
albumID := albums[0].(map[string]any)["id"].(string)
q.Set("id", albumID)
resp = getPath(t, srv.URL, "/rest/getAlbum.view", q)
j = decodeJSON(t, resp)
album := j["album"].(map[string]any)
songs := album["song"].([]any)
if len(songs) != 2 {
t.Fatalf("songs = %v, want 2", songs)
}
songID := songs[0].(map[string]any)["id"].(string)
q.Set("id", songID)
resp = getPath(t, srv.URL, "/rest/getSong.view", q)
j = decodeJSON(t, resp)
song := j["song"].(map[string]any)
if song["title"] == nil {
t.Fatalf("song missing title: %v", song)
}
}
func TestGetAlbumNotFound(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("id", "al_doesnotexist")
resp := getPath(t, srv.URL, "/rest/getAlbum.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrNotFound {
t.Fatalf("expected error 70, got %+v", x)
}
}
func TestGetAlbumMissingID(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
resp := getPath(t, srv.URL, "/rest/getAlbum.view", baseParams("alice", "password123"))
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrMissingParameter {
t.Fatalf("expected error 10, got %+v", x)
}
}
func TestGetIndexesAndMusicDirectory(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
seedLibrary(t, s)
q := baseParams("alice", "password123")
resp := getPath(t, srv.URL, "/rest/getIndexes.view", q)
defer resp.Body.Close()
var out struct {
Indexes struct {
Index []struct {
Artist []struct {
ID string `xml:"id,attr"`
Name string `xml:"name,attr"`
} `xml:"artist"`
} `xml:"index"`
} `xml:"indexes"`
}
if err := xml.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode: %v", err)
}
if len(out.Indexes.Index) == 0 || len(out.Indexes.Index[0].Artist) == 0 {
t.Fatalf("expected at least one artist directory, got %+v", out)
}
dirID := out.Indexes.Index[0].Artist[0].ID
q2 := baseParams("alice", "password123")
q2.Set("id", dirID)
resp2 := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q2)
defer resp2.Body.Close()
var dirOut struct {
Directory struct {
Child []struct {
IsDir bool `xml:"isDir,attr"`
Title string `xml:"title,attr"`
} `xml:"child"`
} `xml:"directory"`
}
if err := xml.NewDecoder(resp2.Body).Decode(&dirOut); err != nil {
t.Fatalf("decode: %v", err)
}
if len(dirOut.Directory.Child) != 1 || !dirOut.Directory.Child[0].IsDir {
t.Fatalf("expected one subdirectory (the album), got %+v", dirOut)
}
}
func TestGetMusicDirectoryUnauthorizedFolder(t *testing.T) {
s, db := newTestServer(t)
u := createTestUser(t, s, "alice", "password123", false)
folder := seedLibrary(t, s)
srv := httptest.NewServer(s.Router())
defer srv.Close()
// Restrict alice to a different (nonexistent) folder set, i.e. no access to `folder`.
other, err := database.CreateMusicFolder(db, "Other", t.TempDir(), "music")
if err != nil {
t.Fatal(err)
}
if err := database.SetAllowedMusicFolders(db, u.ID, []string{other.ID}); err != nil {
t.Fatal(err)
}
q := baseParams("alice", "password123")
q.Set("id", encodeDirID(folder.ID, "Test Artist"))
resp := getPath(t, srv.URL, "/rest/getMusicDirectory.view", q)
x := decodeXML(t, resp)
if x.Status != "failed" || x.Error == nil || x.Error.Code != ErrUnauthorized {
t.Fatalf("expected error 50 for restricted folder, got %+v", x)
}
}
func TestGetVideosEmpty(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("f", "json")
resp := getPath(t, srv.URL, "/rest/getVideos.view", q)
j := decodeJSON(t, resp)
if j["status"] != "ok" {
t.Fatalf("getVideos status = %v", j["status"])
}
}
func TestGetTopSongsUnknownArtist(t *testing.T) {
s, srv := newTestRecorderServer(t)
createTestUser(t, s, "alice", "password123", false)
q := baseParams("alice", "password123")
q.Set("artist", "Nonexistent Artist XYZ")
resp := getPath(t, srv.URL, "/rest/getTopSongs.view", q)
x := decodeXML(t, resp)
if x.Status != "ok" {
t.Fatalf("getTopSongs for unknown artist should return ok with empty list, got %+v", x)
}
}
+279
View File
@@ -0,0 +1,279 @@
package subsonic
import (
"database/sql"
"strings"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// This file is the single place database rows become Subsonic wire
// types. Every handler goes through these functions rather than
// building Child/AlbumID3/ArtistID3 literals itself, which is what
// keeps XML and JSON output (and every endpoint that happens to return
// the same entity type) semantically identical.
func isoTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02T15:04:05.000Z")
}
func nullTimeToISO(t sql.NullTime) string {
if !t.Valid {
return ""
}
return isoTime(t.Time)
}
// songArtistName returns the display artist string for a song: its own
// credited performers if any, otherwise falls back to the album's
// artist.
func songArtistName(db *sql.DB, song *database.Song, albumArtist string) string {
names, err := database.ListSongArtistNames(db, song.ID)
if err == nil && len(names) > 0 {
return strings.Join(names, ", ")
}
return albumArtist
}
// songCoverArt resolves the coverArt id to advertise for a song: the
// owning album's cover art row if it has one, else "".
func songCoverArt(album *database.Album) string {
if album != nil && album.CoverArtID.Valid {
return album.CoverArtID.String
}
return ""
}
// childFromSong builds the polymorphic Child element for a song, given
// its media file and (possibly nil, for albumless tracks) album. userID
// is used to fill in the per-user starred/rating/playCount fields; pass
// "" to omit them (e.g. for anonymous/system contexts, which don't
// exist in practice since every /rest/* call is authenticated).
func childFromSong(db *sql.DB, song *database.Song, mf *database.MediaFile, album *database.Album, userID string) Child {
var albumTitle, artistName, artistID, genre string
var year int
if album != nil {
albumTitle = album.Title
if album.PrimaryAlbumArtistID.Valid {
artistID = album.PrimaryAlbumArtistID.String
}
if album.Year.Valid {
year = int(album.Year.Int64)
}
if name, err := database.AlbumArtistName(db, album); err == nil {
artistName = name
}
}
artistName = songArtistName(db, song, artistName)
if g, err := database.FirstGenreForSong(db, song.ID); err == nil {
genre = g
}
c := Child{
ID: song.ID,
IsDir: false,
Title: song.Title,
Album: albumTitle,
Artist: artistName,
Genre: genre,
Year: year,
CoverArt: songCoverArt(album),
Size: mf.SizeBytes,
ContentType: contentTypeForFormat(mf.Format),
Suffix: suffixForFormat(mf.Format),
Duration: int(mf.DurationSeconds + 0.5),
BitRate: mf.BitrateKbps,
Path: mf.RelativePath,
Created: isoTime(mf.FirstSeenAt),
Type: "music",
}
if song.TrackNumber.Valid {
c.Track = int(song.TrackNumber.Int64)
}
if song.DiscNumber.Valid {
c.DiscNumber = int(song.DiscNumber.Int64)
}
if album != nil {
c.AlbumID = album.ID
}
if artistID != "" {
c.ArtistID = artistID
}
if mf.Kind == "video" {
c.IsVideo = true
}
if userID != "" {
if starredAt, err := starredTime(db, userID, "song", song.ID); err == nil && starredAt != "" {
c.Starred = starredAt
}
if rating, err := userRating(db, userID, "song", song.ID); err == nil {
c.UserRating = rating
}
if count, err := playCount(db, userID, song.ID); err == nil {
c.PlayCount = count
}
}
return c
}
func starredTime(db *sql.DB, userID, itemType, itemID string) (string, error) {
var createdAt string
err := db.QueryRow(`SELECT created_at FROM stars WHERE user_id=? AND item_type=? AND item_id=?`, userID, itemType, itemID).Scan(&createdAt)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
t, _ := parseISOTime(createdAt)
return isoTime(t), nil
}
func userRating(db *sql.DB, userID, itemType, itemID string) (int, error) {
var rating int
err := db.QueryRow(`SELECT rating FROM ratings WHERE user_id=? AND item_type=? AND item_id=?`, userID, itemType, itemID).Scan(&rating)
if err == sql.ErrNoRows {
return 0, nil
}
return rating, err
}
func playCount(db *sql.DB, userID, songID string) (int64, error) {
var n int64
err := db.QueryRow(`SELECT COUNT(*) FROM playback_history WHERE user_id=? AND song_id=?`, userID, songID).Scan(&n)
return n, err
}
func parseISOTime(s string) (time.Time, error) {
return time.Parse("2006-01-02T15:04:05.999999999Z", s)
}
// albumToID3 builds the ID3-browsing AlbumID3 element.
func albumToID3(db *sql.DB, album *database.Album, userID string) AlbumID3 {
songCount, duration, _ := database.AlbumStats(db, album.ID)
artistName, _ := database.AlbumArtistName(db, album)
genre, _ := database.FirstGenreForAlbum(db, album.ID)
a := AlbumID3{
ID: album.ID,
Name: album.Title,
Artist: artistName,
SongCount: songCount,
Duration: int(duration + 0.5),
Created: isoTime(album.CreatedAt),
Genre: genre,
}
if album.PrimaryAlbumArtistID.Valid {
a.ArtistID = album.PrimaryAlbumArtistID.String
}
if album.CoverArtID.Valid {
a.CoverArt = album.CoverArtID.String
}
if album.Year.Valid {
a.Year = int(album.Year.Int64)
}
if userID != "" {
if s, err := starredTime(db, userID, "album", album.ID); err == nil {
a.Starred = s
}
}
return a
}
// childFromAlbum represents an album as a Child (used by getAlbumList,
// the filesystem-style AlbumList, and search2's album matches).
func childFromAlbum(db *sql.DB, album *database.Album, userID string) Child {
_, duration, _ := database.AlbumStats(db, album.ID)
artistName, _ := database.AlbumArtistName(db, album)
genre, _ := database.FirstGenreForAlbum(db, album.ID)
c := Child{
ID: album.ID,
IsDir: true,
Title: album.Title,
Album: album.Title,
Artist: artistName,
Genre: genre,
Duration: int(duration + 0.5),
Created: isoTime(album.CreatedAt),
}
if album.PrimaryAlbumArtistID.Valid {
c.ArtistID = album.PrimaryAlbumArtistID.String
}
if album.CoverArtID.Valid {
c.CoverArt = album.CoverArtID.String
}
if album.Year.Valid {
c.Year = int(album.Year.Int64)
}
if userID != "" {
if s, err := starredTime(db, userID, "album", album.ID); err == nil {
c.Starred = s
}
}
return c
}
// artistToID3 builds the ID3-browsing ArtistID3 element.
func artistToID3(db *sql.DB, artist *database.Artist, userID string) ArtistID3 {
albumCount, _ := database.ArtistAlbumCount(db, artist.ID)
coverArt, _ := database.ArtistCoverArtID(db, artist.ID)
a := ArtistID3{ID: artist.ID, Name: artist.Name, AlbumCount: albumCount, CoverArt: coverArt}
if userID != "" {
if s, err := starredTime(db, userID, "artist", artist.ID); err == nil {
a.Starred = s
}
}
return a
}
// artistToFSArtist builds the filesystem-browsing Artist element (used
// by getIndexes), keyed by music folder directory name rather than an
// ID3 tag identity.
func artistToFSArtist(id, name string) Artist {
return Artist{ID: id, Name: name}
}
// contentTypeForFormat maps a probed container/codec name to a MIME
// type for the Subsonic contentType attribute.
func contentTypeForFormat(format string) string {
switch strings.ToLower(format) {
case "mp3":
return "audio/mpeg"
case "flac":
return "audio/flac"
case "ogg", "oga":
return "audio/ogg"
case "opus":
return "audio/opus"
case "aac", "adts":
return "audio/aac"
case "mov", "mp4", "m4a", "m4b", "3gp", "3g2", "mj2":
return "audio/mp4"
case "wav":
return "audio/wav"
case "aiff", "aif":
return "audio/aiff"
case "asf", "wma":
return "audio/x-ms-wma"
default:
return "application/octet-stream"
}
}
// suffixForFormat maps a probed format name to a plain file extension
// for the Subsonic suffix attribute.
func suffixForFormat(format string) string {
f := strings.ToLower(format)
switch f {
case "mov", "mp4", "3gp", "3g2", "mj2":
return "m4a"
default:
return f
}
}
+98
View File
@@ -0,0 +1,98 @@
package subsonic
import (
"database/sql"
"strconv"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
// folderMap resolves between VibeSonic's opaque music-folder IDs and the
// small sequential integers the Subsonic protocol expects for
// musicFolderId (the official XSD types it as xs:int, and some clients
// build folder-picker UIs assuming small integers). The mapping is
// derived fresh per request from folder creation order, which is stable
// as long as folders aren't deleted/recreated — acceptable for how
// rarely music folders change.
type folderMap struct {
folders []*database.MusicFolder
toInt map[string]int
toFolder map[int]*database.MusicFolder
}
func loadFolderMap(db *sql.DB) (*folderMap, error) {
folders, err := database.ListMusicFolders(db)
if err != nil {
return nil, err
}
fm := &folderMap{
folders: folders,
toInt: make(map[string]int, len(folders)),
toFolder: make(map[int]*database.MusicFolder, len(folders)),
}
for i, f := range folders {
id := i + 1
fm.toInt[f.ID] = id
fm.toFolder[id] = f
}
return fm, nil
}
func (fm *folderMap) IntID(folderID string) string {
return strconv.Itoa(fm.toInt[folderID])
}
func (fm *folderMap) ByIntID(s string) *database.MusicFolder {
n, err := strconv.Atoi(s)
if err != nil {
return nil
}
return fm.toFolder[n]
}
// Allowed returns the folders a user may see: every folder, unless the
// user has an explicit restriction list configured.
func (fm *folderMap) Allowed(db *sql.DB, user *database.User) ([]*database.MusicFolder, error) {
allowedIDs, err := database.AllowedMusicFolderIDs(db, user.ID)
if err != nil {
return nil, err
}
if len(allowedIDs) == 0 {
return fm.folders, nil
}
set := make(map[string]bool, len(allowedIDs))
for _, id := range allowedIDs {
set[id] = true
}
var out []*database.MusicFolder
for _, f := range fm.folders {
if set[f.ID] {
out = append(out, f)
}
}
return out, nil
}
// UserCanSeeFolder reports whether user is allowed to see folderID,
// given their restriction list (empty = unrestricted).
func UserCanSeeFolder(db *sql.DB, user *database.User, folderID string) (bool, error) {
allowedIDs, err := database.AllowedMusicFolderIDs(db, user.ID)
if err != nil {
return false, err
}
if len(allowedIDs) == 0 {
return true, nil
}
for _, id := range allowedIDs {
if id == folderID {
return true, nil
}
}
return false, nil
}
// UserCanSeeMediaFile reports whether user is allowed to see a song
// belonging to the given music folder (per their folder restriction).
func UserCanSeeMediaFile(db *sql.DB, user *database.User, mediaFile *database.MediaFile) (bool, error) {
return UserCanSeeFolder(db, user, mediaFile.MusicFolderID)
}