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
+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
}
}