Compare commits
12 Commits
2bbf52a41b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8f584dcc9 | |||
| 0b10dfe055 | |||
| a054192e45 | |||
| ad9543bf7a | |||
| 480bdc2476 | |||
| 3c284bc414 | |||
| 2956a302e0 | |||
| 252075ee1c | |||
| 2774b93830 | |||
| db6e2818c1 | |||
| d7e21956db | |||
| 4d44632fbf |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ 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({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: logout,
|
mutationFn: logout,
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
@@ -52,6 +53,17 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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) {
|
||||||
if ((event.ctrlKey || event.metaKey) && event.key === '/') {
|
if ((event.ctrlKey || event.metaKey) && event.key === '/') {
|
||||||
@@ -80,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">
|
||||||
@@ -119,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 (
|
||||||
@@ -143,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>
|
||||||
|
|
||||||
@@ -191,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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { ChevronDown, ListMusic, Pause, Play, Repeat2, Rewind, Shuffle, SkipForward, Trash2, 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 { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||||
@@ -188,7 +188,7 @@ export function FullPlayer() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-8 text-white/90">
|
<div className="flex items-center gap-8 text-white/90">
|
||||||
<IconControl active={shuffle} icon={<Shuffle size={22} />} onClick={toggleShuffle} />
|
<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>
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Expand,
|
Expand,
|
||||||
Forward,
|
|
||||||
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)
|
||||||
@@ -37,14 +39,22 @@ export function PlayerBar() {
|
|||||||
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) {
|
||||||
@@ -66,19 +76,71 @@ export function PlayerBar() {
|
|||||||
clearSeekRequest()
|
clearSeekRequest()
|
||||||
}, [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={handleTrackEnded}
|
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">
|
||||||
@@ -91,7 +153,7 @@ 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 active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
|
<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}
|
||||||
@@ -99,7 +161,7 @@ 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 active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
<BarIcon active={repeatMode !== 'off'} icon={<Repeat2 size={18} />} label={repeatMode === 'one' ? '1' : undefined} onClick={cycleRepeatMode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
34
apps/web/src/components/query-state.tsx
Normal file
34
apps/web/src/components/query-state.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,6 +65,7 @@ export type ScanStatus = {
|
|||||||
|
|
||||||
export type HomePayload = {
|
export type HomePayload = {
|
||||||
recentAlbums: Album[]
|
recentAlbums: Album[]
|
||||||
|
recentTracks: Track[]
|
||||||
artists: Artist[]
|
artists: Artist[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,10 @@ export type FavoritesPayload = {
|
|||||||
tracks: Track[]
|
tracks: Track[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TrackListPayload = {
|
||||||
|
items: Track[]
|
||||||
|
}
|
||||||
|
|
||||||
export type PlaylistSummary = {
|
export type PlaylistSummary = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -143,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) {
|
||||||
@@ -206,6 +215,22 @@ export async function fetchFavorites() {
|
|||||||
return request<FavoritesPayload>('/api/favorites')
|
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: {
|
export async function starFavorites(input: {
|
||||||
trackIds?: string[]
|
trackIds?: string[]
|
||||||
albumIds?: string[]
|
albumIds?: string[]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||||
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { coverArtUrl, fetchAlbum, fetchFavorites } 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() {
|
||||||
@@ -20,8 +21,16 @@ export function AlbumDetailPage() {
|
|||||||
|
|
||||||
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)
|
||||||
@@ -44,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>
|
||||||
@@ -92,11 +101,11 @@ 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>
|
||||||
<div className="grid place-items-center text-slate-500">
|
<div className="grid place-items-center text-slate-500">
|
||||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||||
@@ -110,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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
@@ -12,6 +13,14 @@ 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">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||||
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
@@ -18,8 +19,16 @@ export function ArtistDetailPage() {
|
|||||||
|
|
||||||
const artist = artistQuery.data
|
const artist = artistQuery.data
|
||||||
|
|
||||||
|
if (artistQuery.isLoading) {
|
||||||
|
return <LoadingPanel title="Загружаю исполнителя" />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (artistQuery.isError) {
|
||||||
|
return <ErrorPanel onRetry={() => void artistQuery.refetch()} title="Не получилось загрузить исполнителя" />
|
||||||
|
}
|
||||||
|
|
||||||
if (!artist) {
|
if (!artist) {
|
||||||
return <div className="text-slate-400">Загрузка исполнителя...</div>
|
return <ErrorPanel title="Исполнитель не найден" />
|
||||||
}
|
}
|
||||||
|
|
||||||
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
|
const favoriteArtistIds = new Set((favoritesQuery.data?.artists ?? []).map((item) => item.id))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||||
import { Search, SlidersHorizontal } from 'lucide-react'
|
import { Search, SlidersHorizontal } from 'lucide-react'
|
||||||
import { coverArtUrl, fetchArtists } from '@/lib/api'
|
import { coverArtUrl, fetchArtists } from '@/lib/api'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
@@ -12,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">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||||
@@ -17,6 +18,14 @@ export function FavoritesPage() {
|
|||||||
const albums = favorites?.albums ?? []
|
const albums = favorites?.albums ?? []
|
||||||
const artists = favorites?.artists ?? []
|
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) {
|
if (tracks.length === 0 && albums.length === 0 && artists.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="grid min-h-[520px] place-items-center rounded-[16px] border border-dashed border-[#24314f] bg-[#121b2e]">
|
<div className="grid min-h-[520px] place-items-center rounded-[16px] border border-dashed border-[#24314f] bg-[#121b2e]">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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 { FavoriteToggle } from '@/components/favorite-toggle'
|
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||||
import { coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
|
import { type Track, coverArtUrl, fetchFavorites, fetchTracks } from '@/lib/api'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { usePlayerStore } from '@/stores/player-store'
|
import { usePlayerStore } from '@/stores/player-store'
|
||||||
|
|
||||||
@@ -21,6 +22,14 @@ export function TracksPage() {
|
|||||||
const tracks = tracksQuery.data?.items ?? []
|
const tracks = tracksQuery.data?.items ?? []
|
||||||
const favoriteTrackIds = new Set((favoritesQuery.data?.tracks ?? []).map((track) => track.id))
|
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 onClick={() => navigate('/search')} />
|
<HeaderSearch onClick={() => navigate('/search')} />
|
||||||
@@ -60,10 +69,10 @@ 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">
|
||||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||||
@@ -87,7 +96,61 @@ function HeaderSearch({ onClick }: { onClick: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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'
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
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) => ({
|
||||||
@@ -54,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: () =>
|
||||||
@@ -61,26 +63,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
if (!state.currentTrack || state.queue.length === 0) {
|
if (!state.currentTrack || state.queue.length === 0) {
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
if (state.repeatMode === 'one') {
|
|
||||||
return {
|
|
||||||
currentTrack: state.currentTrack,
|
|
||||||
isPlaying: true,
|
|
||||||
currentTime: 0,
|
|
||||||
seekRequest: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||||
|
const currentIndex = index >= 0 ? index : 0
|
||||||
let nextTrack: Track | null = null
|
let nextTrack: Track | null = null
|
||||||
if (state.shuffle && state.queue.length > 1) {
|
if (state.shuffle && state.queue.length > 1) {
|
||||||
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
||||||
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
||||||
} else {
|
} else {
|
||||||
nextTrack = state.queue[index + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
|
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,
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
playPrevious: () =>
|
playPrevious: () =>
|
||||||
@@ -89,11 +85,13 @@ 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) =>
|
playAtIndex: (index) =>
|
||||||
@@ -101,6 +99,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
currentTrack: state.queue[index] ?? state.currentTrack,
|
currentTrack: state.queue[index] ?? state.currentTrack,
|
||||||
isPlaying: !!state.queue[index],
|
isPlaying: !!state.queue[index],
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
duration: state.queue[index] ? 0 : state.duration,
|
||||||
})),
|
})),
|
||||||
removeFromQueue: (trackId) =>
|
removeFromQueue: (trackId) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
@@ -125,10 +124,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|||||||
clearSeekRequest: () => set({ seekRequest: null }),
|
clearSeekRequest: () => set({ seekRequest: null }),
|
||||||
handleTrackEnded: () => {
|
handleTrackEnded: () => {
|
||||||
const state = get()
|
const state = get()
|
||||||
if (state.repeatMode === 'one') {
|
|
||||||
set({ currentTime: 0, seekRequest: 0, isPlaying: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
state.playNext()
|
state.playNext()
|
||||||
},
|
},
|
||||||
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -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
13
go.sum
@@ -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=
|
||||||
|
|||||||
12
internal/db/migrations/0003_play_history.sql
Normal file
12
internal/db/migrations/0003_play_history.sql
Normal 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);
|
||||||
1
internal/db/migrations/0004_track_bitrate.sql
Normal file
1
internal/db/migrations/0004_track_bitrate.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE tracks ADD COLUMN bitrate_kbps INTEGER;
|
||||||
@@ -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"
|
||||||
@@ -58,6 +60,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
|||||||
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)
|
||||||
@@ -75,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)
|
||||||
@@ -82,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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -168,11 +183,17 @@ func (a app) me(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,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 {
|
||||||
@@ -208,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) {
|
||||||
@@ -217,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) {
|
||||||
@@ -239,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,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 == "" {
|
||||||
@@ -564,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())
|
||||||
}
|
}
|
||||||
@@ -613,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 {
|
||||||
@@ -771,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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user