Compare commits

..

5 Commits

21 changed files with 800 additions and 62 deletions

View File

@@ -522,7 +522,7 @@ Responsibilities:
- [x] Implement auth middleware
- [x] Implement current user endpoint
- [x] Implement admin bootstrap user creation
- [ ] Add logout endpoint
- [x] Add logout endpoint
## Library Scanning
@@ -550,7 +550,7 @@ Responsibilities:
- [x] Track detail
- [x] Recent albums
- [x] Random albums or songs
- [ ] Favorites listing
- [x] Favorites listing
- [x] Search endpoint
- [ ] Pagination support
- [ ] Sorting support
@@ -639,8 +639,8 @@ Responsibilities:
- [x] Artist detail page
- [x] Album detail page
- [x] Playlist page
- [ ] Search results page
- [ ] Favorites page
- [x] Search results page
- [x] Favorites page
- [ ] Recently played page
## Frontend Player
@@ -649,10 +649,10 @@ Responsibilities:
- [x] Queue model
- [x] Play/pause
- [x] Next/previous
- [ ] Seek bar
- [x] Seek bar
- [x] Volume control
- [ ] Repeat modes
- [ ] Shuffle
- [x] Repeat modes
- [x] Shuffle
- [x] Track switching
- [x] Keyboard shortcuts
- [x] Mini player
@@ -683,7 +683,7 @@ Responsibilities:
- [x] Single app port for web UI and Subsonic clients
- [x] Reverse proxy example
- [x] HTTP/reverse proxy deployment notes
- [ ] Backup/restore notes
- [x] Backup/restore notes
## Nice-to-Have After MVP

View File

@@ -5,10 +5,12 @@ import { AlbumDetailPage } from '@/pages/album-detail-page'
import { ArtistsPage } from '@/pages/artists-page'
import { ArtistDetailPage } from '@/pages/artist-detail-page'
import { EmptyStatePage } from '@/pages/empty-state-page'
import { FavoritesPage } from '@/pages/favorites-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 { SearchPage } from '@/pages/search-page'
import { TracksPage } from '@/pages/tracks-page'
import { useSessionStore } from '@/stores/session-store'
@@ -29,9 +31,10 @@ export default function App() {
<Route path="/albums" element={<AlbumsPage />} />
<Route path="/albums/:id" element={<AlbumDetailPage />} />
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} />
<Route path="/favorites" element={<EmptyStatePage compact title="Вы еще не добавили песни в избранное!" />} />
<Route path="/favorites" element={<FavoritesPage />} />
<Route path="/playlists" element={<PlaylistsPage />} />
<Route path="/playlists/:id" element={<PlaylistDetailPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@@ -1,3 +1,4 @@
import { useMutation } from '@tanstack/react-query'
import {
ArrowLeft,
ArrowRight,
@@ -19,6 +20,7 @@ import { CommandPalette } from '@/components/command-palette'
import { FullPlayer } from '@/components/full-player'
import { PlayerBar } from '@/components/player-bar'
import { SettingsModal } from '@/components/settings-modal'
import { logout } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
import { useSessionStore } from '@/stores/session-store'
@@ -42,6 +44,13 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [settingsOpen, setSettingsOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
const logoutMutation = useMutation({
mutationFn: logout,
onSettled: () => {
clearSession()
setUserMenuOpen(false)
},
})
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
@@ -99,7 +108,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button>
<button
className="flex w-full items-center justify-between border-t border-[#24314f] px-4 py-3 text-left text-sm text-slate-100 hover:bg-[#18233a]"
onClick={clearSession}
onClick={() => logoutMutation.mutate()}
type="button"
>
Выйти из аккаунта

View File

@@ -150,11 +150,21 @@ export function CommandPalette({
label={track.title}
meta={`${track.artistName}${track.albumTitle}`}
onClick={() => {
navigate('/tracks')
navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
onClose()
}}
/>
))}
{(searchQuery.data?.artists.length || searchQuery.data?.albums.length || searchQuery.data?.tracks.length) ? (
<PaletteRow
label={`Показать все результаты по "${deferredQuery}"`}
meta="Страница поиска"
onClick={() => {
navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
onClose()
}}
/>
) : null}
</div>
</div>
) : (
@@ -177,7 +187,7 @@ export function CommandPalette({
return
}
if (command.action === 'navigate') {
navigate('/tracks')
navigate('/search')
onClose()
return
}

View File

@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Heart } from 'lucide-react'
import { starFavorites, unstarFavorites } from '@/lib/api'
type FavoriteToggleProps = {
entityType: 'track' | 'album' | 'artist'
entityId: string
active: boolean
size?: number
className?: string
}
export function FavoriteToggle({
entityType,
entityId,
active,
size = 18,
className = '',
}: FavoriteToggleProps) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async () => {
const payload =
entityType === 'track'
? { trackIds: [entityId] }
: entityType === 'album'
? { albumIds: [entityId] }
: { artistIds: [entityId] }
return active ? unstarFavorites(payload) : starFavorites(payload)
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['favorites'] }),
queryClient.invalidateQueries({ queryKey: ['tracks'] }),
queryClient.invalidateQueries({ queryKey: ['albums'] }),
queryClient.invalidateQueries({ queryKey: ['artists'] }),
])
},
})
return (
<button
className={className || 'text-slate-400 transition hover:text-white'}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
mutation.mutate()
}}
type="button"
>
<Heart
fill={active ? 'currentColor' : 'none'}
size={size}
strokeWidth={active ? 1.9 : 1.7}
/>
</button>
)
}

