Files
vibesonic/internal/database/subsonic_helpers2.go
T

288 lines
10 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
}
// 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
}