Compare commits

...

17 Commits

Author SHA1 Message Date
d8f584dcc9 fix: correct opensubsonic extensions response 2026-04-03 21:19:32 +03:00
0b10dfe055 fix: improve subsonic client compatibility 2026-04-03 21:16:40 +03:00
a054192e45 fix: align subsonic artist and album response shapes 2026-04-03 21:10:40 +03:00
ad9543bf7a feat: add subsonic library discovery endpoints 2026-04-03 21:07:58 +03:00
480bdc2476 fix: guard home page against null track items 2026-04-03 21:00:51 +03:00
3c284bc414 fix: support subsonic endpoints without view suffix 2026-04-03 20:56:04 +03:00
2956a302e0 fix: copy go.sum before docker backend build 2026-04-03 20:50:05 +03:00
252075ee1c feat: extract track duration and bitrate during scans 2026-04-03 02:34:07 +03:00
2774b93830 feat: add collapsible sidebar rail mode 2026-04-03 02:29:01 +03:00
db6e2818c1 fix: polish player controls and remove fake track stats 2026-04-03 02:27:24 +03:00
d7e21956db fix: improve query loading and error states 2026-04-03 02:19:12 +03:00
4d44632fbf feat: add recently played and scrobble flow 2026-04-03 02:15:57 +03:00
2bbf52a41b docs: add backup notes and refresh checklist 2026-04-03 01:43:46 +03:00
62ab2a9417 feat: improve player controls and queue actions 2026-04-03 01:42:33 +03:00
3abc864abd feat: add search results page and richer navigation 2026-04-03 01:40:30 +03:00
56aa822730 fix: add logout endpoint and session cleanup 2026-04-03 01:38:15 +03:00
1e6f200433 feat: add favorites page and web starring 2026-04-03 01:36:43 +03:00
31 changed files with 2107 additions and 226 deletions

View File

