From 675e173303807375543bf56a534091623be03b73 Mon Sep 17 00:00:00 2001 From: benya Date: Thu, 2 Apr 2026 23:21:01 +0300 Subject: [PATCH] feat: add playlists mvp for web and subsonic --- SUBSONIC_SERVER_BLUEPRINT.md | 13 +- apps/web/src/App.tsx | 5 +- apps/web/src/components/command-palette.tsx | 9 +- apps/web/src/lib/api.ts | 59 ++++ apps/web/src/pages/playlist-detail-page.tsx | 152 +++++++++ apps/web/src/pages/playlists-page.tsx | 95 ++++++ internal/httpapi/router.go | 177 +++++++++- internal/playlist/service.go | 357 ++++++++++++++++++++ internal/subsonic/service.go | 78 +++++ 9 files changed, 930 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/pages/playlist-detail-page.tsx create mode 100644 apps/web/src/pages/playlists-page.tsx create mode 100644 internal/playlist/service.go diff --git a/SUBSONIC_SERVER_BLUEPRINT.md b/SUBSONIC_SERVER_BLUEPRINT.md index 66b5e17..4175219 100644 --- a/SUBSONIC_SERVER_BLUEPRINT.md +++ b/SUBSONIC_SERVER_BLUEPRINT.md @@ -569,11 +569,11 @@ Responsibilities: - [x] Create playlists table - [x] Create playlist tracks table -- [ ] Add create playlist endpoint -- [ ] Add rename playlist endpoint -- [ ] Add delete playlist endpoint +- [x] Add create playlist endpoint +- [x] Add rename playlist endpoint +- [x] Add delete playlist endpoint - [ ] Add reorder tracks endpoint -- [ ] Add add/remove track endpoints +- [x] Add add/remove track endpoints - [ ] Add listening history table - [ ] Record play/scrobble events - [ ] Add recently played endpoint @@ -607,7 +607,7 @@ Responsibilities: - [x] Implement `getStarred2` - [x] Implement `star` - [x] Implement `unstar` -- [ ] Implement playlist endpoints +- [x] Implement playlist endpoints - [ ] Implement `scrobble` - [ ] Test against at least one existing Subsonic client @@ -638,7 +638,7 @@ Responsibilities: - [x] Artists grid/list page - [x] Artist detail page - [x] Album detail page -- [ ] Playlist page +- [x] Playlist page - [ ] Search results page - [ ] Favorites page - [ ] Recently played page @@ -680,6 +680,7 @@ Responsibilities: - [x] Persistent DB volume - [x] Persistent cache volume - [x] Music folder mount strategy +- [ ] Single public HTTPS port for web UI and Subsonic clients - [ ] Reverse proxy example - [ ] HTTPS deployment notes - [ ] Backup/restore notes diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7f00748..37ccb58 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,6 +7,8 @@ import { ArtistDetailPage } from '@/pages/artist-detail-page' import { EmptyStatePage } from '@/pages/empty-state-page' import { HomePage } from '@/pages/home-page' import { LoginPage } from '@/pages/login-page' +import { PlaylistDetailPage } from '@/pages/playlist-detail-page' +import { PlaylistsPage } from '@/pages/playlists-page' import { TracksPage } from '@/pages/tracks-page' import { useSessionStore } from '@/stores/session-store' @@ -28,7 +30,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/components/command-palette.tsx b/apps/web/src/components/command-palette.tsx index b89fedd..3507147 100644 --- a/apps/web/src/components/command-palette.tsx +++ b/apps/web/src/components/command-palette.tsx @@ -128,7 +128,7 @@ export function CommandPalette({ label={artist.name} meta="Исполнитель" onClick={() => { - navigate('/artists') + navigate(`/artists/${artist.id}`) onClose() }} /> @@ -139,7 +139,7 @@ export function CommandPalette({ label={album.title} meta={album.artistName} onClick={() => { - navigate('/albums') + navigate(`/albums/${album.id}`) onClose() }} /> @@ -181,6 +181,11 @@ export function CommandPalette({ onClose() return } + if (command.action === 'create-playlist') { + navigate('/playlists') + onClose() + return + } if (command.action === 'server') { setServerMode(true) setQuery('') diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ef3063b..21affca 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -64,6 +64,21 @@ export type HomePayload = { artists: Artist[] } +export type PlaylistSummary = { + id: string + name: string + comment: string + public: boolean + songCount: number + durationSeconds: number + createdAt: string + updatedAt: string +} + +export type PlaylistDetail = PlaylistSummary & { + tracks: Track[] +} + const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040' async function request(path: string, init?: RequestInit): Promise { @@ -81,6 +96,10 @@ async function request(path: string, init?: RequestInit): Promise { throw new Error(`Request failed: ${response.status}`) } + if (response.status === 204) { + return undefined as T + } + return response.json() as Promise } @@ -131,6 +150,46 @@ export async function triggerScan() { return request('/api/admin/scan', { method: 'POST' }) } +export async function fetchPlaylists() { + return request<{ items: PlaylistSummary[] }>('/api/playlists') +} + +export async function fetchPlaylist(id: string) { + return request(`/api/playlists/${id}`) +} + +export async function createPlaylist(input: { + name: string + comment?: string + public?: boolean + trackIds?: string[] +}) { + return request('/api/playlists', { + method: 'POST', + body: JSON.stringify(input), + }) +} + +export async function updatePlaylist( + id: string, + input: { + name?: string + comment?: string + public?: boolean + addTrackIds?: string[] + removeTrackIds?: string[] + }, +) { + return request(`/api/playlists/${id}`, { + method: 'PATCH', + body: JSON.stringify(input), + }) +} + +export async function deletePlaylist(id: string) { + await request(`/api/playlists/${id}`, { method: 'DELETE' }) +} + export function coverArtUrl(id: string) { const token = useSessionStore.getState().token return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}` diff --git a/apps/web/src/pages/playlist-detail-page.tsx b/apps/web/src/pages/playlist-detail-page.tsx new file mode 100644 index 0000000..7d7c7b8 --- /dev/null +++ b/apps/web/src/pages/playlist-detail-page.tsx @@ -0,0 +1,152 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Heart, Play, Save, Trash2 } from 'lucide-react' +import { useNavigate, useParams } from 'react-router-dom' +import { coverArtUrl, deletePlaylist, fetchPlaylist, updatePlaylist } from '@/lib/api' +import { usePlayerStore } from '@/stores/player-store' + +export function PlaylistDetailPage() { + const { id = '' } = useParams() + const navigate = useNavigate() + const queryClient = useQueryClient() + const setQueue = usePlayerStore((state) => state.setQueue) + const playTrack = usePlayerStore((state) => state.playTrack) + + const playlistQuery = useQuery({ + queryKey: ['playlist', id], + queryFn: () => fetchPlaylist(id), + }) + + const renameMutation = useMutation({ + mutationFn: (name: string) => updatePlaylist(id, { name }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['playlist', id] }) + void queryClient.invalidateQueries({ queryKey: ['playlists'] }) + }, + }) + + const removeMutation = useMutation({ + mutationFn: (trackId: string) => updatePlaylist(id, { removeTrackIds: [trackId] }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['playlist', id] }) + void queryClient.invalidateQueries({ queryKey: ['playlists'] }) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: () => deletePlaylist(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['playlists'] }) + navigate('/playlists') + }, + }) + + const playlist = playlistQuery.data + if (!playlist) { + return
Загрузка плейлиста...
+ } + + return ( +
+
+
+
+
{playlist.songCount}
+
треков
+
+
+
+
Плейлист
+

{playlist.name}

+
+ {playlist.songCount} треков + + {formatDuration(playlist.durationSeconds)} + + Обновлён {new Date(playlist.updatedAt).toLocaleString('ru-RU')} +
+
+
+ +
+
+ + + + +
+ +
+
+
#
+
Название
+
+
Качество
+
Альбом
+
+
+ {playlist.tracks.map((track, index) => ( +
+ + +
{formatShortDuration(track.durationSeconds)}
+
+ FLAC +
+
{track.albumTitle}
+ +
+ ))} +
+
+
+ ) +} + +function formatShortDuration(value: number) { + const minutes = Math.floor(value / 60) + const seconds = value % 60 + return `${minutes}:${seconds.toString().padStart(2, '0')}` +} + +function formatDuration(value: number) { + const minutes = Math.floor(value / 60) + const hours = Math.floor(minutes / 60) + const restMinutes = minutes % 60 + return hours > 0 ? `${hours} ч ${restMinutes} мин` : `${restMinutes} мин` +} diff --git a/apps/web/src/pages/playlists-page.tsx b/apps/web/src/pages/playlists-page.tsx new file mode 100644 index 0000000..bf2e8d4 --- /dev/null +++ b/apps/web/src/pages/playlists-page.tsx @@ -0,0 +1,95 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ListMusic, Plus, Save } from 'lucide-react' +import { Link } from 'react-router-dom' +import { createPlaylist, fetchPlaylists } from '@/lib/api' +import { usePlayerStore } from '@/stores/player-store' + +export function PlaylistsPage() { + const queryClient = useQueryClient() + const queue = usePlayerStore((state) => state.queue) + const playlistsQuery = useQuery({ + queryKey: ['playlists'], + queryFn: fetchPlaylists, + }) + + const createMutation = useMutation({ + mutationFn: createPlaylist, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ['playlists'] }) + }, + }) + + const playlists = playlistsQuery.data?.items ?? [] + + return ( +
+
+ + +
+ + {playlists.length > 0 ? ( +
+ {playlists.map((playlist) => ( + +
+
+ +
+
+ {playlist.songCount} треков +
+
+
{playlist.name}
+
{playlist.comment || 'Без описания'}
+
+ Обновлён: {new Date(playlist.updatedAt).toLocaleString('ru-RU')} +
+ + ))} +
+ ) : ( +
+
+
Пока не создано ни одного плейлиста
+
Создай пустой плейлист или сохрани текущую очередь.
+
+
+ )} +
+ ) +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index ddb42a7..7793bc7 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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") { diff --git a/internal/playlist/service.go b/internal/playlist/service.go new file mode 100644 index 0000000..b73ee7a --- /dev/null +++ b/internal/playlist/service.go @@ -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 +} diff --git a/internal/subsonic/service.go b/internal/subsonic/service.go index 7455d30..6e4bb93 100644 --- a/internal/subsonic/service.go +++ b/internal/subsonic/service.go @@ -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{