Restructure backend into services and fix security, crash and sync issues

Security
- Serve character images through os.Root so names like ../ can't escape
  the characters folder
- Sync start and /sync/reset are POST only (legacy and /api/v1)
- /api/v1/token/cleanup requires a token, password compared in constant
  time, tokens no longer logged
- Echo's rate limiter replaces the custom one; TRUSTED_PROXIES enables
  X-Forwarded-For for reverse proxies

Crashes and races
- Sync state lives in SyncService with atomics and a mutex instead of
  unsynchronised globals (fixes concurrent map writes during full sync)
- Only one sync can run; /api/v1 sync and music routes are locked during
  sync like the legacy ones
- No log.Fatal outside startup: errors become 4xx/5xx responses
- Search uses an in-order character match instead of unescaped regexes
- Empty queue / no songs return 404 instead of panicking or looping

Behaviour
- Sync result is computed once and stays the same between polls
- Swagger and /openapi serve the embedded spec
- Shutdown stops HTTP before closing the database
- Expired tokens are deleted with a context that outlives the request
- Files are served with Range support and correct content types; audio
  has no write deadline

Performance
- Migration 000006 adds soundtrack.fingerprint (names, sizes, mtimes) so
  unchanged folders are skipped without reading any files
- Songs are reconciled per soundtrack with one query plus needed writes;
  renames keep play counts
- Moved MUSIC_PATH updates soundtrack and song paths
- .id cleanup only runs for changed soundtracks or a full sync
- Classic random uses one SQL query; Gitea release lookups are cached
  with a timeout

Structure
- internal/config reads all environment variables once
- sqlc emits a Querier interface; backend globals become services
  injected into handlers
- One route registration shared by legacy and /api/v1
- Removed dead code (dbHelper.go, unused middleware, duplicate Gitea
  functions, embedded Swagger UI copy) and lib/pq
- templ and swag pinned as go tools
This commit is contained in:
Sansan committed 2026-10-06 15:20:14 +02:00
1 parent 75b6f0c1cf
commit 090e794460
78 files changed
+2919 -6303

No files matched your search

