284 lines
7.8 KiB
Go
284 lines
7.8 KiB
Go
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 secondsSince(t time.Time) float64 {
|
|
return time.Since(t).Seconds()
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|