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