package backend import ( "context" "errors" "math/rand/v2" "os" "path/filepath" "slices" "strings" "sync" "music-server/internal/db/repository" "music-server/internal/logging" "github.com/jackc/pgx/v5" "go.uber.org/zap" ) // maxPickAttempts bounds how many broken songs are skipped before giving up. const maxPickAttempts = 100 type SongInfo struct { Soundtrack string `json:"Soundtrack"` SoundtrackPlayed int32 `json:"SoundtrackPlayed"` Song string `json:"Song"` SongPlayed int32 `json:"SongPlayed"` CurrentlyPlaying bool `json:"CurrentlyPlaying"` SongNo int `json:"SongNo"` } // MusicService picks songs and keeps track of the shared play queue. type MusicService struct { q repository.Querier soundCheckPath string cacheMu sync.Mutex soundtracks []repository.Soundtrack // Queue state is shared by all clients and intentionally not locked. queue []repository.Song currentSong int lastFetched repository.Song } // 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, currentSong: -1} } 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 } // Reset clears the play queue and reloads the soundtrack list. func (m *MusicService) Reset(ctx context.Context) error { m.queue = nil m.currentSong = -1 m.cacheMu.Lock() m.soundtracks = nil m.cacheMu.Unlock() _, err := m.allSoundtracks(ctx) return err } // 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. func (m *MusicService) AddLatestToQueue() { if m.lastFetched.Path != "" { m.currentSong = len(m.queue) m.queue = append(m.queue, m.lastFetched) m.lastFetched = repository.Song{} } } // AddLatestPlayed increments the play counters of the current song. func (m *MusicService) AddLatestPlayed(ctx context.Context) error { if !m.validIndex(m.currentSong) { return nil } return m.markPlayed(ctx, m.queue[m.currentSong]) } // SetPlayed increments the play counters of the song at the given queue position. func (m *MusicService) SetPlayed(ctx context.Context, songNumber int) error { if !m.validIndex(songNumber) { return nil } return m.markPlayed(ctx, m.queue[songNumber]) } func (m *MusicService) markPlayed(ctx context.Context, song repository.Song) error { if err := m.q.AddSoundtrackPlayed(ctx, song.SoundtrackID); err != nil { return err } return m.q.AddSongPlayed(ctx, repository.AddSongPlayedParams{SoundtrackID: song.SoundtrackID, SongName: song.SongName}) } func (m *MusicService) validIndex(i int) bool { return i >= 0 && i < len(m.queue) } // RandomSong picks a random song from a random soundtrack. func (m *MusicService) RandomSong(ctx context.Context) (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, candidates) } // RandomSongLowChance picks a random song, favouring soundtracks played less than average. func (m *MusicService) RandomSongLowChance(ctx context.Context) (string, error) { soundtracks, err := m.allSoundtracks(ctx) if err != nil { return "", err } return m.pickAndRemember(ctx, lowChanceWeights(soundtracks)) } func (m *MusicService) pickAndRemember(ctx context.Context, candidates []weightedSoundtrack) (string, error) { song, err := m.pickSong(ctx, candidates) if err != nil { return "", err } m.lastFetched = song return song.Path, nil } // RandomSongClassic picks a random song where every song has the same chance. func (m *MusicService) RandomSongClassic(ctx context.Context) (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 } m.lastFetched = row.Song return row.Song.Path, nil } return "", ErrNoSongs } // SongInfo returns information about the current song in the queue. func (m *MusicService) SongInfo(ctx context.Context) SongInfo { if !m.validIndex(m.currentSong) { return SongInfo{} } song := m.queue[m.currentSong] soundtrack := m.soundtrackFor(ctx, song) return SongInfo{ Soundtrack: soundtrack.SoundtrackName, SoundtrackPlayed: soundtrack.TimesPlayed, Song: song.SongName, SongPlayed: song.TimesPlayed, CurrentlyPlaying: true, SongNo: m.currentSong, } } // PlayedSongs returns all songs in the queue. func (m *MusicService) PlayedSongs(ctx context.Context) []SongInfo { var songList []SongInfo for i, song := range m.queue { soundtrack := m.soundtrackFor(ctx, song) songList = append(songList, SongInfo{ Soundtrack: soundtrack.SoundtrackName, SoundtrackPlayed: soundtrack.TimesPlayed, Song: song.SongName, SongPlayed: song.TimesPlayed, CurrentlyPlaying: i == m.currentSong, SongNo: i, }) } return songList } // Song jumps to the given queue position (clamped to the queue) and returns its path. func (m *MusicService) Song(songNumber int) (string, error) { if len(m.queue) == 0 { return "", ErrQueueEmpty } m.currentSong = max(0, min(songNumber, len(m.queue)-1)) return m.queue[m.currentSong].Path, nil } // NextSong moves to the next song in the queue, staying on the last one at the end. func (m *MusicService) NextSong() (string, error) { if len(m.queue) == 0 { return "", ErrQueueEmpty } return m.Song(m.currentSong + 1) } // PreviousSong moves to the previous song in the queue, staying on the first one at the start. func (m *MusicService) PreviousSong() (string, error) { if len(m.queue) == 0 { return "", ErrQueueEmpty } return m.Song(m.currentSong - 1) } // 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.RemoveBrokenSong(ctx, repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) } // 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, song repository.Song) repository.Soundtrack { soundtracks, err := m.allSoundtracks(ctx) if err != nil { logging.GetLogger().Error("Failed to load soundtracks", zap.Error(err)) return repository.Soundtrack{} } for _, soundtrack := range soundtracks { if soundtrack.ID == song.SoundtrackID { return soundtrack } } return repository.Soundtrack{} }