Foundation: config, schema/migrations, auth, stable IDs, metadata probing
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.noroutes.net/NoRoutes/vibetunes/internal/idgen"
|
||||
)
|
||||
|
||||
// --- Stars (favorites) ---
|
||||
|
||||
func Star(db *sql.DB, userID, itemType, itemID string) error {
|
||||
_, err := db.Exec(`INSERT OR IGNORE INTO stars (id, user_id, item_type, item_id) VALUES (?, ?, ?, ?)`, idgen.New("st"), userID, itemType, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
func Unstar(db *sql.DB, userID, itemType, itemID string) error {
|
||||
_, err := db.Exec(`DELETE FROM stars WHERE user_id = ? AND item_type = ? AND item_id = ?`, userID, itemType, itemID)
|
||||
return err
|
||||
}
|
||||
|
||||
func IsStarred(db *sql.DB, userID, itemType, itemID string) (bool, error) {
|
||||
var n int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM stars WHERE user_id = ? AND item_type = ? AND item_id = ?`, userID, itemType, itemID).Scan(&n)
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
func ListStarredIDs(db *sql.DB, userID, itemType string) ([]string, error) {
|
||||
rows, err := db.Query(`SELECT item_id FROM stars WHERE user_id = ? AND item_type = ? ORDER BY created_at DESC`, userID, itemType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- Ratings ---
|
||||
|
||||
func SetRating(db *sql.DB, userID, itemType, itemID string, rating int) error {
|
||||
if rating <= 0 {
|
||||
_, err := db.Exec(`DELETE FROM ratings WHERE user_id = ? AND item_type = ? AND item_id = ?`, userID, itemType, itemID)
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`INSERT INTO ratings (id, user_id, item_type, item_id, rating) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, item_type, item_id) DO UPDATE SET rating = excluded.rating`,
|
||||
idgen.New("rt"), userID, itemType, itemID, rating)
|
||||
return err
|
||||
}
|
||||
|
||||
// --- Scrobbles & playback history ---
|
||||
|
||||
func RecordScrobble(db *sql.DB, userID, songID, client string, submittedToLastfm bool) error {
|
||||
_, err := db.Exec(`INSERT INTO scrobbles (id, user_id, song_id, client, submitted_to_lastfm) VALUES (?, ?, ?, ?, ?)`,
|
||||
idgen.New("sb"), userID, songID, client, boolInt(submittedToLastfm))
|
||||
return err
|
||||
}
|
||||
|
||||
func RecordPlayback(db *sql.DB, userID, songID string, playDurationSeconds float64) error {
|
||||
_, err := db.Exec(`INSERT INTO playback_history (id, user_id, song_id, play_duration_seconds) VALUES (?, ?, ?, ?)`,
|
||||
idgen.New("ph"), userID, songID, playDurationSeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
type PlaybackHistoryEntry struct {
|
||||
SongID string
|
||||
PlayedAt time.Time
|
||||
}
|
||||
|
||||
func ListRecentlyPlayed(db *sql.DB, userID string, limit int) ([]PlaybackHistoryEntry, error) {
|
||||
rows, err := db.Query(`SELECT song_id, played_at FROM playback_history WHERE user_id = ? ORDER BY played_at DESC LIMIT ?`, userID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PlaybackHistoryEntry
|
||||
for rows.Next() {
|
||||
var e PlaybackHistoryEntry
|
||||
var playedAt string
|
||||
if err := rows.Scan(&e.SongID, &playedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.PlayedAt, _ = parseTime(playedAt)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- Now playing ---
|
||||
|
||||
func SetNowPlaying(db *sql.DB, userID, songID, client string, positionSeconds float64) error {
|
||||
_, err := db.Exec(`INSERT INTO now_playing (user_id, song_id, client, position_seconds) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET song_id=excluded.song_id, started_at=strftime('%Y-%m-%dT%H:%M:%fZ','now'), client=excluded.client, position_seconds=excluded.position_seconds`,
|
||||
userID, songID, client, positionSeconds)
|
||||
return err
|
||||
}
|
||||
|
||||
func ClearNowPlaying(db *sql.DB, userID string) error {
|
||||
_, err := db.Exec(`DELETE FROM now_playing WHERE user_id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
type NowPlayingEntry struct {
|
||||
UserID string
|
||||
Username string
|
||||
SongID string
|
||||
StartedAt time.Time
|
||||
Client string
|
||||
PositionSeconds float64
|
||||
}
|
||||
|
||||
func ListNowPlaying(db *sql.DB) ([]NowPlayingEntry, error) {
|
||||
rows, err := db.Query(`SELECT np.user_id, u.username, np.song_id, np.started_at, np.client, np.position_seconds
|
||||
FROM now_playing np JOIN users u ON u.id = np.user_id ORDER BY np.started_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []NowPlayingEntry
|
||||
for rows.Next() {
|
||||
var e NowPlayingEntry
|
||||
var startedAt string
|
||||
if err := rows.Scan(&e.UserID, &e.Username, &e.SongID, &startedAt, &e.Client, &e.PositionSeconds); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.StartedAt, _ = parseTime(startedAt)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- Bookmarks ---
|
||||
|
||||
type Bookmark struct {
|
||||
ID string
|
||||
SongID string
|
||||
PositionMs int
|
||||
Comment string
|
||||
}
|
||||
|
||||
func SetBookmark(db *sql.DB, userID, songID string, positionMs int, comment string) error {
|
||||
_, err := db.Exec(`INSERT INTO bookmarks (id, user_id, song_id, position_ms, comment) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id, song_id) DO UPDATE SET position_ms=excluded.position_ms, comment=excluded.comment, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
|
||||
idgen.New("bk"), userID, songID, positionMs, comment)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteBookmark(db *sql.DB, userID, songID string) error {
|
||||
_, err := db.Exec(`DELETE FROM bookmarks WHERE user_id = ? AND song_id = ?`, userID, songID)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListBookmarks(db *sql.DB, userID string) ([]*Bookmark, error) {
|
||||
rows, err := db.Query(`SELECT id, song_id, position_ms, comment FROM bookmarks WHERE user_id = ? ORDER BY updated_at DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Bookmark
|
||||
for rows.Next() {
|
||||
var b Bookmark
|
||||
if err := rows.Scan(&b.ID, &b.SongID, &b.PositionMs, &b.Comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// --- Play queue ---
|
||||
|
||||
func SavePlayQueue(db *sql.DB, userID, songIDsJSON string, currentIndex, positionMs int, client string) error {
|
||||
_, err := db.Exec(`INSERT INTO play_queues (user_id, song_ids_json, current_index, position_ms, client) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET song_ids_json=excluded.song_ids_json, current_index=excluded.current_index, position_ms=excluded.position_ms, client=excluded.client, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
|
||||
userID, songIDsJSON, currentIndex, positionMs, client)
|
||||
return err
|
||||
}
|
||||
|
||||
type PlayQueue struct {
|
||||
SongIDsJSON string
|
||||
CurrentIndex int
|
||||
PositionMs int
|
||||
Client string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func GetPlayQueue(db *sql.DB, userID string) (*PlayQueue, error) {
|
||||
var q PlayQueue
|
||||
var updatedAt string
|
||||
err := db.QueryRow(`SELECT song_ids_json, current_index, position_ms, client, updated_at FROM play_queues WHERE user_id = ?`, userID).
|
||||
Scan(&q.SongIDsJSON, &q.CurrentIndex, &q.PositionMs, &q.Client, &updatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.UpdatedAt, _ = parseTime(updatedAt)
|
||||
return &q, nil
|
||||
}
|
||||
Reference in New Issue
Block a user