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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user