This commit is contained in:
2026-10-07 01:43:28 +07:00
parent 494b3fc471
commit e0caa0b409
23 changed files with 5264 additions and 963 deletions
+340
View File
@@ -0,0 +1,340 @@
package playlists_repo
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден")
ErrGameNotFound = errors.New("игра не найдена")
ErrScenarioNotFound = errors.New("сценарий не найден")
)
// Track describes a stored playlist track.
type Track struct {
ID int
Name string
Filename string
Position int
}
// Playlist describes a stored playlist.
type Playlist struct {
ID int
Name string
Tracks []*Track
}
// MusicState describes stored game music state.
type MusicState struct {
Playlist *Playlist
TrackIndex int
PositionMs int64
Volume int32
Loop bool
Playback int32
Version int64
StartedAt *time.Time
ScenarioID *int
GameID *int
}
// Repo persists playlists and game music state.
type Repo struct{ pool *pgxpool.Pool }
// New creates a playlist repository.
func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
// List returns all playlists with their tracks.
func (r *Repo) List(ctx context.Context) ([]*Playlist, error) {
rows, err := r.pool.Query(ctx, `
SELECT p.id, p.name, t.id, t.name, t.filename, t.position
FROM playlists p
LEFT JOIN playlist_tracks t ON t.playlist_id = p.id
ORDER BY p.id, t.position, t.id`)
if err != nil {
return nil, err
}
defer rows.Close()
byID := map[int]*Playlist{}
var result []*Playlist
for rows.Next() {
var p Playlist
var trackID *int
var trackName, filename *string
var position *int
if err := rows.Scan(&p.ID, &p.Name, &trackID, &trackName, &filename, &position); err != nil {
return nil, err
}
item := byID[p.ID]
if item == nil {
item = &Playlist{ID: p.ID, Name: p.Name}
byID[p.ID] = item
result = append(result, item)
}
if trackID != nil {
item.Tracks = append(item.Tracks, &Track{ID: *trackID, Name: *trackName, Filename: *filename, Position: *position})
}
}
return result, rows.Err()
}
// Get returns a playlist by ID.
func (r *Repo) Get(ctx context.Context, id int) (*Playlist, error) {
playlists, err := r.List(ctx)
if err != nil {
return nil, err
}
for _, playlist := range playlists {
if playlist.ID == id {
return playlist, nil
}
}
return nil, ErrPlaylistNotFound
}
// Create creates an empty playlist.
func (r *Repo) Create(ctx context.Context, name string) (*Playlist, error) {
var p Playlist
err := r.pool.QueryRow(ctx, `
INSERT INTO playlists (name)
VALUES ($1)
RETURNING id, name
`,
name,
).Scan(&p.ID, &p.Name)
return &p, err
}
// Update changes a playlist name.
func (r *Repo) Update(ctx context.Context, id int, name string) (*Playlist, error) {
var p Playlist
err := r.pool.QueryRow(ctx, `
UPDATE playlists
SET name = $1, updated_at = NOW()
WHERE id = $2
RETURNING id, name
`,
name,
id,
).Scan(&p.ID, &p.Name)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrPlaylistNotFound
}
if err != nil {
return nil, err
}
return &p, nil
}
// Delete removes a playlist that is not assigned to a scenario or game.
func (r *Repo) Delete(ctx context.Context, id int) error {
var used bool
if err := r.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1)
OR EXISTS (SELECT 1 FROM games WHERE playlist_id = $1)
`, id).Scan(&used); err != nil {
return err
}
if used {
return ErrPlaylistInUse
}
result, err := r.pool.Exec(ctx, `
DELETE FROM playlists
WHERE id = $1
`, id)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrPlaylistNotFound
}
return nil
}
// AddTrack adds a track to a playlist.
func (r *Repo) AddTrack(ctx context.Context, playlistID int, name, filename string) error {
var position int
if err := r.pool.QueryRow(ctx, `
SELECT COALESCE(MAX(position), -1) + 1
FROM playlist_tracks
WHERE playlist_id = $1
`, playlistID).Scan(&position); err != nil {
return err
}
_, err := r.pool.Exec(ctx, `
INSERT INTO playlist_tracks (playlist_id, name, filename, position)
VALUES ($1, $2, $3, $4)
`, playlistID, name, filename, position)
return err
}
// DeleteTrack removes a track and returns its stored filename.
func (r *Repo) DeleteTrack(ctx context.Context, playlistID, trackID int) (string, error) {
var filename string
var used bool
if err := r.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1)
OR EXISTS (SELECT 1 FROM games WHERE playlist_id = $1)
`, playlistID).Scan(&used); err != nil {
return "", err
}
if used {
return "", ErrPlaylistInUse
}
err := r.pool.QueryRow(ctx, `
DELETE FROM playlist_tracks
WHERE id = $1 AND playlist_id = $2
RETURNING filename
`, trackID, playlistID).Scan(&filename)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrTrackNotFound
}
return filename, err
}
// SetScenario assigns a playlist to a scenario.
func (r *Repo) SetScenario(ctx context.Context, scenarioID, playlistID int) error {
if playlistID > 0 {
if _, err := r.Get(ctx, playlistID); err != nil {
return err
}
}
result, err := r.pool.Exec(ctx, `
UPDATE scenarios
SET playlist_id = NULLIF($2, 0), updated_at = NOW()
WHERE id = $1
`, scenarioID, playlistID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrScenarioNotFound
}
return nil
}
// SetGame assigns a playlist to a game.
func (r *Repo) SetGame(ctx context.Context, gameID, playlistID int) error {
if playlistID > 0 {
if _, err := r.Get(ctx, playlistID); err != nil {
return err
}
}
result, err := r.pool.Exec(ctx, `
UPDATE games
SET playlist_id = NULLIF($2, 0), updated_at = NOW()
WHERE id = $1
`, gameID, playlistID)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrGameNotFound
}
return nil
}
// GetMusic returns the effective and current music state for a game.
func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
var gamePlaylistID, scenarioPlaylistID *int
err := r.pool.QueryRow(ctx, `
SELECT g.playlist_id, s.playlist_id
FROM games g
JOIN scenarios s ON s.id = g.scenario_id
WHERE g.id = $1
`,
gameID,
).Scan(&gamePlaylistID, &scenarioPlaylistID)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrGameNotFound
}
if err != nil {
return nil, err
}
playlistID := gamePlaylistID
if playlistID == nil {
playlistID = scenarioPlaylistID
}
state := &MusicState{ScenarioID: scenarioPlaylistID, GameID: gamePlaylistID, Volume: 100, Playback: 1}
if playlistID != nil {
state.Playlist, err = r.Get(ctx, *playlistID)
if err != nil {
return nil, err
}
}
var trackIndex int
var statePlaylistID *int
err = r.pool.QueryRow(ctx, `
SELECT playlist_id, track_index, position_ms, volume, loop, playback, state_version, started_at
FROM game_music_states
WHERE game_id = $1
`, gameID).
Scan(&statePlaylistID, &trackIndex, &state.PositionMs, &state.Volume, &state.Loop, &state.Playback, &state.Version, &state.StartedAt)
if errors.Is(err, pgx.ErrNoRows) {
state.Version = 1
return state, nil
}
if err != nil {
return nil, err
}
state.TrackIndex = trackIndex
if state.Playback == 2 && state.StartedAt != nil {
state.PositionMs += time.Since(*state.StartedAt).Milliseconds()
}
if statePlaylistID != nil && (state.Playlist == nil || state.Playlist.ID != *statePlaylistID) {
state.Playlist, err = r.Get(ctx, *statePlaylistID)
if err != nil {
return nil, err
}
}
return state, nil
}
// SetMusic stores the current music state for a game.
func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int, positionMs int64, volume int32, loop bool, playback int32) (*MusicState, error) {
if playlistID > 0 {
if _, err := r.Get(ctx, playlistID); err != nil {
return nil, err
}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO game_music_states (
game_id, playlist_id, track_index, position_ms, volume, loop,
playback, state_version, started_at
)
VALUES (
$1, NULLIF($2, 0), $3, $4, $5, $6, $7, 1,
CASE WHEN $7 = 2 THEN NOW() ELSE NULL END
)
ON CONFLICT (game_id) DO UPDATE SET
playlist_id = EXCLUDED.playlist_id,
track_index = EXCLUDED.track_index,
position_ms = EXCLUDED.position_ms,
volume = EXCLUDED.volume,
loop = EXCLUDED.loop,
playback = EXCLUDED.playback,
state_version = game_music_states.state_version + 1,
started_at = EXCLUDED.started_at,
updated_at = NOW()
`,
gameID,
playlistID,
trackIndex,
positionMs,
volume,
loop,
playback,
)
if err != nil {
return nil, err
}
return r.GetMusic(ctx, gameID)
}
+1
View File
@@ -8,6 +8,7 @@ type Scenario struct {
Name string
Description *string
Image *string
PlaylistID *int
Scenario string
Author *User
Status string
+6
View File
@@ -59,6 +59,7 @@ func (r *ScenariosRepo) GetScenariosByAuthorID(
scenarios.name,
scenarios.description,
scenarios.image,
scenarios.playlist_id,
scenarios.scenario,
users.id,
users.username,
@@ -80,6 +81,7 @@ func (r *ScenariosRepo) GetAllScenarios(ctx context.Context) ([]*repos.Scenario,
scenarios.name,
scenarios.description,
scenarios.image,
scenarios.playlist_id,
scenarios.scenario,
users.id,
users.username,
@@ -103,6 +105,7 @@ func (r *ScenariosRepo) GetScenariosByStatus(
scenarios.name,
scenarios.description,
scenarios.image,
scenarios.playlist_id,
scenarios.scenario,
users.id,
users.username,
@@ -134,6 +137,7 @@ func (r *ScenariosRepo) getScenarios(ctx context.Context, query string, args ...
&scenario.Name,
&scenario.Description,
&scenario.Image,
&scenario.PlaylistID,
&scenario.Scenario,
&authorID,
&authorUsername,
@@ -172,6 +176,7 @@ func (r *ScenariosRepo) GetScenarioByID(
scenarios.name,
scenarios.description,
scenarios.image,
scenarios.playlist_id,
scenarios.scenario,
users.id,
users.username,
@@ -190,6 +195,7 @@ func (r *ScenariosRepo) GetScenarioByID(
&scenario.Name,
&scenario.Description,
&scenario.Image,
&scenario.PlaylistID,
&scenario.Scenario,
&authorID,
&authorUsername,