package backend import ( "context" "errors" "math/rand/v2" "os" "path/filepath" "slices" "strings" "sync" "music-server/internal/db/repository" "music-server/internal/logging" "music-server/internal/metrics" "github.com/google/uuid" "github.com/jackc/pgx/v5" "go.uber.org/zap" ) // maxPickAttempts bounds how many broken songs are skipped before giving up. const maxPickAttempts = 100 // PickMode is how a random song was picked. It is stored with queued songs and used as // the mode label of the song metrics. type PickMode string const ( PickRandom PickMode = "random" PickLow PickMode = "low" PickClassic PickMode = "classic" ) func init() { // Start every mode at 0, so the series exist before the first song. for _, mode := range []PickMode{PickRandom, PickLow, PickClassic} { metrics.SongsRequested.WithLabelValues(string(mode)) metrics.SongsPlayed.WithLabelValues(string(mode)) } } type SongInfo struct { SoundtrackUuid uuid.UUID `json:"SoundtrackUuid"` Soundtrack string `json:"Soundtrack"` SoundtrackPlayed int32 `json:"SoundtrackPlayed"` SongUuid uuid.UUID `json:"SongUuid"` Song string `json:"Song"` SongPlayed int32 `json:"SongPlayed"` CurrentlyPlaying bool `json:"CurrentlyPlaying"` SongNo int `json:"SongNo"` } // MusicService picks songs and keeps track of the play queues. Every client has its own queue, // stored in the database and identified by its UUID. type MusicService struct { q repository.Querier soundCheckPath string cacheMu sync.Mutex soundtracks []repository.Soundtrack } // NewMusicService creates a MusicService. soundCheckPath is the directory holding sound check songs. func NewMusicService(q repository.Querier, soundCheckPath string) *MusicService { return &MusicService{q: q, soundCheckPath: soundCheckPath} } func (m *MusicService) allSoundtracks(ctx context.Context) ([]repository.Soundtrack, error) { m.cacheMu.Lock() defer m.cacheMu.Unlock() if len(m.soundtracks) == 0 { soundtracks, err := m.q.FindAllSoundtracks(ctx) if err != nil { return nil, err } m.soundtracks = soundtracks } return m.soundtracks, nil } // ReloadSoundtracks reloads the soundtrack list. func (m *MusicService) ReloadSoundtracks(ctx context.Context) error { m.cacheMu.Lock() m.soundtracks = nil m.cacheMu.Unlock() _, err := m.allSoundtracks(ctx) return err } // ResolveQueue returns the queue with the given ID if it is a running queue. // Otherwise, for example when id is empty or the queue has ended, it starts a new queue. func (m *MusicService) ResolveQueue(ctx context.Context, id string) (uuid.UUID, error) { if queueID, err := uuid.Parse(id); err == nil { queue, err := m.q.GetQueue(ctx, queueID) if err == nil && !queue.EndedAt.Valid { return queue.Uuid, nil } if err != nil && !errors.Is(err, pgx.ErrNoRows) { return uuid.Nil, err } } queue, err := m.q.CreateQueue(ctx) return queue.Uuid, err } // NewQueue ends the given queue and starts a new one. func (m *MusicService) NewQueue(ctx context.Context, queueID uuid.UUID) (uuid.UUID, error) { if err := m.q.EndQueue(ctx, queueID); err != nil { return uuid.Nil, err } queue, err := m.q.CreateQueue(ctx) return queue.Uuid, err } // EndAllQueues ends every running queue. Clients get a new queue on their next request. func (m *MusicService) EndAllQueues(ctx context.Context) error { return m.q.EndActiveQueues(ctx) } // SoundCheckSong returns the path of a random sound check song. func (m *MusicService) SoundCheckSong() (string, error) { files, err := os.ReadDir(m.soundCheckPath) if err != nil { return "", err } files = slices.DeleteFunc(files, func(f os.DirEntry) bool { return f.IsDir() }) if len(files) == 0 { return "", ErrNoSongs } return filepath.Join(m.soundCheckPath, files[rand.IntN(len(files))].Name()), nil } // AddLatestToQueue appends the last fetched random song to the queue and makes it the current song. func (m *MusicService) AddLatestToQueue(ctx context.Context, queueID uuid.UUID) error { return m.q.AddLastFetchedToQueue(ctx, queueID) } // AddLatestPlayed increments the play counters of the current song. func (m *MusicService) AddLatestPlayed(ctx context.Context, queueID uuid.UUID) error { queue, songs, err := m.loadQueue(ctx, queueID) if err != nil { return err } return m.markPlayed(ctx, songs, int(queue.CurrentSong)) } // SetPlayed increments the play counters of the song at the given queue position. func (m *MusicService) SetPlayed(ctx context.Context, queueID uuid.UUID, songNumber int) error { _, songs, err := m.loadQueue(ctx, queueID) if err != nil { return err } return m.markPlayed(ctx, songs, songNumber) } // markPlayed increments the play counters of songs[songNumber]. Positions outside the queue // and songs that have been removed from the library are ignored. func (m *MusicService) markPlayed(ctx context.Context, songs []repository.ListQueueSongsRow, songNumber int) error { if songNumber < 0 || songNumber >= len(songs) || songs[songNumber].Path == nil { return nil } song := songs[songNumber] if err := m.q.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil { return err } if err := m.q.AddSongPlayedByUUID(ctx, song.SongUuid); err != nil { return err } metrics.SongsPlayed.WithLabelValues(song.Mode).Inc() return nil } func (m *MusicService) loadQueue(ctx context.Context, queueID uuid.UUID) (repository.Queue, []repository.ListQueueSongsRow, error) { queue, err := m.q.GetQueue(ctx, queueID) if err != nil { return repository.Queue{}, nil, err } songs, err := m.q.ListQueueSongs(ctx, queueID) return queue, songs, err } // RandomSong picks a random song from a random soundtrack. func (m *MusicService) RandomSong(ctx context.Context, queueID uuid.UUID) (string, error) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { return "", err } candidates := make([]weightedSoundtrack, len(soundtracks)) for i, s := range soundtracks { candidates[i] = weightedSoundtrack{soundtrack: s, weight: 1} } return m.pickAndRemember(ctx, queueID, candidates, PickRandom) } // RandomSongLowChance picks a random song, favouring soundtracks played less than average. func (m *MusicService) RandomSongLowChance(ctx context.Context, queueID uuid.UUID) (string, error) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { return "", err } return m.pickAndRemember(ctx, queueID, lowChanceWeights(soundtracks), PickLow) } func (m *MusicService) pickAndRemember( ctx context.Context, queueID uuid.UUID, candidates []weightedSoundtrack, mode PickMode, ) (string, error) { song, err := m.pickSong(ctx, candidates) if err != nil { return "", err } return m.remember(ctx, queueID, song, mode) } // remember stores the song and how it was picked as the queue's last fetched song, // for AddLatestToQueue. func (m *MusicService) remember(ctx context.Context, queueID uuid.UUID, song repository.Song, mode PickMode) (string, error) { modeName := string(mode) err := m.q.SetQueueLastFetched(ctx, repository.SetQueueLastFetchedParams{ Uuid: queueID, LastFetchedSongUuid: song.Uuid, LastFetchedMode: &modeName, }) if err != nil { return "", err } metrics.SongsRequested.WithLabelValues(modeName).Inc() return song.Path, nil } // RandomSongClassic picks a random song where every song has the same chance. func (m *MusicService) RandomSongClassic(ctx context.Context, queueID uuid.UUID) (string, error) { for range maxPickAttempts { row, err := m.q.GetRandomActiveSong(ctx) if errors.Is(err, pgx.ErrNoRows) { return "", ErrNoSongs } if err != nil { return "", err } if !songIsPlayable(row.Song, row.SoundtrackPath) { if err := m.removeBrokenSong(ctx, row.Song); err != nil { return "", err } continue } return m.remember(ctx, queueID, row.Song, PickClassic) } return "", ErrNoSongs } // SongInfo returns information about the current song in the queue. func (m *MusicService) SongInfo(ctx context.Context, queueID uuid.UUID) (SongInfo, error) { queue, songs, err := m.loadQueue(ctx, queueID) if err != nil { return SongInfo{}, err } current := int(queue.CurrentSong) if current < 0 || current >= len(songs) { return SongInfo{}, nil } return m.songInfo(ctx, songs[current], true), nil } // PlayedSongs returns all songs in the queue. func (m *MusicService) PlayedSongs(ctx context.Context, queueID uuid.UUID) ([]SongInfo, error) { queue, songs, err := m.loadQueue(ctx, queueID) if err != nil { return nil, err } songList := []SongInfo{} for _, song := range songs { songList = append(songList, m.songInfo(ctx, song, song.SongNo == queue.CurrentSong)) } return songList, nil } // songInfo describes a queued song. Songs removed from the library keep their stored names. func (m *MusicService) songInfo(ctx context.Context, song repository.ListQueueSongsRow, current bool) SongInfo { info := SongInfo{ SoundtrackUuid: song.SoundtrackUuid, Soundtrack: song.SoundtrackName, SongUuid: song.SongUuid, Song: song.SongName, CurrentlyPlaying: current, SongNo: int(song.SongNo), } if song.TimesPlayed != nil { info.SongPlayed = *song.TimesPlayed } if soundtrack, ok := m.soundtrackFor(ctx, song.SoundtrackUuid); ok { info.Soundtrack = soundtrack.SoundtrackName info.SoundtrackPlayed = soundtrack.TimesPlayed } return info } // Song jumps to the given queue position (clamped to the queue) and returns its path. func (m *MusicService) Song(ctx context.Context, queueID uuid.UUID, songNumber int) (string, error) { _, songs, err := m.loadQueue(ctx, queueID) if err != nil { return "", err } return m.jump(ctx, queueID, songs, songNumber) } // NextSong moves to the next song in the queue, staying on the last one at the end. func (m *MusicService) NextSong(ctx context.Context, queueID uuid.UUID) (string, error) { queue, songs, err := m.loadQueue(ctx, queueID) if err != nil { return "", err } return m.jump(ctx, queueID, songs, int(queue.CurrentSong)+1) } // PreviousSong moves to the previous song in the queue, staying on the first one at the start. func (m *MusicService) PreviousSong(ctx context.Context, queueID uuid.UUID) (string, error) { queue, songs, err := m.loadQueue(ctx, queueID) if err != nil { return "", err } return m.jump(ctx, queueID, songs, int(queue.CurrentSong)-1) } func (m *MusicService) jump(ctx context.Context, queueID uuid.UUID, songs []repository.ListQueueSongsRow, songNumber int) (string, error) { if len(songs) == 0 { return "", ErrQueueEmpty } songNumber = max(0, min(songNumber, len(songs)-1)) err := m.q.SetQueueCurrentSong(ctx, repository.SetQueueCurrentSongParams{Uuid: queueID, CurrentSong: int32(songNumber)}) if err != nil { return "", err } if songs[songNumber].Path == nil { return "", ErrNotFound } return *songs[songNumber].Path, nil } // SoundtrackNames returns all soundtrack names in alphabetical order. func (m *MusicService) SoundtrackNames(ctx context.Context) ([]string, error) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { return nil, err } names := make([]string, len(soundtracks)) for i, s := range soundtracks { names[i] = s.SoundtrackName } return names, nil } // SoundtrackNamesRandom returns all soundtrack names in random order. func (m *MusicService) SoundtrackNamesRandom(ctx context.Context) ([]string, error) { names, err := m.SoundtrackNames(ctx) rand.Shuffle(len(names), func(i, j int) { names[i], names[j] = names[j], names[i] }) return names, err } type weightedSoundtrack struct { soundtrack repository.Soundtrack weight int } // lowChanceWeights gives each soundtrack a weight of how far it is below the average play count (at least 1). func lowChanceWeights(soundtracks []repository.Soundtrack) []weightedSoundtrack { average := averagePlayed(soundtracks) weighted := make([]weightedSoundtrack, len(soundtracks)) for i, s := range soundtracks { weighted[i] = weightedSoundtrack{soundtrack: s, weight: max(1, int(average-s.TimesPlayed))} } return weighted } func averagePlayed(soundtracks []repository.Soundtrack) int32 { if len(soundtracks) == 0 { return 0 } var sum int64 for _, s := range soundtracks { sum += int64(s.TimesPlayed) } return int32(sum / int64(len(soundtracks))) } func pickWeighted(candidates []weightedSoundtrack) int { total := 0 for _, c := range candidates { total += c.weight } n := rand.IntN(total) for i, c := range candidates { if n < c.weight { return i } n -= c.weight } return len(candidates) - 1 } // pickSong picks a random playable song. Soundtracks without songs are dropped from the candidates // and broken songs are removed from the database. func (m *MusicService) pickSong(ctx context.Context, candidates []weightedSoundtrack) (repository.Song, error) { candidates = slices.Clone(candidates) for range maxPickAttempts { if len(candidates) == 0 { return repository.Song{}, ErrNoSongs } i := pickWeighted(candidates) soundtrack := candidates[i].soundtrack songs, err := m.q.FindSongsFromSoundtrack(ctx, soundtrack.ID) if err != nil { return repository.Song{}, err } if len(songs) == 0 { candidates = slices.Delete(candidates, i, i+1) continue } song := songs[rand.IntN(len(songs))] logging.GetLogger().Debug("Selected song", zap.String("song", song.SongName), zap.String("path", song.Path)) if !songIsPlayable(song, soundtrack.Path) { if err := m.removeBrokenSong(ctx, song); err != nil { return repository.Song{}, err } continue } return song, nil } return repository.Song{}, ErrNoSongs } func (m *MusicService) removeBrokenSong(ctx context.Context, song repository.Song) error { logging.GetLogger().Warn("Song not found, removing from database", zap.String("song", song.SongName), zap.String("path", song.Path)) return m.q.RemoveBrokenSongs(ctx, []uuid.UUID{song.Uuid}) } // songIsPlayable reports whether the song file exists and its stored path is consistent. func songIsPlayable(song repository.Song, soundtrackPath string) bool { if song.FileName != nil { if soundtrackPath+*song.FileName != song.Path || strings.HasSuffix(strings.ToLower(*song.FileName), ".wav") { return false } } info, err := os.Stat(song.Path) return err == nil && !info.IsDir() } func (m *MusicService) soundtrackFor(ctx context.Context, soundtrackUUID uuid.UUID) (repository.Soundtrack, bool) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err)) return repository.Soundtrack{}, false } for _, soundtrack := range soundtracks { if soundtrack.Uuid == soundtrackUUID { return soundtrack, true } } return repository.Soundtrack{}, false }