getArtistInfo/2 and getAlbumInfo/2 now return a real MusicBrainz disambiguation/cover-art lookup for entities that carry a locally tagged MusicBrainz ID, gated behind the new VIBESONIC_ENABLE_EXTERNAL_METADATA flag (default off). Responses are cached in the existing external_metadata_cache table for 30 days to avoid hammering the free MusicBrainz/CAA services. VibeSonic still works completely offline: with the flag off, or when an entity has no tagged MusicBrainz ID, or on any network/lookup failure, the handlers fall back to local-only fields exactly as before — nothing is ever fabricated, and a failed external lookup never fails the request. internal/metadataenrich is a small, independently-testable HTTP client for the two real APIs (base URLs overridable for tests, exercised against httptest servers rather than the live network). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014r98AtcBbevpscWLsfwVvv
317 lines
9.1 KiB
Go
317 lines
9.1 KiB
Go
package subsonic
|
|
|
|
import (
|
|
"database/sql"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/similarity"
|
|
"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: local data only (title/name, and a
|
|
// MusicBrainz ID if one was tagged), optionally enriched from
|
|
// MusicBrainz/Cover Art Archive when VIBESONIC_ENABLE_EXTERNAL_METADATA
|
|
// is set and the entity has a tagged MusicBrainz ID — see enrich.go.
|
|
// VibeSonic must work completely offline; enrichment is always
|
|
// best-effort and never blocks or fails the response. ---
|
|
|
|
func handleGetArtistInfo(rc *Context) *apiError {
|
|
id, aerr := rc.Params.Required("id")
|
|
if aerr != nil {
|
|
return aerr
|
|
}
|
|
rc.Response = newResponse()
|
|
rc.Response.ArtistInfo = &ArtistInfo{ArtistInfoBase: artistInfoBase(rc, id)}
|
|
return nil
|
|
}
|
|
|
|
func handleGetArtistInfo2(rc *Context) *apiError {
|
|
id, aerr := rc.Params.Required("id")
|
|
if aerr != nil {
|
|
return aerr
|
|
}
|
|
rc.Response = newResponse()
|
|
rc.Response.ArtistInfo2 = &ArtistInfo2{ArtistInfoBase: artistInfoBase(rc, id)}
|
|
return nil
|
|
}
|
|
|
|
func handleGetAlbumInfo(rc *Context) *apiError {
|
|
id, aerr := rc.Params.Required("id")
|
|
if aerr != nil {
|
|
return aerr
|
|
}
|
|
rc.Response = newResponse()
|
|
rc.Response.AlbumInfo = albumInfo(rc, id)
|
|
return nil
|
|
}
|
|
|
|
func handleGetAlbumInfo2(rc *Context) *apiError {
|
|
id, aerr := rc.Params.Required("id")
|
|
if aerr != nil {
|
|
return aerr
|
|
}
|
|
rc.Response = newResponse()
|
|
rc.Response.AlbumInfo = albumInfo(rc, id)
|
|
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), finds
|
|
// genre-similar songs for it, and — when the seed song has been sonic-
|
|
// analyzed — re-ranks that genre-matched candidate set by actual sonic
|
|
// distance rather than leaving it in the database's random order. This
|
|
// never replaces the genre filter with pure sonic search (that's what
|
|
// getSonicSimilarTracks is for); it just makes "similar" among
|
|
// genre-mates mean something more concrete when the data exists to
|
|
// back it up.
|
|
func similarSongsFor(db *sql.DB, id string, count int) ([]*database.Song, error) {
|
|
if song, err := database.GetSong(db, id); err == nil {
|
|
songs, err := database.SimilarSongsByGenre(db, song.ID, count)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return reorderBySonicSimilarity(db, song.ID, songs), nil
|
|
}
|
|
if _, err := database.GetAlbum(db, id); err == nil {
|
|
songs, err := database.ListSongsByAlbum(db, id)
|
|
if err != nil || len(songs) == 0 {
|
|
return nil, err
|
|
}
|
|
similar, err := database.SimilarSongsByGenre(db, songs[0].ID, count)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return reorderBySonicSimilarity(db, songs[0].ID, similar), nil
|
|
}
|
|
if _, err := database.GetArtist(db, id); err == nil {
|
|
return database.SimilarSongsByArtist(db, id, count)
|
|
}
|
|
return nil, database.ErrNotFound
|
|
}
|
|
|
|
// reorderBySonicSimilarity sorts songs by real sonic distance from
|
|
// seedSongID when both the seed and (some of) the candidates have
|
|
// stored audio_features; candidates without analysis data are left in
|
|
// their existing relative order at the end, never dropped and never
|
|
// assigned a fabricated distance.
|
|
func reorderBySonicSimilarity(db *sql.DB, seedSongID string, songs []*database.Song) []*database.Song {
|
|
if len(songs) < 2 {
|
|
return songs
|
|
}
|
|
seed, err := database.GetAudioFeatures(db, seedSongID)
|
|
if err != nil || seed == nil {
|
|
return songs
|
|
}
|
|
|
|
items := []similarity.Item{{ID: seedSongID, Vector: seed.Vector()}}
|
|
analyzed := map[string]bool{}
|
|
for _, s := range songs {
|
|
f, err := database.GetAudioFeatures(db, s.ID)
|
|
if err != nil || f == nil {
|
|
continue
|
|
}
|
|
items = append(items, similarity.Item{ID: s.ID, Vector: f.Vector()})
|
|
analyzed[s.ID] = true
|
|
}
|
|
if len(items) < 2 {
|
|
return songs
|
|
}
|
|
idx := similarity.Build(items)
|
|
|
|
sorted := make([]*database.Song, len(songs))
|
|
copy(sorted, songs)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
ai, aj := analyzed[sorted[i].ID], analyzed[sorted[j].ID]
|
|
if ai != aj {
|
|
return ai // analyzed songs sort before unanalyzed ones
|
|
}
|
|
if !ai {
|
|
return false // preserve relative order among unanalyzed songs
|
|
}
|
|
return idx.DistanceBetween(seedSongID, sorted[i].ID) < idx.DistanceBetween(seedSongID, sorted[j].ID)
|
|
})
|
|
return sorted
|
|
}
|
|
|
|
func handleGetTopSongs(rc *Context) *apiError {
|
|
count := rc.Params.Int("count", 50)
|
|
db := rc.Server.DB
|
|
|
|
// topSongsByArtistId: an explicit artist ID (the canonical internal
|
|
// ID, same as everywhere else) takes priority over the legacy
|
|
// name-based lookup when both are given, since it's unambiguous
|
|
// where a name match can collide.
|
|
var artistID string
|
|
if id := rc.Params.Get("id"); id != "" {
|
|
artist, err := database.GetArtist(db, id)
|
|
if err != nil {
|
|
rc.Response = newResponse()
|
|
rc.Response.TopSongs = &TopSongs{}
|
|
return nil
|
|
}
|
|
artistID = artist.ID
|
|
} else {
|
|
artistName, aerr := rc.Params.Required("artist")
|
|
if aerr != nil {
|
|
return aerr
|
|
}
|
|
artist, err := database.FindArtistByName(db, artistName)
|
|
if err != nil {
|
|
rc.Response = newResponse()
|
|
rc.Response.TopSongs = &TopSongs{}
|
|
return nil
|
|
}
|
|
artistID = artist.ID
|
|
}
|
|
|
|
songs, err := database.TopSongsByArtist(db, artistID, 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
|
|
}
|