package backend import ( "context" "errors" "strings" "time" "music-server/internal/db/repository" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) // Soundtrack is a soundtrack as returned by the API. type Soundtrack struct { Uuid uuid.UUID `json:"uuid"` Name string `json:"name"` AlternativeNames []string `json:"alternative_names" doc:"Other names the soundtrack is known by"` TimesPlayed int32 `json:"times_played"` LastPlayed *time.Time `json:"last_played,omitempty"` Added time.Time `json:"added"` CoverModified *int64 `json:"cover_modified,omitempty" doc:"When the cover last changed, in Unix seconds. Missing when there is no cover."` } // MaxNameLength is the longest alternative name that can be stored, in bytes. const MaxNameLength = 200 // SoundtrackWithSongList is a soundtrack with all its songs. type SoundtrackWithSongList struct { Soundtrack Songs []Song `json:"songs"` } // Song is a song as returned by the API. type Song struct { Uuid uuid.UUID `json:"uuid"` Name string `json:"name"` FileName *string `json:"file_name,omitempty"` TimesPlayed int32 `json:"times_played"` SoundtrackUuid uuid.UUID `json:"soundtrack_uuid"` SoundtrackName string `json:"soundtrack_name"` } // LibraryService looks up soundtracks and songs by UUID. type LibraryService struct { q repository.Querier } // NewLibraryService creates a LibraryService. func NewLibraryService(q repository.Querier) *LibraryService { return &LibraryService{q: q} } func toSoundtrack(s repository.Soundtrack) Soundtrack { return Soundtrack{ Uuid: s.Uuid, Name: s.SoundtrackName, AlternativeNames: nonNil(s.AlternativeNames), TimesPlayed: s.TimesPlayed, LastPlayed: s.LastPlayed, Added: s.Added, CoverModified: unixSeconds(s.CoverModified), } } // nonNil returns names, or an empty list for nil, so the JSON is [] and never null. func nonNil(names []string) []string { if names == nil { return []string{} } return names } // unixSeconds returns t in Unix seconds, or nil when it is not set. func unixSeconds(t pgtype.Timestamptz) *int64 { if !t.Valid { return nil } seconds := t.Time.Unix() return &seconds } // SetAlternativeNames replaces the alternative names of an active soundtrack. The names // are trimmed, and empty names, duplicates and names equal to the soundtrack's own name // (ignoring case) are dropped. func (l *LibraryService) SetAlternativeNames(ctx context.Context, id uuid.UUID, names []string) (Soundtrack, error) { row, err := l.q.GetSoundtrackByUUID(ctx, id) if err != nil { return Soundtrack{}, notFound(err) } row, err = l.q.SetAlternativeNames(ctx, repository.SetAlternativeNamesParams{ Uuid: id, Names: cleanAlternativeNames(row.SoundtrackName, names), }) if err != nil { return Soundtrack{}, notFound(err) } return toSoundtrack(row), nil } func cleanAlternativeNames(name string, names []string) []string { cleaned := []string{} seen := map[string]bool{strings.ToLower(strings.TrimSpace(name)): true} for _, n := range names { n = strings.TrimSpace(n) key := strings.ToLower(n) if n == "" || seen[key] { continue } seen[key] = true cleaned = append(cleaned, n) } return cleaned } func notFound(err error) error { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } return err } // Soundtracks returns all active soundtracks, sorted by name. func (l *LibraryService) Soundtracks(ctx context.Context) ([]Soundtrack, error) { rows, err := l.q.FindAllSoundtracks(ctx) if err != nil { return nil, err } soundtracks := make([]Soundtrack, len(rows)) for i, row := range rows { soundtracks[i] = toSoundtrack(row) } return soundtracks, nil } // Soundtrack returns an active soundtrack with its songs. func (l *LibraryService) Soundtrack(ctx context.Context, id uuid.UUID) (SoundtrackWithSongList, error) { row, err := l.q.GetSoundtrackByUUID(ctx, id) if err != nil { return SoundtrackWithSongList{}, notFound(err) } songRows, err := l.q.GetSongsBySoundtrackUUID(ctx, id) if err != nil { return SoundtrackWithSongList{}, err } songs := make([]Song, len(songRows)) for i, song := range songRows { songs[i] = toSong(song, row.SoundtrackName) } return SoundtrackWithSongList{Soundtrack: toSoundtrack(row), Songs: songs}, nil } func toSong(s repository.Song, soundtrackName string) Song { return Song{ Uuid: s.Uuid, Name: s.SongName, FileName: s.FileName, TimesPlayed: s.TimesPlayed, SoundtrackUuid: s.SoundtrackUuid, SoundtrackName: soundtrackName, } } // Song returns a song of an active soundtrack. func (l *LibraryService) Song(ctx context.Context, id uuid.UUID) (Song, error) { row, err := l.q.GetActiveSongByUUID(ctx, id) if err != nil { return Song{}, notFound(err) } return toSong(row.Song, row.SoundtrackName), nil } // SongPath returns the file path of a song of an active soundtrack, if the file can be played. func (l *LibraryService) SongPath(ctx context.Context, id uuid.UUID) (string, error) { row, err := l.q.GetActiveSongByUUID(ctx, id) if err != nil { return "", notFound(err) } if !songIsPlayable(row.Song, row.SoundtrackPath) { return "", ErrNotFound } return row.Song.Path, nil }