@@ -522,7 +522,7 @@ Responsibilities:
- [x] Implement auth middleware - [x] Implement auth middleware
- [x] Implement current user endpoint - [x] Implement current user endpoint
- [x] Implement admin bootstrap user creation - [x] Implement admin bootstrap user creation
- [ ] Add logout endpoint - [x] Add logout endpoint
## Library Scanning ## Library Scanning
@@ -550,7 +550,7 @@ Responsibilities:
- [x] Track detail - [x] Track detail
- [x] Recent albums - [x] Recent albums
- [x] Random albums or songs - [x] Random albums or songs
- [ ] Favorites listing - [x] Favorites listing
- [x] Search endpoint - [x] Search endpoint
- [ ] Pagination support - [ ] Pagination support
- [ ] Sorting support - [ ] Sorting support
@@ -574,9 +574,9 @@ Responsibilities:
- [x] Add delete playlist endpoint - [x] Add delete playlist endpoint
- [ ] Add reorder tracks endpoint - [ ] Add reorder tracks endpoint
- [x] Add add/remove track endpoints - [x] Add add/remove track endpoints
- [ ] Add listening history table - [x] Add listening history table
- [ ] Record play/scrobble events - [x] Record play/scrobble events
- [ ] Add recently played endpoint - [x] Add recently played endpoint
## Favorites ## Favorites
@@ -608,7 +608,7 @@ Responsibilities:
- [x] Implement `star` - [x] Implement `star`
- [x] Implement `unstar` - [x] Implement `unstar`
- [x] Implement playlist endpoints - [x] Implement playlist endpoints
- [ ] Implement `scrobble` - [x] Implement `scrobble`
- [ ] Test against at least one existing Subsonic client - [ ] Test against at least one existing Subsonic client
## Frontend Bootstrap ## Frontend Bootstrap
@@ -630,7 +630,7 @@ Responsibilities:
- [ ] Responsive navigation - [ ] Responsive navigation
- [ ] Toast/notification system - [ ] Toast/notification system
- [ ] Error boundary - [ ] Error boundary
- [ ] Query loading/error patterns - [x] Query loading/error patterns
## Frontend Music Views ## Frontend Music Views
@@ -639,8 +639,8 @@ Responsibilities:
- [x] Artist detail page - [x] Artist detail page
- [x] Album detail page - [x] Album detail page
- [x] Playlist page - [x] Playlist page
- [ ] Search results page - [x] Search results page
- [ ] Favorites page - [x] Favorites page
- [ ] Recently played page - [ ] Recently played page
## Frontend Player ## Frontend Player
@@ -649,10 +649,10 @@ Responsibilities:
- [x] Queue model - [x] Queue model
- [x] Play/pause - [x] Play/pause
- [x] Next/previous - [x] Next/previous
- [ ] Seek bar - [x] Seek bar
- [x] Volume control - [x] Volume control
- [ ] Repeat modes - [x] Repeat modes
- [ ] Shuffle - [x] Shuffle
- [x] Track switching - [x] Track switching
- [x] Keyboard shortcuts - [x] Keyboard shortcuts
- [x] Mini player - [x] Mini player
@@ -683,7 +683,7 @@ Responsibilities:
- [x] Single app port for web UI and Subsonic clients - [x] Single app port for web UI and Subsonic clients
- [x] Reverse proxy example - [x] Reverse proxy example
- [x] HTTP/reverse proxy deployment notes - [x] HTTP/reverse proxy deployment notes
- [ ] Backup/restore notes - [x] Backup/restore notes
## Nice-to-Have After MVP ## 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 { ArtistsPage } from '@/pages/artists-page'
import { ArtistDetailPage } from '@/pages/artist-detail-page' import { ArtistDetailPage } from '@/pages/artist-detail-page'
import { EmptyStatePage } from '@/pages/empty-state-page' import { EmptyStatePage } from '@/pages/empty-state-page'
import { FavoritesPage } from '@/pages/favorites-page'
import { HomePage } from '@/pages/home-page' import { HomePage } from '@/pages/home-page'
import { LoginPage } from '@/pages/login-page' import { LoginPage } from '@/pages/login-page'
import { PlaylistDetailPage } from '@/pages/playlist-detail-page' import { PlaylistDetailPage } from '@/pages/playlist-detail-page'
import { PlaylistsPage } from '@/pages/playlists-page' import { PlaylistsPage } from '@/pages/playlists-page'
import { SearchPage } from '@/pages/search-page'
import { TracksPage } from '@/pages/tracks-page' import { TracksPage } from '@/pages/tracks-page'
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from '@/stores/session-store'
@@ -29,9 +31,10 @@ export default function App() {
<Route path="/albums" element={<AlbumsPage />} /> <Route path="/albums" element={<AlbumsPage />} />
<Route path="/albums/:id" element={<AlbumDetailPage />} /> <Route path="/albums/:id" element={<AlbumDetailPage />} />
<Route path="/genres" element={<EmptyStatePage compact title="Жанры" />} /> <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" element={<PlaylistsPage />} />
<Route path="/playlists/:id" element={<PlaylistDetailPage />} /> <Route path="/playlists/:id" element={<PlaylistDetailPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} /> <Route path="/radio" element={<EmptyStatePage compact title="Радио будет доступно позже" />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View File

@@ -1,3 +1,4 @@
import { useMutation } from '@tanstack/react-query'
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@@ -19,6 +20,7 @@ import { CommandPalette } from '@/components/command-palette'
import { FullPlayer } from '@/components/full-player' import { FullPlayer } from '@/components/full-player'
import { PlayerBar } from '@/components/player-bar' import { PlayerBar } from '@/components/player-bar'
import { SettingsModal } from '@/components/settings-modal' import { SettingsModal } from '@/components/settings-modal'
import { logout } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store' import { usePlayerStore } from '@/stores/player-store'
import { useSessionStore } from '@/stores/session-store' import { useSessionStore } from '@/stores/session-store'
@@ -42,6 +44,25 @@ export function AppShell({ children }: { children: React.ReactNode }) {
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false) const [userMenuOpen, setUserMenuOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false) const [paletteOpen, setPaletteOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const logoutMutation = useMutation({
mutationFn: logout,
onSettled: () => {
clearSession()
setUserMenuOpen(false)
},
})
useEffect(() => {
const stored = window.localStorage.getItem('temporserv.sidebar-collapsed')
if (stored === 'true') {
setSidebarCollapsed(true)
}
}, [])
useEffect(() => {
window.localStorage.setItem('temporserv.sidebar-collapsed', String(sidebarCollapsed))
}, [sidebarCollapsed])
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
@@ -71,7 +92,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TopIconButton icon={<ArrowLeft size={16} />} /> <TopIconButton icon={<ArrowLeft size={16} />} />
<TopIconButton icon={<ArrowRight size={16} />} /> <TopIconButton icon={<ArrowRight size={16} />} />
<TopIconButton icon={<Disc3 size={16} />} /> <TopIconButton active={sidebarCollapsed} icon={<Disc3 size={16} />} onClick={() => setSidebarCollapsed((value) => !value)} />
</div> </div>
<div className="flex items-center gap-2 text-sm font-medium text-slate-300"> <div className="flex items-center gap-2 text-sm font-medium text-slate-300">
@@ -99,7 +120,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</button> </button>
<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]" 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" type="button"
> >
Выйти из аккаунта Выйти из аккаунта
@@ -110,22 +131,32 @@ export function AppShell({ children }: { children: React.ReactNode }) {
</div> </div>
</header> </header>
<div className="grid min-h-0 flex-1 grid-cols-[276px_minmax(0,1fr)]"> <div className={['grid min-h-0 flex-1', sidebarCollapsed ? 'grid-cols-[62px_minmax(0,1fr)]' : 'grid-cols-[276px_minmax(0,1fr)]'].join(' ')}>
<aside className="flex min-h-0 flex-col border-r border-[#24314f] bg-[#0a1226]"> <aside className="flex min-h-0 flex-col border-r border-[#24314f] bg-[#0a1226]">
<div className="p-4"> <div className={sidebarCollapsed ? 'p-3' : 'p-4'}>
<div className="flex items-center gap-2 rounded-[10px] border border-[#24314f] bg-[#0c1730] px-3 py-2 text-slate-400"> {sidebarCollapsed ? (
<Search size={16} /> <button
<input className="grid h-10 w-10 place-items-center rounded-[10px] border border-[#24314f] bg-[#0c1730] text-slate-400 transition hover:bg-[#18233a] hover:text-white"
onFocus={() => setPaletteOpen(true)} onClick={() => setPaletteOpen(true)}
className="w-full bg-transparent text-sm outline-none placeholder:text-slate-500" type="button"
placeholder="Поиск..." >
/> <Search size={18} />
<span className="rounded-md border border-[#2b3652] px-2 py-0.5 text-xs text-slate-500">/</span> </button>
</div> ) : (
<div className="flex items-center gap-2 rounded-[10px] border border-[#24314f] bg-[#0c1730] px-3 py-2 text-slate-400">
<Search size={16} />
<input
onFocus={() => setPaletteOpen(true)}
className="w-full bg-transparent text-sm outline-none placeholder:text-slate-500"
placeholder="Поиск..."
/>
<span className="rounded-md border border-[#2b3652] px-2 py-0.5 text-xs text-slate-500">/</span>
</div>
)}
</div> </div>
<div className="px-3 pb-3 text-xs uppercase tracking-[0.18em] text-slate-500">Библиотека</div> {!sidebarCollapsed ? <div className="px-3 pb-3 text-xs uppercase tracking-[0.18em] text-slate-500">Библиотека</div> : null}
<nav className="space-y-1 px-3"> <nav className={sidebarCollapsed ? 'space-y-1 px-2' : 'space-y-1 px-3'}>
{libraryLinks.map((item) => { {libraryLinks.map((item) => {
const Icon = item.icon const Icon = item.icon
return ( return (
@@ -134,29 +165,34 @@ export function AppShell({ children }: { children: React.ReactNode }) {
to={item.to} to={item.to}
className={({ isActive }) => className={({ isActive }) =>
[ [
'flex items-center gap-3 rounded-[10px] px-4 py-3 text-[0.95rem] transition', sidebarCollapsed
? 'flex items-center justify-center rounded-[10px] px-0 py-3 text-[0.95rem] transition'
: 'flex items-center gap-3 rounded-[10px] px-4 py-3 text-[0.95rem] transition',
isActive ? 'bg-[#313d52] text-white' : 'text-slate-100 hover:bg-[#18233a]', isActive ? 'bg-[#313d52] text-white' : 'text-slate-100 hover:bg-[#18233a]',
].join(' ') ].join(' ')
} }
title={sidebarCollapsed ? item.label : undefined}
> >
<Icon size={18} /> <Icon size={18} />
{item.label} {!sidebarCollapsed ? item.label : null}
</NavLink> </NavLink>
) )
})} })}
</nav> </nav>
<div className="mt-5 px-3 pb-3 text-xs uppercase tracking-[0.18em] text-slate-500">Плейлисты</div> {!sidebarCollapsed ? <div className="mt-5 px-3 pb-3 text-xs uppercase tracking-[0.18em] text-slate-500">Плейлисты</div> : null}
<div className="px-3"> <div className={sidebarCollapsed ? 'mt-5 px-2' : 'px-3'}>
<div className="mb-3 flex items-center justify-between text-slate-400"> <div className={sidebarCollapsed ? 'flex justify-center text-slate-400' : 'mb-3 flex items-center justify-between text-slate-400'}>
<span className="text-sm">Плейлисты</span> {!sidebarCollapsed ? <span className="text-sm">Плейлисты</span> : null}
<button className="grid h-7 w-7 place-items-center rounded-md bg-[#0ec28c] text-[#081225]" type="button"> <button className="grid h-7 w-7 place-items-center rounded-md bg-[#0ec28c] text-[#081225]" title="Создать плейлист" type="button">
+ +
</button> </button>
</div> </div>
<div className="rounded-[10px] bg-[#313d52] px-4 py-3 text-[0.95rem] text-slate-100"> {!sidebarCollapsed ? (
Пока не создано ни одного плейлиста <div className="rounded-[10px] bg-[#313d52] px-4 py-3 text-[0.95rem] text-slate-100">
</div> Пока не создано ни одного плейлиста
</div>
) : null}
</div> </div>
</aside> </aside>
@@ -182,13 +218,18 @@ export function AppShell({ children }: { children: React.ReactNode }) {
function TopIconButton({ function TopIconButton({
icon, icon,
onClick, onClick,
active = false,
}: { }: {
icon: React.ReactNode icon: React.ReactNode
onClick?: () => void onClick?: () => void
active?: boolean
}) { }) {
return ( return (
<button <button
className="grid h-8 w-8 place-items-center rounded-md text-slate-300 transition hover:bg-[#18233a] hover:text-white" className={[
'grid h-8 w-8 place-items-center rounded-md text-slate-300 transition hover:bg-[#18233a] hover:text-white',
active ? 'bg-[#313d52] text-white' : '',
].join(' ')}
onClick={onClick} onClick={onClick}
type="button" type="button"
> >

View File

@@ -150,11 +150,21 @@ export function CommandPalette({
label={track.title} label={track.title}
meta={`${track.artistName}${track.albumTitle}`} meta={`${track.artistName}${track.albumTitle}`}
onClick={() => { onClick={() => {
navigate('/tracks') navigate(`/search?q=${encodeURIComponent(deferredQuery)}`)
onClose() 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>
</div> </div>
) : ( ) : (
@@ -177,7 +187,7 @@ export function CommandPalette({
return return
} }
if (command.action === 'navigate') { if (command.action === 'navigate') {
navigate('/tracks') navigate('/search')
onClose() onClose()
return 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 { 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, Shuffle, SkipBack, SkipForward, Trash2, Volume2 } from 'lucide-react'
import { useMemo, useState } from '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' import { usePlayerStore } from '@/stores/player-store'
type LyricsLine = { type LyricsLine = {
@@ -16,12 +17,24 @@ export function FullPlayer() {
const currentTime = usePlayerStore((state) => state.currentTime) const currentTime = usePlayerStore((state) => state.currentTime)
const duration = usePlayerStore((state) => state.duration) const duration = usePlayerStore((state) => state.duration)
const volume = usePlayerStore((state) => state.volume) 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 togglePlayback = usePlayerStore((state) => state.togglePlayback)
const playNext = usePlayerStore((state) => state.playNext) const playNext = usePlayerStore((state) => state.playNext)
const playPrevious = usePlayerStore((state) => state.playPrevious) 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 setVolume = usePlayerStore((state) => state.setVolume)
const seekTo = usePlayerStore((state) => state.seekTo)
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen) const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now') const [tab, setTab] = useState<'queue' | 'now' | 'lyrics'>('now')
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
enabled: !!currentTrack,
})
const lyricsQuery = useQuery({ const lyricsQuery = useQuery({
queryKey: ['lrclib', currentTrack?.id], queryKey: ['lrclib', currentTrack?.id],
@@ -40,6 +53,7 @@ export function FullPlayer() {
}) })
const parsedLyrics = useMemo(() => parseLyrics(lyricsQuery.data?.syncedLyrics ?? lyricsQuery.data?.plainLyrics ?? ''), [lyricsQuery.data]) 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(() => { const activeLine = useMemo(() => {
if (parsedLyrics.length === 0) { if (parsedLyrics.length === 0) {
return -1 return -1
@@ -113,22 +127,35 @@ export function FullPlayer() {
{tab === 'queue' ? ( {tab === 'queue' ? (
<div className="mx-auto max-w-5xl space-y-3"> <div className="mx-auto max-w-5xl space-y-3">
{queue.map((track, index) => ( {queue.map((track, index) => (
<div <button
key={`${track.id}-${index}`} key={`${track.id}-${index}`}
className={[ 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', track.id === currentTrack.id ? 'bg-white/10 text-white' : 'text-white/70',
].join(' ')} ].join(' ')}
onClick={() => playAtIndex(index)}
type="button"
> >
<div className="w-8 text-right text-sm">{index + 1}</div> <div className="w-8 text-right text-sm">{index + 1}</div>
<div className="h-12 w-12 overflow-hidden rounded-[8px] bg-white/10"> <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} {track.coverArtId ? <img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} /> : null}
</div> </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-lg">{track.title}</div>
<div className="truncate text-sm text-white/55">{track.artistName}</div> <div className="truncate text-sm text-white/55">{track.artistName}</div>
</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> </div>
) : null} ) : null}
@@ -137,9 +164,15 @@ export function FullPlayer() {
<div className="mt-8"> <div className="mt-8">
<div className="flex items-center gap-4 text-white/90"> <div className="flex items-center gap-4 text-white/90">
<span className="w-12 text-right text-[2rem]">{formatClock(currentTime)}</span> <span className="w-12 text-right text-[2rem]">{formatClock(currentTime)}</span>
<div className="h-1.5 flex-1 rounded-full bg-white/20"> <input
<div className="h-1.5 rounded-full bg-white" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} /> className="h-1.5 flex-1 accent-white"
</div> 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> <span className="w-12 text-[2rem]">{formatClock(duration)}</span>
</div> </div>
@@ -154,17 +187,17 @@ export function FullPlayer() {
</div> </div>
<div className="flex items-center gap-8 text-white/90"> <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} /> <IconControl icon={<SkipBack size={22} />} onClick={playPrevious} />
<button className="grid h-16 w-16 place-items-center rounded-full bg-white text-[#121827]" onClick={togglePlayback} type="button"> <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]" />} {isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
</button> </button>
<IconControl icon={<SkipForward size={22} />} onClick={playNext} /> <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>
<div className="flex items-center justify-end gap-6 text-white/90"> <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"> <div className="flex items-center gap-3">
<Volume2 size={22} /> <Volume2 size={22} />
<input <input
@@ -214,14 +247,19 @@ function MetaTag({ children }: { children: React.ReactNode }) {
function IconControl({ function IconControl({
icon, icon,
active = false,
label,
onClick, onClick,
}: { }: {
icon: React.ReactNode icon: React.ReactNode
active?: boolean
label?: string
onClick?: () => void onClick?: () => void
}) { }) {
return ( 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} {icon}
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
</button> </button>
) )
} }

View File

@@ -1,43 +1,60 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
Expand, Expand,
Forward,
Heart,
ListMusic, ListMusic,
Pause, Pause,
Play, Play,
Repeat2, Repeat2,
Rewind, SkipBack,
SkipForward,
Shuffle, Shuffle,
Volume2, Volume2,
} from 'lucide-react' } from 'lucide-react'
import { streamUrl } from '@/lib/api' import { coverArtUrl, scrobbleTrack, streamUrl } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store' import { usePlayerStore } from '@/stores/player-store'
export function PlayerBar() { export function PlayerBar() {
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const lastStartedTrackRef = useRef<string | null>(null)
const lastSubmittedTrackRef = useRef<string | null>(null)
const currentTrack = usePlayerStore((state) => state.currentTrack) const currentTrack = usePlayerStore((state) => state.currentTrack)
const isPlaying = usePlayerStore((state) => state.isPlaying) const isPlaying = usePlayerStore((state) => state.isPlaying)
const volume = usePlayerStore((state) => state.volume) const volume = usePlayerStore((state) => state.volume)
const currentTime = usePlayerStore((state) => state.currentTime) const currentTime = usePlayerStore((state) => state.currentTime)
const duration = usePlayerStore((state) => state.duration) 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 togglePlayback = usePlayerStore((state) => state.togglePlayback)
const playNext = usePlayerStore((state) => state.playNext) const playNext = usePlayerStore((state) => state.playNext)
const playPrevious = usePlayerStore((state) => state.playPrevious) 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 setVolume = usePlayerStore((state) => state.setVolume)
const setCurrentTime = usePlayerStore((state) => state.setCurrentTime) const setCurrentTime = usePlayerStore((state) => state.setCurrentTime)
const setDuration = usePlayerStore((state) => state.setDuration) 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) const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
useEffect(() => { useEffect(() => {
if (!audioRef.current || !currentTrack) { if (!audioRef.current) {
return
}
if (!currentTrack) {
audioRef.current.pause()
audioRef.current.removeAttribute('src')
audioRef.current.load()
return return
} }
audioRef.current.src = streamUrl(currentTrack.id) audioRef.current.src = streamUrl(currentTrack.id)
if (isPlaying) { audioRef.current.currentTime = 0
void audioRef.current.play().catch(() => {}) setCurrentTime(0)
} setDuration(0)
}, [currentTrack, isPlaying]) lastStartedTrackRef.current = null
lastSubmittedTrackRef.current = null
}, [currentTrack, setCurrentTime, setDuration])
useEffect(() => { useEffect(() => {
if (!audioRef.current) { if (!audioRef.current) {
@@ -51,18 +68,79 @@ export function PlayerBar() {
} }
}, [isPlaying, volume]) }, [isPlaying, volume])
useEffect(() => {
if (!audioRef.current || seekRequest == null) {
return
}
audioRef.current.currentTime = seekRequest
clearSeekRequest()
}, [seekRequest, clearSeekRequest])
useEffect(() => {
if (!currentTrack || !isPlaying || lastStartedTrackRef.current === currentTrack.id) {
return
}
lastStartedTrackRef.current = currentTrack.id
void scrobbleTrack({
trackId: currentTrack.id,
submission: false,
time: Date.now(),
clientName: 'temporserv-web',
}).catch(() => {})
}, [currentTrack, isPlaying])
return ( return (
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3"> <footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
<audio <audio
ref={audioRef} ref={audioRef}
onEnded={() => {
if (currentTrack && lastSubmittedTrackRef.current !== currentTrack.id) {
lastSubmittedTrackRef.current = currentTrack.id
void scrobbleTrack({
trackId: currentTrack.id,
submission: true,
time: Date.now(),
clientName: 'temporserv-web',
}).catch(() => {})
}
if (repeatMode === 'one' && audioRef.current) {
audioRef.current.currentTime = 0
setCurrentTime(0)
void audioRef.current.play().catch(() => {})
return
}
handleTrackEnded()
}}
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)} onLoadedMetadata={(event) => setDuration(event.currentTarget.duration || 0)}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)} onTimeUpdate={(event) => {
const nextTime = event.currentTarget.currentTime
const nextDuration = event.currentTarget.duration || 0
setCurrentTime(nextTime)
if (
currentTrack &&
lastSubmittedTrackRef.current !== currentTrack.id &&
nextDuration > 0 &&
(nextTime >= Math.min(nextDuration * 0.5, 240) || nextTime >= nextDuration-1)
) {
lastSubmittedTrackRef.current = currentTrack.id
void scrobbleTrack({
trackId: currentTrack.id,
submission: true,
time: Date.now(),
clientName: 'temporserv-web',
}).catch(() => {})
}
}}
preload="metadata" preload="metadata"
/> />
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<div className="grid h-[68px] w-[68px] place-items-center rounded-[8px] bg-[#1b2638]"> <div className="grid h-[68px] w-[68px] place-items-center overflow-hidden rounded-[8px] bg-[#1b2638]">
<div className="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" /> {currentTrack?.coverArtId ? (
<img alt={currentTrack.title} className="h-full w-full object-cover" src={coverArtUrl(currentTrack.id)} />
) : (
<div className="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" />
)}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="line-clamp-1 text-[1.05rem] font-medium text-white"> <div className="line-clamp-1 text-[1.05rem] font-medium text-white">
@@ -74,8 +152,8 @@ export function PlayerBar() {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex items-center gap-5 text-slate-400"> <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} /> <BarIcon icon={<SkipBack size={18} />} onClick={playPrevious} />
<button <button
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105" className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
onClick={togglePlayback} onClick={togglePlayback}
@@ -83,21 +161,26 @@ export function PlayerBar() {
> >
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />} {isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
</button> </button>
<BarIcon icon={<Forward size={18} />} onClick={playNext} /> <BarIcon icon={<SkipForward 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>
<div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500"> <div className="mt-4 flex w-full max-w-xl items-center gap-3 text-xs text-slate-500">
<span>{formatClock(currentTime)}</span> <span>{formatClock(currentTime)}</span>
<div className="h-1.5 flex-1 rounded-full bg-[#1d2940]"> <input
<div className="h-1.5 rounded-full bg-[#16bf8c]" style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }} /> className="h-1.5 flex-1 accent-[#16bf8c]"
</div> 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> <span>{formatClock(duration)}</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-4 text-slate-400"> <div className="flex items-center justify-end gap-4 text-slate-400">
<BarIcon icon={<Heart size={17} />} />
<BarIcon icon={<ListMusic size={17} />} /> <BarIcon icon={<ListMusic size={17} />} />
<BarIcon icon={<Volume2 size={17} />} /> <BarIcon icon={<Volume2 size={17} />} />
<input <input
@@ -117,14 +200,19 @@ export function PlayerBar() {
function BarIcon({ function BarIcon({
icon, icon,
active = false,
label,
onClick, onClick,
}: { }: {
icon: React.ReactNode icon: React.ReactNode
active?: boolean
label?: string
onClick?: () => void onClick?: () => void
}) { }) {
return ( 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} {icon}
{label ? <span className="absolute -right-2 -top-2 text-[10px] font-semibold text-white">{label}</span> : null}
</button> </button>
) )
} }

View File

@@ -0,0 +1,34 @@
export function LoadingPanel({ title = 'Загрузка...' }: { title?: string }) {
return (
<div className="grid min-h-[320px] place-items-center rounded-[16px] border border-[#24314f] bg-[#121b2e]">
<div className="text-center">
<div className="text-3xl font-semibold tracking-tight text-white">{title}</div>
<div className="mt-3 text-base text-slate-400">Подтягиваю данные и подготавливаю экран.</div>
</div>
</div>
)
}
export function ErrorPanel({
title = 'Не удалось загрузить данные',
description = 'Проверь соединение с сервером и попробуй ещё раз.',
onRetry,
}: {
title?: string
description?: string
onRetry?: () => void
}) {
return (
<div className="grid min-h-[320px] place-items-center rounded-[16px] border border-dashed border-[#31405f] bg-[#121b2e]">
<div className="max-w-xl text-center">
<div className="text-3xl font-semibold tracking-tight text-white">{title}</div>
<div className="mt-3 text-base text-slate-400">{description}</div>
{onRetry ? (
<button className="mt-6 rounded-[10px] bg-[#15c98b] px-6 py-3 text-base font-medium text-[#081225]" onClick={onRetry} type="button">
Повторить
</button>
) : null}
</div>
</div>
)
}

View File

@@ -32,7 +32,11 @@ export type Track = {
albumTitle: string albumTitle: string
trackNumber: number trackNumber: number
durationSeconds: number durationSeconds: number
bitrateKbps?: number
contentType?: string
coverArtId?: string coverArtId?: string
playCount?: number
lastPlayedAt?: string
} }
export type ArtistDetail = Artist & { export type ArtistDetail = Artist & {
@@ -61,9 +65,20 @@ export type ScanStatus = {
export type HomePayload = { export type HomePayload = {
recentAlbums: Album[] recentAlbums: Album[]
recentTracks: Track[]
artists: Artist[] artists: Artist[]
} }
export type FavoritesPayload = {
artists: Artist[]
albums: Album[]
tracks: Track[]
}
export type TrackListPayload = {
items: Track[]
}
export type PlaylistSummary = { export type PlaylistSummary = {
id: string id: string
name: string name: string
@@ -110,6 +125,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() { export async function fetchHome() {
return request<HomePayload>('/api/home') return request<HomePayload>('/api/home')
} }
@@ -131,7 +152,7 @@ export async function fetchAlbum(id: string) {
} }
export async function fetchTracks() { export async function fetchTracks() {
return request<{ items: Track[] }>('/api/tracks') return request<TrackListPayload>('/api/tracks')
} }
export async function fetchTrack(id: string) { export async function fetchTrack(id: string) {
@@ -190,6 +211,48 @@ export async function deletePlaylist(id: string) {
await request<void>(`/api/playlists/${id}`, { method: 'DELETE' }) await request<void>(`/api/playlists/${id}`, { method: 'DELETE' })
} }
export async function fetchFavorites() {
return request<FavoritesPayload>('/api/favorites')
}
export async function fetchRecentlyPlayed() {
return request<TrackListPayload>('/api/recently-played')
}
export async function scrobbleTrack(input: {
trackId: string
submission?: boolean
time?: number
clientName?: string
}) {
return request<{ status: string }>('/api/history/scrobble', {
method: 'POST',
body: JSON.stringify(input),
})
}
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) { export function coverArtUrl(id: string) {
const token = useSessionStore.getState().token const token = useSessionStore.getState().token
return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}` return `${API_BASE}/api/cover-art/${id}${token ? `?token=${encodeURIComponent(token)}` : ''}`

View File

@@ -1,7 +1,9 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react' import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { MoreVertical, Play, Shuffle } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { coverArtUrl, fetchAlbum } from '@/lib/api' import { type Track, coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store' import { usePlayerStore } from '@/stores/player-store'
export function AlbumDetailPage() { export function AlbumDetailPage() {
@@ -12,14 +14,28 @@ export function AlbumDetailPage() {
queryKey: ['album', id], queryKey: ['album', id],
queryFn: () => fetchAlbum(id), queryFn: () => fetchAlbum(id),
}) })
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const album = albumQuery.data const album = albumQuery.data
if (albumQuery.isLoading) {
return <LoadingPanel title="Загружаю альбом" />
}
if (albumQuery.isError) {
return <ErrorPanel onRetry={() => void albumQuery.refetch()} title="Не получилось загрузить альбом" />
}
if (!album) { if (!album) {
return <div className="text-slate-400">Загрузка альбома...</div> return <ErrorPanel title="Альбом не найден" />
} }
const totalDuration = album.tracks.reduce((sum, track) => sum + track.durationSeconds, 0) 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 ( return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]"> <div className="overflow-hidden rounded-[14px] bg-[#121b2e]">
@@ -37,7 +53,7 @@ export function AlbumDetailPage() {
<span></span> <span></span>
<span>{album.trackCount} треков</span> <span>{album.trackCount} треков</span>
<span></span> <span></span>
<span>около {formatLongDuration(totalDuration)}</span> <span>{formatLongDuration(totalDuration)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -50,9 +66,7 @@ export function AlbumDetailPage() {
<button className="text-slate-300 transition hover:text-white" type="button"> <button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} /> <Shuffle size={24} />
</button> </button>
<button className="text-slate-300 transition hover:text-white" type="button"> <FavoriteToggle active={favoriteAlbumIds.has(album.id)} className="text-slate-300 transition hover:text-white" entityId={album.id} entityType="album" size={24} />
<Heart size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button"> <button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} /> <MoreVertical size={24} />
</button> </button>
@@ -87,13 +101,15 @@ export function AlbumDetailPage() {
</div> </div>
</div> </div>
<div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div> <div className="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="text-base text-slate-400">1</div> <div className="text-base text-slate-400">{track.playCount ?? 0}</div>
<div className="text-base text-slate-400">недавно</div> <div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
<div className="text-base text-slate-200">935 kbps</div> <div className="text-base text-slate-200">{formatBitrate(track.bitrateKbps)}</div>
<div> <div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span> <span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
</div>
<div className="grid place-items-center text-slate-500">
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
</div> </div>
<div className="text-slate-500"></div>
</button> </button>
))} ))}
</div> </div>
@@ -103,14 +119,78 @@ export function AlbumDetailPage() {
} }
function formatDuration(value: number) { function formatDuration(value: number) {
if (!value) {
return '—'
}
const minutes = Math.floor(value / 60) const minutes = Math.floor(value / 60)
const seconds = value % 60 const seconds = value % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}` return `${minutes}:${seconds.toString().padStart(2, '0')}`
} }
function formatLongDuration(value: number) { function formatLongDuration(value: number) {
if (!value) {
return 'длительность неизвестна'
}
const minutes = Math.floor(value / 60) const minutes = Math.floor(value / 60)
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const restMinutes = minutes % 60 const restMinutes = minutes % 60
return `${hours ? `${hours} ч ` : ''}${restMinutes} мин` return `около ${hours ? `${hours} ч ` : ''}${restMinutes} мин`
}
function formatLastPlayed(value?: string) {
if (!value) {
return '—'
}
const playedAt = new Date(value)
if (Number.isNaN(playedAt.getTime())) {
return '—'
}
const diffMs = Date.now() - playedAt.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) {
return 'только что'
}
if (diffMinutes < 60) {
return `${diffMinutes} мин назад`
}
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) {
return `${diffHours} ч назад`
}
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) {
return `${diffDays} дн назад`
}
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths} мес назад`
}
function formatQuality(track: Track) {
const contentType = (track.contentType ?? '').toLowerCase()
if (contentType.includes('flac')) {
return 'FLAC'
}
if (contentType.includes('mpeg')) {
return 'MP3'
}
if (contentType.includes('mp4')) {
return 'M4A'
}
if (contentType.includes('ogg')) {
return 'OGG'
}
if (contentType.includes('wav')) {
return 'WAV'
}
if (contentType.includes('aac')) {
return 'AAC'
}
return 'AUDIO'
}
function formatBitrate(value?: number) {
if (!value) {
return '—'
}
return `${value} kbps`
} }

View File

@@ -1,9 +1,11 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import { coverArtUrl, fetchAlbums } from '@/lib/api' import { coverArtUrl, fetchAlbums } from '@/lib/api'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
export function AlbumsPage() { export function AlbumsPage() {
const navigate = useNavigate()
const albumsQuery = useQuery({ const albumsQuery = useQuery({
queryKey: ['albums'], queryKey: ['albums'],
queryFn: fetchAlbums, queryFn: fetchAlbums,
@@ -11,13 +13,21 @@ export function AlbumsPage() {
const albums = albumsQuery.data?.items ?? [] const albums = albumsQuery.data?.items ?? []
if (albumsQuery.isLoading) {
return <LoadingPanel title="Загружаю альбомы" />
}
if (albumsQuery.isError) {
return <ErrorPanel onRetry={() => void albumsQuery.refetch()} title="Не получилось загрузить альбомы" />
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-end gap-3"> <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" type="button"> <button className="rounded-[10px] border border-[#24314f] bg-[#0d1628] px-5 py-3 text-base text-white" type="button">
Недавно добавленные Недавно добавленные
</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} /> <Search size={18} />
</button> </button>
</div> </div>

View File

@@ -1,21 +1,38 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Heart, MoreVertical, Play, Shuffle } from 'lucide-react' import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { coverArtUrl, fetchArtist } from '@/lib/api' import { MoreVertical, Play, Shuffle } from 'lucide-react'
import { useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { coverArtUrl, fetchArtist, fetchFavorites } from '@/lib/api'
export function ArtistDetailPage() { export function ArtistDetailPage() {
const { id = '' } = useParams() const { id = '' } = useParams()
const navigate = useNavigate()
const artistQuery = useQuery({ const artistQuery = useQuery({
queryKey: ['artist', id], queryKey: ['artist', id],
queryFn: () => fetchArtist(id), queryFn: () => fetchArtist(id),
}) })
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const artist = artistQuery.data const artist = artistQuery.data
if (!artist) { if (artistQuery.isLoading) {
return <div className="text-slate-400">Загрузка исполнителя...</div> return <LoadingPanel title="Загружаю исполнителя" />
} }
if (artistQuery.isError) {
return <ErrorPanel onRetry={() => void artistQuery.refetch()} title="Не получилось загрузить исполнителя" />
}
if (!artist) {
return <ErrorPanel title="Исполнитель не найден" />
}
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
return ( return (
<div className="overflow-hidden rounded-[14px] bg-[#121b2e]"> <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"> <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 +58,7 @@ export function ArtistDetailPage() {
<button className="text-slate-300 transition hover:text-white" type="button"> <button className="text-slate-300 transition hover:text-white" type="button">
<Shuffle size={24} /> <Shuffle size={24} />
</button> </button>
<button className="text-slate-300 transition hover:text-white" type="button"> <FavoriteToggle active={favoriteArtistIds.has(artist.id)} className="text-slate-300 transition hover:text-white" entityId={artist.id} entityType="artist" size={24} />
<Heart size={24} />
</button>
<button className="text-slate-300 transition hover:text-white" type="button"> <button className="text-slate-300 transition hover:text-white" type="button">
<MoreVertical size={24} /> <MoreVertical size={24} />
</button> </button>
@@ -58,10 +73,10 @@ export function ArtistDetailPage() {
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{artist.albums.map((album) => ( {artist.albums.map((album) => (
<article key={album.id}> <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} {album.coverArtId ? <img alt={album.title} className="h-full w-full object-cover" src={coverArtUrl(album.id)} /> : null}
</div> </button>
<div className="mt-3 line-clamp-1 text-[1.08rem] font-semibold text-white">{album.title}</div> <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> <div className="text-base text-slate-400">{artist.name}</div>
</article> </article>
))} ))}

View File

@@ -1,9 +1,11 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { SlidersHorizontal } from 'lucide-react' import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { Search, SlidersHorizontal } from 'lucide-react'
import { coverArtUrl, fetchArtists } from '@/lib/api' import { coverArtUrl, fetchArtists } from '@/lib/api'
import { Link } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
export function ArtistsPage() { export function ArtistsPage() {
const navigate = useNavigate()
const artistsQuery = useQuery({ const artistsQuery = useQuery({
queryKey: ['artists'], queryKey: ['artists'],
queryFn: fetchArtists, queryFn: fetchArtists,
@@ -11,6 +13,14 @@ export function ArtistsPage() {
const artists = artistsQuery.data?.items ?? [] const artists = artistsQuery.data?.items ?? []
if (artistsQuery.isLoading) {
return <LoadingPanel title="Загружаю исполнителей" />
}
if (artistsQuery.isError) {
return <ErrorPanel onRetry={() => void artistsQuery.refetch()} title="Не получилось загрузить исполнителей" />
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -20,7 +30,10 @@ export function ArtistsPage() {
</button> </button>
</div> </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="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"> <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,141 @@
import { useQuery } from '@tanstack/react-query'
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
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 (favoritesQuery.isLoading) {
return <LoadingPanel title="Загружаю избранное" />
}
if (favoritesQuery.isError) {
return <ErrorPanel onRetry={() => void favoritesQuery.refetch()} title="Не получилось загрузить избранное" />
}
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

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronLeft, ChevronRight } from 'lucide-react' import { ChevronLeft, ChevronRight } from 'lucide-react'
import { coverArtUrl, fetchHome, fetchTracks } from '@/lib/api' import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { type Track, coverArtUrl, fetchHome, fetchRecentlyPlayed, fetchTracks } from '@/lib/api'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store' import { usePlayerStore } from '@/stores/player-store'
@@ -14,11 +15,34 @@ export function HomePage() {
queryKey: ['tracks'], queryKey: ['tracks'],
queryFn: fetchTracks, queryFn: fetchTracks,
}) })
const recentTracksQuery = useQuery({
queryKey: ['recently-played'],
queryFn: fetchRecentlyPlayed,
})
const heroTrack = tracksQuery.data?.items[0] const allTracks = tracksQuery.data?.items ?? []
const heroTrack = allTracks[0]
const recentAlbums = homeQuery.data?.recentAlbums ?? [] const recentAlbums = homeQuery.data?.recentAlbums ?? []
const recentTracks = recentTracksQuery.data?.items ?? homeQuery.data?.recentTracks ?? []
const popularAlbums = [...recentAlbums].reverse() const popularAlbums = [...recentAlbums].reverse()
if (homeQuery.isLoading || tracksQuery.isLoading) {
return <LoadingPanel title="Загружаю домашнюю страницу" />
}
if (homeQuery.isError || tracksQuery.isError) {
return (
<ErrorPanel
onRetry={() => {
void homeQuery.refetch()
void tracksQuery.refetch()
void recentTracksQuery.refetch()
}}
title="Не получилось собрать главную"
/>
)
}
return ( return (
<div className="space-y-10"> <div className="space-y-10">
<section className="relative overflow-hidden rounded-[14px] bg-[#111b2e]"> <section className="relative overflow-hidden rounded-[14px] bg-[#111b2e]">
@@ -46,12 +70,59 @@ export function HomePage() {
</div> </div>
</section> </section>
<AlbumRow title="Недавно прослушанные" albums={recentAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} /> <TrackRow title="Недавно прослушанные" tracks={recentTracks} onPlayAll={() => setQueue(recentTracks)} />
<AlbumRow title="Наиболее прослушиваемые" albums={popularAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} /> <AlbumRow title="Недавно добавленные" albums={recentAlbums} onPlayAll={() => setQueue(allTracks)} />
<AlbumRow title="Наиболее прослушиваемые" albums={popularAlbums} onPlayAll={() => setQueue(allTracks)} />
</div> </div>
) )
} }
function TrackRow({
title,
tracks,
onPlayAll,
}: {
title: string
tracks: Track[]
onPlayAll: () => void
}) {
const playTrack = usePlayerStore((state) => state.playTrack)
return (
<section>
<div className="mb-5 flex items-center justify-between">
<h3 className="text-[2rem] font-semibold tracking-tight text-white">{title}</h3>
<div className="flex items-center gap-3">
<button className="text-base text-slate-400 transition hover:text-white" onClick={onPlayAll} type="button">
Еще
</button>
<CarouselButton icon={<ChevronLeft size={18} />} />
<CarouselButton icon={<ChevronRight size={18} />} />
</div>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-7">
{tracks.map((track) => (
<button
key={track.id}
className="text-left"
onClick={() => playTrack(track, tracks)}
type="button"
>
<div className="aspect-square overflow-hidden rounded-[8px] bg-[#232d42]">
{track.coverArtId ? (
<img alt={track.title} className="h-full w-full object-cover" src={coverArtUrl(track.id)} />
) : null}
</div>
<div className="mt-3 line-clamp-1 block text-[1.08rem] font-semibold text-white">{track.title}</div>
<div className="line-clamp-1 text-base text-slate-400">{track.artistName}</div>
<div className="line-clamp-1 text-sm text-slate-500">{track.albumTitle}</div>
</button>
))}
</div>
</section>
)
}
function AlbumRow({ function AlbumRow({
title, title,
albums, albums,

View File

@@ -92,7 +92,7 @@ export function LoginPage() {
{mutation.isError ? ( {mutation.isError ? (
<div className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-200"> <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> </div>
) : null} ) : null}
</form> </form>
@@ -111,4 +111,3 @@ function InfoCard({ label, value }: { label: string; value: string }) {
</div> </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,38 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Heart, Search } from 'lucide-react' import { ErrorPanel, LoadingPanel } from '@/components/query-state'
import { coverArtUrl, fetchTracks } from '@/lib/api' import { Search } from 'lucide-react'
import { FavoriteToggle } from '@/components/favorite-toggle'
import { type Track, coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
import { useNavigate } from 'react-router-dom'
import { usePlayerStore } from '@/stores/player-store' import { usePlayerStore } from '@/stores/player-store'
export function TracksPage() { export function TracksPage() {
const navigate = useNavigate()
const setQueue = usePlayerStore((state) => state.setQueue) const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack) const playTrack = usePlayerStore((state) => state.playTrack)
const tracksQuery = useQuery({ const tracksQuery = useQuery({
queryKey: ['tracks'], queryKey: ['tracks'],
queryFn: fetchTracks, queryFn: fetchTracks,
}) })
const favoritesQuery = useQuery({
queryKey: ['favorites'],
queryFn: fetchFavorites,
})
const tracks = tracksQuery.data?.items ?? [] const tracks = tracksQuery.data?.items ?? []
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
if (tracksQuery.isLoading) {
return <LoadingPanel title="Загружаю треки" />
}
if (tracksQuery.isError) {
return <ErrorPanel onRetry={() => void tracksQuery.refetch()} title="Не получилось загрузить треки" />
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<HeaderSearch /> <HeaderSearch onClick={() => navigate('/search')} />
<div className="overflow-hidden rounded-[12px] border border-[#24314f] bg-[#121b2e]"> <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 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> <div>#</div>
@@ -52,13 +69,13 @@ export function TracksPage() {
</div> </div>
<div className="truncate text-base text-slate-300">{track.albumTitle}</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="text-base text-slate-200">{formatDuration(track.durationSeconds)}</div>
<div className="text-base text-slate-400">1</div> <div className="text-base text-slate-400">{track.playCount ?? 0}</div>
<div className="text-base text-slate-400">1 месяц назад</div> <div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
<div> <div>
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span> <span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
</div> </div>
<div className="grid place-items-center text-slate-500"> <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> </div>
</button> </button>
))} ))}
@@ -68,10 +85,10 @@ export function TracksPage() {
) )
} }
function HeaderSearch() { function HeaderSearch({ onClick }: { onClick: () => void }) {
return ( return (
<div className="flex items-center justify-end"> <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} /> <Search size={18} />
</button> </button>
</div> </div>
@@ -79,7 +96,61 @@ function HeaderSearch() {
} }
function formatDuration(durationSeconds: number) { function formatDuration(durationSeconds: number) {
if (!durationSeconds) {
return '—'
}
const minutes = Math.floor(durationSeconds / 60) const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60 const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}` return `${minutes}:${seconds.toString().padStart(2, '0')}`
} }
function formatLastPlayed(value?: string) {
if (!value) {
return '—'
}
const playedAt = new Date(value)
if (Number.isNaN(playedAt.getTime())) {
return '—'
}
const diffMs = Date.now() - playedAt.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) {
return 'только что'
}
if (diffMinutes < 60) {
return `${diffMinutes} мин назад`
}
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) {
return `${diffHours} ч назад`
}
const diffDays = Math.floor(diffHours / 24)
if (diffDays < 30) {
return `${diffDays} дн назад`
}
const diffMonths = Math.floor(diffDays / 30)
return `${diffMonths} мес назад`
}
function formatQuality(track: Track) {
const contentType = (track.contentType ?? '').toLowerCase()
if (contentType.includes('flac')) {
return 'FLAC'
}
if (contentType.includes('mpeg')) {
return 'MP3'
}
if (contentType.includes('mp4')) {
return 'M4A'
}
if (contentType.includes('ogg')) {
return 'OGG'
}
if (contentType.includes('wav')) {
return 'WAV'
}
if (contentType.includes('aac')) {
return 'AAC'
}
return 'AUDIO'
}

View File

@@ -8,15 +8,25 @@ type PlayerState = {
volume: number volume: number
currentTime: number currentTime: number
duration: number duration: number
shuffle: boolean
repeatMode: 'off' | 'all' | 'one'
fullPlayerOpen: boolean fullPlayerOpen: boolean
seekRequest: number | null
setQueue: (tracks: Track[], startIndex?: number) => void setQueue: (tracks: Track[], startIndex?: number) => void
playTrack: (track: Track, queue?: Track[]) => void playTrack: (track: Track, queue?: Track[]) => void
togglePlayback: () => void togglePlayback: () => void
playNext: () => void playNext: () => void
playPrevious: () => void playPrevious: () => void
playAtIndex: (index: number) => void
removeFromQueue: (trackId: string) => void
toggleShuffle: () => void
cycleRepeatMode: () => void
setVolume: (volume: number) => void setVolume: (volume: number) => void
setCurrentTime: (currentTime: number) => void setCurrentTime: (currentTime: number) => void
setDuration: (duration: number) => void setDuration: (duration: number) => void
seekTo: (currentTime: number) => void
clearSeekRequest: () => void
handleTrackEnded: () => void
setFullPlayerOpen: (fullPlayerOpen: boolean) => void setFullPlayerOpen: (fullPlayerOpen: boolean) => void
} }
@@ -27,13 +37,17 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
volume: 0.7, volume: 0.7,
currentTime: 0, currentTime: 0,
duration: 0, duration: 0,
shuffle: false,
repeatMode: 'off',
fullPlayerOpen: false, fullPlayerOpen: false,
seekRequest: null,
setQueue: (queue, startIndex = 0) => setQueue: (queue, startIndex = 0) =>
set({ set({
queue, queue,
currentTrack: queue[startIndex] ?? null, currentTrack: queue[startIndex] ?? null,
isPlaying: queue.length > 0, isPlaying: queue.length > 0,
currentTime: 0, currentTime: 0,
duration: 0,
}), }),
playTrack: (currentTrack, queue) => playTrack: (currentTrack, queue) =>
set((state) => ({ set((state) => ({
@@ -41,6 +55,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
queue: queue ?? state.queue, queue: queue ?? state.queue,
isPlaying: true, isPlaying: true,
currentTime: 0, currentTime: 0,
duration: 0,
})), })),
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })), togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
playNext: () => playNext: () =>
@@ -49,10 +64,19 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return state return state
} }
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id) const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
const nextTrack = state.queue[index + 1] ?? state.queue[0] ?? null const currentIndex = index >= 0 ? index : 0
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[currentIndex + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
}
return { return {
currentTrack: nextTrack, currentTrack: nextTrack,
isPlaying: !!nextTrack, isPlaying: !!nextTrack,
currentTime: 0,
duration: 0,
} }
}), }),
playPrevious: () => playPrevious: () =>
@@ -61,15 +85,46 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
return state return state
} }
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id) const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
const previousTrack = state.queue[index - 1] ?? state.queue[state.queue.length - 1] ?? null const currentIndex = index >= 0 ? index : 0
const previousTrack = state.queue[currentIndex - 1] ?? state.queue[state.queue.length - 1] ?? null
return { return {
currentTrack: previousTrack, currentTrack: previousTrack,
isPlaying: !!previousTrack, isPlaying: !!previousTrack,
currentTime: 0, currentTime: 0,
duration: 0,
} }
}), }),
playAtIndex: (index) =>
set((state) => ({
currentTrack: state.queue[index] ?? state.currentTrack,
isPlaying: !!state.queue[index],
currentTime: 0,
duration: state.queue[index] ? 0 : state.duration,
})),
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 }), setVolume: (volume) => set({ volume }),
setCurrentTime: (currentTime) => set({ currentTime }), setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }), setDuration: (duration) => set({ duration }),
seekTo: (currentTime) => set({ currentTime, seekRequest: currentTime }),
clearSeekRequest: () => set({ seekRequest: null }),
handleTrackEnded: () => {
const state = get()
state.playNext()
},
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }), 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

