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 } // RecordPlaybackReport stores one raw playback-timeline event (the // OpenSubsonic reportPlayback extension), independent from the classic // scrobbles/playback_history tables so a client's frequent timeline // pings don't get conflated with actual "this was listened to" history. func RecordPlaybackReport(db *sql.DB, userID, songID string, positionMs int, state, client string, playbackRate float64) error { _, err := db.Exec(`INSERT INTO playback_reports (id, user_id, song_id, position_ms, state, playback_rate, client) VALUES (?, ?, ?, ?, ?, ?, ?)`, idgen.New("pr"), userID, songID, positionMs, state, playbackRate, client) return err }