#9 Major routes refactor and expansion, #15 Added API for statistics, Added search cache backend and functionality tests, Backend sync improvements
Build / build (push) Successful in 56s
Build / build (push) Successful in 56s
This commit is contained in:
1 parent
e86c0f8e04
commit
a8a2602f50
34 files changed
+1819
-1294
No files matched your search
@@ -0,0 +1,100 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SoundtrackData holds precomputed search data for a soundtrack
|
||||
type SoundtrackData struct {
|
||||
Original string
|
||||
Lowercase string
|
||||
Cleaned string
|
||||
}
|
||||
|
||||
var (
|
||||
searchCache []SoundtrackData
|
||||
searchCacheMutex sync.Mutex
|
||||
searchRegexCache = make(map[string]*regexp.Regexp)
|
||||
searchRegexCacheMutex sync.Mutex
|
||||
)
|
||||
|
||||
// GetSearchCache returns the precomputed search cache, initializing it if needed
|
||||
func GetSearchCache() []SoundtrackData {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
|
||||
if len(searchCache) == 0 {
|
||||
loadSearchCache()
|
||||
}
|
||||
return searchCache
|
||||
}
|
||||
|
||||
// ReloadSearchCache clears and reloads the search cache.
|
||||
// This should be called after a sync operation to ensure search results are up to date.
|
||||
func ReloadSearchCache() {
|
||||
searchCacheMutex.Lock()
|
||||
defer searchCacheMutex.Unlock()
|
||||
searchCache = nil
|
||||
loadSearchCache()
|
||||
}
|
||||
|
||||
// loadSearchCache loads the soundtrack data into the cache
|
||||
func loadSearchCache() {
|
||||
soundtracks := GetAllSoundtracks()
|
||||
searchCache = make([]SoundtrackData, len(soundtracks))
|
||||
|
||||
for i, soundtrack := range soundtracks {
|
||||
cleaned := cleanSearchTerm(soundtrack)
|
||||
lowercase := strings.ToLower(soundtrack)
|
||||
searchCache[i] = SoundtrackData{
|
||||
Original: soundtrack,
|
||||
Lowercase: lowercase,
|
||||
Cleaned: cleaned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CleanSearchTermForMatch cleans a search term for better matching
|
||||
func CleanSearchTermForMatch(term string) string {
|
||||
term = strings.ReplaceAll(term, " ", "")
|
||||
term = strings.ReplaceAll(term, "é", "e")
|
||||
term = strings.ReplaceAll(term, "+", "plus")
|
||||
term = strings.ReplaceAll(term, "&", "and")
|
||||
term = strings.ReplaceAll(term, "'n", "and")
|
||||
return strings.ToLower(term)
|
||||
}
|
||||
|
||||
// cleanSearchTerm cleans a search term for better matching (internal)
|
||||
func cleanSearchTerm(term string) string {
|
||||
return CleanSearchTermForMatch(term)
|
||||
}
|
||||
|
||||
// MatchRegexCached checks if a search term matches using cached compiled regex
|
||||
func MatchRegexCached(searchTerm string, soundtrackName string) bool {
|
||||
if searchTerm == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check cache first
|
||||
searchRegexCacheMutex.Lock()
|
||||
re, exists := searchRegexCache[searchTerm]
|
||||
if !exists {
|
||||
re = compileSearchRegex(searchTerm)
|
||||
searchRegexCache[searchTerm] = re
|
||||
}
|
||||
searchRegexCacheMutex.Unlock()
|
||||
|
||||
return re.MatchString(soundtrackName)
|
||||
}
|
||||
|
||||
// compileSearchRegex compiles a search term into a regex pattern
|
||||
func compileSearchRegex(searchTerm string) *regexp.Regexp {
|
||||
regText := ".*"
|
||||
for _, letter := range searchTerm {
|
||||
regText += string(letter) + ".*"
|
||||
}
|
||||
re, _ := regexp.Compile(regText)
|
||||
return re
|
||||
}
|
||||
+78
-15
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/panjf2000/ants/v2"
|
||||
|
||||
"github.com/MShekow/directory-checksum/directory_checksum"
|
||||
@@ -178,11 +179,13 @@ func SyncResult() SyncResponse {
|
||||
func SyncSoundtracksFull() {
|
||||
syncSoundtracks(true)
|
||||
Reset()
|
||||
ReloadSearchCache()
|
||||
}
|
||||
|
||||
func SyncSoundtracksOnlyChanges() {
|
||||
syncSoundtracks(false)
|
||||
Reset()
|
||||
ReloadSearchCache()
|
||||
}
|
||||
|
||||
func syncSoundtracks(full bool) {
|
||||
@@ -295,6 +298,7 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
|
||||
var status SoundtrackStatus = NewSoundtrack
|
||||
var oldSoundtrack repository.Soundtrack
|
||||
var id int32 = -1
|
||||
var soundtrackUuid uuid.UUID
|
||||
|
||||
//fmt.Printf("Soundtracks before: %d\n", len(soundtracksBeforeSync))
|
||||
|
||||
@@ -304,16 +308,19 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
|
||||
if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash == dirHash {
|
||||
status = NotChanged
|
||||
id = oldSoundtrack.ID
|
||||
soundtrackUuid = oldSoundtrack.Uuid
|
||||
//fmt.Printf("Soundtrack not changed\n")
|
||||
break
|
||||
} else if oldSoundtrack.SoundtrackName == file.Name() && oldSoundtrack.Hash != dirHash {
|
||||
status = SoundtrackChanged
|
||||
id = oldSoundtrack.ID
|
||||
soundtrackUuid = oldSoundtrack.Uuid
|
||||
//fmt.Printf("Soundtrack changed\n")
|
||||
break
|
||||
} else if oldSoundtrack.SoundtrackName != file.Name() && oldSoundtrack.Hash == dirHash {
|
||||
status = TitleChanged
|
||||
id = oldSoundtrack.ID
|
||||
soundtrackUuid = oldSoundtrack.Uuid
|
||||
//fmt.Printf("SoundtrackName changed\n")
|
||||
break
|
||||
}
|
||||
@@ -328,34 +335,37 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
|
||||
}
|
||||
switch status {
|
||||
case NewSoundtrack:
|
||||
id = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
|
||||
id, soundtrackUuid = insertSoundtrack(file.Name(), soundtrackDir, dirHash)
|
||||
logging.GetLogger().Debug("New soundtrack detected",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("soundtrack", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
soundtracksAdded = append(soundtracksAdded, file.Name())
|
||||
checkSongs(entries, soundtrackDir, id)
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
case SoundtrackChanged:
|
||||
logging.GetLogger().Debug("Soundtrack changed",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("soundtrack", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: dirHash, ID: id})
|
||||
handleError("UpdateSoundtrackHash", err, "")
|
||||
soundtracksChangedContent = append(soundtracksChangedContent, file.Name())
|
||||
checkSongs(entries, soundtrackDir, id)
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
case TitleChanged:
|
||||
logging.GetLogger().Debug("Soundtrack title changed",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("oldName", oldSoundtrack.SoundtrackName),
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
err = repo.UpdateSoundtrackName(BackendCtx(), repository.UpdateSoundtrackNameParams{Name: file.Name(), Path: soundtrackDir, ID: id})
|
||||
handleError("UpdateSoundtrackName", err, "")
|
||||
checkSongs(entries, soundtrackDir, id)
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
if soundtracksChangedTitle == nil {
|
||||
soundtracksChangedTitle = make(map[string]string)
|
||||
}
|
||||
@@ -373,10 +383,11 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
checkSongs(entries, soundtrackDir, id)
|
||||
checkSongs(entries, soundtrackDir, id, soundtrackUuid)
|
||||
soundtracksReAdded = append(soundtracksReAdded, file.Name())
|
||||
logging.GetLogger().Debug("Soundtrack added again",
|
||||
zap.Int32("id", id),
|
||||
zap.String("uuid", soundtrackUuid.String()),
|
||||
zap.String("newName", file.Name()),
|
||||
zap.String("hash", dirHash),
|
||||
zap.String("status", status.String()))
|
||||
@@ -390,6 +401,15 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
|
||||
zap.String("status", status.String()))
|
||||
err = repo.RemoveSoundtrackDeletionDate(BackendCtx(), id)
|
||||
handleError("RemoveSoundtrackDeletionDate", err, "")
|
||||
|
||||
// Remove .id files and update hash if any were removed
|
||||
if removeIdFilesInSoundtrack(soundtrackDir) {
|
||||
newHash := getHashForDir(soundtrackDir)
|
||||
if newHash != dirHash {
|
||||
err = repo.UpdateSoundtrackHash(BackendCtx(), repository.UpdateSoundtrackHashParams{Hash: newHash, ID: id})
|
||||
handleError("UpdateSoundtrackHash for .id cleanup", err, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
foldersSynced++
|
||||
logging.GetLogger().Debug("Sync progress",
|
||||
@@ -398,24 +418,31 @@ func syncSoundtrack(file os.DirEntry, foldersToSkip []string, baseDir string, fu
|
||||
zap.Int("percent", int((foldersSynced/numberOfFoldersToSync)*100)))
|
||||
}
|
||||
|
||||
func insertSoundtrack(name string, path string, hash string) int32 {
|
||||
func insertSoundtrack(name string, path string, hash string) (int32, uuid.UUID) {
|
||||
var duplicateError = errors.New("ERROR: duplicate key value violates unique")
|
||||
id, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{SoundtrackName: name, Path: path, Hash: hash})
|
||||
soundtrackUuid := uuid.New()
|
||||
result, err := repo.InsertSoundtrack(BackendCtx(), repository.InsertSoundtrackParams{
|
||||
SoundtrackName: name,
|
||||
Path: path,
|
||||
Hash: hash,
|
||||
Uuid: soundtrackUuid,
|
||||
})
|
||||
handleError("InsertSoundtrack", err, "")
|
||||
if err != nil {
|
||||
logging.GetLogger().Warn("ID collision detected, resetting sequence")
|
||||
logging.GetLogger().Warn("ID collision or UUID duplicate detected, resetting sequence")
|
||||
if strings.HasPrefix(err.Error(), duplicateError.Error()) {
|
||||
logging.GetLogger().Debug("Resetting soundtrack ID sequence")
|
||||
_, err = repo.ResetSoundtrackIdSeq(BackendCtx())
|
||||
handleError("ResetSoundtrackIdSeq", err, "")
|
||||
id = insertSoundtrack(name, path, hash)
|
||||
id, uuid := insertSoundtrack(name, path, hash)
|
||||
return id, uuid
|
||||
}
|
||||
}
|
||||
return id
|
||||
return result.ID, result.Uuid
|
||||
|
||||
}
|
||||
|
||||
func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
|
||||
func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) int32 {
|
||||
//hasher := md5.New()
|
||||
var numberOfSongs int32
|
||||
numberOfFiles := len(entries)
|
||||
@@ -425,7 +452,7 @@ func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
|
||||
for _, entry := range entries {
|
||||
poolSong.Submit(func() {
|
||||
defer songWg.Done()
|
||||
if checkSong(entry, soundtrackDir, id) {
|
||||
if checkSong(entry, soundtrackDir, id, soundtrackUuid) {
|
||||
numberOfSongs++
|
||||
}
|
||||
})
|
||||
@@ -434,7 +461,7 @@ func checkSongs(entries []os.DirEntry, soundtrackDir string, id int32) int32 {
|
||||
return numberOfSongs
|
||||
}
|
||||
|
||||
func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool {
|
||||
func checkSong(entry os.DirEntry, soundtrackDir string, id int32, soundtrackUuid uuid.UUID) bool {
|
||||
fileInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to get file info", zap.String("filename", entry.Name()), zap.String("error", err.Error()))
|
||||
@@ -487,8 +514,17 @@ func checkSong(entry os.DirEntry, soundtrackDir string, id int32) bool {
|
||||
err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path})
|
||||
handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
} else {
|
||||
err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash})
|
||||
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash))
|
||||
songUuid := uuid.New()
|
||||
err = repo.AddSong(BackendCtx(), repository.AddSongParams{
|
||||
SoundtrackID: id,
|
||||
SongName: songName,
|
||||
Path: path,
|
||||
FileName: &fileName,
|
||||
Hash: songHash,
|
||||
Uuid: songUuid,
|
||||
SoundtrackUuid: soundtrackUuid,
|
||||
})
|
||||
handleError("AddSong", err, fmt.Sprintf("SoundtrackID: %d | UUID: %s | Path: %s | SongName: %s | SongHash: %s", id, songUuid.String(), path, entry.Name(), songHash))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -558,6 +594,33 @@ func isCoverImage(entry fs.FileInfo) bool {
|
||||
(strings.HasSuffix(entry.Name(), ".jpg") || strings.HasSuffix(entry.Name(), ".png"))
|
||||
}
|
||||
|
||||
func removeIdFilesInSoundtrack(soundtrackDir string) bool {
|
||||
entries, err := os.ReadDir(soundtrackDir)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to read soundtrack directory for .id file cleanup",
|
||||
zap.String("path", soundtrackDir),
|
||||
zap.String("error", err.Error()))
|
||||
return false
|
||||
}
|
||||
|
||||
anyRemoved := false
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".id") {
|
||||
filePath := soundtrackDir + entry.Name()
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
logging.GetLogger().Error("Failed to remove .id file",
|
||||
zap.String("path", filePath),
|
||||
zap.String("error", err.Error()))
|
||||
} else {
|
||||
logging.GetLogger().Info("Removed .id file", zap.String("path", filePath))
|
||||
anyRemoved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return anyRemoved
|
||||
}
|
||||
|
||||
func contains(s []string, searchTerm string) bool {
|
||||
i := sort.SearchStrings(s, searchTerm)
|
||||
return i < len(s) && s[i] == searchTerm
|
||||
|
||||
@@ -7,8 +7,10 @@ type VersionData struct {
|
||||
|
||||
var data = []VersionData{
|
||||
{
|
||||
Version: "5.0.0-Beta",
|
||||
Version: "5.0.0",
|
||||
Changelog: []string{
|
||||
"#9 - Major routes refactor and expansion",
|
||||
"#15 - Added API for statistics",
|
||||
"#16 - Upgrade Echo framework from v4 to v5",
|
||||
"#17 - Add Zap structured logging framework",
|
||||
"#18 - Add OpenAPI/Swagger documentation",
|
||||
@@ -17,6 +19,8 @@ var data = []VersionData{
|
||||
"#21 - Refactor handlers into domain-specific files",
|
||||
"#22 - Change VersionData Changelog from string to string array",
|
||||
"#23 - Update all dependencies to latest versions",
|
||||
"Added search cache backend",
|
||||
"Backend sync improvements",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Revert: Rename soundtrack table back to game
|
||||
ALTER TABLE soundtrack RENAME TO game;
|
||||
|
||||
-- Revert primary key sequence
|
||||
ALTER SEQUENCE soundtrack_id_seq RENAME TO game_id_seq;
|
||||
|
||||
-- Revert columns in game table
|
||||
ALTER TABLE game RENAME COLUMN soundtrack_name TO game_name;
|
||||
|
||||
-- Revert song table: rename soundtrack_id back to game_id
|
||||
ALTER TABLE song RENAME COLUMN soundtrack_id TO game_id;
|
||||
|
||||
-- Revert song primary key
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
||||
ALTER TABLE song ADD PRIMARY KEY (game_id, path);
|
||||
ALTER TABLE song RENAME CONSTRAINT song_pkey_soundtrack TO song_pkey;
|
||||
|
||||
-- Revert song_list table references
|
||||
ALTER TABLE song_list RENAME COLUMN soundtrack_name TO game_name;
|
||||
|
||||
-- Revert foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
||||
ALTER TABLE song ADD CONSTRAINT song_game_id_fkey
|
||||
FOREIGN KEY (game_id) REFERENCES game(id);
|
||||
|
||||
-- Revert indexes
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_deleted RENAME TO idx_game_deleted;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_hash RENAME TO idx_game_hash;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_path RENAME TO idx_game_path;
|
||||
ALTER INDEX IF EXISTS idx_soundtrack_name RENAME TO idx_game_name;
|
||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id RENAME TO idx_song_game_id;
|
||||
ALTER INDEX IF EXISTS idx_song_soundtrack_id_song_name RENAME TO idx_song_game_id_song_name;
|
||||
ALTER INDEX IF EXISTS song_list_soundtrack_name_idx RENAME TO song_list_game_name_idx;
|
||||
@@ -1,32 +0,0 @@
|
||||
-- Rename game table to soundtrack
|
||||
ALTER TABLE game RENAME TO soundtrack;
|
||||
|
||||
-- Rename primary key sequence
|
||||
ALTER SEQUENCE game_id_seq RENAME TO soundtrack_id_seq;
|
||||
|
||||
-- Rename columns in soundtrack table
|
||||
ALTER TABLE soundtrack RENAME COLUMN game_name TO soundtrack_name;
|
||||
|
||||
-- Update song table: rename game_id to soundtrack_id
|
||||
ALTER TABLE song RENAME COLUMN game_id TO soundtrack_id;
|
||||
|
||||
-- Update song primary key
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_pkey;
|
||||
ALTER TABLE song ADD PRIMARY KEY (soundtrack_id, path);
|
||||
|
||||
-- Update song_list table references
|
||||
ALTER TABLE song_list RENAME COLUMN game_name TO soundtrack_name;
|
||||
|
||||
-- Rename foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_game_id_fkey;
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||
|
||||
-- Rename indexes
|
||||
ALTER INDEX IF EXISTS idx_game_deleted RENAME TO idx_soundtrack_deleted;
|
||||
ALTER INDEX IF EXISTS idx_game_hash RENAME TO idx_soundtrack_hash;
|
||||
ALTER INDEX IF EXISTS idx_game_path RENAME TO idx_soundtrack_path;
|
||||
ALTER INDEX IF EXISTS idx_game_name RENAME TO idx_soundtrack_name;
|
||||
ALTER INDEX IF EXISTS idx_song_game_id RENAME TO idx_song_soundtrack_id;
|
||||
ALTER INDEX IF EXISTS idx_song_game_id_song_name RENAME TO idx_song_soundtrack_id_song_name;
|
||||
ALTER INDEX IF EXISTS song_list_game_name_idx RENAME TO song_list_soundtrack_name_idx;
|
||||
@@ -1,24 +0,0 @@
|
||||
-- Rollback: Remove id column and restore composite PK
|
||||
|
||||
-- Step 1: Drop indexes created in up migration
|
||||
DROP INDEX IF EXISTS idx_song_soundtrack_id;
|
||||
DROP INDEX IF EXISTS idx_song_path;
|
||||
|
||||
-- Step 2: Drop foreign key constraint
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
||||
|
||||
-- Step 3: Drop new primary key
|
||||
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
||||
|
||||
-- Step 4: Drop unique constraint on id
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_id_unique;
|
||||
|
||||
-- Step 5: Restore composite primary key
|
||||
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path);
|
||||
|
||||
-- Step 6: Drop the id column
|
||||
ALTER TABLE song DROP COLUMN id;
|
||||
|
||||
-- Step 7: Recreate original foreign key (soundtrack_id references soundtrack.id)
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||
@@ -1,36 +0,0 @@
|
||||
-- Migration: Add id column to song table and change PK from composite to single column
|
||||
-- This prepares the song table for eventual UUID migration
|
||||
|
||||
-- Step 1: Add new id column (nullable initially)
|
||||
ALTER TABLE song ADD COLUMN id serial4;
|
||||
|
||||
-- Step 2: Create unique constraint on id (allows backfilling)
|
||||
ALTER TABLE song ADD CONSTRAINT song_id_unique UNIQUE (id);
|
||||
|
||||
-- Step 3: Backfill existing rows with sequential IDs
|
||||
-- Use DEFAULT which pulls from the sequence
|
||||
UPDATE song SET id = DEFAULT WHERE id IS NULL;
|
||||
|
||||
-- Step 4: Verify all rows have an id
|
||||
-- If this returns 0, backfill worked
|
||||
-- SELECT COUNT(*) FROM song WHERE id IS NULL;
|
||||
|
||||
-- Step 5: Drop the composite primary key (soundtrack_id, path)
|
||||
ALTER TABLE song DROP CONSTRAINT song_pkey;
|
||||
|
||||
-- Step 6: Add new primary key on id column
|
||||
ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (id);
|
||||
|
||||
-- Step 7: Ensure soundtrack_id remains a foreign key to soundtrack
|
||||
-- First drop existing FK if it exists (from the rename migration)
|
||||
ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey;
|
||||
|
||||
-- Then recreate it
|
||||
ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey
|
||||
FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id);
|
||||
|
||||
-- Step 8: Create index on soundtrack_id for query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id);
|
||||
|
||||
-- Step 9: Create index on path for lookups (previously part of PK)
|
||||
CREATE INDEX IF NOT EXISTS idx_song_path ON song(path);
|
||||
@@ -5,7 +5,7 @@ DELETE FROM song;
|
||||
DELETE FROM song WHERE soundtrack_id = $1;
|
||||
|
||||
-- name: AddSong :exec
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5);
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7);
|
||||
|
||||
-- name: CheckSong :one
|
||||
SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||
@@ -34,11 +34,19 @@ WHERE soundtrack_id = $1 AND song_name = $2;
|
||||
-- name: FetchAllSongs :many
|
||||
SELECT * FROM song;
|
||||
|
||||
-- name: GetSongById :one
|
||||
SELECT * FROM song WHERE id = $1;
|
||||
-- name: GetSongByUUID :one
|
||||
SELECT * FROM song WHERE uuid = $1;
|
||||
|
||||
-- name: RemoveBrokenSong :exec
|
||||
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2;
|
||||
|
||||
-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song WHERE id = ANY($1);
|
||||
DELETE FROM song WHERE uuid = ANY($1);
|
||||
|
||||
-- name: GetSongsBySoundtrackUUID :many
|
||||
SELECT *
|
||||
FROM song
|
||||
WHERE soundtrack_uuid = $1;
|
||||
|
||||
-- name: GetSongUUIDsBySoundtrackId :many
|
||||
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1;
|
||||
@@ -29,7 +29,7 @@ UPDATE soundtrack SET deleted=NULL WHERE id=$1;
|
||||
SELECT id FROM soundtrack WHERE soundtrack_name = $1;
|
||||
|
||||
-- name: InsertSoundtrack :one
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id;
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid;
|
||||
|
||||
-- name: InsertSoundtrackWithExistingId :exec
|
||||
INSERT INTO soundtrack (id, soundtrack_name, path, hash, added) VALUES ($1, $2, $3, $4, now());
|
||||
@@ -47,3 +47,12 @@ ORDER BY soundtrack_name;
|
||||
|
||||
-- name: AddSoundtrackPlayed :exec
|
||||
UPDATE soundtrack SET times_played = times_played + 1, last_played = now() WHERE id = $1;
|
||||
|
||||
-- name: GetSoundtrackByUUID :one
|
||||
SELECT *
|
||||
FROM soundtrack
|
||||
WHERE uuid = $1
|
||||
AND deleted IS NULL;
|
||||
|
||||
-- name: GetSoundtrackUUIDById :one
|
||||
SELECT uuid FROM soundtrack WHERE id = $1;
|
||||
@@ -7,6 +7,7 @@ package repository
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -20,13 +21,14 @@ type Session struct {
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
Hash string `json:"hash"`
|
||||
FileName *string `json:"file_name"`
|
||||
ID pgtype.Int4 `json:"id"`
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
TimesPlayed int32 `json:"times_played"`
|
||||
Hash string `json:"hash"`
|
||||
FileName *string `json:"file_name"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
}
|
||||
|
||||
type SongList struct {
|
||||
@@ -48,6 +50,7 @@ type Soundtrack struct {
|
||||
LastPlayed *time.Time `json:"last_played"`
|
||||
NumberOfSongs int32 `json:"number_of_songs"`
|
||||
Hash string `json:"hash"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
type Vgmq struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const addHashToSong = `-- name: AddHashToSong :exec
|
||||
@@ -27,15 +27,17 @@ func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) er
|
||||
}
|
||||
|
||||
const addSong = `-- name: AddSong :exec
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5)
|
||||
INSERT INTO song(soundtrack_id, song_name, path, file_name, hash, uuid, soundtrack_uuid) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
|
||||
type AddSongParams struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
FileName *string `json:"file_name"`
|
||||
Hash string `json:"hash"`
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SongName string `json:"song_name"`
|
||||
Path string `json:"path"`
|
||||
FileName *string `json:"file_name"`
|
||||
Hash string `json:"hash"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
}
|
||||
|
||||
func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||
@@ -45,6 +47,8 @@ func (q *Queries) AddSong(ctx context.Context, arg AddSongParams) error {
|
||||
arg.Path,
|
||||
arg.FileName,
|
||||
arg.Hash,
|
||||
arg.Uuid,
|
||||
arg.SoundtrackUuid,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -110,7 +114,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int
|
||||
}
|
||||
|
||||
const fetchAllSongs = `-- name: FetchAllSongs :many
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song
|
||||
`
|
||||
|
||||
func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
@@ -129,7 +133,8 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
&i.ID,
|
||||
&i.Uuid,
|
||||
&i.SoundtrackUuid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +147,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) {
|
||||
}
|
||||
|
||||
const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
|
||||
FROM song
|
||||
WHERE soundtrack_id = $1
|
||||
`
|
||||
@@ -163,7 +168,8 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
&i.ID,
|
||||
&i.Uuid,
|
||||
&i.SoundtrackUuid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -175,12 +181,12 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getSongById = `-- name: GetSongById :one
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE id = $1
|
||||
const getSongByUUID = `-- name: GetSongByUUID :one
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE uuid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) {
|
||||
row := q.db.QueryRow(ctx, getSongById, id)
|
||||
func (q *Queries) GetSongByUUID(ctx context.Context, argUuid uuid.UUID) (Song, error) {
|
||||
row := q.db.QueryRow(ctx, getSongByUUID, argUuid)
|
||||
var i Song
|
||||
err := row.Scan(
|
||||
&i.SoundtrackID,
|
||||
@@ -189,13 +195,43 @@ func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error)
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
&i.ID,
|
||||
&i.Uuid,
|
||||
&i.SoundtrackUuid,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSongUUIDsBySoundtrackId = `-- name: GetSongUUIDsBySoundtrackId :many
|
||||
SELECT uuid, soundtrack_uuid FROM song WHERE soundtrack_id = $1
|
||||
`
|
||||
|
||||
type GetSongUUIDsBySoundtrackIdRow struct {
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetSongUUIDsBySoundtrackId(ctx context.Context, soundtrackID int32) ([]GetSongUUIDsBySoundtrackIdRow, error) {
|
||||
rows, err := q.db.Query(ctx, getSongUUIDsBySoundtrackId, soundtrackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetSongUUIDsBySoundtrackIdRow
|
||||
for rows.Next() {
|
||||
var i GetSongUUIDsBySoundtrackIdRow
|
||||
if err := rows.Scan(&i.Uuid, &i.SoundtrackUuid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getSongWithHash = `-- name: GetSongWithHash :one
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id FROM song WHERE hash = $1
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid FROM song WHERE hash = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) {
|
||||
@@ -208,11 +244,47 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
&i.ID,
|
||||
&i.Uuid,
|
||||
&i.SoundtrackUuid,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getSongsBySoundtrackUUID = `-- name: GetSongsBySoundtrackUUID :many
|
||||
SELECT soundtrack_id, song_name, path, times_played, hash, file_name, uuid, soundtrack_uuid
|
||||
FROM song
|
||||
WHERE soundtrack_uuid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSongsBySoundtrackUUID(ctx context.Context, soundtrackUuid uuid.UUID) ([]Song, error) {
|
||||
rows, err := q.db.Query(ctx, getSongsBySoundtrackUUID, soundtrackUuid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Song
|
||||
for rows.Next() {
|
||||
var i Song
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SongName,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.Hash,
|
||||
&i.FileName,
|
||||
&i.Uuid,
|
||||
&i.SoundtrackUuid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const removeBrokenSong = `-- name: RemoveBrokenSong :exec
|
||||
DELETE FROM song WHERE soundtrack_id = $1 AND path = $2
|
||||
`
|
||||
@@ -228,11 +300,11 @@ func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongPara
|
||||
}
|
||||
|
||||
const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec
|
||||
DELETE FROM song WHERE id = ANY($1)
|
||||
DELETE FROM song WHERE uuid = ANY($1)
|
||||
`
|
||||
|
||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error {
|
||||
_, err := q.db.Exec(ctx, removeBrokenSongs, id)
|
||||
func (q *Queries) RemoveBrokenSongs(ctx context.Context, argUuid uuid.UUID) error {
|
||||
_, err := q.db.Exec(ctx, removeBrokenSongs, argUuid)
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const addSoundtrackPlayed = `-- name: AddSoundtrackPlayed :exec
|
||||
@@ -28,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
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
FROM soundtrack
|
||||
WHERE deleted IS NULL
|
||||
ORDER BY soundtrack_name
|
||||
@@ -54,6 +56,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error)
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -66,7 +69,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
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
FROM soundtrack
|
||||
ORDER BY soundtrack_name
|
||||
`
|
||||
@@ -91,6 +94,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,7 +118,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
|
||||
SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid
|
||||
FROM soundtrack
|
||||
WHERE id = $1
|
||||
AND deleted IS NULL
|
||||
@@ -134,6 +138,33 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
)
|
||||
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
|
||||
FROM soundtrack
|
||||
WHERE uuid = $1
|
||||
AND deleted IS NULL
|
||||
`
|
||||
|
||||
func (q *Queries) GetSoundtrackByUUID(ctx context.Context, argUuid uuid.UUID) (Soundtrack, error) {
|
||||
row := q.db.QueryRow(ctx, getSoundtrackByUUID, argUuid)
|
||||
var i Soundtrack
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SoundtrackName,
|
||||
&i.Added,
|
||||
&i.Deleted,
|
||||
&i.LastChanged,
|
||||
&i.Path,
|
||||
&i.TimesPlayed,
|
||||
&i.LastPlayed,
|
||||
&i.NumberOfSongs,
|
||||
&i.Hash,
|
||||
&i.Uuid,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -149,21 +180,43 @@ func (q *Queries) GetSoundtrackNameById(ctx context.Context, id int32) (string,
|
||||
return soundtrack_name, err
|
||||
}
|
||||
|
||||
const getSoundtrackUUIDById = `-- name: GetSoundtrackUUIDById :one
|
||||
SELECT uuid FROM soundtrack WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetSoundtrackUUIDById(ctx context.Context, id int32) (uuid.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, getSoundtrackUUIDById, id)
|
||||
var uuid uuid.UUID
|
||||
err := row.Scan(&uuid)
|
||||
return uuid, err
|
||||
}
|
||||
|
||||
const insertSoundtrack = `-- name: InsertSoundtrack :one
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added) VALUES ($1, $2, $3, now()) returning id
|
||||
INSERT INTO soundtrack (soundtrack_name, path, hash, added, uuid) VALUES ($1, $2, $3, now(), $4) returning id, uuid
|
||||
`
|
||||
|
||||
type InsertSoundtrackParams struct {
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, insertSoundtrack, arg.SoundtrackName, arg.Path, arg.Hash)
|
||||
var id int32
|
||||
err := row.Scan(&id)
|
||||
return id, err
|
||||
type InsertSoundtrackRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Uuid uuid.UUID `json:"uuid"`
|
||||
}
|
||||
|
||||
func (q *Queries) InsertSoundtrack(ctx context.Context, arg InsertSoundtrackParams) (InsertSoundtrackRow, error) {
|
||||
row := q.db.QueryRow(ctx, insertSoundtrack,
|
||||
arg.SoundtrackName,
|
||||
arg.Path,
|
||||
arg.Hash,
|
||||
arg.Uuid,
|
||||
)
|
||||
var i InsertSoundtrackRow
|
||||
err := row.Scan(&i.ID, &i.Uuid)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertSoundtrackWithExistingId = `-- name: InsertSoundtrackWithExistingId :exec
|
||||
|
||||
@@ -66,63 +66,6 @@ func (q *Queries) GetLastPlayedSoundtracks(ctx context.Context, limit int32) ([]
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetLeastPlayedSoundtracksWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Least played soundtracks with their songs
|
||||
func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetLeastPlayedSoundtracksWithSongsRow
|
||||
for rows.Next() {
|
||||
var i GetLeastPlayedSoundtracksWithSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getLeastPlayedSongsWithSoundtrack = `-- name: GetLeastPlayedSongsWithSoundtrack :many
|
||||
SELECT
|
||||
s.soundtrack_id as soundtrack_id,
|
||||
@@ -175,7 +118,7 @@ func (q *Queries) GetLeastPlayedSongsWithSoundtrack(ctx context.Context, limit i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||
const getLeastPlayedSoundtracksWithSongs = `-- name: GetLeastPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
@@ -193,11 +136,11 @@ FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||
ORDER BY g.times_played ASC, g.soundtrack_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetMostPlayedSoundtracksWithSongsRow struct {
|
||||
type GetLeastPlayedSoundtracksWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
@@ -205,16 +148,16 @@ type GetMostPlayedSoundtracksWithSongsRow struct {
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Most played soundtracks with their songs
|
||||
func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
|
||||
// Least played soundtracks with their songs
|
||||
func (q *Queries) GetLeastPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetLeastPlayedSoundtracksWithSongsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getLeastPlayedSoundtracksWithSongs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetMostPlayedSoundtracksWithSongsRow
|
||||
var items []GetLeastPlayedSoundtracksWithSongsRow
|
||||
for rows.Next() {
|
||||
var i GetMostPlayedSoundtracksWithSongsRow
|
||||
var i GetLeastPlayedSoundtracksWithSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
@@ -284,6 +227,63 @@ func (q *Queries) GetMostPlayedSongsWithSoundtrack(ctx context.Context, limit in
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getMostPlayedSoundtracksWithSongs = `-- name: GetMostPlayedSoundtracksWithSongs :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
g.soundtrack_name,
|
||||
g.times_played as soundtrack_played,
|
||||
g.last_played as soundtrack_last_played,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'song_name', s.song_name,
|
||||
'path', s.path,
|
||||
'times_played', s.times_played,
|
||||
'file_name', s.file_name
|
||||
)
|
||||
) as songs
|
||||
FROM soundtrack g
|
||||
LEFT JOIN song s ON g.id = s.soundtrack_id
|
||||
WHERE g.deleted IS NULL
|
||||
GROUP BY g.id, g.soundtrack_name, g.times_played, g.last_played
|
||||
ORDER BY g.times_played DESC, g.soundtrack_name
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetMostPlayedSoundtracksWithSongsRow struct {
|
||||
SoundtrackID int32 `json:"soundtrack_id"`
|
||||
SoundtrackName string `json:"soundtrack_name"`
|
||||
SoundtrackPlayed int32 `json:"soundtrack_played"`
|
||||
SoundtrackLastPlayed *time.Time `json:"soundtrack_last_played"`
|
||||
Songs []byte `json:"songs"`
|
||||
}
|
||||
|
||||
// Most played soundtracks with their songs
|
||||
func (q *Queries) GetMostPlayedSoundtracksWithSongs(ctx context.Context, limit int32) ([]GetMostPlayedSoundtracksWithSongsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getMostPlayedSoundtracksWithSongs, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetMostPlayedSoundtracksWithSongsRow
|
||||
for rows.Next() {
|
||||
var i GetMostPlayedSoundtracksWithSongsRow
|
||||
if err := rows.Scan(
|
||||
&i.SoundtrackID,
|
||||
&i.SoundtrackName,
|
||||
&i.SoundtrackPlayed,
|
||||
&i.SoundtrackLastPlayed,
|
||||
&i.Songs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getNeverPlayedSoundtracks = `-- name: GetNeverPlayedSoundtracks :many
|
||||
SELECT
|
||||
g.id as soundtrack_id,
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +18,15 @@ import (
|
||||
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
// Session auth disable bypass - allow all requests through without token validation
|
||||
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||
if disableSessionAuth {
|
||||
logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
|
||||
zap.String("path", c.Request().URL.Path),
|
||||
zap.String("ip", c.RealIP()))
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Extract token from Authorization header
|
||||
authHeader := c.Request().Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
|
||||
+137
-28
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -52,7 +53,6 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
|
||||
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
|
||||
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
|
||||
e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler)))
|
||||
|
||||
e.Static("/", "/frontend")
|
||||
|
||||
@@ -117,53 +117,162 @@ func (s *Server) RegisterRoutes() http.Handler {
|
||||
// Create /api/v1 group
|
||||
apiV1 := e.Group("/api/v1")
|
||||
|
||||
// Public endpoints - no token required
|
||||
apiV1.POST("/token", func(c *echo.Context) error {
|
||||
// Create rate limiter for token creation: 10 requests per minute per IP
|
||||
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
|
||||
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
|
||||
|
||||
// Token endpoints (public - no token required)
|
||||
tokenGroup := apiV1.Group("/token")
|
||||
tokenGroup.POST("", func(c *echo.Context) error {
|
||||
return s.tokenHandler.CreateTokenHandler(c)
|
||||
})
|
||||
apiV1.DELETE("/token", func(c *echo.Context) error {
|
||||
}, tokenRateLimitMiddleware)
|
||||
tokenGroup.DELETE("", func(c *echo.Context) error {
|
||||
return s.tokenHandler.DeleteTokenHandler(c)
|
||||
})
|
||||
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
|
||||
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
|
||||
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
|
||||
})
|
||||
|
||||
// Protected endpoints - require valid token
|
||||
// Create token auth middleware with pool access
|
||||
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
|
||||
|
||||
// Protected group with token authentication
|
||||
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
|
||||
|
||||
// Statistics API endpoints (protected by token auth)
|
||||
statistics := s.statisticsHandler
|
||||
protectedV1.GET("/statistics/soundtracks/most-played", func(c *echo.Context) error {
|
||||
return statistics.GetMostPlayedSoundtracks(c)
|
||||
// Statistics endpoints (protected)
|
||||
statisticsHandler := s.statisticsHandler
|
||||
statsGroup := protectedV1.Group("/statistics")
|
||||
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetMostPlayedSoundtracks(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/soundtracks/least-played", func(c *echo.Context) error {
|
||||
return statistics.GetLeastPlayedSoundtracks(c)
|
||||
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetLeastPlayedSoundtracks(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/soundtracks/never-played", func(c *echo.Context) error {
|
||||
return statistics.GetNeverPlayedSoundtracks(c)
|
||||
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetNeverPlayedSoundtracks(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/soundtracks/last-played", func(c *echo.Context) error {
|
||||
return statistics.GetLastPlayedSoundtracks(c)
|
||||
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetLastPlayedSoundtracks(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/soundtracks/oldest-played", func(c *echo.Context) error {
|
||||
return statistics.GetOldestPlayedSoundtracks(c)
|
||||
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetOldestPlayedSoundtracks(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
|
||||
return statistics.GetMostPlayedSongs(c)
|
||||
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetMostPlayedSongs(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error {
|
||||
return statistics.GetLeastPlayedSongs(c)
|
||||
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetLeastPlayedSongs(c)
|
||||
})
|
||||
protectedV1.GET("/statistics/summary", func(c *echo.Context) error {
|
||||
return statistics.GetStatisticsSummary(c)
|
||||
statsGroup.GET("/summary", func(c *echo.Context) error {
|
||||
return statisticsHandler.GetStatisticsSummary(c)
|
||||
})
|
||||
|
||||
// Future: VGMQ endpoints will be added to protectedV1 group
|
||||
_ = protectedV1 // Use the variable to avoid unused variable error
|
||||
// Version endpoints (protected)
|
||||
versionGroup := protectedV1.Group("/version")
|
||||
versionGroup.GET("", func(c *echo.Context) error {
|
||||
return version.GetLatestVersion(c)
|
||||
})
|
||||
versionGroup.GET("/history", func(c *echo.Context) error {
|
||||
return version.GetVersionHistory(c)
|
||||
})
|
||||
|
||||
// Character endpoints (protected)
|
||||
characterGroup := protectedV1.Group("/character")
|
||||
characterGroup.GET("", func(c *echo.Context) error {
|
||||
return character.GetCharacter(c)
|
||||
})
|
||||
characterGroup.GET("/list", func(c *echo.Context) error {
|
||||
return character.GetCharacterList(c)
|
||||
})
|
||||
|
||||
// Download endpoints (protected)
|
||||
downloadGroup := protectedV1.Group("/download")
|
||||
downloadGroup.GET("", func(c *echo.Context) error {
|
||||
return download.checkLatest(c)
|
||||
})
|
||||
downloadGroup.GET("/list", func(c *echo.Context) error {
|
||||
return download.listAssetsOfLatest(c)
|
||||
})
|
||||
downloadGroup.GET("/windows", func(c *echo.Context) error {
|
||||
return download.downloadLatestWindows(c)
|
||||
})
|
||||
downloadGroup.GET("/linux", func(c *echo.Context) error {
|
||||
return download.downloadLatestLinux(c)
|
||||
})
|
||||
|
||||
// Sync endpoints (protected)
|
||||
protectedSyncGroup := protectedV1.Group("/sync")
|
||||
protectedSyncGroup.GET("", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
|
||||
return sync.SyncProgress(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewFull(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewFull(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
|
||||
return sync.SyncSoundtracksNewOnlyChanges(c)
|
||||
})
|
||||
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
|
||||
return sync.ResetDB(c)
|
||||
})
|
||||
|
||||
// Music endpoints (protected)
|
||||
protectedMusicGroup := protectedV1.Group("/music")
|
||||
protectedMusicGroup.GET("", func(c *echo.Context) error {
|
||||
return music.GetSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
|
||||
return music.GetSoundCheckSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
|
||||
return music.ResetMusic(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
|
||||
return music.GetRandomSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
|
||||
return music.GetRandomSongLowChance(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
|
||||
return music.GetRandomSongClassic(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
|
||||
return music.GetSongInfo(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
|
||||
return music.GetPlayedSongs(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
|
||||
return music.GetNextSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
|
||||
return music.GetPreviousSong(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
|
||||
return music.GetAllSoundtracksRandom(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
|
||||
return music.GetAllSoundtracks(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
|
||||
return music.GetAllSoundtracksRandom(c)
|
||||
})
|
||||
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
|
||||
return music.PutPlayed(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
|
||||
return music.AddLatestToQue(c)
|
||||
})
|
||||
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
|
||||
return music.AddLatestPlayed(c)
|
||||
})
|
||||
|
||||
routes := e.Router().Routes()
|
||||
sort.Slice(routes, func(i, j int) bool {
|
||||
|
||||
+20
-16
@@ -15,19 +15,14 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
db *db.Database
|
||||
tokenHandler *TokenHandler
|
||||
port int
|
||||
db *db.Database
|
||||
tokenHandler *TokenHandler
|
||||
statisticsHandler *StatisticsHandler
|
||||
httpServer *http.Server
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
var (
|
||||
host = os.Getenv("DB_HOST")
|
||||
dbPort = os.Getenv("DB_PORT")
|
||||
dbName = os.Getenv("DB_NAME")
|
||||
username = os.Getenv("DB_USERNAME")
|
||||
password = os.Getenv("DB_PASSWORD")
|
||||
musicPath = os.Getenv("MUSIC_PATH")
|
||||
charactersPath = os.Getenv("CHARACTERS_PATH")
|
||||
logLevel = os.Getenv("LOG_LEVEL")
|
||||
@@ -37,11 +32,20 @@ var (
|
||||
// NewServerInstance creates a new Server instance with all dependencies initialized.
|
||||
// Use this for dependency injection and proper lifecycle management.
|
||||
func NewServerInstance() *Server {
|
||||
// Re-read environment variables at server creation time (not at package import time)
|
||||
// This allows tests to override environment variables
|
||||
host := os.Getenv("DB_HOST")
|
||||
dbPort := os.Getenv("DB_PORT")
|
||||
dbName := os.Getenv("DB_NAME")
|
||||
username := os.Getenv("DB_USERNAME")
|
||||
password := os.Getenv("DB_PASSWORD")
|
||||
|
||||
// Initialize logger
|
||||
if logLevel == "" {
|
||||
logLevel = "info"
|
||||
currentLogLevel := logLevel
|
||||
if currentLogLevel == "" {
|
||||
currentLogLevel = "info"
|
||||
}
|
||||
logging.Init(logLevel, logJSON)
|
||||
logging.Init(currentLogLevel, logJSON)
|
||||
|
||||
logger := logging.GetLogger()
|
||||
|
||||
@@ -68,15 +72,15 @@ func NewServerInstance() *Server {
|
||||
|
||||
// Initialize token handler with database pool
|
||||
tokenHandler := NewTokenHandler(database.Pool)
|
||||
|
||||
|
||||
// Initialize statistics handler
|
||||
statisticsHandler := NewStatisticsHandler()
|
||||
|
||||
// Create the server instance
|
||||
appServer := &Server{
|
||||
port: port,
|
||||
db: database,
|
||||
tokenHandler: tokenHandler,
|
||||
port: port,
|
||||
db: database,
|
||||
tokenHandler: tokenHandler,
|
||||
statisticsHandler: statisticsHandler,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
// saveEnvVars saves the current state of relevant environment variables
|
||||
func saveEnvVars() map[string]string {
|
||||
vars := []string{
|
||||
"DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
|
||||
"MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
|
||||
}
|
||||
saved := make(map[string]string)
|
||||
for _, v := range vars {
|
||||
saved[v] = os.Getenv(v)
|
||||
}
|
||||
return saved
|
||||
}
|
||||
|
||||
// restoreEnvVars restores environment variables from a saved state
|
||||
func restoreEnvVars(saved map[string]string) {
|
||||
for k, v := range saved {
|
||||
if v == "" {
|
||||
os.Unsetenv(k)
|
||||
} else {
|
||||
os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkTableExists returns true if the specified table exists in the database
|
||||
func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
|
||||
var exists bool
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
)
|
||||
`, tableName).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
|
||||
func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
|
||||
// Copy the SQL dump file to the container
|
||||
basename := filepath.Base(dumpPath)
|
||||
targetPath := "/tmp/" + basename
|
||||
if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
|
||||
return fmt.Errorf("failed to copy dump file to container: %w", err)
|
||||
}
|
||||
|
||||
// Execute psql to load the dump as the postgres superuser
|
||||
// We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
|
||||
// The postgres user is the superuser created by the Docker image
|
||||
// Use sh instead of bash as postgres:15-alpine uses Alpine Linux
|
||||
cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
|
||||
|
||||
exitCode, reader, err := container.Exec(ctx, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute psql: %w", err)
|
||||
}
|
||||
|
||||
// Read the output
|
||||
output, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read psql output: %w", err)
|
||||
}
|
||||
|
||||
if exitCode != 0 {
|
||||
return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
|
||||
}
|
||||
|
||||
t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
|
||||
return nil
|
||||
}
|
||||
|
||||
// createRoleInContainer creates a role in the PostgreSQL container
|
||||
func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
|
||||
// Get the mapped port for the container
|
||||
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get mapped port: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the postgres database to create the role
|
||||
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
|
||||
mappedPort.Port())
|
||||
|
||||
pool, err := pgxpool.New(ctx, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to postgres database: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Check if role already exists
|
||||
var exists bool
|
||||
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
|
||||
if err == nil && exists {
|
||||
return nil // Role already exists
|
||||
}
|
||||
|
||||
// Clear any error from the role check (role might not exist, which is expected)
|
||||
// Create the role
|
||||
_, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create role %s: %w", roleName, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createDatabaseInContainer creates a new database in the PostgreSQL container
|
||||
func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
|
||||
// Get the mapped port for the container
|
||||
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get mapped port: %w", err)
|
||||
}
|
||||
|
||||
// Add a small delay to ensure PostgreSQL is fully ready
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Connect to the default 'postgres' database to create the new database
|
||||
// Use localhost and the mapped port
|
||||
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
|
||||
mappedPort.Port())
|
||||
|
||||
pool, err := pgxpool.New(ctx, connStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to postgres database: %w", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
// Check if database already exists and drop it if so
|
||||
var exists int
|
||||
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
|
||||
if err == nil && exists == 1 {
|
||||
_, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop existing database: %w", err)
|
||||
}
|
||||
t.Logf("Dropped existing database %s", dbname)
|
||||
}
|
||||
|
||||
// Create the new database
|
||||
_, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database: %w", err)
|
||||
}
|
||||
|
||||
t.Logf("Created database %s", dbname)
|
||||
return nil
|
||||
}
|
||||
|
||||
// startPostgreSQLContainer starts a PostgreSQL container and returns it
|
||||
func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "postgres:15-alpine",
|
||||
ExposedPorts: []string{"5432/tcp"},
|
||||
Env: map[string]string{
|
||||
"POSTGRES_PASSWORD": "postgres",
|
||||
"POSTGRES_DB": "postgres",
|
||||
},
|
||||
WaitingFor: wait.ForLog("database system is ready to accept connections"),
|
||||
}
|
||||
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start container: %w", err)
|
||||
}
|
||||
|
||||
return container, nil
|
||||
}
|
||||
|
||||
// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
|
||||
func TestMusicRefDump(t *testing.T) {
|
||||
testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
|
||||
}
|
||||
|
||||
// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
|
||||
func TestMusicTestDump(t *testing.T) {
|
||||
testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
|
||||
}
|
||||
|
||||
// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
|
||||
func TestMusicProdDump(t *testing.T) {
|
||||
testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
|
||||
}
|
||||
|
||||
// testSQLDump is the common test function for all SQL dump tests
|
||||
func testSQLDump(t *testing.T, dumpFile, dbname string) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Save original environment variables
|
||||
originalEnv := saveEnvVars()
|
||||
defer restoreEnvVars(originalEnv)
|
||||
|
||||
// Start PostgreSQL container
|
||||
t.Log("Starting PostgreSQL container...")
|
||||
container, err := startPostgreSQLContainer(t, ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
t.Log("Stopping container...")
|
||||
container.Terminate(ctx)
|
||||
}()
|
||||
|
||||
// Get container connection details
|
||||
endpoint, err := container.Endpoint(ctx, "")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Container running at: %s", endpoint)
|
||||
|
||||
// Create database for this test
|
||||
t.Logf("Creating database %s...", dbname)
|
||||
err = createDatabaseInContainer(t, ctx, container, dbname)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create common roles that might be referenced in dump files
|
||||
err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
|
||||
if err != nil {
|
||||
t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
|
||||
}
|
||||
|
||||
// Load the SQL dump
|
||||
// The dump files are in the project root
|
||||
// Get the absolute path to the dump file
|
||||
absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
|
||||
require.NoError(t, err)
|
||||
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
|
||||
// Try relative to current working directory
|
||||
absDumpPath = filepath.Join(".", dumpFile)
|
||||
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
|
||||
absDumpPath = dumpFile // Try as-is
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Loading SQL dump from: %s", absDumpPath)
|
||||
err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// ============================================
|
||||
// PRE-MIGRATION CHECK
|
||||
// ============================================
|
||||
t.Log("Running pre-migration checks...")
|
||||
|
||||
// For connecting to the database from the test, we need to use the container's endpoint
|
||||
// But the container's endpoint might be localhost:random_port
|
||||
// We need to parse it to get host and port
|
||||
// endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
|
||||
// Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
|
||||
// Let's extract the mapped port
|
||||
|
||||
// Get the mapped port from the container
|
||||
mappedPort, err := container.MappedPort(ctx, "5432")
|
||||
require.NoError(t, err)
|
||||
t.Logf("Container mapped port: %s", mappedPort)
|
||||
|
||||
// Use localhost as the host since we're on the same machine
|
||||
dbHost := "localhost"
|
||||
dbPort := mappedPort.Port()
|
||||
|
||||
// Create a connection to the database to check pre-migration state
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
dbHost, dbPort, "postgres", "postgres", dbname)
|
||||
|
||||
pool, err := pgxpool.New(ctx, connStr)
|
||||
require.NoError(t, err)
|
||||
defer pool.Close()
|
||||
|
||||
// Check that game table exists (pre-migration state)
|
||||
gameExists, err := checkTableExists(ctx, pool, "game")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, gameExists, "Before migration: game table should exist")
|
||||
|
||||
// Check that soundtrack table does NOT exist
|
||||
soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
|
||||
|
||||
t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
|
||||
|
||||
// ============================================
|
||||
// START APPLICATION (triggers migrations)
|
||||
// ============================================
|
||||
t.Log("Starting application...")
|
||||
|
||||
// Set environment variables for the server
|
||||
// The application needs to connect to the container's mapped port
|
||||
os.Setenv("DB_HOST", dbHost)
|
||||
os.Setenv("DB_PORT", dbPort)
|
||||
os.Setenv("DB_USERNAME", "postgres")
|
||||
os.Setenv("DB_PASSWORD", "postgres")
|
||||
os.Setenv("DB_NAME", dbname)
|
||||
os.Setenv("MUSIC_PATH", "./testMusic")
|
||||
os.Setenv("CHARACTERS_PATH", "./testCharacters")
|
||||
|
||||
// Use different ports for each test to allow for sequential execution
|
||||
// and to make port detection reliable
|
||||
portMap := map[string]string{
|
||||
"music_server_ref_test": "8082",
|
||||
"music_server_test_dump_test": "8083",
|
||||
"music_server_prod_test": "8084",
|
||||
}
|
||||
appPort := portMap[dbname]
|
||||
if appPort == "" {
|
||||
appPort = "8085" // fallback
|
||||
}
|
||||
os.Setenv("PORT", appPort)
|
||||
|
||||
os.Setenv("DISABLE_SESSION_AUTH", "true")
|
||||
os.Setenv("LOG_LEVEL", "error")
|
||||
|
||||
// Create server instance (this runs migrations automatically)
|
||||
serverInstance := NewServerInstance()
|
||||
httpServer := serverInstance.HTTPServer()
|
||||
|
||||
// Start HTTP server in background
|
||||
go func() {
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
t.Logf("Server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
t.Log("Stopping HTTP server...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
httpServer.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
// Wait for server to start by polling /health endpoint
|
||||
// Use the fixed port we configured for the application
|
||||
healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
|
||||
|
||||
maxAttempts := 30
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
resp, err := http.Get(healthURL)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var healthData map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&healthData)
|
||||
if healthData["status"] == "up" {
|
||||
t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if attempt == maxAttempts {
|
||||
require.Fail(t, "Server did not become healthy within timeout")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// POST-MIGRATION CHECK
|
||||
// ============================================
|
||||
t.Log("Running post-migration checks...")
|
||||
|
||||
// Check that game table no longer exists
|
||||
gameExistsAfter, err := checkTableExists(ctx, pool, "game")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
|
||||
|
||||
// Check that soundtrack table now exists
|
||||
soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
|
||||
|
||||
// Verify data was preserved
|
||||
var soundtrackCount int
|
||||
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
|
||||
t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
|
||||
|
||||
// ============================================
|
||||
// HEALTH CHECK
|
||||
// ============================================
|
||||
t.Log("Running health check...")
|
||||
|
||||
resp, err := http.Get(healthURL)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var healthData map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&healthData)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "up", healthData["status"])
|
||||
|
||||
t.Log("✓ Health check passed")
|
||||
t.Log("✓ Test completed successfully!")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"music-server/internal/db"
|
||||
"music-server/internal/db/repository"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -122,33 +123,37 @@ func insertTestData(t *testing.T) {
|
||||
SoundtrackName: st.name,
|
||||
Path: st.path,
|
||||
Hash: "test-hash-" + st.name,
|
||||
Uuid: uuid.New(),
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
|
||||
}
|
||||
|
||||
// Get soundtrack IDs
|
||||
// Get soundtrack IDs and UUIDs
|
||||
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
|
||||
|
||||
// Insert songs for each soundtrack
|
||||
songData := []struct {
|
||||
soundtrackID int32
|
||||
songs []string
|
||||
soundtrackID int32
|
||||
soundtrackUuid uuid.UUID
|
||||
songs []string
|
||||
}{
|
||||
{soundtrackIDs[0].ID, []string{"Song A", "Song B"}},
|
||||
{soundtrackIDs[1].ID, []string{"Song C", "Song D"}},
|
||||
{soundtrackIDs[2].ID, []string{"Song E"}},
|
||||
{soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}},
|
||||
{soundtrackIDs[4].ID, []string{"Song I"}},
|
||||
{soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
|
||||
{soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
|
||||
{soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
|
||||
{soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
|
||||
{soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}},
|
||||
}
|
||||
|
||||
for _, sd := range songData {
|
||||
for _, songName := range sd.songs {
|
||||
err := queries.AddSong(ctx, repository.AddSongParams{
|
||||
SoundtrackID: sd.soundtrackID,
|
||||
SongName: songName,
|
||||
Path: "/path/to/" + songName + ".mp3",
|
||||
SoundtrackID: sd.soundtrackID,
|
||||
SongName: songName,
|
||||
Path: "/path/to/" + songName + ".mp3",
|
||||
Uuid: uuid.New(),
|
||||
SoundtrackUuid: sd.soundtrackUuid,
|
||||
})
|
||||
require.NoError(t, err, "Failed to insert song: %s", songName)
|
||||
}
|
||||
|
||||
@@ -4,28 +4,30 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"music-server/internal/db/repository"
|
||||
"music-server/internal/logging"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TokenRequest represents a request to generate a new token
|
||||
type TokenRequest struct {
|
||||
Password string `json:"password"` // Required: API password
|
||||
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
|
||||
}
|
||||
|
||||
// TokenResponse represents the response with a new token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClientType string `json:"client_type"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
ClientType string `json:"client_type"`
|
||||
}
|
||||
|
||||
// TokenHandler contains the database pool for token operations
|
||||
@@ -53,13 +55,14 @@ func (h *TokenHandler) generateToken() (string, error) {
|
||||
// POST /api/v1/token
|
||||
//
|
||||
// @Summary Create session token
|
||||
// @Description Returns a new session token for API access
|
||||
// @Description Returns a new session token for API access. Requires valid API password.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body TokenRequest true "Client type"
|
||||
// @Param request body TokenRequest true "Password and client type"
|
||||
// @Success 200 {object} TokenResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /api/v1/token [post]
|
||||
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
@@ -68,6 +71,27 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
|
||||
}
|
||||
|
||||
// Check if session auth is disabled
|
||||
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
|
||||
apiPassword := os.Getenv("API_PASSWORD")
|
||||
|
||||
// Password validation logic:
|
||||
// - If session auth is disabled, skip validation
|
||||
// - If API_PASSWORD is empty, allow any password (open access mode)
|
||||
// - If API_PASSWORD is set, require exact password match
|
||||
if !disableSessionAuth {
|
||||
if apiPassword != "" {
|
||||
// API password is set - require exact match
|
||||
if req.Password != apiPassword {
|
||||
logging.GetLogger().Warn("Invalid password attempt for token creation",
|
||||
zap.String("ip", c.RealIP()),
|
||||
zap.String("user_agent", c.Request().UserAgent()))
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
|
||||
}
|
||||
}
|
||||
// If API_PASSWORD is empty, allow any password through (open access mode)
|
||||
}
|
||||
|
||||
if req.ClientType == "" {
|
||||
req.ClientType = "web"
|
||||
}
|
||||
@@ -98,8 +122,8 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
|
||||
}
|
||||
|
||||
response := TokenResponse{
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
Token: session.Token,
|
||||
ExpiresAt: session.ExpiresAt.Time,
|
||||
ClientType: *session.ClientType,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user