feat: add playlists mvp for web and subsonic

This commit is contained in:
2026-04-02 23:21:01 +03:00
parent b16f9de6c8
commit 675e173303
9 changed files with 930 additions and 15 deletions

View File

@@ -15,21 +15,24 @@ import (
"github.com/benya/temporserv/internal/auth"
"github.com/benya/temporserv/internal/config"
"github.com/benya/temporserv/internal/library"
"github.com/benya/temporserv/internal/playlist"
"github.com/benya/temporserv/internal/scanner"
"github.com/benya/temporserv/internal/subsonic"
)
type app struct {
auth *auth.Service
library *library.Service
scanner *scanner.Service
auth *auth.Service
library *library.Service
playlists *playlist.Service
scanner *scanner.Service
}
func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service) http.Handler {
application := app{
auth: auth.NewService(database, cfg.EncryptionKey),
library: library.NewService(database),
scanner: scanService,
auth: auth.NewService(database, cfg.EncryptionKey),
library: library.NewService(database),
playlists: playlist.NewService(database),
scanner: scanService,
}
r := chi.NewRouter()
@@ -59,6 +62,11 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
private.Get("/tracks", application.tracks)
private.Get("/tracks/{id}", application.trackByID)
private.Get("/search", application.search)
private.Get("/playlists", application.playlistsList)
private.Post("/playlists", application.createPlaylist)
private.Get("/playlists/{id}", application.playlistByID)
private.Patch("/playlists/{id}", application.updatePlaylist)
private.Delete("/playlists/{id}", application.deletePlaylist)
private.Get("/admin/scan-status", application.scanStatus)
private.Post("/admin/scan", application.scanLibrary)
})
@@ -85,6 +93,10 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
authed.Get("/getStarred2.view", application.subsonicStarred2)
authed.Get("/star.view", application.subsonicStar)
authed.Get("/unstar.view", application.subsonicUnstar)
authed.Get("/getPlaylists.view", application.subsonicPlaylists)
authed.Get("/getPlaylist.view", application.subsonicPlaylistByID)
authed.Get("/createPlaylist.view", application.subsonicCreatePlaylist)
authed.Get("/updatePlaylist.view", application.subsonicUpdatePlaylist)
authed.Get("/getScanStatus.view", application.subsonicScanStatus)
authed.Get("/startScan.view", application.subsonicStartScan)
authed.Get("/getCoverArt.view", application.subsonicCoverArt)
@@ -217,6 +229,80 @@ func (a app) search(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, results)
}
func (a app) playlistsList(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
items, err := a.playlists.List(r.Context(), user.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load playlists"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a app) playlistByID(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
item, err := a.playlists.ByID(r.Context(), user.ID, chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, playlist.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "playlist not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load playlist"})
return
}
writeJSON(w, http.StatusOK, item)
}
func (a app) createPlaylist(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
var payload playlist.CreateInput
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
item, err := a.playlists.Create(r.Context(), user.ID, payload)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create playlist"})
return
}
writeJSON(w, http.StatusCreated, item)
}
func (a app) updatePlaylist(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
var payload playlist.UpdateInput
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
item, err := a.playlists.Update(r.Context(), user.ID, chi.URLParam(r, "id"), payload)
if err != nil {
if errors.Is(err, playlist.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "playlist not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update playlist"})
return
}
writeJSON(w, http.StatusOK, item)
}
func (a app) deletePlaylist(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
err := a.playlists.Delete(r.Context(), user.ID, chi.URLParam(r, "id"))
if err != nil {
if errors.Is(err, playlist.ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "playlist not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to delete playlist"})
return
}
w.WriteHeader(http.StatusNoContent)
}
func (a app) subsonicArtists(w http.ResponseWriter, r *http.Request) {
artists, err := a.library.Artists(r.Context(), 1000)
if err != nil {
@@ -289,6 +375,63 @@ func (a app) subsonicUnstar(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse())
}
func (a app) subsonicPlaylists(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
items, err := a.playlists.List(r.Context(), user.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load playlists"))
return
}
writeJSON(w, http.StatusOK, subsonic.PlaylistsResponse(user.Username, items))
}
func (a app) subsonicPlaylistByID(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
item, err := a.playlists.ByID(r.Context(), user.ID, strings.TrimSpace(r.URL.Query().Get("id")))
if err != nil {
if errors.Is(err, playlist.ErrNotFound) {
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "playlist not found"))
return
}
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load playlist"))
return
}
writeJSON(w, http.StatusOK, subsonic.PlaylistResponse(user.Username, item))
}
func (a app) subsonicCreatePlaylist(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
item, err := a.playlists.Create(r.Context(), user.ID, playlist.CreateInput{
Name: strings.TrimSpace(r.URL.Query().Get("name")),
TrackIDs: readMultiValue(r, "songId"),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to create playlist"))
return
}
writeJSON(w, http.StatusOK, subsonic.PlaylistResponse(user.Username, item))
}
func (a app) subsonicUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
item, err := a.playlists.Update(r.Context(), user.ID, strings.TrimSpace(r.URL.Query().Get("playlistId")), playlist.UpdateInput{
Name: optionalString(r.URL.Query().Get("name")),
Comment: optionalString(r.URL.Query().Get("comment")),
Public: optionalBool(r.URL.Query().Get("public")),
AddTrackIDs: readMultiValue(r, "songIdToAdd"),
RemovePositions: playlist.ParseIndices(readMultiValue(r, "songIndexToRemove")),
})
if err != nil {
if errors.Is(err, playlist.ErrNotFound) {
writeJSON(w, http.StatusNotFound, subsonic.ErrorResponse(70, "playlist not found"))
return
}
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to update playlist"))
return
}
writeJSON(w, http.StatusOK, subsonic.PlaylistResponse(user.Username, item))
}
func (a app) subsonicArtistByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.ArtistByID(r.Context(), r.URL.Query().Get("id"))
if err != nil {
@@ -479,6 +622,28 @@ func parsePositiveInt(raw string) int {
return value
}
func optionalString(raw string) *string {
if strings.TrimSpace(raw) == "" {
return nil
}
value := strings.TrimSpace(raw)
return &value
}
func optionalBool(raw string) *bool {
value := strings.ToLower(strings.TrimSpace(raw))
switch value {
case "true", "1":
result := true
return &result
case "false", "0":
result := false
return &result
default:
return nil
}
}
func spaFallback(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api") || strings.HasPrefix(r.URL.Path, "/rest") || strings.HasPrefix(r.URL.Path, "/health") {

View File

@@ -0,0 +1,357 @@
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
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/benya/temporserv/internal/library"
"github.com/benya/temporserv/internal/playlist"
"github.com/benya/temporserv/internal/scanner"
)
@@ -24,6 +25,8 @@ type Response struct {
RandomSong []SongRef `json:"randomSongs,omitempty"`
SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"`
Starred2 *Starred2 `json:"starred2,omitempty"`
Playlists *Playlists `json:"playlists,omitempty"`
Playlist *Playlist `json:"playlist,omitempty"`
ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
Error *ErrorRef `json:"error,omitempty"`
}
@@ -103,6 +106,34 @@ type Starred2 struct {
Song []SongRef `json:"song,omitempty"`
}
type Playlists struct {
Playlist []PlaylistSummary `json:"playlist,omitempty"`
}
type Playlist struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Public bool `json:"public"`
Comment string `json:"comment,omitempty"`
SongCount int `json:"songCount"`
Duration int `json:"duration,omitempty"`
Created string `json:"created,omitempty"`
Changed string `json:"changed,omitempty"`
Entry []SongRef `json:"entry,omitempty"`
}
type PlaylistSummary struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Public bool `json:"public"`
SongCount int `json:"songCount"`
Duration int `json:"duration,omitempty"`
Created string `json:"created,omitempty"`
Changed string `json:"changed,omitempty"`
}
type ErrorRef struct {
Code int `json:"code"`
Message string `json:"message"`
@@ -237,6 +268,53 @@ func Starred2Response(results library.StarredResults) Envelope {
return response
}
func PlaylistsResponse(owner string, playlists []playlist.Summary) Envelope {
response := PingResponse()
payload := &Playlists{}
for _, item := range playlists {
payload.Playlist = append(payload.Playlist, PlaylistSummary{
ID: item.ID,
Name: item.Name,
Owner: owner,
Public: item.Public,
SongCount: item.SongCount,
Duration: item.Duration,
Created: formatTime(item.CreatedAt),
Changed: formatTime(item.UpdatedAt),
})
}
response.SubsonicResponse.Playlists = payload
return response
}
func PlaylistResponse(owner string, detail playlist.Detail) Envelope {
response := PingResponse()
item := &Playlist{
ID: detail.ID,
Name: detail.Name,
Owner: owner,
Public: detail.Public,
Comment: detail.Comment,
SongCount: detail.SongCount,
Duration: detail.Duration,
Created: formatTime(detail.CreatedAt),
Changed: formatTime(detail.UpdatedAt),
}
for _, track := range detail.Tracks {
item.Entry = append(item.Entry, SongRef{
ID: track.ID,
Title: track.Title,
Album: track.AlbumTitle,
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.CoverArtID,
})
}
response.SubsonicResponse.Playlist = item
return response
}
func AlbumResponse(album library.AlbumDetail) Envelope {
response := PingResponse()
item := &AlbumFull{