View File

@@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { ChevronDown, Heart, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Volume2 } from 'lucide-react'
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Trash2, Volume2 } from 'lucide-react'
import { useMemo, useState } from 'react'
import { coverArtUrl } from '@/lib/api'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
type LyricsLine = {
@@ -16,12 +17,24 @@ export function FullPlayer() {
const currentTime = usePlayerStore((state) => state.currentTime)
const duration = usePlayerStore((state) => state.duration)
const volume = usePlayerStore((state) => state.volume)
const shuffle = usePlayerStore((state) => state.shuffle)
const repeatMode = usePlayerStore((state) => state.repeatMode)
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
const playNext = usePlayerStore((state) => state.playNext)
const playPrevious = usePlayerStore((state) => state.playPrevious)
const playAtIndex = usePlayerStore((state) => state.playAtIndex)
const removeFromQueue = usePlayerStore((state) => state.removeFromQueue)
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
const setVolume = usePlayerStore((state) => state.setVolume)
const seekTo = usePlayerStore((state) => state.seekTo)
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now')
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
enabled: !!currentTrack,
})
const lyricsQuery = useQuery({
queryKey: ['lrclib', currentTrack?.id],
@@ -40,6 +53,7 @@ export function FullPlayer() {
})
const parsedLyrics = useMemo(() => parseLyrics(lyricsQuery.data?.syncedLyrics ?? lyricsQuery.data?.plainLyrics ?? ''), [lyricsQuery.data])
const favoriteTrackIds = useMemo(() => new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id)), [favoritesQuery.data])
const activeLine = useMemo(() => {
if (parsedLyrics.length === 0) {
return -1
@@ -113,22 +127,35 @@ export function FullPlayer() {
{tab === 'queue' ? (
<div className="mx-auto max-w-5xl space-y-3">
{queue.map((track, index) => (
<div
<button
key={`${track.id}-${index}`}
className={[
'flex items-center gap-4 rounded-[12px] px-4 py-3',
'flex w-full items-center gap-4 rounded-[12px] px-4 py-3 text-left',
track.id === currentTrack.id ? 'bg-white/10 text-white' : 'text-white/70',
].join(' ')}
onClick={() => playAtIndex(index)}
type="button"
>
<div className="w-8 text-right text-sm">{index + 1}</div>
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-white/10">
{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="min-w-0 flex-1">
<div className="truncate text-lg">{track.title}</div>
<div className="truncate text-sm text-white/55">{track.artistName}</div>
</div>
</div>
<button
className="text-white/50 transition hover:text-white"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
removeFromQueue(track.id)
}}
type="button"
>
<Trash2 size={16} />
</button>
</button>
))}
</div>
) : null}
@@ -137,9 +164,15 @@ export function FullPlayer() {
<div className="mt-8">
<div className="flex items-center gap-4 text-white/90">
<span className="w-12 text-right text-[2rem]">{formatClock(currentTime)}</span>
<div className="h-1.5 flex-1 rounded-full bg-white/20">
<div className="h-1.5 rounded-full bg-white" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
</div>
<input
className="h-1.5 flex-1 accent-white"
max={duration || 0}
min={0}
onChange={(event) => seekTo(Number(event.target.value))}
step={0.1}
type="range"
value={Math.min(currentTime, duration || 0)}
/>
<span className="w-12 text-[2rem]">{formatClock(duration)}</span>
</div>
@@ -154,17 +187,17 @@ export function FullPlayer() {
</div>
<div className="flex items-center gap-8 text-white/90">
<IconControl icon={<Shuffle size={22} />} />
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
<IconControl icon={<Rewind size={22} />} onClick={playPrevious} />
<button className="grid h-16 w-16 place-items-center rounded-full bg-white text-[#121827]" onClick={togglePlayback} type="button">
{isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
</button>
<IconControl icon={<SkipForward size={22} />} onClick={playNext} />
<IconControl icon={<Repeat2 size={22} />} />
<IconControl active={repeatMode !== 'off'} icon={<Repeat2 size={22} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
</div>
<div className="flex items-center justify-end gap-6 text-white/90">
<IconControl icon={<Heart size={22} />} />
<FavoriteToggle active={favoriteTrackIds.has(currentTrack.id)} className="transition hover:text-white text-white/90" entityId={currentTrack.id} entityType="track" size={22} />
<div className="flex items-center gap-3">
<Volume2 size={22} />
<input
@@ -214,14 +247,19 @@ function MetaTag({ children }: { children: React.ReactNode }) {
function IconControl({
icon,
active = false,
label,
onClick,
}: {
icon: React.ReactNode
active?: boolean
label?: string
onClick?: () => void
}) {
return (
<button className="transition hover:text-white" onClick={onClick} type="button">
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
{icon}
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
</button>
)
}

View File

@@ -2,7 +2,6 @@ import { useEffect, useRef } from 'react'
import {
Expand,
Forward,
Heart,
ListMusic,
Pause,
Play,
@@ -21,12 +20,20 @@ export function PlayerBar() {
const volume = usePlayerStore((state) => state.volume)
const currentTime = usePlayerStore((state) => state.currentTime)
const duration = usePlayerStore((state) => state.duration)
const shuffle = usePlayerStore((state) => state.shuffle)
const repeatMode = usePlayerStore((state) => state.repeatMode)
const seekRequest = usePlayerStore((state) => state.seekRequest)
const togglePlayback = usePlayerStore((state) => state.togglePlayback)
const playNext = usePlayerStore((state) => state.playNext)
const playPrevious = usePlayerStore((state) => state.playPrevious)
const toggleShuffle = usePlayerStore((state) => state.toggleShuffle)
const cycleRepeatMode = usePlayerStore((state) => state.cycleRepeatMode)
const setVolume = usePlayerStore((state) => state.setVolume)
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime)
const setDuration = usePlayerStore((state) => state.setDuration)
const seekTo = usePlayerStore((state) => state.seekTo)
const clearSeekRequest = usePlayerStore((state) => state.clearSeekRequest)
const handleTrackEnded = usePlayerStore((state) => state.handleTrackEnded)
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
useEffect(() => {
@@ -51,10 +58,19 @@ export function PlayerBar() {
}
}, [isPlaying, volume])
useEffect(() => {
if (!audioRef.current || seekRequest == null) {
return
}
audioRef.current.currentTime = seekRequest
clearSeekRequest()
}, [seekRequest, clearSeekRequest])
return (
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
<audio
ref={audioRef}
onEnded={handleTrackEnded}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
preload="metadata"
@@ -74,7 +90,7 @@ export function PlayerBar() {
<div className="flex flex-col items-center">
<div className="flex items-center gap-5 text-slate-400">
<BarIcon icon={<Shuffle size={18} />} />
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
<BarIcon icon={<Rewind size={18} />} onClick={playPrevious} />
<button
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
@@ -84,20 +100,25 @@ export function PlayerBar() {
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
</button>
<BarIcon icon={<Forward size={18} />} onClick={playNext} />
<BarIcon icon={<Repeat2 size={18} />} />
<BarIcon active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
</div>
<div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500">
<span>{formatClock(currentTime)}</span>
<div className="h-1.5 flex-1 rounded-full bg-[#1d2940]">
<div className="h-1.5 rounded-full bg-[#16bf8c]" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} />
</div>
<input
className="h-1.5 flex-1 accent-[#16bf8c]"
max={duration || 0}
min={0}
onChange={(event) => seekTo(Number(event.target.value))}
step={0.1}
type="range"
value={Math.min(currentTime, duration || 0)}
/>
<span>{formatClock(duration)}</span>
</div>
</div>
<div className="flex items-center justify-end gap-4 text-slate-400">
<BarIcon icon={<Heart size={17} />} />
<BarIcon icon={<ListMusic size={17} />} />
<BarIcon icon={<Volume2 size={17} />} />
<input
@@ -117,14 +138,19 @@ export function PlayerBar() {
function BarIcon({
icon,
active = false,
label,
onClick,
}: {
icon: React.ReactNode
active?: boolean
label?: string
onClick?: () => void
}) {
return (
<button className="transition hover:text-white" onClick={onClick} type="button">
<button className={['relative transition hover:text-white', active ? 'text-[#16bf8c]' : ''].join(' ')} onClick={onClick} type="button">
{icon}
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
</button>
)
}

View File

@@ -64,6 +64,12 @@ export type HomePayload = {
artists: Artist[]
}
export type FavoritesPayload = {
artists: Artist[]
albums: Album[]
tracks: Track[]
}
export type PlaylistSummary = {
id: string
name: string
@@ -110,6 +116,12 @@ export async function login(username: string, password: string) {
})
}
export async function logout() {
return request<{ status: string }>('/api/auth/logout', {
method: 'POST',
})
}
export async function fetchHome() {
return request<HomePayload>('/api/home')
}
@@ -190,6 +202,32 @@ export async function deletePlaylist(id: string) {
await request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
}
export async function fetchFavorites() {
return request<FavoritesPayload>('/api/favorites')
}
export async function starFavorites(input: {
trackIds?: string[]
albumIds?: string[]
artistIds?: string[]
}) {
return request<FavoritesPayload>('/api/favorites', {
method: 'POST',
body: JSON.stringify(input),
})
}
export async function unstarFavorites(input: {
trackIds?: string[]
albumIds?: string[]
artistIds?: string[]
}) {
return request<FavoritesPayload>('/api/favorites', {
method: 'DELETE',
body: JSON.stringify(input),
})
}
export function coverArtUrl(id: string) {
const token = useSessionStore.getState().token
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`

View File

@@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
import { MoreVertical, Play, Shuffle } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { useParams } from 'react-router-dom'
import { coverArtUrl, fetchAlbum } from '@/lib/api'
import { coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function AlbumDetailPage() {
@@ -12,6 +13,10 @@ export function AlbumDetailPage() {
queryKey: ['album', id],
queryFn: () => fetchAlbum(id),
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const album = albumQuery.data
@@ -20,6 +25,8 @@ export function AlbumDetailPage() {
}
const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0)
const favoriteAlbumIds = new Set((favoritesQuery.data?.albums ?? []).map((item) => item.id))
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id))
return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
@@ -50,9 +57,7 @@ export function AlbumDetailPage() {
<button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Heart size={24} />
</button>
<FavoriteToggle active={favoriteAlbumIds.has(album.id)} className="text-slate-300 transition hover:text-white" entityId={album.id} entityType="album" size={24} />
<button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} />
</button>
@@ -93,7 +98,9 @@ export function AlbumDetailPage() {
<div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
</div>
<div className="text-slate-500"></div>
<div className="grid place-items-center text-slate-500">
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
</div>
</button>
))}
</div>

View File

@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { coverArtUrl, fetchAlbums } from '@/lib/api'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
export function AlbumsPage() {
const navigate = useNavigate()
const albumsQuery = useQuery({
queryKey: ['albums'],
queryFn: fetchAlbums,
@@ -17,7 +18,7 @@ export function AlbumsPage() {
<button className="rounded-[10px] border border-[#24314f] bg-[#0d1628] px-5 py-3 text-base text-white" type="button">
Недавно добавленные
</button>
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={() => navigate('/search')} type="button">
<Search size={18} />
</button>
</div>

View File

@@ -1,14 +1,20 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react'
import { coverArtUrl, fetchArtist } from '@/lib/api'
import { useParams } from 'react-router-dom'
import { MoreVertical, Play, Shuffle } from 'lucide-react'
import { useNavigate, useParams } from 'react-router-dom'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchArtist, fetchFavorites } from '@/lib/api'
export function ArtistDetailPage() {
const { id = '' } = useParams()
const navigate = useNavigate()
const artistQuery = useQuery({
queryKey: ['artist', id],
queryFn: () => fetchArtist(id),
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const artist = artistQuery.data
@@ -16,6 +22,8 @@ export function ArtistDetailPage() {
return <div className="text-slate-400">Загрузка исполнителя...</div>
}
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
<div className="flex min-h-[300px] items-end gap-5 bg-[linear-gradient(180deg,rgba(189,16,37,0.78),rgba(125,15,29,0.92))] px-8 py-6">
@@ -41,9 +49,7 @@ export function ArtistDetailPage() {
<button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button">
<Heart size={24} />
</button>
<FavoriteToggle active={favoriteArtistIds.has(artist.id)} className="text-slate-300 transition hover:text-white" entityId={artist.id} entityType="artist" size={24} />
<button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} />
</button>
@@ -58,10 +64,10 @@ export function ArtistDetailPage() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{artist.albums.map((album) => (
<article key={album.id}>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
<button className="aspect-square w-full overflow-hidden rounded-[8px] bg-[#232d42]" onClick={() => navigate(`/albums/${album.id}`)} type="button">
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</div>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div>
</button>
<button className="mt-3 line-clamp-1 text-left text-[1.08rem] font-semibold text-white" onClick={() => navigate(`/albums/${album.id}`)} type="button">{album.title}</button>
<div className="text-base text-slate-400">{artist.name}</div>
</article>
))}

View File

@@ -1,9 +1,10 @@
import { useQuery } from '@tanstack/react-query'
import { SlidersHorizontal } from 'lucide-react'
import { Search, SlidersHorizontal } from 'lucide-react'
import { coverArtUrl, fetchArtists } from '@/lib/api'
import { Link } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
export function ArtistsPage() {
const navigate = useNavigate()
const artistsQuery = useQuery({
queryKey: ['artists'],
queryFn: fetchArtists,
@@ -20,7 +21,10 @@ export function ArtistsPage() {
</button>
</div>
<div className="max-w-sm rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400">Поиск...</div>
<button className="flex max-w-sm items-center gap-3 rounded-[8px] border border-[#24314f] bg-[#0d1628] px-4 py-3 text-sm text-slate-400" onClick={() => navigate('/search')} type="button">
<Search size={16} />
Поиск...
</button>
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#0f182a]">
<div className="grid grid-cols-[56px_minmax(0,2fr)_260px] gap-4 border-b border-[#24314f] px-5 py-3 text-base text-slate-300">

View File

@@ -0,0 +1,132 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function FavoritesPage() {
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const favorites = favoritesQuery.data
const tracks = favorites?.tracks ?? []
const albums = favorites?.albums ?? []
const artists = favorites?.artists ?? []
if (tracks.length === 0 && albums.length === 0 && artists.length === 0) {
return (
<div className="grid min-h-[520px] 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>
)
}
return (
<div className="space-y-10">
{tracks.length > 0 ? (
<section className="space-y-4">
<div className="text-[2rem] font-semibold tracking-tight text-white">Любимые треки</div>
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
<div>#</div>
<div>Название</div>
<div>Альбом</div>
<div></div>
<div></div>
</div>
{tracks.map((track, index) => (
<button
key={track.id}
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 border-t border-[#1f2940] px-4 py-3 text-left transition hover:bg-[#172237]"
onClick={() => {
setQueue(tracks, index)
playTrack(track, tracks)
}}
type="button"
>
<div className="text-lg text-slate-200">{index + 1}</div>
<div className="flex min-w-0 items-center gap-3">
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#303b4d]">
{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>
</div>
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="grid place-items-center">
<FavoriteToggle active entityId={track.id} entityType="track" />
</div>
</button>
))}
</div>
</section>
) : null}
{albums.length > 0 ? (
<section>
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Любимые альбомы</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{albums.map((album) => (
<article key={album.id}>
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<Link className="line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
{album.title}
</Link>
<div className="text-base text-slate-400">{album.artistName}</div>
</div>
<FavoriteToggle active entityId={album.id} entityType="album" />
</div>
</article>
))}
</div>
</section>
) : null}
{artists.length > 0 ? (
<section>
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Любимые исполнители</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{artists.map((artist) => (
<Link
key={artist.id}
className="flex items-center justify-between rounded-[12px] border border-[#24314f] bg-[#121b2e] px-5 py-4 transition hover:border-[#39527e] hover:bg-[#16233b]"
to={`/artists/${artist.id}`}
>
<div className="flex min-w-0 items-center gap-4">
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-[#303b4d]">
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
</div>
<div className="min-w-0">
<div className="truncate text-lg font-medium text-white">{artist.name}</div>
<div className="text-sm text-slate-400">{artist.albumCount} альбомов</div>
</div>
</div>
<FavoriteToggle active entityId={artist.id} entityType="artist" />
</Link>
))}
</div>
</section>
) : null}
</div>
)
}
function formatDuration(durationSeconds: number) {
const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -92,7 +92,7 @@ export function LoginPage() {
{mutation.isError ? (
<div className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
Could not reach the backend. Make sure the API is running on `http://localhost:4040`.
Could not reach the backend. Make sure the API is running on the same origin, or on `http://localhost:5050` in dev.
</div>
) : null}
</form>
@@ -111,4 +111,3 @@ function InfoCard({ label, value }: { label: string; value: string }) {
</div>
)
}

View File

@@ -0,0 +1,165 @@
import { useQuery } from '@tanstack/react-query'
import { Search } from 'lucide-react'
import { FormEvent, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites, searchLibrary } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function SearchPage() {
const navigate = useNavigate()
const [params] = useSearchParams()
const initialQuery = params.get('q') ?? ''
const [query, setQuery] = useState(initialQuery)
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const searchQuery = useQuery({
queryKey: ['search-page', initialQuery],
queryFn: () => searchLibrary(initialQuery),
enabled: initialQuery.trim().length > 0,
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const results = searchQuery.data
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((item) => item.id))
const favoriteAlbumIds = new Set((favoritesQuery.data?.albums ?? []).map((item) => item.id))
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
function onSubmit(event: FormEvent) {
event.preventDefault()
navigate(`/search?q=${encodeURIComponent(query.trim())}`)
}
return (
<div className="space-y-8">
<form className="flex items-center gap-3" onSubmit={onSubmit}>
<div className="flex flex-1 items-center gap-3 rounded-[12px] border border-[#24314f] bg-[#0d1628] px-4 py-3">
<Search size={18} className="text-slate-400" />
<input
className="w-full bg-transparent text-base text-white outline-none placeholder:text-slate-500"
onChange={(event) => setQuery(event.target.value)}
placeholder="Поиск альбома, исполнителя или трека"
value={query}
/>
</div>
<button className="rounded-[10px] bg-[#16bf8c] px-5 py-3 text-base font-medium text-[#081225]" type="submit">
Искать
</button>
</form>
{!initialQuery ? (
<div className="rounded-[14px] border border-dashed border-[#24314f] bg-[#121b2e] px-8 py-14 text-center text-slate-400">
Введи название трека, альбома или исполнителя.
</div>
) : null}
{results?.artists.length ? (
<section>
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Исполнители</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{results.artists.map((artist) => (
<Link
key={artist.id}
className="flex items-center justify-between rounded-[12px] border border-[#24314f] bg-[#121b2e] px-5 py-4 transition hover:border-[#39527e] hover:bg-[#16233b]"
to={`/artists/${artist.id}`}
>
<div className="flex min-w-0 items-center gap-4">
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-[#303b4d]">
{artist.coverArtId ? <img alt={artist.name} className="h-full w-full object-cover" src={coverArtUrl(artist.id)} /> : null}
</div>
<div className="min-w-0">
<div className="truncate text-lg font-medium text-white">{artist.name}</div>
<div className="text-sm text-slate-400">{artist.albumCount} альбомов</div>
</div>
</div>
<FavoriteToggle active={favoriteArtistIds.has(artist.id)} entityId={artist.id} entityType="artist" />
</Link>
))}
</div>
</section>
) : null}
{results?.albums.length ? (
<section>
<div className="mb-5 text-[2rem] font-semibold tracking-tight text-white">Альбомы</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{results.albums.map((album) => (
<article key={album.id}>
<Link className="block aspect-square overflow-hidden rounded-[8px] bg-[#232d42]" to={`/albums/${album.id}`}>
{album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</Link>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<Link className="line-clamp-1 block text-[1.08rem] font-semibold text-white hover:underline" to={`/albums/${album.id}`}>
{album.title}
</Link>
<div className="text-base text-slate-400">{album.artistName}</div>
</div>
<FavoriteToggle active={favoriteAlbumIds.has(album.id)} entityId={album.id} entityType="album" />
</div>
</article>
))}
</div>
</section>
) : null}
{results?.tracks.length ? (
<section className="space-y-4">
<div className="text-[2rem] font-semibold tracking-tight text-white">Треки</div>
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
<div>#</div>
<div>Название</div>
<div>Альбом</div>
<div></div>
<div></div>
</div>
{results.tracks.map((track, index) => (
<button
key={track.id}
className="grid w-full grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_48px] gap-4 border-t border-[#1f2940] px-4 py-3 text-left transition hover:bg-[#172237]"
onClick={() => {
setQueue(results.tracks, index)
playTrack(track, results.tracks)
}}
type="button"
>
<div className="text-lg text-slate-200">{index + 1}</div>
<div className="flex min-w-0 items-center gap-3">
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-[6px] bg-[#303b4d]">
{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>
</div>
<div className="truncate text-base text-slate-300">{track.albumTitle}</div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="grid place-items-center">
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
</div>
</button>
))}
</div>
</section>
) : null}
{initialQuery && !searchQuery.isLoading && !results?.artists.length && !results?.albums.length && !results?.tracks.length ? (
<div className="rounded-[14px] border border-dashed border-[#24314f] bg-[#121b2e] px-8 py-14 text-center text-slate-400">
Ничего не найдено по запросу "{initialQuery}".
</div>
) : null}
</div>
)
}
function formatDuration(durationSeconds: number) {
const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -1,21 +1,29 @@
import { useQuery } from '@tanstack/react-query'
import { Heart, Search } from 'lucide-react'
import { coverArtUrl, fetchTracks } from '@/lib/api'
import { Search } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
import { useNavigate } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store'
export function TracksPage() {
const navigate = useNavigate()
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const tracksQuery = useQuery({
queryKey: ['tracks'],
queryFn: fetchTracks,
})
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const tracks = tracksQuery.data?.items ?? []
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
return (
<div className="space-y-4">
<HeaderSearch />
<HeaderSearch onClick={() => navigate('/search')} />
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]">
<div className="grid grid-cols-[56px_minmax(0,2.3fr)_minmax(0,1.4fr)_80px_140px_180px_120px_48px] gap-4 bg-[#202b3c] px-4 py-3 text-base text-slate-300">
<div>#</div>
@@ -58,7 +66,7 @@ export function TracksPage() {
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
</div>
<div className="grid place-items-center text-slate-500">
<Heart size={16} />
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
</div>
</button>
))}
@@ -68,10 +76,10 @@ export function TracksPage() {
)
}
function HeaderSearch() {
function HeaderSearch({ onClick }: { onClick: () => void }) {
return (
<div className="flex items-center justify-end">
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" type="button">
<button className="grid h-10 w-10 place-items-center rounded-[8px] border border-[#24314f] text-slate-400 hover:bg-[#18233a] hover:text-white" onClick={onClick} type="button">
<Search size={18} />
</button>
</div>

View File

@@ -8,15 +8,25 @@ type PlayerState = {
volume: number
currentTime: number
duration: number
shuffle: boolean
repeatMode: 'off' | 'all' | 'one'
fullPlayerOpen: boolean
seekRequest: number | null
setQueue: (tracks: Track[], startIndex?: number) => void
playTrack: (track: Track, queue?: Track[]) => void
togglePlayback: () => void
playNext: () => void
playPrevious: () => void
playAtIndex: (index: number) => void
removeFromQueue: (trackId: string) => void
toggleShuffle: () => void
cycleRepeatMode: () => void
setVolume: (volume: number) => void
setCurrentTime: (currentTime: number) => void
setDuration: (duration: number) => void
seekTo: (currentTime: number) => void
clearSeekRequest: () => void
handleTrackEnded: () => void
setFullPlayerOpen: (fullPlayerOpen: boolean) => void
}
@@ -27,7 +37,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
volume: 0.7,
currentTime: 0,
duration: 0,
shuffle: false,
repeatMode: 'off',
fullPlayerOpen: false,
seekRequest: null,
setQueue: (queue, startIndex = 0) =>
set({
queue,
@@ -48,11 +61,26 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
if (!state.currentTrack || state.queue.length === 0) {
return state
}
if (state.repeatMode === 'one') {
return {
currentTrack: state.currentTrack,
isPlaying: true,
currentTime: 0,
seekRequest: 0,
}
}
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
const nextTrack = state.queue[index + 1] ?? state.queue[0] ?? null
let nextTrack: Track | null = null
if (state.shuffle && state.queue.length > 1) {
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
} else {
nextTrack = state.queue[index + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
}
return {
currentTrack: nextTrack,
isPlaying: !!nextTrack,
currentTime: 0,
}
}),
playPrevious: () =>
@@ -68,8 +96,40 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
currentTime: 0,
}
}),
playAtIndex: (index) =>
set((state) => ({
currentTrack: state.queue[index] ?? state.currentTrack,
isPlaying: !!state.queue[index],
currentTime: 0,
})),
removeFromQueue: (trackId) =>
set((state) => {
const nextQueue = state.queue.filter((track) => track.id !== trackId)
const removedCurrent = state.currentTrack?.id === trackId
const nextCurrent = removedCurrent ? nextQueue[0] ?? null : state.currentTrack
return {
queue: nextQueue,
currentTrack: nextCurrent,
isPlaying: removedCurrent ? !!nextCurrent : state.isPlaying,
}
}),
toggleShuffle: () => set((state) => ({ shuffle: !state.shuffle })),
cycleRepeatMode: () =>
set((state) => ({
repeatMode: state.repeatMode === 'off' ? 'all' : state.repeatMode === 'all' ? 'one' : 'off',
})),
setVolume: (volume) => set({ volume }),
setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }),
seekTo: (currentTime) => set({ currentTime, seekRequest: currentTime }),
clearSeekRequest: () => set({ seekRequest: null }),
handleTrackEnded: () => {
const state = get()
if (state.repeatMode === 'one') {
set({ currentTime: 0, seekRequest: 0, isPlaying: true })
return
}
state.playNext()
},
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
}))

52
deploy/BACKUP_RESTORE.md Normal file
View File

@@ -0,0 +1,52 @@
# Backup And Restore
The minimum persistent state for this project is:
- `data/app.db`
- `data/artwork/`
- your music library mount, if the server machine is the primary storage location
## What To Back Up
Recommended:
- entire `data/` directory
- entire `media/` directory if the same host stores the original files
- your `.env` or deployment environment settings
Why:
- `app.db` stores users, sessions, playlists, favorites, and scanned metadata
- `artwork/` stores extracted embedded covers
- `media/` contains the source files used to rebuild the library index
## Simple Backup Example
PowerShell:
```powershell
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
New-Item -ItemType Directory -Force -Path ".\\backups\\$stamp" | Out-Null
Copy-Item -Recurse -Force .\\data ".\\backups\\$stamp\\data"
Copy-Item -Recurse -Force .\\media ".\\backups\\$stamp\\media"
```
## Restore Example
1. Stop the server.
2. Restore `data/` from backup.
3. Restore `media/` if needed.
4. Start the server again.
PowerShell:
```powershell
Copy-Item -Recurse -Force ".\\backups\\20260403-010000\\data\\*" ".\\data"
Copy-Item -Recurse -Force ".\\backups\\20260403-010000\\media\\*" ".\\media"
```
## Notes
- If `media/` is already backed up elsewhere, restoring `data/app.db` and `data/artwork/` is usually enough.
- If `artwork/` is lost but `media/` is intact, the server can rebuild extracted covers during future scans.
- If `app.db` is lost, the library can be rescanned from `media/`, but playlists, favorites, sessions, and users will be lost unless restored from backup.

View File

@@ -75,3 +75,23 @@ server {
- In production the frontend uses relative URLs, so it works correctly behind the same origin without hardcoded API hosts.
- In local frontend development, Vite proxies `/api`, `/rest`, and `/health` to `http://127.0.0.1:5050`.
- If you later enable HTTPS on an external reverse proxy, clients should still connect to one public base URL only.
- Web UI and Subsonic clients should always use the same public base URL, only differing by path usage.
## Recommended Public Contract
Public examples:
- browser: `https://music.example.com/`
- Subsonic clients: `https://music.example.com`
Internal upstream:
- `http://127.0.0.1:5050`
Do not publish separate public ports for:
- web UI
- `/api/*`
- `/rest/*`
- `/api/stream/*`
- `/api/cover-art/*`

View File

@@ -47,6 +47,10 @@ func NewService(db *sql.DB, encryptionKey string) *Service {
}
func (s *Service) Login(ctx context.Context, username, password string) (Session, error) {
if err := s.cleanupExpiredSessions(ctx); err != nil {
return Session{}, fmt.Errorf("cleanup expired sessions: %w", err)
}
user, passwordHash, _, err := s.findUserByUsername(ctx, username)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -107,6 +111,8 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
return User{}, ErrUnauthorized
}
_ = s.cleanupExpiredSessions(ctx)
user, err := s.findUserByToken(ctx, token)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -118,6 +124,16 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
return user, nil
}
func (s *Service) Logout(ctx context.Context, token string) error {
if strings.TrimSpace(token) == "" {
return nil
}
if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token = ?`, strings.TrimSpace(token)); err != nil {
return fmt.Errorf("delete session: %w", err)
}
return nil
}
func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, password, token, salt string) (User, error) {
if username == "" {
return User{}, ErrUnauthorized
@@ -262,6 +278,11 @@ func (s *Service) storeSubsonicSecret(ctx context.Context, userID, password stri
return err
}
func (s *Service) cleanupExpiredSessions(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at <= ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
func EncryptSubsonicSecret(value, key string) (string, error) {
return encryptSecret(value, key)
}

View File

@@ -52,6 +52,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", application.login)
api.Post("/auth/logout", application.logout)
api.Group(func(private chi.Router) {
private.Use(application.requireAuth)
@@ -64,6 +65,9 @@ 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("/favorites", application.favorites)
private.Post("/favorites", application.starFavorites)
private.Delete("/favorites", application.unstarFavorites)
private.Get("/playlists", application.playlistsList)
private.Post("/playlists", application.createPlaylist)
private.Get("/playlists/{id}", application.playlistByID)
@@ -139,6 +143,25 @@ func (a app) login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, session)
}
func (a app) logout(w http.ResponseWriter, r *http.Request) {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if token == "" {
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err == nil {
token = strings.TrimSpace(payload.Token)
}
}
if err := a.auth.Logout(r.Context(), token); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "logout failed"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (a app) me(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
writeJSON(w, http.StatusOK, user)
@@ -234,6 +257,62 @@ func (a app) search(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, results)
}
func (a app) favorites(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
results, err := a.library.Starred(r.Context(), user.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
return
}
writeJSON(w, http.StatusOK, results)
}
func (a app) starFavorites(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
var payload struct {
TrackIDs []string `json:"trackIds"`
AlbumIDs []string `json:"albumIds"`
ArtistIDs []string `json:"artistIds"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.library.Star(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to star favorites"})
return
}
results, err := a.library.Starred(r.Context(), user.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
return
}
writeJSON(w, http.StatusOK, results)
}
func (a app) unstarFavorites(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
var payload struct {
TrackIDs []string `json:"trackIds"`
AlbumIDs []string `json:"albumIds"`
ArtistIDs []string `json:"artistIds"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if err := a.library.Unstar(r.Context(), user.ID, payload.TrackIDs, payload.AlbumIDs, payload.ArtistIDs); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unstar favorites"})
return
}
results, err := a.library.Starred(r.Context(), user.ID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load favorites"})
return
}
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)