@@ -1,6 +1,8 @@
FROM golang:1.25-alpine AS backend-build FROM golang:1.25-alpine AS backend-build
WORKDIR /src WORKDIR /src
COPY go.mod ./ COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY cmd ./cmd COPY cmd ./cmd
COPY internal ./internal COPY internal ./internal
RUN go build -o /out/temporserv ./cmd/server RUN go build -o /out/temporserv ./cmd/server

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 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`. - 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. - 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/*`

5
go.mod
View File

@@ -12,7 +12,12 @@ require (
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mewkiz/flac v1.0.13 // indirect
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect

13
go.sum
View File

@@ -8,8 +8,20 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs=
github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU=
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8=
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -22,6 +34,7 @@ golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

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) { 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) user, passwordHash, _, err := s.findUserByUsername(ctx, username)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -107,6 +111,8 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
return User{}, ErrUnauthorized return User{}, ErrUnauthorized
} }
_ = s.cleanupExpiredSessions(ctx)
user, err := s.findUserByToken(ctx, token) user, err := s.findUserByToken(ctx, token)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
@@ -118,6 +124,16 @@ func (s *Service) CurrentUserByToken(ctx context.Context, token string) (User, e
return user, nil 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) { func (s *Service) CurrentUserBySubsonicAuth(ctx context.Context, username, password, token, salt string) (User, error) {
if username == "" { if username == "" {
return User{}, ErrUnauthorized return User{}, ErrUnauthorized
@@ -262,6 +278,11 @@ func (s *Service) storeSubsonicSecret(ctx context.Context, userID, password stri
return err 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) { func EncryptSubsonicSecret(value, key string) (string, error) {
return encryptSecret(value, key) return encryptSecret(value, key)
} }

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS play_history (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
track_id TEXT NOT NULL,
event_type TEXT NOT NULL,
played_at TEXT NOT NULL,
client_name TEXT,
submission INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_id, played_at DESC);
CREATE INDEX IF NOT EXISTS idx_play_history_track_played_at ON play_history(track_id, played_at DESC);

View File

@@ -0,0 +1 @@
ALTER TABLE tracks ADD COLUMN bitrate_kbps INTEGER;

View File

@@ -5,9 +5,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -52,11 +54,13 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
r.Route("/api", func(api chi.Router) { r.Route("/api", func(api chi.Router) {
api.Post("/auth/login", application.login) api.Post("/auth/login", application.login)
api.Post("/auth/logout", application.logout)
api.Group(func(private chi.Router) { api.Group(func(private chi.Router) {
private.Use(application.requireAuth) private.Use(application.requireAuth)
private.Get("/me", application.me) private.Get("/me", application.me)
private.Get("/home", application.home) private.Get("/home", application.home)
private.Get("/recently-played", application.recentlyPlayed)
private.Get("/artists", application.artists) private.Get("/artists", application.artists)
private.Get("/artists/{id}", application.artistByID) private.Get("/artists/{id}", application.artistByID)
private.Get("/albums", application.albums) private.Get("/albums", application.albums)
@@ -64,6 +68,9 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
private.Get("/tracks", application.tracks) private.Get("/tracks", application.tracks)
private.Get("/tracks/{id}", application.trackByID) private.Get("/tracks/{id}", application.trackByID)
private.Get("/search", application.search) 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.Get("/playlists", application.playlistsList)
private.Post("/playlists", application.createPlaylist) private.Post("/playlists", application.createPlaylist)
private.Get("/playlists/{id}", application.playlistByID) private.Get("/playlists/{id}", application.playlistByID)
@@ -71,6 +78,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
private.Delete("/playlists/{id}", application.deletePlaylist) private.Delete("/playlists/{id}", application.deletePlaylist)
private.Get("/admin/scan-status", application.scanStatus) private.Get("/admin/scan-status", application.scanStatus)
private.Post("/admin/scan", application.scanLibrary) private.Post("/admin/scan", application.scanLibrary)
private.Post("/history/scrobble", application.recordPlayEvent)
}) })
api.Get("/cover-art/{id}", application.coverArt) api.Get("/cover-art/{id}", application.coverArt)
@@ -78,31 +86,42 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
}) })
r.Route("/rest", func(rest chi.Router) { r.Route("/rest", func(rest chi.Router) {
rest.Get("/ping.view", func(w http.ResponseWriter, r *http.Request) { restGet(rest, "ping", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse()) writeJSON(w, http.StatusOK, subsonic.PingResponse())
}) })
rest.Get("/getLicense.view", func(w http.ResponseWriter, r *http.Request) { restGet(rest, "getLicense", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PingResponse()) writeJSON(w, http.StatusOK, subsonic.PingResponse())
}) })
restGet(rest, "getOpenSubsonicExtensions", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.OpenSubsonicExtensionsResponse())
})
rest.Group(func(authed chi.Router) { rest.Group(func(authed chi.Router) {
authed.Use(application.requireSubsonicAuth) authed.Use(application.requireSubsonicAuth)
authed.Get("/getArtists.view", application.subsonicArtists) restGet(authed, "getArtists", application.subsonicArtists)
authed.Get("/getArtist.view", application.subsonicArtistByID) restGet(authed, "getArtist", application.subsonicArtistByID)
authed.Get("/getAlbum.view", application.subsonicAlbumByID) restGet(authed, "getAlbum", application.subsonicAlbumByID)
authed.Get("/getSong.view", application.subsonicSongByID) restGet(authed, "getSong", application.subsonicSongByID)
authed.Get("/getRandomSongs.view", application.subsonicRandomSongs) restGet(authed, "getRandomSongs", application.subsonicRandomSongs)
authed.Get("/search3.view", application.subsonicSearch3) restGet(authed, "getAlbumList2", application.subsonicAlbumList2)
authed.Get("/getStarred2.view", application.subsonicStarred2) restGet(authed, "getSongsByGenre", application.subsonicSongsByGenre)
authed.Get("/star.view", application.subsonicStar) restGet(authed, "getMusicFolders", application.subsonicMusicFolders)
authed.Get("/unstar.view", application.subsonicUnstar) restGet(authed, "getGenres", application.subsonicGenres)
authed.Get("/getPlaylists.view", application.subsonicPlaylists) restGet(authed, "getPodcasts", application.subsonicPodcasts)
authed.Get("/getPlaylist.view", application.subsonicPlaylistByID) restGet(authed, "getNewestPodcasts", application.subsonicNewestPodcasts)
authed.Get("/createPlaylist.view", application.subsonicCreatePlaylist) restGet(authed, "getInternetRadioStations", application.subsonicInternetRadioStations)
authed.Get("/updatePlaylist.view", application.subsonicUpdatePlaylist) restGet(authed, "search3", application.subsonicSearch3)
authed.Get("/getScanStatus.view", application.subsonicScanStatus) restGet(authed, "getStarred2", application.subsonicStarred2)
authed.Get("/startScan.view", application.subsonicStartScan) restGet(authed, "star", application.subsonicStar)
authed.Get("/getCoverArt.view", application.subsonicCoverArt) restGet(authed, "unstar", application.subsonicUnstar)
authed.Get("/stream.view", application.subsonicStream) restGet(authed, "getPlaylists", application.subsonicPlaylists)
restGet(authed, "getPlaylist", application.subsonicPlaylistByID)
restGet(authed, "createPlaylist", application.subsonicCreatePlaylist)
restGet(authed, "updatePlaylist", application.subsonicUpdatePlaylist)
restGet(authed, "getScanStatus", application.subsonicScanStatus)
restGet(authed, "startScan", application.subsonicStartScan)
restGet(authed, "getCoverArt", application.subsonicCoverArt)
restGet(authed, "stream", application.subsonicStream)
restGet(authed, "scrobble", application.subsonicScrobble)
}) })
}) })
@@ -139,17 +158,42 @@ func (a app) login(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, session) 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) { func (a app) me(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r) user := currentUserFromContext(r)
writeJSON(w, http.StatusOK, user) writeJSON(w, http.StatusOK, user)
} }
func (a app) home(w http.ResponseWriter, r *http.Request) { func (a app) home(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
home, err := a.library.Home(r.Context()) home, err := a.library.Home(r.Context())
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
return return
} }
home.RecentTracks, err = a.library.PopulateTrackStats(r.Context(), user.ID, home.RecentTracks)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
return
}
writeJSON(w, http.StatusOK, home) writeJSON(w, http.StatusOK, home)
} }
@@ -162,6 +206,21 @@ func (a app) artists(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
} }
func (a app) recentlyPlayed(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
items, err := a.library.RecentTracks(r.Context(), user.ID, 24)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load recent tracks"})
return
}
items, err = a.library.PopulateTrackStats(r.Context(), user.ID, items)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load recent tracks"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a app) artistByID(w http.ResponseWriter, r *http.Request) { func (a app) artistByID(w http.ResponseWriter, r *http.Request) {
item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id")) item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id"))
if err != nil { if err != nil {
@@ -185,6 +244,7 @@ func (a app) albums(w http.ResponseWriter, r *http.Request) {
} }
func (a app) albumByID(w http.ResponseWriter, r *http.Request) { func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id")) item, err := a.library.AlbumByID(r.Context(), chi.URLParam(r, "id"))
if err != nil { if err != nil {
if errors.Is(err, library.ErrNotFound) { if errors.Is(err, library.ErrNotFound) {
@@ -194,19 +254,31 @@ func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
return return
} }
item.Tracks, err = a.library.PopulateTrackStats(r.Context(), user.ID, item.Tracks)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
return
}
writeJSON(w, http.StatusOK, item) writeJSON(w, http.StatusOK, item)
} }
func (a app) tracks(w http.ResponseWriter, r *http.Request) { func (a app) tracks(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
items, err := a.library.Tracks(r.Context(), 200) items, err := a.library.Tracks(r.Context(), 200)
if err != nil { if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
return return
} }
items, err = a.library.PopulateTrackStats(r.Context(), user.ID, items)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load tracks"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items}) writeJSON(w, http.StatusOK, map[string]any{"items": items})
} }
func (a app) trackByID(w http.ResponseWriter, r *http.Request) { func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id")) item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
if err != nil { if err != nil {
if errors.Is(err, library.ErrNotFound) { if errors.Is(err, library.ErrNotFound) {
@@ -216,6 +288,14 @@ func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"}) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
return return
} }
enriched, err := a.library.PopulateTrackStats(r.Context(), user.ID, []library.Track{item})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
return
}
if len(enriched) > 0 {
item = enriched[0]
}
writeJSON(w, http.StatusOK, item) writeJSON(w, http.StatusOK, item)
} }
@@ -234,6 +314,62 @@ func (a app) search(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, results) 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) { func (a app) playlistsList(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r) user := currentUserFromContext(r)
items, err := a.playlists.List(r.Context(), user.ID) items, err := a.playlists.List(r.Context(), user.ID)
@@ -332,6 +468,99 @@ func (a app) subsonicRandomSongs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks)) writeJSON(w, http.StatusOK, subsonic.RandomSongsResponse(tracks))
} }
func (a app) subsonicAlbumList2(w http.ResponseWriter, r *http.Request) {
size := 60
if raw := strings.TrimSpace(r.URL.Query().Get("size")); raw != "" {
if parsed := parsePositiveInt(raw); parsed > 0 {
size = parsed
}
}
offset := parsePositiveInt(r.URL.Query().Get("offset"))
albums, err := a.library.Albums(r.Context(), 5000)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load albums"))
return
}
if genre := strings.TrimSpace(r.URL.Query().Get("genre")); genre != "" {
filtered := make([]library.Album, 0, len(albums))
for _, album := range albums {
if strings.EqualFold(strings.TrimSpace(album.Genre), genre) {
filtered = append(filtered, album)
}
}
albums = filtered
}
typeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type")))
switch typeName {
case "alphabeticalbyname":
sort.SliceStable(albums, func(i, j int) bool {
return strings.ToLower(albums[i].Title) < strings.ToLower(albums[j].Title)
})
case "random":
rand.Shuffle(len(albums), func(i, j int) {
albums[i], albums[j] = albums[j], albums[i]
})
default:
sort.SliceStable(albums, func(i, j int) bool {
return albums[i].Year > albums[j].Year
})
}
if offset > len(albums) {
albums = []library.Album{}
} else if offset > 0 {
albums = albums[offset:]
}
if size < len(albums) {
albums = albums[:size]
}
writeJSON(w, http.StatusOK, subsonic.AlbumList2Response(albums))
}
func (a app) subsonicSongsByGenre(w http.ResponseWriter, r *http.Request) {
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
if genre == "" {
writeJSON(w, http.StatusBadRequest, subsonic.ErrorResponse(10, "missing genre"))
return
}
count := parsePositiveInt(r.URL.Query().Get("count"))
offset := parsePositiveInt(r.URL.Query().Get("offset"))
tracks, err := a.library.SongsByGenre(r.Context(), genre, count, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load songs by genre"))
return
}
writeJSON(w, http.StatusOK, subsonic.SongsByGenreResponse(tracks))
}
func (a app) subsonicMusicFolders(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.MusicFoldersResponse())
}
func (a app) subsonicGenres(w http.ResponseWriter, r *http.Request) {
genres, err := a.library.Genres(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to load genres"))
return
}
writeJSON(w, http.StatusOK, subsonic.GenresResponse(genres))
}
func (a app) subsonicPodcasts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.PodcastsResponse())
}
func (a app) subsonicNewestPodcasts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.NewestPodcastsResponse())
}
func (a app) subsonicInternetRadioStations(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.InternetRadioStationsResponse())
}
func (a app) subsonicOpenSubsonicExtensions(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, subsonic.OpenSubsonicExtensionsResponse())
}
func (a app) subsonicSearch3(w http.ResponseWriter, r *http.Request) { func (a app) subsonicSearch3(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("query")) query := strings.TrimSpace(r.URL.Query().Get("query"))
if query == "" { if query == "" {
@@ -485,6 +714,40 @@ func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
func (a app) recordPlayEvent(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
var payload struct {
TrackID string `json:"trackId"`
Submission bool `json:"submission"`
Time int64 `json:"time"`
ClientName string `json:"clientName"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if strings.TrimSpace(payload.TrackID) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "trackId is required"})
return
}
playedAt := time.Now().UTC()
if payload.Time > 0 {
playedAt = time.UnixMilli(payload.Time).UTC()
}
eventType := "play"
if payload.Submission {
eventType = "scrobble"
}
if err := a.library.RecordPlayEvent(r.Context(), user.ID, payload.TrackID, eventType, payload.ClientName, playedAt, payload.Submission); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to record play event"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}
func (a app) scanStatus(w http.ResponseWriter, r *http.Request) { func (a app) scanStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, a.scanner.Status()) writeJSON(w, http.StatusOK, a.scanner.Status())
} }
@@ -534,6 +797,40 @@ func (a app) subsonicStream(w http.ResponseWriter, r *http.Request) {
a.serveTrackByID(w, r, r.URL.Query().Get("id")) a.serveTrackByID(w, r, r.URL.Query().Get("id"))
} }
func (a app) subsonicScrobble(w http.ResponseWriter, r *http.Request) {
user := currentUserFromContext(r)
trackIDs := readMultiValue(r, "id")
if len(trackIDs) == 0 {
writeJSON(w, http.StatusBadRequest, subsonic.ErrorResponse(10, "missing track id"))
return
}
submission := false
if value := strings.TrimSpace(r.URL.Query().Get("submission")); value != "" {
submission = value == "true" || value == "1"
}
timestamp := time.Now().UTC()
if raw := strings.TrimSpace(r.URL.Query().Get("time")); raw != "" {
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 {
// Subsonic sends seconds since epoch.
timestamp = time.Unix(parsed, 0).UTC()
}
}
for _, trackID := range trackIDs {
eventType := "play"
if submission {
eventType = "scrobble"
}
if err := a.library.RecordPlayEvent(r.Context(), user.ID, trackID, eventType, "subsonic", timestamp, submission); err != nil {
writeJSON(w, http.StatusInternalServerError, subsonic.ErrorResponse(0, "failed to record scrobble"))
return
}
}
writeJSON(w, http.StatusOK, subsonic.PingResponse())
}
func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) { func (a app) serveCoverArtByID(w http.ResponseWriter, r *http.Request, id string) {
path, err := a.library.CoverArtPathByEntityID(r.Context(), id) path, err := a.library.CoverArtPathByEntityID(r.Context(), id)
if err != nil { if err != nil {
@@ -692,3 +989,8 @@ func detectFrontendRoot() string {
return "" return ""
} }
func restGet(router chi.Router, endpoint string, handler http.HandlerFunc) {
router.Get("/"+endpoint, handler)
router.Get("/"+endpoint+".view", handler)
}

View File

@@ -5,6 +5,8 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"time"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
@@ -23,6 +25,7 @@ type Album struct {
Title string `json:"title"` Title string `json:"title"`
Year int `json:"year"` Year int `json:"year"`
TrackCount int `json:"trackCount"` TrackCount int `json:"trackCount"`
Genre string `json:"genre"`
CoverArtID string `json:"coverArtId"` CoverArtID string `json:"coverArtId"`
} }
@@ -33,15 +36,20 @@ type Track struct {
Title string `json:"title"` Title string `json:"title"`
ArtistName string `json:"artistName"` ArtistName string `json:"artistName"`
AlbumTitle string `json:"albumTitle"` AlbumTitle string `json:"albumTitle"`
Genre string `json:"genre"`
TrackNumber int `json:"trackNumber"` TrackNumber int `json:"trackNumber"`
DurationSecs int `json:"durationSeconds"` DurationSecs int `json:"durationSeconds"`
BitrateKbps int `json:"bitrateKbps"`
FilePath string `json:"filePath"` FilePath string `json:"filePath"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
CoverArtID string `json:"coverArtId"` CoverArtID string `json:"coverArtId"`
PlayCount int `json:"playCount"`
LastPlayedAt string `json:"lastPlayedAt"`
} }
type HomePayload struct { type HomePayload struct {
RecentAlbums []Album `json:"recentAlbums"` RecentAlbums []Album `json:"recentAlbums"`
RecentTracks []Track `json:"recentTracks"`
Artists []Artist `json:"artists"` Artists []Artist `json:"artists"`
} }
@@ -67,6 +75,12 @@ type StarredResults struct {
Tracks []Track `json:"tracks"` Tracks []Track `json:"tracks"`
} }
type GenreSummary struct {
Value string `json:"value"`
AlbumCount int `json:"albumCount"`
SongCount int `json:"songCount"`
}
type Service struct { type Service struct {
db *sql.DB db *sql.DB
} }
@@ -86,8 +100,14 @@ func (s *Service) Home(ctx context.Context) (HomePayload, error) {
return HomePayload{}, err return HomePayload{}, err
} }
recentTracks, err := s.RecentTracks(ctx, "", 8)
if err != nil {
return HomePayload{}, err
}
return HomePayload{ return HomePayload{
RecentAlbums: albums, RecentAlbums: albums,
RecentTracks: recentTracks,
Artists: artists, Artists: artists,
}, nil }, nil
} }
@@ -153,6 +173,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count
, COALESCE(al.genre, '')
, COALESCE(al.cover_art_id, '') , COALESCE(al.cover_art_id, '')
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
@@ -170,7 +191,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
var albums []Album var albums []Album
for rows.Next() { for rows.Next() {
var album Album var album Album
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
return nil, fmt.Errorf("scan album: %w", err) return nil, fmt.Errorf("scan album: %w", err)
} }
albums = append(albums, album) albums = append(albums, album)
@@ -182,7 +203,7 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) { func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '') `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.genre, ''), COALESCE(al.cover_art_id, '')
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.id LEFT JOIN tracks t ON t.album_id = al.id
@@ -199,7 +220,7 @@ func (s *Service) Albums(ctx context.Context, limit int) ([]Album, error) {
var albums []Album var albums []Album
for rows.Next() { for rows.Next() {
var album Album var album Album
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
return nil, fmt.Errorf("scan all albums: %w", err) return nil, fmt.Errorf("scan all albums: %w", err)
} }
albums = append(albums, album) albums = append(albums, album)
@@ -213,7 +234,7 @@ func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error)
err := s.db.QueryRowContext( err := s.db.QueryRowContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0),
COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '') COUNT(t.id) AS track_count, COALESCE(al.genre, ''), COALESCE(al.cover_art_id, '')
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.id LEFT JOIN tracks t ON t.album_id = al.id
@@ -227,6 +248,7 @@ func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error)
&album.Title, &album.Title,
&album.Year, &album.Year,
&album.TrackCount, &album.TrackCount,
&album.Genre,
&album.CoverArtID, &album.CoverArtID,
) )
if err != nil { if err != nil {
@@ -248,8 +270,8 @@ func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error)
func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) { func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
@@ -272,8 +294,10 @@ func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
&track.Title, &track.Title,
&track.ArtistName, &track.ArtistName,
&track.AlbumTitle, &track.AlbumTitle,
&track.Genre,
&track.TrackNumber, &track.TrackNumber,
&track.DurationSecs, &track.DurationSecs,
&track.BitrateKbps,
&track.FilePath, &track.FilePath,
&track.ContentType, &track.ContentType,
&track.CoverArtID, &track.CoverArtID,
@@ -291,8 +315,8 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
err := s.db.QueryRowContext( err := s.db.QueryRowContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
@@ -305,8 +329,10 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
&track.Title, &track.Title,
&track.ArtistName, &track.ArtistName,
&track.AlbumTitle, &track.AlbumTitle,
&track.Genre,
&track.TrackNumber, &track.TrackNumber,
&track.DurationSecs, &track.DurationSecs,
&track.BitrateKbps,
&track.FilePath, &track.FilePath,
&track.ContentType, &track.ContentType,
&track.CoverArtID, &track.CoverArtID,
@@ -366,6 +392,62 @@ func (s *Service) Starred(ctx context.Context, userID string) (StarredResults, e
}, nil }, nil
} }
func (s *Service) Genres(ctx context.Context) ([]GenreSummary, error) {
rows, err := s.db.QueryContext(
ctx,
`SELECT al.genre, COUNT(DISTINCT al.id) AS album_count, COUNT(t.id) AS song_count
FROM albums al
LEFT JOIN tracks t ON t.album_id = al.id
WHERE TRIM(COALESCE(al.genre, '')) <> ''
GROUP BY al.genre
ORDER BY song_count DESC, album_count DESC, al.genre ASC`,
)
if err != nil {
return nil, fmt.Errorf("query genres: %w", err)
}
defer rows.Close()
var genres []GenreSummary
for rows.Next() {
var genre GenreSummary
if err := rows.Scan(&genre.Value, &genre.AlbumCount, &genre.SongCount); err != nil {
return nil, fmt.Errorf("scan genre: %w", err)
}
genres = append(genres, genre)
}
return genres, rows.Err()
}
func (s *Service) SongsByGenre(ctx context.Context, genre string, count, offset int) ([]Track, error) {
if count <= 0 {
count = 50
}
if offset < 0 {
offset = 0
}
rows, err := s.db.QueryContext(
ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE LOWER(TRIM(COALESCE(al.genre, ''))) = LOWER(TRIM(?))
ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC
LIMIT ? OFFSET ?`,
genre,
count,
offset,
)
if err != nil {
return nil, fmt.Errorf("query songs by genre: %w", err)
}
defer rows.Close()
return scanTracks(rows)
}
func (s *Service) Star(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string) error { func (s *Service) Star(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string) error {
return s.updateFavorites(ctx, userID, trackIDs, albumIDs, artistIDs, true) return s.updateFavorites(ctx, userID, trackIDs, albumIDs, artistIDs, true)
} }
@@ -406,10 +488,140 @@ func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string
return "", fmt.Errorf("query track cover art: %w", err) return "", fmt.Errorf("query track cover art: %w", err)
} }
func (s *Service) RecentTracks(ctx context.Context, userID string, limit int) ([]Track, error) {
if limit <= 0 {
limit = 10
}
if userID != "" {
rows, err := s.db.QueryContext(
ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
JOIN (
SELECT track_id, MAX(played_at) AS last_played_at
FROM play_history
WHERE user_id = ?
GROUP BY track_id
) history ON history.track_id = t.id
ORDER BY history.last_played_at DESC
LIMIT ?`,
userID,
limit,
)
if err != nil {
return nil, fmt.Errorf("query recent tracks by user: %w", err)
}
defer rows.Close()
return scanTracks(rows)
}
rows, err := s.db.QueryContext(
ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t
JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id
ORDER BY al.updated_at DESC, t.updated_at DESC, t.track_number ASC
LIMIT ?`,
limit,
)
if err != nil {
return nil, fmt.Errorf("query fallback recent tracks: %w", err)
}
defer rows.Close()
return scanTracks(rows)
}
func (s *Service) RecordPlayEvent(ctx context.Context, userID, trackID, eventType, clientName string, playedAt time.Time, submission bool) error {
if strings.TrimSpace(userID) == "" || strings.TrimSpace(trackID) == "" {
return nil
}
if eventType == "" {
eventType = "play"
}
if playedAt.IsZero() {
playedAt = time.Now().UTC()
}
_, err := s.db.ExecContext(
ctx,
`INSERT INTO play_history (id, user_id, track_id, event_type, played_at, client_name, submission)
VALUES (lower(hex(randomblob(16))), ?, ?, ?, ?, ?, ?)`,
userID,
trackID,
eventType,
playedAt.UTC().Format(time.RFC3339),
strings.TrimSpace(clientName),
boolToInt(submission),
)
if err != nil {
return fmt.Errorf("insert play history event: %w", err)
}
return nil
}
func (s *Service) PopulateTrackStats(ctx context.Context, userID string, tracks []Track) ([]Track, error) {
if userID == "" || len(tracks) == 0 {
return tracks, nil
}
placeholders := make([]string, 0, len(tracks))
args := make([]any, 0, len(tracks)+1)
args = append(args, userID)
for _, track := range tracks {
placeholders = append(placeholders, "?")
args = append(args, track.ID)
}
query := fmt.Sprintf(
`SELECT track_id, COUNT(*) AS play_count, COALESCE(MAX(played_at), '')
FROM play_history
WHERE user_id = ? AND track_id IN (%s)
GROUP BY track_id`,
strings.Join(placeholders, ","),
)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query track stats: %w", err)
}
defer rows.Close()
type stats struct {
playCount int
lastPlayedAt string
}
byTrackID := map[string]stats{}
for rows.Next() {
var trackID string
var item stats
if err := rows.Scan(&trackID, &item.playCount, &item.lastPlayedAt); err != nil {
return nil, fmt.Errorf("scan track stats: %w", err)
}
byTrackID[trackID] = item
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate track stats: %w", err)
}
for index := range tracks {
if item, ok := byTrackID[tracks[index].ID]; ok {
tracks[index].PlayCount = item.playCount
tracks[index].LastPlayedAt = item.lastPlayedAt
}
}
return tracks, nil
}
func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) { func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '') `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.genre, ''), COALESCE(al.cover_art_id, '')
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.id LEFT JOIN tracks t ON t.album_id = al.id
@@ -426,7 +638,7 @@ func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Albu
var albums []Album var albums []Album
for rows.Next() { for rows.Next() {
var album Album var album Album
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
return nil, fmt.Errorf("scan album by artist: %w", err) return nil, fmt.Errorf("scan album by artist: %w", err)
} }
albums = append(albums, album) albums = append(albums, album)
@@ -438,8 +650,8 @@ func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Albu
func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track, error) { func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
@@ -452,28 +664,7 @@ func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track,
} }
defer rows.Close() defer rows.Close()
var tracks []Track return scanTracks(rows)
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.DurationSecs,
&track.FilePath,
&track.ContentType,
&track.CoverArtID,
); err != nil {
return nil, fmt.Errorf("scan tracks by album: %w", err)
}
tracks = append(tracks, track)
}
return tracks, rows.Err()
} }
func (s *Service) searchArtists(ctx context.Context, pattern string, limit int) ([]Artist, error) { func (s *Service) searchArtists(ctx context.Context, pattern string, limit int) ([]Artist, error) {
@@ -508,7 +699,7 @@ func (s *Service) searchArtists(ctx context.Context, pattern string, limit int)
func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) ([]Album, error) { func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) ([]Album, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.cover_art_id, '') `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.genre, ''), COALESCE(al.cover_art_id, '')
FROM albums al FROM albums al
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.id LEFT JOIN tracks t ON t.album_id = al.id
@@ -528,7 +719,7 @@ func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) (
var albums []Album var albums []Album
for rows.Next() { for rows.Next() {
var album Album var album Album
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
return nil, fmt.Errorf("scan searched album: %w", err) return nil, fmt.Errorf("scan searched album: %w", err)
} }
albums = append(albums, album) albums = append(albums, album)
@@ -539,8 +730,8 @@ func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) (
func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) ([]Track, error) { func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) ([]Track, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM tracks t FROM tracks t
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
@@ -557,27 +748,7 @@ func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) (
} }
defer rows.Close() defer rows.Close()
var tracks []Track return scanTracks(rows)
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.DurationSecs,
&track.FilePath,
&track.ContentType,
&track.CoverArtID,
); err != nil {
return nil, fmt.Errorf("scan searched track: %w", err)
}
tracks = append(tracks, track)
}
return tracks, rows.Err()
} }
func (s *Service) updateFavorites(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string, star bool) error { func (s *Service) updateFavorites(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string, star bool) error {
@@ -650,7 +821,7 @@ func (s *Service) starredArtists(ctx context.Context, userID string) ([]Artist,
func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, error) { func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.cover_art_id, '') `SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.genre, ''), COALESCE(al.cover_art_id, '')
FROM favorites f FROM favorites f
JOIN albums al ON al.id = f.entity_id JOIN albums al ON al.id = f.entity_id
JOIN artists a ON a.id = al.artist_id JOIN artists a ON a.id = al.artist_id
@@ -668,7 +839,7 @@ func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, er
var albums []Album var albums []Album
for rows.Next() { for rows.Next() {
var album Album var album Album
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil { if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.Genre, &album.CoverArtID); err != nil {
return nil, fmt.Errorf("scan starred album: %w", err) return nil, fmt.Errorf("scan starred album: %w", err)
} }
albums = append(albums, album) albums = append(albums, album)
@@ -679,8 +850,8 @@ func (s *Service) starredAlbums(ctx context.Context, userID string) ([]Album, er
func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, error) { func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, error) {
rows, err := s.db.QueryContext( rows, err := s.db.QueryContext(
ctx, ctx,
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0), `SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(al.genre, ''), COALESCE(t.track_number, 0),
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '') COALESCE(t.duration_seconds, 0), COALESCE(t.bitrate_kbps, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
FROM favorites f FROM favorites f
JOIN tracks t ON t.id = f.entity_id JOIN tracks t ON t.id = f.entity_id
JOIN artists a ON a.id = t.artist_id JOIN artists a ON a.id = t.artist_id
@@ -694,6 +865,10 @@ func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, er
} }
defer rows.Close() defer rows.Close()
return scanTracks(rows)
}
func scanTracks(rows *sql.Rows) ([]Track, error) {
var tracks []Track var tracks []Track
for rows.Next() { for rows.Next() {
var track Track var track Track
@@ -704,15 +879,24 @@ func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, er
&track.Title, &track.Title,
&track.ArtistName, &track.ArtistName,
&track.AlbumTitle, &track.AlbumTitle,
&track.Genre,
&track.TrackNumber, &track.TrackNumber,
&track.DurationSecs, &track.DurationSecs,
&track.BitrateKbps,
&track.FilePath, &track.FilePath,
&track.ContentType, &track.ContentType,
&track.CoverArtID, &track.CoverArtID,
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan starred track: %w", err) return nil, fmt.Errorf("scan track row: %w", err)
} }
tracks = append(tracks, track) tracks = append(tracks, track)
} }
return tracks, rows.Err() return tracks, rows.Err()
} }
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}

