// Package fakedb provides an in-memory repository.Querier for tests. package fakedb import ( "cmp" "context" "math/rand/v2" "slices" "sync" "time" "music-server/internal/db/repository" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) // Store is an in-memory implementation of the queries used by the application. // Calling a query it does not implement panics through the nil embedded interface. type Store struct { repository.Querier mu sync.Mutex nextID int32 Soundtracks []repository.Soundtrack Songs []repository.Song Sessions map[string]repository.Session Queues []repository.Queue QueueSongs []repository.QueueSong // Err, when set, is returned by every implemented query. Err error } // New returns an empty Store. func New() *Store { return &Store{Sessions: map[string]repository.Session{}} } func (s *Store) lock() func() { s.mu.Lock() return s.mu.Unlock } // AddSoundtrack inserts a soundtrack and returns it with its ID and UUID set. func (s *Store) AddSoundtrack(st repository.Soundtrack) repository.Soundtrack { defer s.lock()() s.nextID++ st.ID = s.nextID if st.Uuid == uuid.Nil { st.Uuid = uuid.New() } s.Soundtracks = append(s.Soundtracks, st) return st } // AddTestSong inserts a song and returns it with its UUID and soundtrack UUID set. func (s *Store) AddTestSong(song repository.Song) repository.Song { defer s.lock()() if song.Uuid == uuid.Nil { song.Uuid = uuid.New() } if i := s.soundtrackIndex(song.SoundtrackID); song.SoundtrackUuid == uuid.Nil && i >= 0 { song.SoundtrackUuid = s.Soundtracks[i].Uuid } s.Songs = append(s.Songs, song) return song } // Soundtrack returns the soundtrack with the given name. func (s *Store) Soundtrack(name string) (repository.Soundtrack, bool) { defer s.lock()() for _, st := range s.Soundtracks { if st.SoundtrackName == name { return st, true } } return repository.Soundtrack{}, false } // SongsOf returns the songs of a soundtrack sorted by path. func (s *Store) SongsOf(soundtrackID int32) []repository.Song { defer s.lock()() var songs []repository.Song for _, song := range s.Songs { if song.SoundtrackID == soundtrackID { songs = append(songs, song) } } slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.Path, b.Path) }) return songs } func (s *Store) soundtrackIndex(id int32) int { return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.ID == id }) } func (s *Store) updateSoundtrack(id int32, f func(*repository.Soundtrack)) error { defer s.lock()() if s.Err != nil { return s.Err } if i := s.soundtrackIndex(id); i >= 0 { f(&s.Soundtracks[i]) } return nil } func sortedByName(soundtracks []repository.Soundtrack) []repository.Soundtrack { slices.SortFunc(soundtracks, func(a, b repository.Soundtrack) int { return cmp.Compare(a.SoundtrackName, b.SoundtrackName) }) return soundtracks } func (s *Store) FindAllSoundtracks(context.Context) ([]repository.Soundtrack, error) { defer s.lock()() if s.Err != nil { return nil, s.Err } var active []repository.Soundtrack for _, st := range s.Soundtracks { if st.Deleted == nil { active = append(active, st) } } return sortedByName(active), nil } func (s *Store) GetAllSoundtracksIncludingDeleted(context.Context) ([]repository.Soundtrack, error) { defer s.lock()() if s.Err != nil { return nil, s.Err } return sortedByName(slices.Clone(s.Soundtracks)), nil } func (s *Store) InsertSoundtrack(_ context.Context, arg repository.InsertSoundtrackParams) (repository.InsertSoundtrackRow, error) { if s.Err != nil { return repository.InsertSoundtrackRow{}, s.Err } st := s.AddSoundtrack(repository.Soundtrack{ SoundtrackName: arg.SoundtrackName, Path: arg.Path, Hash: arg.Hash, Uuid: arg.Uuid, Added: time.Now(), }) return repository.InsertSoundtrackRow{ID: st.ID, Uuid: st.Uuid}, nil } func (s *Store) ResetSoundtrackIdSeq(context.Context) (int64, error) { return int64(s.nextID), s.Err } func (s *Store) UpdateSoundtrackHash(_ context.Context, arg repository.UpdateSoundtrackHashParams) error { return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Hash = arg.Hash }) } func (s *Store) UpdateSoundtrackName(_ context.Context, arg repository.UpdateSoundtrackNameParams) error { return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.SoundtrackName, st.Path = arg.Name, arg.Path }) } func (s *Store) SetSoundtrackFingerprint(_ context.Context, arg repository.SetSoundtrackFingerprintParams) error { return s.updateSoundtrack(arg.ID, func(st *repository.Soundtrack) { st.Fingerprint = arg.Fingerprint }) } func (s *Store) RemoveSoundtrackDeletionDate(_ context.Context, id int32) error { return s.updateSoundtrack(id, func(st *repository.Soundtrack) { st.Deleted = nil }) } func (s *Store) AddSoundtrackPlayedByUUID(_ context.Context, id uuid.UUID) error { defer s.lock()() now := time.Now() for i := range s.Soundtracks { if s.Soundtracks[i].Uuid == id { s.Soundtracks[i].TimesPlayed++ s.Soundtracks[i].LastPlayed = &now } } return s.Err } func (s *Store) GetSoundtrackById(_ context.Context, id int32) (repository.Soundtrack, error) { defer s.lock()() if s.Err != nil { return repository.Soundtrack{}, s.Err } if i := s.soundtrackIndex(id); i >= 0 && s.Soundtracks[i].Deleted == nil { return s.Soundtracks[i], nil } return repository.Soundtrack{}, pgx.ErrNoRows } func (s *Store) GetSoundtrackByUUID(_ context.Context, id uuid.UUID) (repository.Soundtrack, error) { defer s.lock()() if s.Err != nil { return repository.Soundtrack{}, s.Err } for _, st := range s.Soundtracks { if st.Uuid == id && st.Deleted == nil { return st, nil } } return repository.Soundtrack{}, pgx.ErrNoRows } func (s *Store) soundtrackByUUID(id uuid.UUID) int { return slices.IndexFunc(s.Soundtracks, func(st repository.Soundtrack) bool { return st.Uuid == id }) } func (s *Store) updateSoundtrackByUUID(id uuid.UUID, f func(*repository.Soundtrack)) error { defer s.lock()() if s.Err != nil { return s.Err } if i := s.soundtrackByUUID(id); i >= 0 { f(&s.Soundtracks[i]) } return nil } func (s *Store) SetAlternativeNames(_ context.Context, arg repository.SetAlternativeNamesParams) (repository.Soundtrack, error) { defer s.lock()() if s.Err != nil { return repository.Soundtrack{}, s.Err } i := s.soundtrackByUUID(arg.Uuid) if i < 0 || s.Soundtracks[i].Deleted != nil { return repository.Soundtrack{}, pgx.ErrNoRows } st := &s.Soundtracks[i] st.AlternativeNames = slices.Clone(arg.Names) if st.CoverFile == nil { st.CoverChecked = pgtype.Timestamptz{} } return *st, nil } func (s *Store) ListSoundtracksNeedingCover( _ context.Context, checkedBefore pgtype.Timestamptz, ) ([]repository.ListSoundtracksNeedingCoverRow, error) { defer s.lock()() if s.Err != nil { return nil, s.Err } var rows []repository.ListSoundtracksNeedingCoverRow for _, st := range sortedByName(slices.Clone(s.Soundtracks)) { if st.Deleted != nil || st.CoverFile != nil || st.CoverManual || (st.CoverChecked.Valid && !st.CoverChecked.Time.Before(checkedBefore.Time)) { continue } rows = append(rows, repository.ListSoundtracksNeedingCoverRow{ Uuid: st.Uuid, SoundtrackName: st.SoundtrackName, AlternativeNames: st.AlternativeNames, }) } return rows, nil } func (s *Store) SetCover(_ context.Context, arg repository.SetCoverParams) error { return s.updateSoundtrackByUUID(arg.Uuid, func(st *repository.Soundtrack) { now := pgtype.Timestamptz{Time: time.Now(), Valid: true} st.CoverFile, st.CoverSource, st.CoverExternalID = arg.File, arg.Source, arg.ExternalID st.CoverManual, st.CoverModified, st.CoverChecked = arg.Manual, now, now }) } func (s *Store) MarkCoverChecked(_ context.Context, id uuid.UUID) error { return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) { st.CoverChecked = pgtype.Timestamptz{Time: time.Now(), Valid: true} }) } func (s *Store) ClearCover(_ context.Context, id uuid.UUID) error { return s.updateSoundtrackByUUID(id, func(st *repository.Soundtrack) { st.CoverFile, st.CoverSource, st.CoverExternalID, st.CoverManual = nil, nil, nil, false st.CoverModified, st.CoverChecked = pgtype.Timestamptz{}, pgtype.Timestamptz{} }) } func (s *Store) ListCoverFiles(context.Context) ([]string, error) { defer s.lock()() if s.Err != nil { return nil, s.Err } var files []string for _, st := range s.Soundtracks { if st.CoverFile != nil { files = append(files, *st.CoverFile) } } return files, nil } func (s *Store) MarkSoundtracksDeletedExcept(_ context.Context, ids []int32) error { defer s.lock()() if s.Err != nil { return s.Err } now := time.Now() for i := range s.Soundtracks { if s.Soundtracks[i].Deleted == nil && !slices.Contains(ids, s.Soundtracks[i].ID) { s.Soundtracks[i].Deleted = &now } } return nil } func (s *Store) ClearSoundtracks(context.Context) error { defer s.lock()() s.Soundtracks = nil return s.Err } func (s *Store) ClearSongs(context.Context) error { defer s.lock()() s.Songs = nil return s.Err } func (s *Store) FindSongsFromSoundtrack(_ context.Context, soundtrackID int32) ([]repository.Song, error) { if s.Err != nil { return nil, s.Err } return s.SongsOf(soundtrackID), nil } func (s *Store) FetchAllSongs(context.Context) ([]repository.Song, error) { defer s.lock()() return slices.Clone(s.Songs), s.Err } func (s *Store) AddSong(_ context.Context, arg repository.AddSongParams) error { if s.Err != nil { return s.Err } s.AddTestSong(repository.Song{ SoundtrackID: arg.SoundtrackID, SongName: arg.SongName, Path: arg.Path, FileName: arg.FileName, Hash: arg.Hash, Uuid: arg.Uuid, SoundtrackUuid: arg.SoundtrackUuid, }) return nil } func (s *Store) UpdateSongByUUID(_ context.Context, arg repository.UpdateSongByUUIDParams) error { defer s.lock()() for i := range s.Songs { if s.Songs[i].Uuid == arg.Uuid { s.Songs[i].SongName, s.Songs[i].FileName, s.Songs[i].Path, s.Songs[i].Hash = arg.SongName, arg.FileName, arg.Path, arg.Hash } } return s.Err } func (s *Store) AddSongPlayedByUUID(_ context.Context, id uuid.UUID) error { defer s.lock()() for i := range s.Songs { if s.Songs[i].Uuid == id { s.Songs[i].TimesPlayed++ } } return s.Err } func (s *Store) GetSongsBySoundtrackUUID(_ context.Context, id uuid.UUID) ([]repository.Song, error) { defer s.lock()() if s.Err != nil { return nil, s.Err } var songs []repository.Song for _, song := range s.Songs { if song.SoundtrackUuid == id { songs = append(songs, song) } } slices.SortFunc(songs, func(a, b repository.Song) int { return cmp.Compare(a.SongName, b.SongName) }) return songs, nil } func (s *Store) GetActiveSongByUUID(_ context.Context, id uuid.UUID) (repository.GetActiveSongByUUIDRow, error) { defer s.lock()() if s.Err != nil { return repository.GetActiveSongByUUIDRow{}, s.Err } for _, song := range s.Songs { if song.Uuid != id { continue } if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil { st := s.Soundtracks[i] return repository.GetActiveSongByUUIDRow{Song: song, SoundtrackName: st.SoundtrackName, SoundtrackPath: st.Path}, nil } } return repository.GetActiveSongByUUIDRow{}, pgx.ErrNoRows } func (s *Store) RemoveBrokenSongs(_ context.Context, uuids []uuid.UUID) error { defer s.lock()() s.Songs = slices.DeleteFunc(s.Songs, func(song repository.Song) bool { return slices.Contains(uuids, song.Uuid) }) return s.Err } func (s *Store) GetRandomActiveSong(context.Context) (repository.GetRandomActiveSongRow, error) { defer s.lock()() if s.Err != nil { return repository.GetRandomActiveSongRow{}, s.Err } var rows []repository.GetRandomActiveSongRow for _, song := range s.Songs { if i := s.soundtrackIndex(song.SoundtrackID); i >= 0 && s.Soundtracks[i].Deleted == nil { rows = append(rows, repository.GetRandomActiveSongRow{Song: song, SoundtrackPath: s.Soundtracks[i].Path}) } } if len(rows) == 0 { return repository.GetRandomActiveSongRow{}, pgx.ErrNoRows } return rows[rand.IntN(len(rows))], nil } func (s *Store) CreateSession(_ context.Context, arg repository.CreateSessionParams) (repository.Session, error) { defer s.lock()() if s.Err != nil { return repository.Session{}, s.Err } session := repository.Session{ Token: arg.Token, IpAddress: arg.IpAddress, UserAgent: arg.UserAgent, ClientType: arg.ClientType, ExpiresAt: arg.ExpiresAt, } s.Sessions[arg.Token] = session return session, nil } func (s *Store) GetSession(_ context.Context, token string) (repository.Session, error) { defer s.lock()() if s.Err != nil { return repository.Session{}, s.Err } session, ok := s.Sessions[token] if !ok { return repository.Session{}, pgx.ErrNoRows } return session, nil } func (s *Store) DeleteSession(ctx context.Context, token string) error { if err := ctx.Err(); err != nil { return err } defer s.lock()() delete(s.Sessions, token) return s.Err } func (s *Store) DeleteExpiredSessions(context.Context) error { defer s.lock()() for token, session := range s.Sessions { if session.ExpiresAt.Time.Before(time.Now()) { delete(s.Sessions, token) } } return s.Err } // HasSession reports whether a session with the given token exists. func (s *Store) HasSession(token string) bool { defer s.lock()() _, ok := s.Sessions[token] return ok } func (s *Store) queueIndex(id uuid.UUID) int { return slices.IndexFunc(s.Queues, func(q repository.Queue) bool { return q.Uuid == id }) } func (s *Store) updateQueue(id uuid.UUID, f func(*repository.Queue)) error { defer s.lock()() if s.Err != nil { return s.Err } if i := s.queueIndex(id); i >= 0 { f(&s.Queues[i]) } return nil } func (s *Store) CreateQueue(context.Context) (repository.Queue, error) { defer s.lock()() if s.Err != nil { return repository.Queue{}, s.Err } queue := repository.Queue{Uuid: uuid.New(), CurrentSong: -1, StartedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}} s.Queues = append(s.Queues, queue) return queue, nil } func (s *Store) GetQueue(_ context.Context, id uuid.UUID) (repository.Queue, error) { defer s.lock()() if s.Err != nil { return repository.Queue{}, s.Err } if i := s.queueIndex(id); i >= 0 { return s.Queues[i], nil } return repository.Queue{}, pgx.ErrNoRows } func endQueue(q *repository.Queue) { if !q.EndedAt.Valid { q.EndedAt = pgtype.Timestamptz{Time: time.Now(), Valid: true} } } func (s *Store) EndQueue(_ context.Context, id uuid.UUID) error { return s.updateQueue(id, endQueue) } func (s *Store) EndActiveQueues(context.Context) error { defer s.lock()() for i := range s.Queues { endQueue(&s.Queues[i]) } return s.Err } func (s *Store) SetQueueLastFetched(_ context.Context, arg repository.SetQueueLastFetchedParams) error { return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.LastFetchedSongUuid, q.LastFetchedMode = arg.LastFetchedSongUuid, arg.LastFetchedMode }) } func (s *Store) SetQueueCurrentSong(_ context.Context, arg repository.SetQueueCurrentSongParams) error { return s.updateQueue(arg.Uuid, func(q *repository.Queue) { q.CurrentSong = arg.CurrentSong }) } func (s *Store) AddLastFetchedToQueue(_ context.Context, id uuid.UUID) error { defer s.lock()() if s.Err != nil { return s.Err } i := s.queueIndex(id) if i < 0 { return nil } queue := &s.Queues[i] songIndex := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == queue.LastFetchedSongUuid }) if songIndex < 0 { return nil } song := s.Songs[songIndex] var soundtrackName string if st := s.soundtrackIndex(song.SoundtrackID); st >= 0 { soundtrackName = s.Soundtracks[st].SoundtrackName } mode := "unknown" if queue.LastFetchedMode != nil { mode = *queue.LastFetchedMode } songNo := int32(0) for _, qs := range s.QueueSongs { if qs.QueueUuid == id { songNo = max(songNo, qs.SongNo+1) } } s.QueueSongs = append(s.QueueSongs, repository.QueueSong{ QueueUuid: id, SongNo: songNo, SongUuid: song.Uuid, SoundtrackName: soundtrackName, SongName: song.SongName, Mode: mode, }) queue.CurrentSong = songNo queue.LastFetchedSongUuid, queue.LastFetchedMode = uuid.Nil, nil return nil } func (s *Store) ListQueueSongs(_ context.Context, id uuid.UUID) ([]repository.ListQueueSongsRow, error) { defer s.lock()() if s.Err != nil { return nil, s.Err } var rows []repository.ListQueueSongsRow for _, qs := range s.QueueSongs { if qs.QueueUuid != id { continue } row := repository.ListQueueSongsRow{ SongNo: qs.SongNo, SongUuid: qs.SongUuid, SoundtrackName: qs.SoundtrackName, SongName: qs.SongName, Mode: qs.Mode, } if i := slices.IndexFunc(s.Songs, func(song repository.Song) bool { return song.Uuid == qs.SongUuid }); i >= 0 { song := s.Songs[i] row.Path, row.TimesPlayed, row.SoundtrackUuid = &song.Path, &song.TimesPlayed, song.SoundtrackUuid } rows = append(rows, row) } slices.SortFunc(rows, func(a, b repository.ListQueueSongsRow) int { return cmp.Compare(a.SongNo, b.SongNo) }) return rows, nil } func (s *Store) CountLibrary(context.Context) (repository.CountLibraryRow, error) { defer s.lock()() if s.Err != nil { return repository.CountLibraryRow{}, s.Err } var count repository.CountLibraryRow for _, st := range s.Soundtracks { if st.Deleted != nil { continue } count.Soundtracks++ for _, song := range s.Songs { if song.SoundtrackID == st.ID { count.Songs++ } } } return count, nil } // Statistics queries return empty results; tests that need data use their own fakes. func (s *Store) GetMostPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetMostPlayedSoundtracksWithSongsRow, error) { return nil, s.Err } func (s *Store) GetLeastPlayedSoundtracksWithSongs(context.Context, int32) ([]repository.GetLeastPlayedSoundtracksWithSongsRow, error) { return nil, s.Err } func (s *Store) GetLastPlayedSoundtracks(context.Context, int32) ([]repository.GetLastPlayedSoundtracksRow, error) { return nil, s.Err } func (s *Store) GetOldestPlayedSoundtracks(context.Context, int32) ([]repository.GetOldestPlayedSoundtracksRow, error) { return nil, s.Err } func (s *Store) GetAllSoundtracksWithSongs(context.Context) ([]repository.GetAllSoundtracksWithSongsRow, error) { return nil, s.Err } func (s *Store) GetNeverPlayedSoundtracks(context.Context) ([]repository.GetNeverPlayedSoundtracksRow, error) { return nil, s.Err } func (s *Store) GetMostPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetMostPlayedSongsWithSoundtrackRow, error) { return nil, s.Err } func (s *Store) GetLeastPlayedSongsWithSoundtrack(context.Context, int32) ([]repository.GetLeastPlayedSongsWithSoundtrackRow, error) { return nil, s.Err } func (s *Store) GetStatisticsSummary(context.Context) (repository.GetStatisticsSummaryRow, error) { return repository.GetStatisticsSummaryRow{}, s.Err }