278 lines
9.5 KiB
Go
278 lines
9.5 KiB
Go
package database
|
|
|
|
import (
|
|
"database/sql"
|
|
|
|
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
|
)
|
|
|
|
// ListVideoMediaFiles returns every media file the scanner identified as
|
|
// a video (as opposed to audio) track.
|
|
func ListVideoMediaFiles(db *sql.DB) ([]*MediaFile, error) {
|
|
rows, err := db.Query(mediaFileSelect + ` WHERE kind = 'video' AND missing = 0 ORDER BY relative_path`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanMediaFiles(rows)
|
|
}
|
|
|
|
// FindArtistByName looks up an artist by exact, case-insensitive name —
|
|
// used by endpoints that take an artist name rather than ID (getTopSongs).
|
|
func FindArtistByName(db *sql.DB, name string) (*Artist, error) {
|
|
var id string
|
|
err := db.QueryRow(`SELECT id FROM artists WHERE name = ? COLLATE NOCASE LIMIT 1`, name).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return GetArtist(db, id)
|
|
}
|
|
|
|
// TopSongsByArtist returns an artist's songs ordered by total play count
|
|
// (across all users) descending, falling back to any order for songs
|
|
// that have never been played, up to limit.
|
|
func TopSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
|
rows, err := db.Query(`SELECT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
|
FROM songs s
|
|
JOIN song_artists sa ON sa.song_id = s.id
|
|
LEFT JOIN (SELECT song_id, COUNT(*) AS plays FROM playback_history GROUP BY song_id) ph ON ph.song_id = s.id
|
|
WHERE sa.artist_id = ?
|
|
GROUP BY s.id
|
|
ORDER BY COALESCE(MAX(ph.plays), 0) DESC, s.title COLLATE NOCASE
|
|
LIMIT ?`, artistID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanSongs(rows)
|
|
}
|
|
|
|
// SimilarSongsByGenre returns up to limit random songs sharing at least
|
|
// one genre with seedSongID, excluding the seed itself. A simple,
|
|
// honest local substitute for an external recommendation service.
|
|
func SimilarSongsByGenre(db *sql.DB, seedSongID string, limit int) ([]*Song, error) {
|
|
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
|
FROM songs s
|
|
JOIN song_genres sg ON sg.song_id = s.id
|
|
WHERE sg.genre_id IN (SELECT genre_id FROM song_genres WHERE song_id = ?)
|
|
AND s.id != ?
|
|
ORDER BY RANDOM() LIMIT ?`, seedSongID, seedSongID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanSongs(rows)
|
|
}
|
|
|
|
// SimilarSongsByArtist returns up to limit random songs by the given
|
|
// artist (or sharing a genre with them if that artist has few songs),
|
|
// excluding none in particular — used for getSimilarSongs2's artist
|
|
// seed.
|
|
func SimilarSongsByArtist(db *sql.DB, artistID string, limit int) ([]*Song, error) {
|
|
rows, err := db.Query(`SELECT DISTINCT s.id, s.media_file_id, s.album_id, s.title, s.sort_title, s.track_number, s.track_total, s.disc_number, s.disc_total,
|
|
s.composer, s.conductor, s.isrc, s.bpm, s.musicbrainz_track_id, s.musicbrainz_recording_id,
|
|
s.replaygain_track_gain_db, s.replaygain_track_peak, s.replaygain_album_gain_db, s.replaygain_album_peak
|
|
FROM songs s
|
|
JOIN song_genres sg ON sg.song_id = s.id
|
|
WHERE sg.genre_id IN (
|
|
SELECT DISTINCT sg2.genre_id FROM song_artists sa2
|
|
JOIN song_genres sg2 ON sg2.song_id = sa2.song_id
|
|
WHERE sa2.artist_id = ?
|
|
)
|
|
ORDER BY RANDOM() LIMIT ?`, artistID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanSongs(rows)
|
|
}
|
|
|
|
// AlbumsByHighestRating orders albums by their average user rating
|
|
// (unrated albums last).
|
|
func AlbumsByHighestRating(db *sql.DB, limit, offset int) ([]*Album, error) {
|
|
rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at
|
|
FROM albums al
|
|
LEFT JOIN (SELECT item_id, AVG(rating) AS avg_rating FROM ratings WHERE item_type='album' GROUP BY item_id) r ON r.item_id = al.id
|
|
ORDER BY COALESCE(r.avg_rating, 0) DESC, al.sort_title COLLATE NOCASE
|
|
LIMIT ? OFFSET ?`, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAlbums(rows)
|
|
}
|
|
|
|
// AlbumsByPlayCount orders albums by total play count across all users
|
|
// (an album with all its songs never played sorts last).
|
|
func AlbumsByPlayCount(db *sql.DB, limit, offset int) ([]*Album, error) {
|
|
rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at
|
|
FROM albums al
|
|
LEFT JOIN songs s ON s.album_id = al.id
|
|
LEFT JOIN playback_history ph ON ph.song_id = s.id
|
|
GROUP BY al.id
|
|
ORDER BY COUNT(ph.id) DESC, al.sort_title COLLATE NOCASE
|
|
LIMIT ? OFFSET ?`, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAlbums(rows)
|
|
}
|
|
|
|
// AlbumsByRecentlyPlayed orders albums by the most recent play of any of
|
|
// their songs by the given user, most recent first.
|
|
func AlbumsByRecentlyPlayed(db *sql.DB, userID string, limit, offset int) ([]*Album, error) {
|
|
rows, err := db.Query(`SELECT al.id, al.title, al.sort_title, al.year, al.original_year, al.record_label, al.musicbrainz_id, al.primary_album_artist_id, al.cover_art_id, al.created_at
|
|
FROM albums al
|
|
JOIN songs s ON s.album_id = al.id
|
|
JOIN playback_history ph ON ph.song_id = s.id AND ph.user_id = ?
|
|
GROUP BY al.id
|
|
ORDER BY MAX(ph.played_at) DESC
|
|
LIMIT ? OFFSET ?`, userID, limit, offset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanAlbums(rows)
|
|
}
|
|
|
|
// SetSongLyrics replaces a song's stored (unsynced) lyrics with
|
|
// plainText, split into lines. Called by the scanner when a file has
|
|
// embedded lyrics.
|
|
func SetSongLyrics(db *sql.DB, songID, language, plainText string) error {
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.Exec(`DELETE FROM lyrics WHERE song_id = ?`, songID); err != nil {
|
|
return err
|
|
}
|
|
if plainText == "" {
|
|
return tx.Commit()
|
|
}
|
|
lyricsID := idgen.New("ly")
|
|
if _, err := tx.Exec(`INSERT INTO lyrics (id, song_id, language, source, synced) VALUES (?, ?, ?, 'embedded', 0)`,
|
|
lyricsID, songID, language); err != nil {
|
|
return err
|
|
}
|
|
for i, line := range splitLines(plainText) {
|
|
if _, err := tx.Exec(`INSERT INTO lyrics_lines (id, lyrics_id, line_index, text) VALUES (?, ?, ?, ?)`,
|
|
idgen.New("ll"), lyricsID, i, line); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\n' {
|
|
line := s[start:i]
|
|
line = trimCR(line)
|
|
lines = append(lines, line)
|
|
start = i + 1
|
|
}
|
|
}
|
|
lines = append(lines, trimCR(s[start:]))
|
|
return lines
|
|
}
|
|
|
|
func trimCR(s string) string {
|
|
if len(s) > 0 && s[len(s)-1] == '\r' {
|
|
return s[:len(s)-1]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// SongLyricsPlainText returns a song's stored lyrics as plain text
|
|
// (lines joined with "\n"), and whether any were found.
|
|
func SongLyricsPlainText(db *sql.DB, songID string) (string, bool, error) {
|
|
var lyricsID string
|
|
err := db.QueryRow(`SELECT id FROM lyrics WHERE song_id = ? ORDER BY created_at DESC LIMIT 1`, songID).Scan(&lyricsID)
|
|
if err == sql.ErrNoRows {
|
|
return "", false, nil
|
|
}
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
rows, err := db.Query(`SELECT text FROM lyrics_lines WHERE lyrics_id = ? ORDER BY line_index`, lyricsID)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer rows.Close()
|
|
var lines []string
|
|
for rows.Next() {
|
|
var t string
|
|
if err := rows.Scan(&t); err != nil {
|
|
return "", false, err
|
|
}
|
|
lines = append(lines, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return "", false, err
|
|
}
|
|
text := ""
|
|
for i, l := range lines {
|
|
if i > 0 {
|
|
text += "\n"
|
|
}
|
|
text += l
|
|
}
|
|
return text, true, nil
|
|
}
|
|
|
|
// FindSongByTitleArtist searches for a song by title (and optionally
|
|
// artist name), used by the legacy getLyrics endpoint.
|
|
func FindSongByTitleArtist(db *sql.DB, title, artist string) (*Song, error) {
|
|
if artist == "" {
|
|
var id string
|
|
err := db.QueryRow(`SELECT id FROM songs WHERE title = ? COLLATE NOCASE LIMIT 1`, title).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return GetSong(db, id)
|
|
}
|
|
var id string
|
|
err := db.QueryRow(`SELECT s.id FROM songs s
|
|
JOIN song_artists sa ON sa.song_id = s.id
|
|
JOIN artists a ON a.id = sa.artist_id
|
|
WHERE s.title = ? COLLATE NOCASE AND a.name = ? COLLATE NOCASE LIMIT 1`, title, artist).Scan(&id)
|
|
if err == sql.ErrNoRows {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return GetSong(db, id)
|
|
}
|
|
|
|
// TotalMediaFileCount returns how many (non-missing) media files are
|
|
// indexed across all folders, for getScanStatus's idle "count".
|
|
func TotalMediaFileCount(db *sql.DB) (int64, error) {
|
|
var n int64
|
|
err := db.QueryRow(`SELECT COUNT(*) FROM media_files WHERE missing = 0`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// TotalFilesSeenAcrossScans sums files_seen for folders currently mid-
|
|
// scan, for getScanStatus's in-progress "count".
|
|
func TotalFilesSeenAcrossScans(db *sql.DB) (int64, error) {
|
|
var n int64
|
|
err := db.QueryRow(`SELECT COALESCE(SUM(files_seen), 0) FROM scan_state WHERE status = 'scanning'`).Scan(&n)
|
|
return n, err
|
|
}
|