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 }