feat: add playlists mvp for web and subsonic
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<Route path="/albums/:id" element={<AlbumDetailPage />} />
|
||||
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
|
||||
<Route path="/favorites" element={<EmptyStatePage compact title="Вы еще не добавили песни в избранное!" />} />
|
||||
<Route path="/playlists" element={<EmptyStatePage title="Пока не создано ни одного плейлиста" action="Создать плейлист" />} />
|
||||
<Route path="/playlists" element={<PlaylistsPage />} />
|
||||
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
|
||||
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
@@ -81,6 +96,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
throw new Error(`Request failed: ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
@@ -131,6 +150,46 @@ export async function triggerScan() {
|
||||
return request<ScanStatus>('/api/admin/scan', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function fetchPlaylists() {
|
||||
return request<{ items: PlaylistSummary[] }>('/api/playlists')
|
||||
}
|
||||
|
||||
export async function fetchPlaylist(id: string) {
|
||||
return request<PlaylistDetail>(`/api/playlists/${id}`)
|
||||
}
|
||||
|
||||
export async function createPlaylist(input: {
|
||||
name: string
|
||||
comment?: string
|
||||
public?: boolean
|
||||
trackIds?: string[]
|
||||
}) {
|
||||
return request<PlaylistDetail>('/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<PlaylistDetail>(`/api/playlists/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePlaylist(id: string) {
|
||||
await request<void>(`/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)}` : ''}`
|
||||
|
||||
152
apps/web/src/pages/playlist-detail-page.tsx
Normal file
152
apps/web/src/pages/playlist-detail-page.tsx
Normal file
@@ -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 <div className="text-slate-400">Загрузка плейлиста...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
|
||||
<div className="flex min-h-[280px] items-end gap-5 bg-[linear-gradient(180deg,rgba(96,109,135,0.75),rgba(42,53,76,0.9))] px-8 py-6">
|
||||
<div className="grid h-64 w-64 place-items-center rounded-[8px] bg-[#2a3447] text-slate-100">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl font-semibold">{playlist.songCount}</div>
|
||||
<div className="mt-2 text-lg text-slate-300">треков</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl text-white">Плейлист</div>
|
||||
<h1 className="mt-3 text-[4rem] font-semibold leading-none text-white">{playlist.name}</h1>
|
||||
<div className="mt-5 flex flex-wrap items-center gap-2 text-lg text-slate-200">
|
||||
<span>{playlist.songCount} треков</span>
|
||||
<span>•</span>
|
||||
<span>{formatDuration(playlist.durationSeconds)}</span>
|
||||
<span>•</span>
|
||||
<span>Обновлён {new Date(playlist.updatedAt).toLocaleString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[linear-gradient(180deg,rgba(53,64,91,0.38),rgba(18,27,46,0.98))] px-8 py-6">
|
||||
<div className="mb-8 flex items-center gap-6">
|
||||
<button
|
||||
className="grid h-14 w-14 place-items-center rounded-full bg-[#16bf8c] text-[#081225]"
|
||||
onClick={() => setQueue(playlist.tracks)}
|
||||
type="button"
|
||||
>
|
||||
<Play size={24} className="translate-x-[2px]" />
|
||||
</button>
|
||||
<button
|
||||
className="text-slate-300 transition hover:text-white"
|
||||
onClick={() => {
|
||||
const name = window.prompt('Новое название плейлиста', playlist.name)
|
||||
if (name && name !== playlist.name) {
|
||||
renameMutation.mutate(name)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Save size={24} />
|
||||
</button>
|
||||
<button className="text-slate-300 transition hover:text-white" type="button">
|
||||
<Heart size={24} />
|
||||
</button>
|
||||
<button className="text-slate-300 transition hover:text-red-300" onClick={() => deleteMutation.mutate()} type="button">
|
||||
<Trash2 size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[12px] border border-[#24314f]">
|
||||
<div className="grid grid-cols-[56px_minmax(0,2fr)_100px_120px_100px_52px] gap-4 border-b border-[#24314f] px-5 py-3 text-base text-slate-300">
|
||||
<div>#</div>
|
||||
<div>Название</div>
|
||||
<div>◷</div>
|
||||
<div>Качество</div>
|
||||
<div>Альбом</div>
|
||||
<div />
|
||||
</div>
|
||||
{playlist.tracks.map((track, index) => (
|
||||
<div
|
||||
key={track.id}
|
||||
className="grid grid-cols-[56px_minmax(0,2fr)_100px_120px_100px_52px] gap-4 border-t border-[#1f2940] px-5 py-4"
|
||||
>
|
||||
<button className="text-left text-lg text-white" onClick={() => playTrack(track, playlist.tracks)} type="button">
|
||||
{index + 1}
|
||||
</button>
|
||||
<button className="flex min-w-0 items-center gap-3 text-left" onClick={() => playTrack(track, playlist.tracks)} type="button">
|
||||
<div className="h-10 w-10 overflow-hidden rounded-[6px] bg-[#313d52]">
|
||||
{track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[1.02rem] text-white">{track.title}</div>
|
||||
<div className="truncate text-base text-slate-400">{track.artistName}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="text-base text-slate-200">{formatShortDuration(track.durationSeconds)}</div>
|
||||
<div>
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||
</div>
|
||||
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
|
||||
<button className="text-slate-400 transition hover:text-red-300" onClick={() => removeMutation.mutate(track.id)} type="button">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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} мин`
|
||||
}
|
||||
95
apps/web/src/pages/playlists-page.tsx
Normal file
95
apps/web/src/pages/playlists-page.tsx
Normal file
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<button
|
||||
className="rounded-[10px] border border-[#24314f] bg-[#0d1628] px-5 py-3 text-base text-white"
|
||||
onClick={() => {
|
||||
const name = window.prompt('Название нового плейлиста', `Playlist ${playlists.length + 1}`)
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
createMutation.mutate({ name })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Plus size={18} />
|
||||
Создать плейлист
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[10px] bg-[#16bf8c] px-5 py-3 text-base font-medium text-[#081225]"
|
||||
onClick={() => {
|
||||
const name = window.prompt('Сохранить текущую очередь как плейлист', 'Queue Playlist')
|
||||
if (!name) {
|
||||
return
|
||||
}
|
||||
createMutation.mutate({ name, trackIds: queue.map((track) => track.id) })
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Save size={18} />
|
||||
Сохранить очередь
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{playlists.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{playlists.map((playlist) => (
|
||||
<Link
|
||||
key={playlist.id}
|
||||
className="rounded-[14px] border border-[#24314f] bg-[#121b2e] p-5 transition hover:border-[#39527e] hover:bg-[#16233b]"
|
||||
to={`/playlists/${playlist.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-[10px] bg-[#1e2a40] text-slate-200">
|
||||
<ListMusic size={22} />
|
||||
</div>
|
||||
<div className="rounded-full bg-[#364157] px-3 py-1 text-xs font-semibold text-white">
|
||||
{playlist.songCount} треков
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 text-2xl font-semibold text-white">{playlist.name}</div>
|
||||
<div className="mt-2 text-base text-slate-400">{playlist.comment || 'Без описания'}</div>
|
||||
<div className="mt-5 text-sm text-slate-500">
|
||||
Обновлён: {new Date(playlist.updatedAt).toLocaleString('ru-RU')}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid min-h-[480px] place-items-center rounded-[16px] border border-dashed border-[#24314f] bg-[#121b2e]">
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-semibold text-white">Пока не создано ни одного плейлиста</div>
|
||||
<div className="mt-4 text-lg text-slate-400">Создай пустой плейлист или сохрани текущую очередь.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
357
internal/playlist/service.go
Normal file
357
internal/playlist/service.go
Normal 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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user