generated from VLADIMIR/template
up
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"evening_detective_server/internal/services/playlist_service"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func playlistErrorStatus(err error, fallback string) error {
|
||||
switch {
|
||||
case errors.Is(err, playlist_service.ErrInvalidName),
|
||||
errors.Is(err, playlist_service.ErrInvalidTrack):
|
||||
return status.Error(codes.InvalidArgument, err.Error())
|
||||
case errors.Is(err, playlist_service.ErrPlaylistNotFound),
|
||||
errors.Is(err, playlist_service.ErrTrackNotFound),
|
||||
errors.Is(err, playlist_service.ErrGameNotFound),
|
||||
errors.Is(err, playlist_service.ErrScenarioNotFound):
|
||||
return status.Error(codes.NotFound, err.Error())
|
||||
case errors.Is(err, playlist_service.ErrPlaylistInUse):
|
||||
return status.Error(codes.FailedPrecondition, err.Error())
|
||||
default:
|
||||
return status.Error(codes.Internal, fallback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"evening_detective_server/internal/modules/roles"
|
||||
proto "evening_detective_server/proto"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func canManagePlaylists(ctx context.Context) bool {
|
||||
claims, err := claimsFromContext(ctx)
|
||||
return err == nil && claims != nil &&
|
||||
(roles.HasRole(claims, roles.Author) || roles.HasRole(claims, roles.Organizer))
|
||||
}
|
||||
|
||||
func (s *server) GetPlaylists(ctx context.Context, _ *proto.GetPlaylistsReq) (*proto.GetPlaylistsRsp, error) {
|
||||
if !canManagePlaylists(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
items, err := s.playlistService.List(ctx)
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось получить плейлисты")
|
||||
}
|
||||
result := &proto.GetPlaylistsRsp{}
|
||||
for _, item := range items {
|
||||
result.Playlists = append(result.Playlists, mapPlaylist(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) CreatePlaylist(ctx context.Context, req *proto.CreatePlaylistReq) (*proto.CreatePlaylistRsp, error) {
|
||||
if !canManagePlaylists(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
item, err := s.playlistService.Create(ctx, req.GetName())
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось создать плейлист")
|
||||
}
|
||||
return &proto.CreatePlaylistRsp{Playlist: mapPlaylist(item)}, nil
|
||||
}
|
||||
|
||||
func (s *server) UpdatePlaylist(ctx context.Context, req *proto.UpdatePlaylistReq) (*proto.UpdatePlaylistRsp, error) {
|
||||
if !canManagePlaylists(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
item, err := s.playlistService.Update(ctx, int(req.GetID()), req.GetName())
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось изменить плейлист")
|
||||
}
|
||||
return &proto.UpdatePlaylistRsp{Playlist: mapPlaylist(item)}, nil
|
||||
}
|
||||
|
||||
func (s *server) DeletePlaylist(ctx context.Context, req *proto.DeletePlaylistReq) (*proto.DeletePlaylistRsp, error) {
|
||||
if !canManagePlaylists(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if err := s.playlistService.Delete(ctx, int(req.GetID())); err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось удалить плейлист")
|
||||
}
|
||||
return &proto.DeletePlaylistRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) UploadPlaylistTrack(ctx context.Context, req *proto.UploadPlaylistTrackReq) (*proto.UploadPlaylistTrackRsp, error) {
|
||||
if !canManagePlaylists(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if err := s.playlistService.UploadTrack(
|
||||
ctx,
|
||||
int(req.GetPlaylistID()),
|
||||
req.GetName(),
|
||||
req.GetFilename(),
|
||||
req.GetData(),
|
||||
); err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось загрузить MP3")
|
||||
}
|
||||
item, err := s.playlistService.Get(ctx, int(req.GetPlaylistID()))
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось получить плейлист")
|
||||
}
|
||||
return &proto.UploadPlaylistTrackRsp{Playlist: mapPlaylist(item)}, nil
|
||||
}
|
||||
|
||||
func (s *server) DeletePlaylistTrack(ctx context.Context, req *proto.DeletePlaylistTrackReq) (*proto.DeletePlaylistTrackRsp, error) {
|
||||
if !canManagePlaylists(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if err := s.playlistService.DeleteTrack(
|
||||
ctx,
|
||||
int(req.GetPlaylistID()),
|
||||
int(req.GetTrackID()),
|
||||
); err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось удалить трек")
|
||||
}
|
||||
return &proto.DeletePlaylistTrackRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) SetScenarioPlaylist(ctx context.Context, req *proto.SetScenarioPlaylistReq) (*proto.SetScenarioPlaylistRsp, error) {
|
||||
claims, err := claimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !roles.HasRole(claims, roles.Author) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
if err := s.playlistService.SetScenario(
|
||||
ctx,
|
||||
int(req.GetID()),
|
||||
int(req.GetPlaylistID()),
|
||||
); err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось назначить плейлист сценарию")
|
||||
}
|
||||
return &proto.SetScenarioPlaylistRsp{}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGameMusic(ctx context.Context, req *proto.GetGameMusicReq) (*proto.GetGameMusicRsp, error) {
|
||||
if !canManageScreens(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
state, err := s.playlistService.GetGame(ctx, int(req.GetGameID()))
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось получить музыку игры")
|
||||
}
|
||||
playlists, err := s.playlistService.List(ctx)
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось получить плейлисты")
|
||||
}
|
||||
result := &proto.GetGameMusicRsp{State: mapMusicState(state)}
|
||||
for _, item := range playlists {
|
||||
result.Playlists = append(result.Playlists, mapPlaylist(item))
|
||||
}
|
||||
if state.ScenarioID != nil {
|
||||
result.ScenarioPlaylistId = int32(*state.ScenarioID)
|
||||
}
|
||||
if state.GameID != nil {
|
||||
result.GamePlaylistId = int32(*state.GameID)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *server) SetGameMusic(ctx context.Context, req *proto.SetGameMusicReq) (*proto.SetGameMusicRsp, error) {
|
||||
if !canManageScreens(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
}
|
||||
state, err := s.playlistService.SetGame(
|
||||
ctx,
|
||||
int(req.GetGameID()),
|
||||
int(req.GetPlaylistID()),
|
||||
int(req.GetTrackIndex()),
|
||||
req.GetPositionMs(),
|
||||
req.GetVolume(),
|
||||
req.GetLoop(),
|
||||
int32(req.GetPlayback()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, playlistErrorStatus(err, "не удалось изменить музыку игры")
|
||||
}
|
||||
return &proto.SetGameMusicRsp{State: mapMusicState(state)}, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"evening_detective_server/internal/services/playlist_service"
|
||||
proto "evening_detective_server/proto"
|
||||
)
|
||||
|
||||
func mapPlaylist(value *playlist_service.Playlist) *proto.Playlist {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := &proto.Playlist{
|
||||
ID: int32(value.ID),
|
||||
Name: value.Name,
|
||||
}
|
||||
for _, track := range value.Tracks {
|
||||
result.Tracks = append(result.Tracks, &proto.PlaylistTrack{
|
||||
ID: int32(track.ID),
|
||||
Name: track.Name,
|
||||
Filename: track.Filename,
|
||||
Position: int32(track.Position),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mapMusicState(value *playlist_service.MusicState) *proto.MusicState {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &proto.MusicState{
|
||||
Playlist: mapPlaylist(value.Playlist),
|
||||
TrackIndex: int32(value.TrackIndex),
|
||||
PositionMs: value.PositionMs,
|
||||
Volume: value.Volume,
|
||||
Loop: value.Loop,
|
||||
Playback: proto.MusicPlaybackState(value.Playback),
|
||||
StateVersion: value.Version,
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,42 @@ func (s *server) GetScreenState(ctx context.Context, req *proto.GetScreenStateRe
|
||||
return &proto.GetScreenStateRsp{State: state}, nil
|
||||
}
|
||||
|
||||
func (s *server) AdvanceScreenMusic(ctx context.Context, req *proto.AdvanceScreenMusicReq) (*proto.AdvanceScreenMusicRsp, error) {
|
||||
if req == nil || req.ScreenID == "" || req.AccessToken == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
|
||||
}
|
||||
id, err := strconv.Atoi(req.ScreenID)
|
||||
if err != nil || id <= 0 {
|
||||
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
|
||||
}
|
||||
session, err := s.screenService.Get(ctx, id, hashScreenToken(req.AccessToken))
|
||||
if errors.Is(err, screen_service.ErrScreenNotFound) {
|
||||
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
|
||||
}
|
||||
if err != nil || session.GameID == nil {
|
||||
return nil, status.Error(codes.FailedPrecondition, "экран не привязан к игре")
|
||||
}
|
||||
state, err := s.playlistService.GetGame(ctx, *session.GameID)
|
||||
if err != nil || state.Playlist == nil || len(state.Playlist.Tracks) == 0 {
|
||||
return &proto.AdvanceScreenMusicRsp{State: mapMusicState(state)}, nil
|
||||
}
|
||||
next := state.TrackIndex + 1
|
||||
playback := state.Playback
|
||||
if next >= len(state.Playlist.Tracks) {
|
||||
if state.Loop {
|
||||
next = 0
|
||||
} else {
|
||||
next = len(state.Playlist.Tracks) - 1
|
||||
playback = 1
|
||||
}
|
||||
}
|
||||
updated, err := s.playlistService.SetGame(ctx, *session.GameID, state.Playlist.ID, next, 0, state.Volume, state.Loop, playback)
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.Internal, "не удалось переключить трек")
|
||||
}
|
||||
return &proto.AdvanceScreenMusicRsp{State: mapMusicState(updated)}, nil
|
||||
}
|
||||
|
||||
func (s *server) GetGameScreens(ctx context.Context, req *proto.GetGameScreensReq) (*proto.GetGameScreensRsp, error) {
|
||||
if !canManageScreens(ctx) {
|
||||
return nil, status.Error(codes.PermissionDenied, "permission denied")
|
||||
@@ -201,6 +237,11 @@ func (s *server) enrichScreenState(ctx context.Context, session *screen_service.
|
||||
if session.GameID == nil {
|
||||
return
|
||||
}
|
||||
if s.playlistService != nil {
|
||||
if music, err := s.playlistService.GetGame(ctx, *session.GameID); err == nil {
|
||||
state.Music = mapMusicState(music)
|
||||
}
|
||||
}
|
||||
game, err := s.gameService.GetGameMedia(ctx, *session.GameID)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"evening_detective_server/internal/services/gameplay_service"
|
||||
"evening_detective_server/internal/services/map_service"
|
||||
"evening_detective_server/internal/services/participation_service"
|
||||
"evening_detective_server/internal/services/playlist_service"
|
||||
"evening_detective_server/internal/services/scenarios_service"
|
||||
"evening_detective_server/internal/services/screen_plan_service"
|
||||
"evening_detective_server/internal/services/screen_service"
|
||||
@@ -33,6 +34,7 @@ type server struct {
|
||||
ticketService *ticket_service.TicketService
|
||||
participationService *participation_service.ParticipationService
|
||||
screenService *screen_service.ScreenService
|
||||
playlistService *playlist_service.Service
|
||||
screenPlanService *screen_plan_service.ScreenPlanService
|
||||
mapService *map_service.MapService
|
||||
}
|
||||
@@ -51,6 +53,7 @@ func NewServer(
|
||||
ticketService *ticket_service.TicketService,
|
||||
participationService *participation_service.ParticipationService,
|
||||
screenService *screen_service.ScreenService,
|
||||
playlistService *playlist_service.Service,
|
||||
screenPlanService *screen_plan_service.ScreenPlanService,
|
||||
mapService *map_service.MapService,
|
||||
) proto.EveningDetectiveServerServer {
|
||||
@@ -67,6 +70,7 @@ func NewServer(
|
||||
ticketService: ticketService,
|
||||
participationService: participationService,
|
||||
screenService: screenService,
|
||||
playlistService: playlistService,
|
||||
screenPlanService: screenPlanService,
|
||||
mapService: mapService,
|
||||
}
|
||||
|
||||
@@ -27,20 +27,26 @@ func mapScenario(o *scenarios_service.Scenario) *proto.Scenario {
|
||||
if o.PublishedAt != nil {
|
||||
publishedAt = timestamppb.New(*o.PublishedAt)
|
||||
}
|
||||
var playlistID *int32
|
||||
if o.PlaylistID != nil {
|
||||
value := int32(*o.PlaylistID)
|
||||
playlistID = &value
|
||||
}
|
||||
return &proto.Scenario{
|
||||
ID: int32(o.ID),
|
||||
Name: o.Name,
|
||||
Description: o.Description,
|
||||
Image: o.Image,
|
||||
Story: mapStory(o.Story),
|
||||
Author: mapUser(o.Author),
|
||||
Status: o.Status,
|
||||
UpdatedAt: timestamppb.New(o.UpdatedAt),
|
||||
CreatedAt: timestamppb.New(o.CreatedAt),
|
||||
PublishedAt: publishedAt,
|
||||
AverageRating: o.AverageRating,
|
||||
ReviewsCount: o.ReviewsCount,
|
||||
Difficulty: o.Difficulty,
|
||||
ID: int32(o.ID),
|
||||
Name: o.Name,
|
||||
Description: o.Description,
|
||||
Image: o.Image,
|
||||
Story: mapStory(o.Story),
|
||||
Author: mapUser(o.Author),
|
||||
Status: o.Status,
|
||||
UpdatedAt: timestamppb.New(o.UpdatedAt),
|
||||
CreatedAt: timestamppb.New(o.CreatedAt),
|
||||
PublishedAt: publishedAt,
|
||||
AverageRating: o.AverageRating,
|
||||
ReviewsCount: o.ReviewsCount,
|
||||
Difficulty: o.Difficulty,
|
||||
MusicPlaylistId: playlistID,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type Scenario struct {
|
||||
Name string
|
||||
Description *string
|
||||
Image *string
|
||||
PlaylistID *int
|
||||
Scenario string
|
||||
Author *User
|
||||
Status string
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -146,3 +146,8 @@ func (s *FileService) DownloadFile(
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
// DeleteFile removes a stored file.
|
||||
func (s *FileService) DeleteFile(ctx context.Context, filename string) error {
|
||||
return s.fileStorage.Delete(ctx, filename)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package playlist_service
|
||||
|
||||
import "evening_detective_server/internal/repos/playlists_repo"
|
||||
|
||||
func mapPlaylist(value *playlists_repo.Playlist) *Playlist {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
result := &Playlist{ID: value.ID, Name: value.Name}
|
||||
for _, track := range value.Tracks {
|
||||
result.Tracks = append(result.Tracks, &Track{
|
||||
ID: track.ID,
|
||||
Name: track.Name,
|
||||
Filename: track.Filename,
|
||||
Position: track.Position,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func mapMusicState(value *playlists_repo.MusicState) *MusicState {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &MusicState{
|
||||
Playlist: mapPlaylist(value.Playlist),
|
||||
TrackIndex: value.TrackIndex,
|
||||
PositionMs: value.PositionMs,
|
||||
Volume: value.Volume,
|
||||
Loop: value.Loop,
|
||||
Playback: value.Playback,
|
||||
Version: value.Version,
|
||||
StartedAt: value.StartedAt,
|
||||
ScenarioID: value.ScenarioID,
|
||||
GameID: value.GameID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package playlist_service
|
||||
|
||||
import "time"
|
||||
|
||||
// Track describes an MP3 track in a playlist.
|
||||
type Track struct {
|
||||
ID int
|
||||
Name string
|
||||
Filename string
|
||||
Position int
|
||||
}
|
||||
|
||||
// Playlist describes a reusable playlist.
|
||||
type Playlist struct {
|
||||
ID int
|
||||
Name string
|
||||
Tracks []*Track
|
||||
}
|
||||
|
||||
// MusicState describes the synchronized music state of a game.
|
||||
type MusicState struct {
|
||||
Playlist *Playlist
|
||||
TrackIndex int
|
||||
PositionMs int64
|
||||
Volume int32
|
||||
Loop bool
|
||||
Playback int32
|
||||
Version int64
|
||||
StartedAt *time.Time
|
||||
ScenarioID *int
|
||||
GameID *int
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package playlist_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"evening_detective_server/internal/modules/file_storage"
|
||||
"evening_detective_server/internal/repos/playlists_repo"
|
||||
"evening_detective_server/internal/services/file_service"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidName = errors.New("некорректное имя плейлиста")
|
||||
ErrInvalidTrack = errors.New("некорректный трек")
|
||||
ErrPlaylistNotFound = errors.New("плейлист не найден")
|
||||
ErrPlaylistInUse = errors.New("плейлист используется")
|
||||
ErrTrackNotFound = errors.New("трек не найден")
|
||||
ErrGameNotFound = errors.New("игра не найдена")
|
||||
ErrScenarioNotFound = errors.New("сценарий не найден")
|
||||
)
|
||||
|
||||
// Service implements playlist and game music use cases.
|
||||
type Service struct {
|
||||
repo *playlists_repo.Repo
|
||||
files *file_service.FileService
|
||||
}
|
||||
|
||||
// New creates a playlist service.
|
||||
func New(repo *playlists_repo.Repo, files *file_service.FileService) *Service {
|
||||
return &Service{repo: repo, files: files}
|
||||
}
|
||||
|
||||
// List returns all playlists.
|
||||
func (s *Service) List(ctx context.Context) ([]*Playlist, error) {
|
||||
items, err := s.repo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
result := make([]*Playlist, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, mapPlaylist(item))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Get returns a playlist by ID.
|
||||
func (s *Service) Get(ctx context.Context, id int) (*Playlist, error) {
|
||||
item, err := s.repo.Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return mapPlaylist(item), nil
|
||||
}
|
||||
|
||||
// Create validates and creates a playlist.
|
||||
func (s *Service) Create(ctx context.Context, name string) (*Playlist, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len(name) > 255 {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
item, err := s.repo.Create(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mapPlaylist(item), nil
|
||||
}
|
||||
|
||||
// Update validates and updates a playlist.
|
||||
func (s *Service) Update(ctx context.Context, id int, name string) (*Playlist, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if id <= 0 || name == "" || len(name) > 255 {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
item, err := s.repo.Update(ctx, id, name)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return mapPlaylist(item), nil
|
||||
}
|
||||
|
||||
// Delete removes a playlist.
|
||||
func (s *Service) Delete(ctx context.Context, id int) error {
|
||||
return mapError(s.repo.Delete(ctx, id))
|
||||
}
|
||||
|
||||
// UploadTrack stores an MP3 file and adds it to a playlist.
|
||||
func (s *Service) UploadTrack(ctx context.Context, playlistID int, name, filename string, data []byte) error {
|
||||
if playlistID <= 0 || strings.TrimSpace(name) == "" || len(data) == 0 || !strings.HasSuffix(strings.ToLower(filename), ".mp3") {
|
||||
return ErrInvalidTrack
|
||||
}
|
||||
if _, err := s.repo.Get(ctx, playlistID); err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
stored, fileType, err := s.files.UploadFile(ctx, filename, data)
|
||||
if err != nil || fileType != "audio" || !strings.HasSuffix(strings.ToLower(stored), ".mp3") {
|
||||
return ErrInvalidTrack
|
||||
}
|
||||
if err := s.repo.AddTrack(ctx, playlistID, strings.TrimSpace(name), stored); err != nil {
|
||||
_ = s.files.DeleteFile(ctx, stored)
|
||||
return mapError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTrack removes a track and its stored file.
|
||||
func (s *Service) DeleteTrack(ctx context.Context, playlistID, trackID int) error {
|
||||
filename, err := s.repo.DeleteTrack(ctx, playlistID, trackID)
|
||||
if err != nil {
|
||||
return mapError(err)
|
||||
}
|
||||
if err := s.files.DeleteFile(ctx, filename); err != nil && !errors.Is(err, file_storage.ErrFileNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetScenario assigns a playlist to a scenario.
|
||||
func (s *Service) SetScenario(ctx context.Context, scenarioID, playlistID int) error {
|
||||
return mapError(s.repo.SetScenario(ctx, scenarioID, playlistID))
|
||||
}
|
||||
|
||||
// SetGame updates the music state for a game.
|
||||
func (s *Service) SetGame(
|
||||
ctx context.Context,
|
||||
gameID, playlistID, trackIndex int,
|
||||
positionMs int64,
|
||||
volume int32,
|
||||
loop bool,
|
||||
playback int32,
|
||||
) (*MusicState, error) {
|
||||
if trackIndex < 0 || positionMs < 0 || volume < 0 || volume > 100 || playback < 1 || playback > 3 {
|
||||
return nil, ErrInvalidTrack
|
||||
}
|
||||
state, err := s.repo.SetMusic(ctx, gameID, playlistID, trackIndex, positionMs, volume, loop, playback)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return mapMusicState(state), nil
|
||||
}
|
||||
|
||||
// GetGame returns the music state for a game.
|
||||
func (s *Service) GetGame(ctx context.Context, gameID int) (*MusicState, error) {
|
||||
state, err := s.repo.GetMusic(ctx, gameID)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
return mapMusicState(state), nil
|
||||
}
|
||||
|
||||
func mapError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, playlists_repo.ErrPlaylistNotFound):
|
||||
return ErrPlaylistNotFound
|
||||
case errors.Is(err, playlists_repo.ErrPlaylistInUse):
|
||||
return ErrPlaylistInUse
|
||||
case errors.Is(err, playlists_repo.ErrTrackNotFound):
|
||||
return ErrTrackNotFound
|
||||
case errors.Is(err, playlists_repo.ErrGameNotFound):
|
||||
return ErrGameNotFound
|
||||
case errors.Is(err, playlists_repo.ErrScenarioNotFound):
|
||||
return ErrScenarioNotFound
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Scenario struct {
|
||||
Name string
|
||||
Description *string
|
||||
Image *string
|
||||
PlaylistID *int
|
||||
Story *storytelling.Story
|
||||
Author *users_service.User
|
||||
Status string
|
||||
|
||||
@@ -34,6 +34,7 @@ func MapScenarioLight(
|
||||
Name: o.Name,
|
||||
Description: o.Description,
|
||||
Image: mapImage(o.Image, domain),
|
||||
PlaylistID: o.PlaylistID,
|
||||
Story: nil,
|
||||
Author: mapAuthor(o.Author),
|
||||
Status: o.Status,
|
||||
|
||||
Reference in New Issue
Block a user