358 lines
10 KiB
Go
358 lines
10 KiB
Go
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
|
|
}
|