+69 -84
View File
@@ -2,54 +2,57 @@ package db
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"net"
"net/url"
"time"
"music-server/internal/logging"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"github.com/jackc/pgx/v5/stdlib"
"go.uber.org/zap"
)
// Database holds the database connection pool and context
//go:embed "migrations/*.sql"
var MigrationsFs embed.FS
// Database holds the database connection pool
type Database struct {
Pool *pgxpool.Pool
Ctx context.Context
}
// ConnString builds a PostgreSQL connection URL.
func ConnString(host, port, user, password, dbname string) string {
u := url.URL{
Scheme: "postgres",
User: url.UserPassword(user, password),
Host: net.JoinHostPort(host, port),
Path: dbname,
RawQuery: "sslmode=disable",
}
return u.String()
}
// NewDatabase creates a new Database instance with connection pool
func NewDatabase(host, port, user, password, dbname string) (*Database, error) {
ctx := context.Background()
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
logging.GetLogger().Debug("Database connection info",
zap.String("host", host),
zap.String("port", port),
zap.String("dbname", dbname))
pool, err := pgxpool.New(ctx, psqlInfo)
func NewDatabase(ctx context.Context, connString string) (*Database, error) {
pool, err := pgxpool.New(ctx, connString)
if err != nil {
return nil, fmt.Errorf("unable to connect to database: %w", err)
}
// Test connection
var success string
err = pool.QueryRow(ctx, "select 'Successfully connected!'").Scan(&success)
if err != nil {
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("database query failed: %w", err)
return nil, fmt.Errorf("database ping failed: %w", err)
}
logging.GetLogger().Info("Database connected", zap.String("status", success))
return &Database{Pool: pool, Ctx: ctx}, nil
logging.GetLogger().Info("Database connected")
return &Database{Pool: pool}, nil
}
// Close closes the database connection pool
@@ -61,82 +64,64 @@ func (db *Database) Close() {
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func (db *Database) Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
func (db *Database) Health(ctx context.Context) map[string]string {
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
err := db.Pool.Ping(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = err.Error()
return stats
if err := db.Pool.Ping(ctx); err != nil {
return map[string]string{"status": "down", "error": err.Error()}
}
stats["status"] = "up"
return stats
return map[string]string{"status": "up"}
}
// RunMigrations runs all pending database migrations to the latest version.
// Uses the existing pool to extract connection details.
func (db *Database) RunMigrations() error {
// Extract connection info from pool config
connConfig := db.Pool.Config().ConnConfig
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=disable",
connConfig.User,
connConfig.Password,
connConfig.Host,
connConfig.Port,
connConfig.Database)
logging.GetLogger().Debug("Migration info", zap.String("url", migrationURL))
sqlDb, err := sql.Open("postgres", migrationURL)
m, closeFn, err := db.NewMigrator()
if err != nil {
return fmt.Errorf("failed to open database for migration: %w", err)
return err
}
defer sqlDb.Close()
defer closeFn()
driver, err := postgres.WithInstance(sqlDb, &postgres.Config{})
version, _, err := m.Version()
if err != nil && !errors.Is(err, migrate.ErrNilVersion) {
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
if err := m.Up(); err != nil {
if !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("migration failed: %w", err)
}
logging.GetLogger().Info("Database already up to date")
return nil
}
versionAfter, _, _ := m.Version()
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
return nil
}
// NewMigrator returns a migrator for the embedded migrations using the pool's connection settings.
// The returned function closes the underlying connection.
func (db *Database) NewMigrator() (*migrate.Migrate, func(), error) {
sqlDB := stdlib.OpenDB(*db.Pool.Config().ConnConfig)
driver, err := pgxmigrate.WithInstance(sqlDB, &pgxmigrate.Config{})
if err != nil {
return fmt.Errorf("failed to create migration driver: %w", err)
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to create migration driver: %w", err)
}
files, err := iofs.New(MigrationsFs, "migrations")
if err != nil {
return fmt.Errorf("failed to create migration files: %w", err)
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to read migration files: %w", err)
}
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
m, err := migrate.NewWithInstance("iofs", files, "pgx5", driver)
if err != nil {
return fmt.Errorf("failed to create migrator: %w", err)
sqlDB.Close()
return nil, nil, fmt.Errorf("failed to create migrator: %w", err)
}
// Get current version for logging
version, _, err := m.Version()
if err != nil {
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
// Run all pending migrations to latest version
err = m.Up()
if err != nil {
if err == migrate.ErrNoChange {
logging.GetLogger().Info("Database already up to date")
} else {
return fmt.Errorf("migration failed: %w", err)
}
} else {
// Get new version after migration
versionAfter, _, _ := m.Version()
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
}
logging.GetLogger().Info("Migration completed")
return nil
return m, func() { m.Close() }, nil
}
-207
View File
@@ -1,207 +0,0 @@
package db
import (
"context"
"database/sql"
"embed"
"fmt"
"strconv"
"time"
"music-server/internal/logging"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"go.uber.org/zap"
)
// TODO: DEPRECATED - Remove these global variables once all code is migrated to use Database struct
// Use database.go's Database struct instead. These globals remain for backward compatibility
// with legacy code paths. New code should use the Database struct from database.go.
var Dbpool *pgxpool.Pool
var Ctx = context.Background()
//go:embed "migrations/*.sql"
var MigrationsFs embed.FS
func InitDB(host string, port string, user string, password string, dbname string) {
psqlInfo := fmt.Sprintf("host=%s port=%s user=%s "+
"password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
logging.GetLogger().Debug("Database connection info", zap.String("host", host), zap.String("port", port), zap.String("dbname", dbname))
var err error
Dbpool, err = pgxpool.New(Ctx, psqlInfo)
if err != nil {
logging.GetLogger().Fatal("Unable to connect to database", zap.String("error", err.Error()))
}
var success string
err = Dbpool.QueryRow(Ctx, "select 'Successfully connected!'").Scan(&success)
if err != nil {
logging.GetLogger().Fatal("Database query failed", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Database connected", zap.String("status", success))
}
func CloseDb() {
logging.GetLogger().Info("Closing database connection")
Dbpool.Close()
}
func ResetSoundtrackIdSeq() {
_, err := Dbpool.Query(Ctx, "SELECT setval('soundtrack_id_seq', (SELECT MAX(id) FROM soundtrack)+1);")
if err != nil {
logging.GetLogger().Error("Failed to reset soundtrack ID sequence", zap.String("error", err.Error()))
}
}
func createDb(host string, port string, user string, password string, dbname string) {
// Connect to the default postgres database to create new database
// In PostgreSQL, we need to connect to an existing database (postgres) to create a new one
conninfo := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", host, port, user, password)
db, err := sql.Open("postgres", conninfo)
defer db.Close()
if err != nil {
logging.GetLogger().Fatal("Failed to connect for database creation", zap.String("error", err.Error()))
}
_, err = db.Exec("create database " + dbname)
if err != nil {
//handle the error
logging.GetLogger().Fatal("Failed to create database", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Database created", zap.String("dbname", dbname))
}
func Migrate_db(host string, port string, user string, password string, dbname string) {
migrationInfo := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbname)
logging.GetLogger().Debug("Migration info", zap.String("url", migrationInfo))
db, err := sql.Open("postgres", migrationInfo)
if err != nil {
logging.GetLogger().Error("Failed to open database for migration", zap.String("error", err.Error()))
}
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
logging.GetLogger().Error("Failed to create migration driver", zap.String("error", err.Error()))
}
files, err := iofs.New(MigrationsFs, "migrations")
if err != nil {
logging.GetLogger().Fatal("Failed to create migration files", zap.String("error", err.Error()))
}
m, err := migrate.NewWithInstance("iofs", files, "postgres", driver)
if err != nil {
logging.GetLogger().Fatal("Failed to create migrator", zap.String("error", err.Error()))
}
/*m, err := migrate.NewWithDatabaseInstance(
"file://./db/migrations/",
"postgres", driver)
if err != nil {
logging.GetLogger().Error("Migration setup error", zap.String("error", err.Error()))
}*/
version, _, err := m.Version()
if err != nil {
logging.GetLogger().Error("Failed to get migration version", zap.String("error", err.Error()))
}
logging.GetLogger().Info("Migration version before", zap.Uint("version", version))
//err = m.Force(1)
//err = m.Up() // or m.Steps(2) if you want to explicitly set the number of migrations to run
//if err != nil {
// logging.GetLogger().Error("Force migration error", zap.String("error", err.Error()))
//}
// Use Up() to apply all pending migrations instead of Migrate(2)
err = m.Up()
if err != nil {
if err == migrate.ErrNoChange {
logging.GetLogger().Info("Database already up to date")
} else {
logging.GetLogger().Error("Migration error", zap.String("error", err.Error()))
}
} else {
versionAfter, _, err := m.Version()
if err != nil {
logging.GetLogger().Error("Failed to get migration version after", zap.String("error", err.Error()))
} else {
logging.GetLogger().Info("Migrated to version", zap.Uint("version", versionAfter))
}
}
logging.GetLogger().Info("Migration completed")
db.Close()
}
// Health checks the health of the database connection by pinging the database.
// It returns a map with keys indicating various health statistics.
func Health() map[string]string {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
stats := make(map[string]string)
// Ping the database
//err := s.db.PingContext(ctx)
err := Dbpool.Ping(ctx)
if err != nil {
stats["status"] = "down"
stats["error"] = fmt.Sprintf("db down: %v", err)
logging.GetLogger().Fatal("Database health check failed", zap.String("error", err.Error()))
return stats
}
// Database is up, add more statistics
stats["status"] = "up"
stats["message"] = "It's healthy"
// Get database stats (like open connections, in use, idle, etc.)
//dbStats := s.db.Stats()
dbStats := Dbpool.Stat()
//stats["open_connections"] = strconv.Itoa(dbStats.OpenConnections)
stats["open_connections"] = strconv.Itoa(int(dbStats.NewConnsCount()))
//stats["in_use"] = strconv.Itoa(dbStats.InUse)
stats["in_use"] = strconv.Itoa(int(dbStats.AcquiredConns()))
//stats["idle"] = strconv.Itoa(dbStats.Idle)
stats["idle"] = strconv.Itoa(int(dbStats.IdleConns()))
//stats["wait_count"] = strconv.FormatInt(dbStats.WaitCount, 10)
stats["wait_count"] = strconv.FormatInt(dbStats.AcquireCount(), 10)
//stats["wait_duration"] = dbStats.WaitDuration.String()
stats["wait_duration"] = dbStats.AcquireDuration().String()
//stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleClosed, 10)
stats["max_idle_closed"] = strconv.FormatInt(dbStats.MaxIdleDestroyCount(), 10)
//stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeClosed, 10)
stats["max_lifetime_closed"] = strconv.FormatInt(dbStats.MaxLifetimeDestroyCount(), 10)
// Evaluate stats to provide a health message
if int(dbStats.NewConnsCount()) > 40 { // Assuming 50 is the max for this example
stats["message"] = "The database is experiencing heavy load."
}
if dbStats.AcquireCount() > 1000 {
stats["message"] = "The database has a high number of wait events, indicating potential bottlenecks."
}
if dbStats.MaxIdleDestroyCount() > int64(dbStats.NewConnsCount())/2 {
stats["message"] = "Many idle connections are being closed, consider revising the connection pool settings."
}
if dbStats.MaxLifetimeDestroyCount() > int64(dbStats.NewConnsCount())/2 {
stats["message"] = "Many connections are being closed due to max lifetime, consider increasing max lifetime or revising the connection usage pattern."
}
return stats
}
-251
View File
@@ -1,251 +0,0 @@
package db
import (
"database/sql"
"fmt"
"os"
"testing"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
_ "github.com/lib/pq"
"github.com/stretchr/testify/require"
)
// TestMigrationsStepByStep tests applying migrations incrementally
// Then adding data manually, then completing migrations
func TestMigrationsStepByStep(t *testing.T) {
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
// Use a unique database name for this test
dbname := "music_server_migration_test"
if host == "" || port == "" || user == "" || password == "" {
t.Skip("Test database environment variables not set (DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD)")
}
// Clean up: drop database if it exists
cleanupDB(t, host, port, user, password, dbname)
defer cleanupDB(t, host, port, user, password, dbname)
// Create the database
createTestDB(t, host, port, user, password, dbname)
// Step 1: Apply first 4 migrations (before soundtrack rename)
// This creates: game, song, vgmq, song_list tables
// And sessions table with indexes
t.Run("ApplyFirst4Migrations", func(t *testing.T) {
applyMigrations(t, host, port, user, password, dbname, 4)
})
// Step 2: Add data manually to game and song tables
t.Run("AddManualData", func(t *testing.T) {
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
// Insert 5 games manually
for i := 1; i <= 5; i++ {
gameName := fmt.Sprintf("Manual Game %d", i)
path := fmt.Sprintf("/manual/path/game%d", i)
hash := fmt.Sprintf("hash-%d", i)
_, err := db.Exec(`INSERT INTO game (game_name, path, hash, added)
VALUES ($1, $2, $3, NOW())`,
gameName, path, hash)
require.NoError(t, err, "Failed to insert game %d", i)
}
// Insert songs for each game
songs := []struct {
gameID int
name string
path string
}{
{1, "Song A", "/path/a.mp3"},
{1, "Song B", "/path/b.mp3"},
{2, "Song C", "/path/c.mp3"},
{2, "Song D", "/path/d.mp3"},
{3, "Song E", "/path/e.mp3"},
{4, "Song F", "/path/f.mp3"},
{4, "Song G", "/path/g.mp3"},
{4, "Song H", "/path/h.mp3"},
{5, "Song I", "/path/i.mp3"},
}
for _, s := range songs {
_, err := db.Exec(`INSERT INTO song (game_id, song_name, path, hash)
VALUES ($1, $2, $3, $4)`,
s.gameID, s.name, s.path, fmt.Sprintf("song-hash-%s", s.name))
require.NoError(t, err, "Failed to insert song %s", s.name)
}
// Verify data was inserted
var gameCount int
err = db.QueryRow("SELECT COUNT(*) FROM game").Scan(&gameCount)
require.NoError(t, err)
require.Equal(t, 5, gameCount, "Expected 5 games")
var songCount int
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
require.NoError(t, err)
require.Equal(t, 9, songCount, "Expected 9 songs")
t.Log("✓ Manually inserted 5 games with 9 songs")
})
// Step 3: Apply migration 5 (rename game→soundtrack)
t.Run("ApplyMigration5", func(t *testing.T) {
// Apply the remaining migrations (just migration 5)
applyMigrations(t, host, port, user, password, dbname, 1)
// Verify tables were renamed
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
// Check that soundtrack table exists
var soundtrackCount int
err = db.QueryRow("SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
require.NoError(t, err)
require.Equal(t, 5, soundtrackCount, "Expected 5 soundtracks after migration")
// Check that game table no longer exists
_, err = db.Exec("SELECT 1 FROM game LIMIT 1")
require.Error(t, err, "game table should not exist after migration")
// Check that song table has soundtrack_id column
var songCount int
err = db.QueryRow("SELECT COUNT(*) FROM song").Scan(&songCount)
require.NoError(t, err)
require.Equal(t, 9, songCount, "Expected 9 songs after migration")
// Verify data integrity: soundtrack_name values
rows, err := db.Query("SELECT soundtrack_name FROM soundtrack ORDER BY id")
require.NoError(t, err)
defer rows.Close()
expectedNames := []string{"Manual Game 1", "Manual Game 2", "Manual Game 3", "Manual Game 4", "Manual Game 5"}
actualNames := make([]string, 0)
for rows.Next() {
var name string
err := rows.Scan(&name)
require.NoError(t, err)
actualNames = append(actualNames, name)
}
require.Equal(t, expectedNames, actualNames, "Soundtrack names should match original game names")
t.Log("✓ Migration 5 applied successfully, data preserved")
})
}
// cleanupDB drops the test database
func cleanupDB(t *testing.T, host, port, user, password, dbname string) {
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
host, port, user, password)
db, err := sql.Open("postgres", connStr)
if err != nil {
t.Logf("Warning: could not connect to cleanup DB: %v", err)
return
}
defer db.Close()
// Check if database exists before dropping
var exists int
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
t.Logf("Warning: could not check if DB exists: %v", err)
return
}
if exists == 1 {
_, err = db.Exec("DROP DATABASE " + dbname + " WITH (FORCE)")
if err != nil {
t.Logf("Warning: could not drop DB: %v", err)
}
}
}
// createTestDB creates a fresh test database
func createTestDB(t *testing.T, host, port, user, password, dbname string) {
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable",
host, port, user, password)
db, err := sql.Open("postgres", connStr)
require.NoError(t, err)
defer db.Close()
// Drop if exists
cleanupDB(t, host, port, user, password, dbname)
// Create database
_, err = db.Exec("CREATE DATABASE " + dbname)
require.NoError(t, err)
// Enable UUID extension if needed
connStrDB := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
host, port, user, password, dbname)
db2, err := sql.Open("postgres", connStrDB)
require.NoError(t, err)
defer db2.Close()
_, err = db2.Exec("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
if err != nil {
t.Logf("Note: uuid-ossp extension may not be available: %v", err)
}
}
// applyMigrations applies n migrations to the database using Go migrate library
func applyMigrations(t *testing.T, host, port, user, password, dbname string, steps int) {
migrationURL := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable",
user, password, host, port, dbname)
db, err := sql.Open("postgres", migrationURL)
require.NoError(t, err)
defer db.Close()
driver, err := postgres.WithInstance(db, &postgres.Config{})
require.NoError(t, err)
m, err := migrate.NewWithDatabaseInstance(
"file://migrations",
"postgres", driver)
require.NoError(t, err)
// Get current version
version, _, err := m.Version()
if err != nil && err != migrate.ErrNilVersion {
require.NoError(t, err)
}
if err == migrate.ErrNilVersion {
version = 0
}
t.Logf("Current migration version: %d", version)
// Apply exactly 'steps' migrations
if steps > 0 {
err = m.Steps(steps)
if err != nil && err != migrate.ErrNoChange {
require.NoError(t, err)
}
} else if steps < 0 {
err = m.Steps(steps)
require.NoError(t, err)
}
// Get new version
newVersion, _, err := m.Version()
if err != nil && err != migrate.ErrNilVersion {
require.NoError(t, err)
}
if err == migrate.ErrNilVersion {
newVersion = 0
}
t.Logf("Migration version after applying %d steps: %d", steps, newVersion)
}
@@ -0,0 +1 @@
ALTER TABLE soundtrack DROP COLUMN fingerprint;
@@ -0,0 +1,3 @@
-- Cheap fingerprint (file names, sizes and modification times) used to skip
-- content hashing of soundtracks that have not changed since the last sync.
ALTER TABLE soundtrack ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';
+13 -1
View File
@@ -41,7 +41,7 @@ SELECT * FROM song WHERE uuid = $1;
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
-- name: RemoveBrokenSongs :exec
DELETE FROM song WHERE uuid = ANY($1);
DELETE FROM song WHERE uuid = ANY(sqlc.arg(uuids)::uuid[]);
-- name: GetSongsBySoundtrackUUID :many
SELECT *
@@ -50,3 +50,15 @@ WHERE soundtrack_uuid = $1;
-- name: GetSongUUIDsBySoundtrackId :many
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
-- name: UpdateSongByUUID :exec
UPDATE song SET song_name=sqlc.arg(song_name), file_name=sqlc.arg(file_name), path=sqlc.arg(path), hash=sqlc.arg(hash)
WHERE uuid=sqlc.arg(uuid);
-- name: GetRandomActiveSong :one
SELECT sqlc.embed(song), soundtrack.path AS soundtrack_path
FROM song
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE soundtrack.deleted IS NULL
ORDER BY random()
LIMIT 1;
+6
View File
@@ -13,6 +13,12 @@ AND deleted IS NULL;
-- name: SetSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL;
-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY(sqlc.arg(ids)::int[]));
-- name: SetSoundtrackFingerprint :exec
UPDATE soundtrack SET fingerprint=sqlc.arg(fingerprint) WHERE id=sqlc.arg(id);
-- name: ClearSoundtracks :exec
DELETE FROM soundtrack;
+1
View File
@@ -51,6 +51,7 @@ type Soundtrack struct {
NumberOfSongs int32 `json:"number_of_songs"`
Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"`
Fingerprint string `json:"fingerprint"`
}
type Vgmq struct {
+76
View File
@@ -0,0 +1,76 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package repository
import (
"context"
"time"
"github.com/google/uuid"
)
type Querier interface {
AddHashToSong(ctx context.Context, arg AddHashToSongParams) error
AddSong(ctx context.Context, arg AddSongParams) error
AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) error
AddSoundtrackPlayed(ctx context.Context, id int32) error
CheckSong(ctx context.Context, arg CheckSongParams) (int64, error)
CheckSongWithHash(ctx context.Context, hash string) (int64, error)
ClearSongs(ctx context.Context) error
ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int32) error
ClearSoundtracks(ctx context.Context) error
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
DeleteExpiredSessions(ctx context.Context) error
DeleteSession(ctx context.Context, token string) error
FetchAllSongs(ctx context.Context) ([]Song, error)
FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
FindSongsFromSoundtrack(ctx context.Context, soundtrackID int32) ([]Song, error)
GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soundtrack, error)
GetIdBySoundtrackName(ctx context.Context, soundtrackName string) (int32, error)
// Last played soundtracks (most recently played)
GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]GetLastPlayedSoundtracksRow, error)
// Least played songs with their soundtrack info
GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetLeastPlayedSongsWithSoundtrackRow, error)
// Least played soundtracks with their songs
GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error)
// Most played songs with their soundtrack info
GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit int32) ([]GetMostPlayedSongsWithSoundtrackRow, error)
// Most played soundtracks with their songs
GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error)
// Soundtracks that have never been played (times_played = 0)
GetNeverPlayedSoundtracks(ctx context.Context) ([]GetNeverPlayedSoundtracksRow, error)
// Oldest played soundtracks (least recently played, but has been played at least once)
GetOldestPlayedSoundtracks(ctx context.Context, limit int32) ([]GetOldestPlayedSoundtracksRow, error)
GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error)
GetSession(ctx context.Context, token string) (Session, error)
GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error)
GetSongList(ctx context.Context, matchDate time.Time) ([]SongList, error)
GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error)
GetSongWithHash(ctx context.Context, hash string) (Song, error)
GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error)
GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, error)
GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error)
GetSoundtrackNameById(ctx context.Context, id int32) (string, error)
GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error)
// Get statistics summary
GetStatisticsSummary(ctx context.Context) (GetStatisticsSummaryRow, error)
InsertSongInList(ctx context.Context, arg InsertSongInListParams) error
InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error)
InsertSoundtrackWithExistingId(ctx context.Context, arg InsertSoundtrackWithExistingIdParams) error
ListSessions(ctx context.Context) ([]Session, error)
MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error
RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error
RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error
RemoveSoundtrackDeletionDate(ctx context.Context, id int32) error
ResetSoundtrackIdSeq(ctx context.Context) (int64, error)
SetSoundtrackDeletionDate(ctx context.Context) error
SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error
UpdateSong(ctx context.Context, arg UpdateSongParams) error
UpdateSongByUUID(ctx context.Context, arg UpdateSongByUUIDParams) error
UpdateSoundtrackHash(ctx context.Context, arg UpdateSoundtrackHashParams) error
UpdateSoundtrackName(ctx context.Context, arg UpdateSoundtrackNameParams) error
}
var _ Querier = (*Queries)(nil)
+58 -3
View File
@@ -181,6 +181,37 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
return items, nil
}
const getRandomActiveSong = `-- name: GetRandomActiveSong :one
SELECT song.soundtrack_id, song.song_name, song.path, song.times_played, song.hash, song.file_name, song.uuid, song.soundtrack_uuid, soundtrack.path AS soundtrack_path
FROM song
JOIN soundtrack ON soundtrack.id = song.soundtrack_id
WHERE soundtrack.deleted IS NULL
ORDER BY random()
LIMIT 1
`
type GetRandomActiveSongRow struct {
Song Song `json:"song"`
SoundtrackPath string `json:"soundtrack_path"`
}
func (q *Queries) GetRandomActiveSong(ctx context.Context) (GetRandomActiveSongRow, error) {
row := q.db.QueryRow(ctx, getRandomActiveSong)
var i GetRandomActiveSongRow
err := row.Scan(
&i.Song.SoundtrackID,
&i.Song.SongName,
&i.Song.Path,
&i.Song.TimesPlayed,
&i.Song.Hash,
&i.Song.FileName,
&i.Song.Uuid,
&i.Song.SoundtrackUuid,
&i.SoundtrackPath,
)
return i, err
}
const getSongByUUID = `-- name: GetSongByUUID :one
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
`
@@ -300,11 +331,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara
}
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
DELETE FROM song WHERE uuid = ANY($1)
DELETE FROM song WHERE uuid = ANY($1::uuid[])
`
func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
_, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
func (q *Queries) RemoveBrokenSongs(ctx context.Context, uuids []uuid.UUID) error {
_, err := q.db.Exec(ctx, removeBrokenSongs, uuids)
return err
}
@@ -328,3 +359,27 @@ func (q *Queries) UpdateSong(ctx context.Context, arg UpdateSongParams) error {
)
return err
}
const updateSongByUUID = `-- name: UpdateSongByUUID :exec
UPDATE song SET song_name=$1, file_name=$2, path=$3, hash=$4
WHERE uuid=$5
`
type UpdateSongByUUIDParams struct {
SongName string `json:"song_name"`
FileName *string `json:"file_name"`
Path string `json:"path"`
Hash string `json:"hash"`
Uuid uuid.UUID `json:"uuid"`
}
func (q *Queries) UpdateSongByUUID(ctx context.Context, arg UpdateSongByUUIDParams) error {
_, err := q.db.Exec(ctx, updateSongByUUID,
arg.SongName,
arg.FileName,
arg.Path,
arg.Hash,
arg.Uuid,
)
return err
}
+31 -4
View File
@@ -30,7 +30,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error {
}
const findAllSoundtracks = `-- name: FindAllSoundtracks :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
WHERE deleted IS NULL
ORDER BY soundtrack_name
@@ -57,6 +57,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
); err != nil {
return nil, err
}
@@ -69,7 +70,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
}
const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
ORDER BY soundtrack_name
`
@@ -95,6 +96,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
); err != nil {
return nil, err
}
@@ -118,7 +120,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri
}
const getSoundtrackById = `-- name: GetSoundtrackById :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
WHERE id = $1
AND deleted IS NULL
@@ -139,12 +141,13 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
)
return i, err
}
const getSoundtrackByUUID = `-- name: GetSoundtrackByUUID :one
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid, fingerprint
FROM soundtrack
WHERE uuid = $1
AND deleted IS NULL
@@ -165,6 +168,7 @@ func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (S
&i.NumberOfSongs,
&i.Hash,
&i.Uuid,
&i.Fingerprint,
)
return i, err
}
@@ -240,6 +244,15 @@ func (q *Queries) InsertSoundtrackWithExistingId(ctx context.Context, arg Insert
return err
}
const markSoundtracksDeletedExcept = `-- name: MarkSoundtracksDeletedExcept :exec
UPDATE soundtrack SET deleted=now() WHERE deleted IS NULL AND NOT (id = ANY($1::int[]))
`
func (q *Queries) MarkSoundtracksDeletedExcept(ctx context.Context, ids []int32) error {
_, err := q.db.Exec(ctx, markSoundtracksDeletedExcept, ids)
return err
}
const removeSoundtrackDeletionDate = `-- name: RemoveSoundtrackDeletionDate :exec
UPDATE soundtrack SET deleted=NULL WHERE id=$1
`
@@ -269,6 +282,20 @@ func (q *Queries) SetSoundtrackDeletionDate(ctx context.Context) error {
return err
}
const setSoundtrackFingerprint = `-- name: SetSoundtrackFingerprint :exec
UPDATE soundtrack SET fingerprint=$1 WHERE id=$2
`
type SetSoundtrackFingerprintParams struct {
Fingerprint string `json:"fingerprint"`
ID int32 `json:"id"`
}
func (q *Queries) SetSoundtrackFingerprint(ctx context.Context, arg SetSoundtrackFingerprintParams) error {
_, err := q.db.Exec(ctx, setSoundtrackFingerprint, arg.Fingerprint, arg.ID)
return err
}
const updateSoundtrackHash = `-- name: UpdateSoundtrackHash :exec
UPDATE soundtrack SET hash=$1, last_changed=now() WHERE id=$2
`
-150
View File
@@ -1,150 +0,0 @@
package db
import (
"context"
"database/sql"
"fmt"
"log"
"os"
"sync"
"testing"
)
var (
testDBSetupOnce sync.Once
testDBHost string
testDBPort string
testDBUser string
testDBPassword string
testDBName string
// TestDatabase is the database instance for tests
TestDatabase *Database
)
// TestSetupDB initializes the test database using existing functions
// It creates the database if it doesn't exist and runs migrations
// Uses sync.Once to ensure it only runs once across all tests
func TestSetupDB(t *testing.T) {
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
dbname := os.Getenv("DB_NAME")
if host == "" || port == "" || user == "" || password == "" || dbname == "" {
t.Skip("Test database environment variables not set")
}
// Store for TestTearDownDB
testDBHost = host
testDBPort = port
testDBUser = user
testDBPassword = password
testDBName = dbname
// Only run setup once
testDBSetupOnce.Do(func() {
// Create the database first (testuser is a superuser in the container)
createTestDatabase(host, port, dbname, user, password)
// Create database instance and run migrations
var err error
TestDatabase, err = NewDatabase(host, port, user, password, dbname)
if err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
// Clean up any existing schema to ensure clean state
ctx := context.Background()
_, err = TestDatabase.Pool.Exec(ctx, "DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;")
if err != nil {
t.Logf("Warning: Could not clean schema: %v", err)
// Continue anyway, migrations might still work
}
// Run migrations
if err := TestDatabase.RunMigrations(); err != nil {
// Clean up on failure to prevent nil pointer issues in other tests
TestDatabase.Close()
TestDatabase = nil
t.Fatalf("Failed to run migrations: %v", err)
}
})
}
// createTestDatabase creates the test database
// In the test container, POSTGRES_USER is created as a superuser
func createTestDatabase(host, port, dbname, user, password string) {
// Connect to the postgres database to create new database
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=postgres sslmode=disable", host, port, user, password)
db, err := sql.Open("postgres", connStr)
if err != nil {
log.Println("Warning: Could not connect to create test database:", err)
return
}
defer db.Close()
// Check if database exists
var dbExists int
err = db.QueryRow("SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&dbExists)
if err != nil && err != sql.ErrNoRows {
log.Println("Warning: Could not check if database exists:", err)
return
}
if dbExists == 0 {
// Create database
_, err = db.Exec("CREATE DATABASE " + dbname)
if err != nil {
log.Println("Warning: Could not create database:", err)
return
}
log.Println("Created test database:", dbname)
}
}
// TestTearDownDB closes the test database connection
// Note: We don't actually close the pool between tests to avoid
// "closed pool" errors when tests run sequentially
func TestTearDownDB(t *testing.T) {
// CloseDb() // Disabled to prevent pool closure between sequential tests
// Note: We also don't nil TestDatabase to allow reuse across tests
// if TestDatabase != nil {
// TestDatabase.Close()
// TestDatabase = nil
// }
}
// TestClearDatabase clears all data from the test database
// Useful for running tests with a clean slate
func TestClearDatabase(t *testing.T) {
if TestDatabase == nil || TestDatabase.Pool == nil {
t.Skip("Database not initialized")
}
// Clear all tables in reverse order to respect foreign keys
// Note: This assumes the tables exist and have the expected structure
// After migration 000005, game table was renamed to soundtrack
tables := []string{
"song_list",
"song",
"soundtrack",
"vgmq",
"sessions",
}
ctx := context.Background()
for _, table := range tables {
_, err := TestDatabase.Pool.Exec(ctx, "TRUNCATE TABLE "+table+" CASCADE")
if err != nil {
t.Logf("Failed to truncate table %s: %v", table, err)
}
}
// Reset sequences (renamed from game_id_seq to soundtrack_id_seq in migration 000005)
var seqErr error
_, seqErr = TestDatabase.Pool.Exec(ctx, "SELECT setval('soundtrack_id_seq', 1, false)")
if seqErr != nil {
t.Logf("Failed to reset soundtrack_id_seq: %v", seqErr)
}
}