Files
MusicServer/internal/backend/music.go
T
Sansan 973ebf224b
CI / integration (push) Successful in 49s
CI / docker (push) Successful in 1m0s
CI / test (push) Successful in 1m6s
CI / publish (push) Skipped
Generate the OpenAPI spec with Huma instead of swag
The spec is now built from the same calls that register the routes, so it
can't drift from the router like the swag comments did (all /api/v1
routes were missing).

- /api/v1 endpoints are typed Huma operations; the spec is OpenAPI 3.1 at
  /openapi.json (3.0 at /openapi-3.0.json, YAML too), docs at /docs.
  /openapi and /swagger/* redirect there
- Bearer token security is declared per operation; token creation and
  the new GET /api/v1/health are public
- Legacy routes use the same handlers, are hidden from the spec and keep
  the Deprecation header
- /sync/reset is hidden (debug only); the /sync/new, /sync/quick and
  /sync/new/full aliases are removed (use /sync and /sync/full)
- Invalid parameters return 422 (validation); every error is
  {"error": "..."}; empty lists are [] instead of null
- /sync/progress is documented as progress or result (oneOf)
- Tests validate the spec and check real responses of every documented
  operation against it (kin-openapi)
- Removed swag annotations, cmd/docs, echo-swagger and the swag tool
2026-10-06 16:31:06 +02:00

368 lines
11 KiB
Go

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/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
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 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.AddSoundtrackPlayedByUUID(ctx, song.SoundtrackUuid); err != nil {
return err
}
return m.q.AddSongPlayedByUUID(ctx, song.Uuid)
}
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{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
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 {
songList := []SongInfo{}
for i, song := range m.queue {
soundtrack := m.soundtrackFor(ctx, song)
songList = append(songList, SongInfo{
SoundtrackUuid: soundtrack.Uuid,
Soundtrack: soundtrack.SoundtrackName,
SoundtrackPlayed: soundtrack.TimesPlayed,
SongUuid: song.Uuid,
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.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, 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.Uuid == song.SoundtrackUuid {
return soundtrack
}
}
return repository.Soundtrack{}
}