View File

@@ -5,8 +5,10 @@ import (
"crypto/sha1" "crypto/sha1"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/binary"
"fmt" "fmt"
"io/fs" "io/fs"
"math"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
@@ -15,6 +17,8 @@ import (
"time" "time"
"github.com/dhowden/tag" "github.com/dhowden/tag"
"github.com/hajimehoshi/go-mp3"
"github.com/mewkiz/flac"
) )
var supportedExtensions = []string{ var supportedExtensions = []string{
@@ -75,6 +79,7 @@ type scannedTrack struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
DurationSecs int DurationSecs int
BitrateKbps int
FilePath string FilePath string
ContentType string ContentType string
} }
@@ -230,7 +235,7 @@ func (s *Service) runScan(ctx context.Context) (Result, error) {
for _, track := range tracks { for _, track := range tracks {
if _, err := tx.ExecContext( if _, err := tx.ExecContext(
ctx, ctx,
`INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, `INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, bitrate_kbps, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
track.ID, track.ID,
track.AlbumID, track.AlbumID,
track.ArtistID, track.ArtistID,
@@ -238,6 +243,7 @@ func (s *Service) runScan(ctx context.Context) (Result, error) {
track.TrackNumber, track.TrackNumber,
track.DiscNumber, track.DiscNumber,
track.DurationSecs, track.DurationSecs,
track.BitrateKbps,
track.FilePath, track.FilePath,
track.ContentType, track.ContentType,
now, now,
@@ -336,6 +342,14 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
coverArt = findCoverArt(filepath.Dir(path)) coverArt = findCoverArt(filepath.Dir(path))
} }
fileInfo, statErr := file.Stat()
fileSize := int64(0)
if statErr == nil {
fileSize = fileInfo.Size()
}
contentType := detectContentType(path)
durationSecs, bitrateKbps := scanAudioProperties(path, contentType, fileSize)
return scannedItem{ return scannedItem{
artist: scannedArtist{ artist: scannedArtist{
ID: artistID, ID: artistID,
@@ -356,9 +370,10 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
Title: title, Title: title,
TrackNumber: trackNumber, TrackNumber: trackNumber,
DiscNumber: discNumber, DiscNumber: discNumber,
DurationSecs: 0, DurationSecs: durationSecs,
BitrateKbps: bitrateKbps,
FilePath: path, FilePath: path,
ContentType: detectContentType(path), ContentType: contentType,
}, },
}, nil }, nil
} }
@@ -462,6 +477,120 @@ func coverExtension(mimeType string) string {
} }
} }
func scanAudioProperties(path, contentType string, fileSize int64) (int, int) {
durationSecs := extractDurationSeconds(path, contentType)
bitrateKbps := calculateBitrateKbps(fileSize, durationSecs)
return durationSecs, bitrateKbps
}
func extractDurationSeconds(path, contentType string) int {
switch strings.ToLower(filepath.Ext(path)) {
case ".flac":
return extractFLACDurationSeconds(path)
case ".mp3":
return extractMP3DurationSeconds(path)
case ".wav":
return extractWAVDurationSeconds(path)
default:
_ = contentType
return 0
}
}
func extractFLACDurationSeconds(path string) int {
stream, err := flac.ParseFile(path)
if err != nil || stream == nil || stream.Info == nil || stream.Info.SampleRate == 0 || stream.Info.NSamples == 0 {
return 0
}
return int(math.Round(float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)))
}
func extractMP3DurationSeconds(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
decoder, err := mp3.NewDecoder(file)
if err != nil {
return 0
}
if decoder.SampleRate() == 0 || decoder.Length() <= 0 {
return 0
}
seconds := float64(decoder.Length()) / 4 / float64(decoder.SampleRate())
return int(math.Round(seconds))
}
func extractWAVDurationSeconds(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
header := make([]byte, 12)
if _, err := file.Read(header); err != nil {
return 0
}
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
return 0
}
var byteRate uint32
var dataSize uint32
for {
chunkHeader := make([]byte, 8)
if _, err := file.Read(chunkHeader); err != nil {
break
}
chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8])
chunkID := string(chunkHeader[0:4])
switch chunkID {
case "fmt ":
if chunkSize < 16 {
return 0
}
chunkData := make([]byte, chunkSize)
if _, err := file.Read(chunkData); err != nil {
return 0
}
byteRate = binary.LittleEndian.Uint32(chunkData[8:12])
case "data":
dataSize = chunkSize
if _, err := file.Seek(int64(chunkSize), 1); err != nil {
return 0
}
default:
if _, err := file.Seek(int64(chunkSize), 1); err != nil {
return 0
}
}
if chunkSize%2 == 1 {
if _, err := file.Seek(1, 1); err != nil {
return 0
}
}
if byteRate > 0 && dataSize > 0 {
return int(math.Round(float64(dataSize) / float64(byteRate)))
}
}
return 0
}
func calculateBitrateKbps(fileSize int64, durationSecs int) int {
if fileSize <= 0 || durationSecs <= 0 {
return 0
}
return int(math.Round(float64(fileSize*8) / float64(durationSecs) / 1000))
}
func (s *Service) tryMarkStarted() bool { func (s *Service) tryMarkStarted() bool {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()

View File

@@ -1,7 +1,10 @@
package subsonic package subsonic
import ( import (
"sort"
"strings"
"time" "time"
"unicode"
"github.com/benya/temporserv/internal/library" "github.com/benya/temporserv/internal/library"
"github.com/benya/temporserv/internal/playlist" "github.com/benya/temporserv/internal/playlist"
@@ -18,15 +21,23 @@ type Response struct {
Type string `json:"type"` Type string `json:"type"`
Server string `json:"serverVersion"` Server string `json:"serverVersion"`
OpenAPI bool `json:"openSubsonic"` OpenAPI bool `json:"openSubsonic"`
Artists []ArtistRef `json:"artists,omitempty"` Artists *Artists `json:"artists,omitempty"`
Artist *ArtistFull `json:"artist,omitempty"` Artist *ArtistFull `json:"artist,omitempty"`
Album *AlbumFull `json:"album,omitempty"` Album *AlbumFull `json:"album,omitempty"`
AlbumList2 *AlbumList2 `json:"albumList2,omitempty"`
SongsByGenre *SongsByGenre `json:"songsByGenre,omitempty"`
Song *SongFull `json:"song,omitempty"` Song *SongFull `json:"song,omitempty"`
RandomSong []SongRef `json:"randomSongs,omitempty"` RandomSong []SongRef `json:"randomSongs,omitempty"`
SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"` SearchResult3 *SearchResult3 `json:"searchResult3,omitempty"`
Starred2 *Starred2 `json:"starred2,omitempty"` Starred2 *Starred2 `json:"starred2,omitempty"`
Playlists *Playlists `json:"playlists,omitempty"` Playlists *Playlists `json:"playlists,omitempty"`
Playlist *Playlist `json:"playlist,omitempty"` Playlist *Playlist `json:"playlist,omitempty"`
MusicFolders *MusicFolders `json:"musicFolders,omitempty"`
Genres *Genres `json:"genres,omitempty"`
Podcasts *Podcasts `json:"podcasts,omitempty"`
NewestPods *NewestPods `json:"newestPodcasts,omitempty"`
RadioStations *RadioStations `json:"internetRadioStations,omitempty"`
Extensions []Extension `json:"openSubsonicExtensions,omitempty"`
ScanStatus *ScanStatus `json:"scanStatus,omitempty"` ScanStatus *ScanStatus `json:"scanStatus,omitempty"`
Error *ErrorRef `json:"error,omitempty"` Error *ErrorRef `json:"error,omitempty"`
} }
@@ -36,6 +47,15 @@ type ArtistRef struct {
Name string `json:"name"` Name string `json:"name"`
} }
type Artists struct {
Index []ArtistIndex `json:"index,omitempty"`
}
type ArtistIndex struct {
Name string `json:"name"`
Artist []ArtistRef `json:"artist,omitempty"`
}
type SongRef struct { type SongRef struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -56,10 +76,12 @@ type ArtistFull struct {
type AlbumRef struct { type AlbumRef struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name,omitempty"`
Title string `json:"title"`
Artist string `json:"artist"` Artist string `json:"artist"`
ArtistID string `json:"artistId"` ArtistID string `json:"artistId"`
Year int `json:"year,omitempty"` Year int `json:"year,omitempty"`
Genre string `json:"genre,omitempty"`
CoverArt string `json:"coverArt,omitempty"` CoverArt string `json:"coverArt,omitempty"`
} }
@@ -73,6 +95,14 @@ type AlbumFull struct {
Song []SongRef `json:"song,omitempty"` Song []SongRef `json:"song,omitempty"`
} }
type AlbumList2 struct {
Album []AlbumRef `json:"album,omitempty"`
}
type SongsByGenre struct {
Song []SongRef `json:"song,omitempty"`
}
type SongFull struct { type SongFull struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
@@ -134,6 +164,42 @@ type PlaylistSummary struct {
Changed string `json:"changed,omitempty"` Changed string `json:"changed,omitempty"`
} }
type MusicFolders struct {
MusicFolder []MusicFolder `json:"musicFolder,omitempty"`
}
type MusicFolder struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Genres struct {
Genre []Genre `json:"genre,omitempty"`
}
type Genre struct {
Value string `json:"value"`
AlbumCount int `json:"albumCount,omitempty"`
SongCount int `json:"songCount,omitempty"`
}
type Podcasts struct {
Channel []any `json:"channel,omitempty"`
}
type NewestPods struct {
Episode []any `json:"episode,omitempty"`
}
type RadioStations struct {
InternetRadioStation []any `json:"internetRadioStation,omitempty"`
}
type Extension struct {
Name string `json:"name"`
Versions []int `json:"versions"`
}
type ErrorRef struct { type ErrorRef struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
@@ -153,12 +219,34 @@ func PingResponse() Envelope {
func ArtistsResponse(artists []library.Artist) Envelope { func ArtistsResponse(artists []library.Artist) Envelope {
response := PingResponse() response := PingResponse()
groups := map[string][]ArtistRef{}
for _, artist := range artists { for _, artist := range artists {
response.SubsonicResponse.Artists = append(response.SubsonicResponse.Artists, ArtistRef{ initial := "#"
name := []rune(strings.TrimSpace(artist.Name))
if len(name) > 0 {
first := unicode.ToUpper(name[0])
if unicode.IsLetter(first) {
initial = string(first)
}
}
groups[initial] = append(groups[initial], ArtistRef{
ID: artist.ID, ID: artist.ID,
Name: artist.Name, Name: artist.Name,
}) })
} }
keys := make([]string, 0, len(groups))
for key := range groups {
keys = append(keys, key)
}
sort.Strings(keys)
payload := &Artists{}
for _, key := range keys {
payload.Index = append(payload.Index, ArtistIndex{
Name: key,
Artist: groups[key],
})
}
response.SubsonicResponse.Artists = payload
return response return response
} }
@@ -172,7 +260,7 @@ func RandomSongsResponse(tracks []library.Track) Envelope {
Artist: track.ArtistName, Artist: track.ArtistName,
AlbumID: track.AlbumID, AlbumID: track.AlbumID,
ArtistID: track.ArtistID, ArtistID: track.ArtistID,
CoverArt: track.CoverArtID, CoverArt: track.AlbumID,
}) })
} }
return response return response
@@ -190,10 +278,12 @@ func ArtistResponse(artist library.ArtistDetail) Envelope {
item.Albums = append(item.Albums, AlbumRef{ item.Albums = append(item.Albums, AlbumRef{
ID: album.ID, ID: album.ID,
Name: album.Title, Name: album.Title,
Title: album.Title,
Artist: album.ArtistName, Artist: album.ArtistName,
ArtistID: album.ArtistID, ArtistID: album.ArtistID,
Year: album.Year, Year: album.Year,
CoverArt: album.CoverArtID, Genre: album.Genre,
CoverArt: album.ID,
}) })
} }
response.SubsonicResponse.Artist = item response.SubsonicResponse.Artist = item
@@ -213,10 +303,12 @@ func Search3Response(results library.SearchResults) Envelope {
payload.Album = append(payload.Album, AlbumRef{ payload.Album = append(payload.Album, AlbumRef{
ID: album.ID, ID: album.ID,
Name: album.Title, Name: album.Title,
Title: album.Title,
Artist: album.ArtistName, Artist: album.ArtistName,
ArtistID: album.ArtistID, ArtistID: album.ArtistID,
Year: album.Year, Year: album.Year,
CoverArt: album.CoverArtID, Genre: album.Genre,
CoverArt: album.ID,
}) })
} }
for _, track := range results.Tracks { for _, track := range results.Tracks {
@@ -227,7 +319,7 @@ func Search3Response(results library.SearchResults) Envelope {
Artist: track.ArtistName, Artist: track.ArtistName,
AlbumID: track.AlbumID, AlbumID: track.AlbumID,
ArtistID: track.ArtistID, ArtistID: track.ArtistID,
CoverArt: track.CoverArtID, CoverArt: track.AlbumID,
}) })
} }
response.SubsonicResponse.SearchResult3 = payload response.SubsonicResponse.SearchResult3 = payload
@@ -247,10 +339,12 @@ func Starred2Response(results library.StarredResults) Envelope {
payload.Album = append(payload.Album, AlbumRef{ payload.Album = append(payload.Album, AlbumRef{
ID: album.ID, ID: album.ID,
Name: album.Title, Name: album.Title,
Title: album.Title,
Artist: album.ArtistName, Artist: album.ArtistName,
ArtistID: album.ArtistID, ArtistID: album.ArtistID,
Year: album.Year, Year: album.Year,
CoverArt: album.CoverArtID, Genre: album.Genre,
CoverArt: album.ID,
}) })
} }
for _, track := range results.Tracks { for _, track := range results.Tracks {
@@ -261,7 +355,7 @@ func Starred2Response(results library.StarredResults) Envelope {
Artist: track.ArtistName, Artist: track.ArtistName,
AlbumID: track.AlbumID, AlbumID: track.AlbumID,
ArtistID: track.ArtistID, ArtistID: track.ArtistID,
CoverArt: track.CoverArtID, CoverArt: track.AlbumID,
}) })
} }
response.SubsonicResponse.Starred2 = payload response.SubsonicResponse.Starred2 = payload
@@ -308,7 +402,7 @@ func PlaylistResponse(owner string, detail playlist.Detail) Envelope {
Artist: track.ArtistName, Artist: track.ArtistName,
AlbumID: track.AlbumID, AlbumID: track.AlbumID,
ArtistID: track.ArtistID, ArtistID: track.ArtistID,
CoverArt: track.CoverArtID, CoverArt: track.AlbumID,
}) })
} }
response.SubsonicResponse.Playlist = item response.SubsonicResponse.Playlist = item
@@ -323,20 +417,60 @@ func AlbumResponse(album library.AlbumDetail) Envelope {
Artist: album.ArtistName, Artist: album.ArtistName,
ArtistID: album.ArtistID, ArtistID: album.ArtistID,
Year: album.Year, Year: album.Year,
CoverArt: album.CoverArtID, CoverArt: album.ID,
} }
for _, track := range album.Tracks { for _, track := range album.Tracks {
item.Song = append(item.Song, SongRef{ item.Song = append(item.Song, SongRef{
ID: track.ID, ID: track.ID,
Title: track.Title, Title: track.Title,
Album: track.AlbumTitle, Album: track.AlbumTitle,
Artist: track.ArtistName, Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.AlbumID,
}) })
} }
response.SubsonicResponse.Album = item response.SubsonicResponse.Album = item
return response return response
} }
func AlbumList2Response(albums []library.Album) Envelope {
response := PingResponse()
payload := &AlbumList2{}
for _, album := range albums {
payload.Album = append(payload.Album, AlbumRef{
ID: album.ID,
Name: album.Title,
Title: album.Title,
Artist: album.ArtistName,
ArtistID: album.ArtistID,
Year: album.Year,
Genre: album.Genre,
CoverArt: album.ID,
})
}
response.SubsonicResponse.AlbumList2 = payload
return response
}
func SongsByGenreResponse(tracks []library.Track) Envelope {
response := PingResponse()
payload := &SongsByGenre{}
for _, track := range tracks {
payload.Song = append(payload.Song, SongRef{
ID: track.ID,
Title: track.Title,
Album: track.AlbumTitle,
Artist: track.ArtistName,
AlbumID: track.AlbumID,
ArtistID: track.ArtistID,
CoverArt: track.AlbumID,
})
}
response.SubsonicResponse.SongsByGenre = payload
return response
}
func SongResponse(track library.Track) Envelope { func SongResponse(track library.Track) Envelope {
response := PingResponse() response := PingResponse()
response.SubsonicResponse.Song = &SongFull{ response.SubsonicResponse.Song = &SongFull{
@@ -348,7 +482,7 @@ func SongResponse(track library.Track) Envelope {
ArtistID: track.ArtistID, ArtistID: track.ArtistID,
Track: track.TrackNumber, Track: track.TrackNumber,
Duration: track.DurationSecs, Duration: track.DurationSecs,
CoverArt: track.CoverArtID, CoverArt: track.AlbumID,
} }
return response return response
} }
@@ -366,6 +500,55 @@ func ScanStatusResponse(status scanner.Status) Envelope {
return response return response
} }
func MusicFoldersResponse() Envelope {
response := PingResponse()
response.SubsonicResponse.MusicFolders = &MusicFolders{
MusicFolder: []MusicFolder{{ID: "1", Name: "Music"}},
}
return response
}
func GenresResponse(genres []library.GenreSummary) Envelope {
response := PingResponse()
payload := &Genres{}
for _, genre := range genres {
payload.Genre = append(payload.Genre, Genre{
Value: genre.Value,
AlbumCount: genre.AlbumCount,
SongCount: genre.SongCount,
})
}
response.SubsonicResponse.Genres = payload
return response
}
func PodcastsResponse() Envelope {
response := PingResponse()
response.SubsonicResponse.Podcasts = &Podcasts{}
return response
}
func NewestPodcastsResponse() Envelope {
response := PingResponse()
response.SubsonicResponse.NewestPods = &NewestPods{}
return response
}
func InternetRadioStationsResponse() Envelope {
response := PingResponse()
response.SubsonicResponse.RadioStations = &RadioStations{}
return response
}
func OpenSubsonicExtensionsResponse() Envelope {
response := PingResponse()
response.SubsonicResponse.Extensions = []Extension{
{Name: "formPost", Versions: []int{1}},
{Name: "apiKeyAuthentication", Versions: []int{1}},
}
return response
}
func ErrorResponse(code int, message string) Envelope { func ErrorResponse(code int, message string) Envelope {
response := PingResponse() response := PingResponse()
response.SubsonicResponse.Status = "failed" response.SubsonicResponse.Status = "failed"