package playlist import ( "context" "crypto/sha1" "database/sql" "encoding/hex" "errors" "fmt" "slices" "strconv" "strings" "time" ) var ErrNotFound = errors.New("playlist not found") type Summary struct { ID string `json:"id"` Name string `json:"name"` Comment string `json:"comment"` Public bool `json:"public"` SongCount int `json:"songCount"` Duration int `json:"durationSeconds"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type Track struct { ID string `json:"id"` AlbumID string `json:"albumId"` ArtistID string `json:"artistId"` Title string `json:"title"` ArtistName string `json:"artistName"` AlbumTitle string `json:"albumTitle"` TrackNumber int `json:"trackNumber"` DurationSeconds int `json:"durationSeconds"` CoverArtID string `json:"coverArtId"` } type Detail struct { Summary Tracks []Track `json:"tracks"` } type CreateInput struct { Name string `json:"name"` Comment string `json:"comment"` Public bool `json:"public"` TrackIDs []string `json:"trackIds"` } type UpdateInput struct { Name *string `json:"name"` Comment *string `json:"comment"` Public *bool `json:"public"` AddTrackIDs []string `json:"addTrackIds"` RemoveTrackIDs []string `json:"removeTrackIds"` RemovePositions []int `json:"removePositions"` } type Service struct { db *sql.DB } func NewService(db *sql.DB) *Service { return &Service{db: db} } func (s *Service) List(ctx context.Context, userID string) ([]Summary, error) { rows, err := s.db.QueryContext( ctx, `SELECT p.id, p.name, COALESCE(p.comment, ''), p.public, COALESCE(COUNT(pt.track_id), 0), COALESCE(SUM(t.duration_seconds), 0), p.created_at, p.updated_at FROM playlists p LEFT JOIN playlist_tracks pt ON pt.playlist_id = p.id LEFT JOIN tracks t ON t.id = pt.track_id WHERE p.user_id = ? GROUP BY p.id, p.name, p.comment, p.public, p.created_at, p.updated_at ORDER BY p.updated_at DESC, p.name ASC`, userID, ) if err != nil { return nil, fmt.Errorf("query playlists: %w", err) } defer rows.Close() var items []Summary for rows.Next() { var item Summary var createdAt string var updatedAt string var public int if err := rows.Scan(&item.ID, &item.Name, &item.Comment, &public, &item.SongCount, &item.Duration, &createdAt, &updatedAt); err != nil { return nil, fmt.Errorf("scan playlists: %w", err) } item.Public = public == 1 item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) items = append(items, item) } return items, rows.Err() } func (s *Service) ByID(ctx context.Context, userID, playlistID string) (Detail, error) { summary, err := s.loadSummary(ctx, userID, playlistID) if err != nil { return Detail{}, err } rows, err := s.db.QueryContext( ctx, `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), COALESCE(t.duration_seconds, 0), COALESCE(al.cover_art_id, '') FROM playlist_tracks pt JOIN tracks t ON t.id = pt.track_id JOIN artists a ON a.id = t.artist_id JOIN albums al ON al.id = t.album_id WHERE pt.playlist_id = ? ORDER BY pt.position ASC`, playlistID, ) if err != nil { return Detail{}, fmt.Errorf("query playlist tracks: %w", err) } defer rows.Close() detail := Detail{Summary: summary} for rows.Next() { var track Track if err := rows.Scan(&track.ID, &track.AlbumID, &track.ArtistID, &track.Title, &track.ArtistName, &track.AlbumTitle, &track.TrackNumber, &track.DurationSeconds, &track.CoverArtID); err != nil { return Detail{}, fmt.Errorf("scan playlist track: %w", err) } detail.Tracks = append(detail.Tracks, track) } return detail, rows.Err() } func (s *Service) Create(ctx context.Context, userID string, input CreateInput) (Detail, error) { name := strings.TrimSpace(input.Name) if name == "" { name = "Новый плейлист" } now := time.Now().UTC().Format(time.RFC3339) playlistID := hashID("playlist", userID, name, now) tx, err := s.db.BeginTx(ctx, nil) if err != nil { return Detail{}, fmt.Errorf("begin create playlist: %w", err) } if _, err := tx.ExecContext(ctx, `INSERT INTO playlists (id, user_id, name, comment, public, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, playlistID, userID, name, strings.TrimSpace(input.Comment), boolToInt(input.Public), now, now); err != nil { _ = tx.Rollback() return Detail{}, fmt.Errorf("insert playlist: %w", err) } if err := s.replaceTracksTx(ctx, tx, playlistID, input.TrackIDs); err != nil { _ = tx.Rollback() return Detail{}, err } if err := tx.Commit(); err != nil { return Detail{}, fmt.Errorf("commit create playlist: %w", err) } return s.ByID(ctx, userID, playlistID) } func (s *Service) Update(ctx context.Context, userID, playlistID string, input UpdateInput) (Detail, error) { detail, err := s.ByID(ctx, userID, playlistID) if err != nil { return Detail{}, err } nextTrackIDs := make([]string, 0, len(detail.Tracks)) for _, track := range detail.Tracks { nextTrackIDs = append(nextTrackIDs, track.ID) } if len(input.RemovePositions) > 0 { slices.Sort(input.RemovePositions) for index := len(input.RemovePositions) - 1; index >= 0; index -= 1 { position := input.RemovePositions[index] if position < 0 || position >= len(nextTrackIDs) { continue } nextTrackIDs = append(nextTrackIDs[:position], nextTrackIDs[position+1:]...) } } if len(input.RemoveTrackIDs) > 0 { removeSet := map[string]struct{}{} for _, id := range input.RemoveTrackIDs { removeSet[id] = struct{}{} } filtered := nextTrackIDs[:0] for _, id := range nextTrackIDs { if _, found := removeSet[id]; !found { filtered = append(filtered, id) } } nextTrackIDs = filtered } nextTrackIDs = append(nextTrackIDs, input.AddTrackIDs...) tx, err := s.db.BeginTx(ctx, nil) if err != nil { return Detail{}, fmt.Errorf("begin update playlist: %w", err) } name := detail.Name if input.Name != nil && strings.TrimSpace(*input.Name) != "" { name = strings.TrimSpace(*input.Name) } comment := detail.Comment if input.Comment != nil { comment = strings.TrimSpace(*input.Comment) } publicValue := detail.Public if input.Public != nil { publicValue = *input.Public } if _, err := tx.ExecContext(ctx, `UPDATE playlists SET name = ?, comment = ?, public = ?, updated_at = ? WHERE id = ? AND user_id = ?`, name, comment, boolToInt(publicValue), time.Now().UTC().Format(time.RFC3339), playlistID, userID); err != nil { _ = tx.Rollback() return Detail{}, fmt.Errorf("update playlist metadata: %w", err) } if err := s.replaceTracksTx(ctx, tx, playlistID, nextTrackIDs); err != nil { _ = tx.Rollback() return Detail{}, err } if err := tx.Commit(); err != nil { return Detail{}, fmt.Errorf("commit update playlist: %w", err) } return s.ByID(ctx, userID, playlistID) } func (s *Service) Delete(ctx context.Context, userID, playlistID string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin delete playlist: %w", err) } result, err := tx.ExecContext(ctx, `DELETE FROM playlists WHERE id = ? AND user_id = ?`, playlistID, userID) if err != nil { _ = tx.Rollback() return fmt.Errorf("delete playlist: %w", err) } rows, err := result.RowsAffected() if err != nil { _ = tx.Rollback() return fmt.Errorf("playlist rows affected: %w", err) } if rows == 0 { _ = tx.Rollback() return ErrNotFound } if _, err := tx.ExecContext(ctx, `DELETE FROM playlist_tracks WHERE playlist_id = ?`, playlistID); err != nil { _ = tx.Rollback() return fmt.Errorf("delete playlist tracks: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("commit delete playlist: %w", err) } return nil } func (s *Service) loadSummary(ctx context.Context, userID, playlistID string) (Summary, error) { var summary Summary var createdAt string var updatedAt string var public int err := s.db.QueryRowContext( ctx, `SELECT p.id, p.name, COALESCE(p.comment, ''), p.public, COALESCE(COUNT(pt.track_id), 0), COALESCE(SUM(t.duration_seconds), 0), p.created_at, p.updated_at FROM playlists p LEFT JOIN playlist_tracks pt ON pt.playlist_id = p.id LEFT JOIN tracks t ON t.id = pt.track_id WHERE p.id = ? AND p.user_id = ? GROUP BY p.id, p.name, p.comment, p.public, p.created_at, p.updated_at`, playlistID, userID, ).Scan(&summary.ID, &summary.Name, &summary.Comment, &public, &summary.SongCount, &summary.Duration, &createdAt, &updatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Summary{}, ErrNotFound } return Summary{}, fmt.Errorf("load playlist summary: %w", err) } summary.Public = public == 1 summary.CreatedAt = parseTime(createdAt) summary.UpdatedAt = parseTime(updatedAt) return summary, nil } func (s *Service) replaceTracksTx(ctx context.Context, tx *sql.Tx, playlistID string, trackIDs []string) error { if _, err := tx.ExecContext(ctx, `DELETE FROM playlist_tracks WHERE playlist_id = ?`, playlistID); err != nil { return fmt.Errorf("clear playlist tracks: %w", err) } position := 0 for _, trackID := range trackIDs { if strings.TrimSpace(trackID) == "" { continue } var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM tracks WHERE id = ?`, trackID).Scan(&exists); err != nil { return fmt.Errorf("check playlist track: %w", err) } if exists == 0 { continue } if _, err := tx.ExecContext(ctx, `INSERT INTO playlist_tracks (playlist_id, track_id, position) VALUES (?, ?, ?)`, playlistID, trackID, position); err != nil { return fmt.Errorf("insert playlist track: %w", err) } position += 1 } return nil } func parseTime(raw string) time.Time { parsed, err := time.Parse(time.RFC3339, raw) if err != nil { return time.Time{} } return parsed } func boolToInt(value bool) int { if value { return 1 } return 0 } func hashID(parts ...string) string { sum := sha1.Sum([]byte(strings.Join(parts, "::"))) return hex.EncodeToString(sum[:]) } func ParseIndices(values []string) []int { var parsed []int for _, value := range values { index, err := strconv.Atoi(strings.TrimSpace(value)) if err == nil { parsed = append(parsed, index) } } return parsed }