fix: polish player controls and remove fake track stats
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { coverArtUrl, fetchFavorites } from '@/lib/api'
|
||||
@@ -188,7 +188,7 @@ export function FullPlayer() {
|
||||
|
||||
<div className="flex items-center gap-8 text-white/90">
|
||||
<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">
|
||||
{isPlaying ? <Pause size={28} /> : <Play size={28} className="translate-x-[2px]" />}
|
||||
</button>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
Expand,
|
||||
Forward,
|
||||
ListMusic,
|
||||
Pause,
|
||||
Play,
|
||||
Repeat2,
|
||||
Rewind,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Shuffle,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { scrobbleTrack, streamUrl } from '@/lib/api'
|
||||
import { coverArtUrl, scrobbleTrack, streamUrl } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function PlayerBar() {
|
||||
@@ -39,15 +39,22 @@ export function PlayerBar() {
|
||||
const setFullPlayerOpen = usePlayerStore((state) => state.setFullPlayerOpen)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current || !currentTrack) {
|
||||
if (!audioRef.current) {
|
||||
return
|
||||
}
|
||||
if (!currentTrack) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.removeAttribute('src')
|
||||
audioRef.current.load()
|
||||
return
|
||||
}
|
||||
audioRef.current.src = streamUrl(currentTrack.id)
|
||||
audioRef.current.currentTime = 0
|
||||
setCurrentTime(0)
|
||||
setDuration(0)
|
||||
lastStartedTrackRef.current = null
|
||||
lastSubmittedTrackRef.current = null
|
||||
if (isPlaying) {
|
||||
void audioRef.current.play().catch(() => {})
|
||||
}
|
||||
}, [currentTrack, isPlaying])
|
||||
}, [currentTrack, setCurrentTime, setDuration])
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
@@ -96,6 +103,12 @@ export function PlayerBar() {
|
||||
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)}
|
||||
@@ -122,8 +135,12 @@ export function PlayerBar() {
|
||||
/>
|
||||
|
||||
<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="h-7 w-7 rounded-full border-l-2 border-r-2 border-[#f1f5fb]" />
|
||||
<div className="grid h-[68px] w-[68px] place-items-center overflow-hidden rounded-[8px] bg-[#1b2638]">
|
||||
{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 className="min-w-0">
|
||||
<div className="line-clamp-1 text-[1.05rem] font-medium text-white">
|
||||
@@ -136,7 +153,7 @@ export function PlayerBar() {
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-5 text-slate-400">
|
||||
<BarIcon active={shuffle} icon={<Shuffle size={18} />} onClick={toggleShuffle} />
|
||||
<BarIcon icon={<Rewind size={18} />} onClick={playPrevious} />
|
||||
<BarIcon icon={<SkipBack size={18} />} onClick={playPrevious} />
|
||||
<button
|
||||
className="grid h-11 w-11 place-items-center rounded-full bg-[#16bf8c] text-[#081225] transition hover:brightness-105"
|
||||
onClick={togglePlayback}
|
||||
@@ -144,7 +161,7 @@ export function PlayerBar() {
|
||||
>
|
||||
{isPlaying ? <Pause size={18} /> : <Play size={18} className="translate-x-[1px]" />}
|
||||
</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} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ export type Track = {
|
||||
albumTitle: string
|
||||
trackNumber: number
|
||||
durationSeconds: number
|
||||
contentType?: string
|
||||
coverArtId?: string
|
||||
playCount?: number
|
||||
lastPlayedAt?: string
|
||||
}
|
||||
|
||||
export type ArtistDetail = Artist & {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||
import { MoreVertical, Play, Shuffle } from 'lucide-react'
|
||||
import { FavoriteToggle } from '@/components/favorite-toggle'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
|
||||
import { type Track, coverArtUrl, fetchAlbum, fetchFavorites } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function AlbumDetailPage() {
|
||||
@@ -53,7 +53,7 @@ export function AlbumDetailPage() {
|
||||
<span>•</span>
|
||||
<span>{album.trackCount} треков</span>
|
||||
<span>•</span>
|
||||
<span>около {formatLongDuration(totalDuration)}</span>
|
||||
<span>{formatLongDuration(totalDuration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,11 +101,11 @@ export function AlbumDetailPage() {
|
||||
</div>
|
||||
</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">недавно</div>
|
||||
<div className="text-base text-slate-200">935 kbps</div>
|
||||
<div className="text-base text-slate-400">{track.playCount ?? 0}</div>
|
||||
<div className="text-base text-slate-400">{formatLastPlayed(track.lastPlayedAt)}</div>
|
||||
<div className="text-base text-slate-200">—</div>
|
||||
<div>
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
|
||||
</div>
|
||||
<div className="grid place-items-center text-slate-500">
|
||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||
@@ -119,14 +119,71 @@ export function AlbumDetailPage() {
|
||||
}
|
||||
|
||||
function formatDuration(value: number) {
|
||||
if (!value) {
|
||||
return '—'
|
||||
}
|
||||
const minutes = Math.floor(value / 60)
|
||||
const seconds = value % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatLongDuration(value: number) {
|
||||
if (!value) {
|
||||
return 'длительность неизвестна'
|
||||
}
|
||||
const minutes = Math.floor(value / 60)
|
||||
const hours = Math.floor(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'
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { ErrorPanel, LoadingPanel } from '@/components/query-state'
|
||||
import { Search } from 'lucide-react'
|
||||
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 { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
@@ -69,10 +69,10 @@ export function TracksPage() {
|
||||
</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-400">1</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">{formatLastPlayed(track.lastPlayedAt)}</div>
|
||||
<div>
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">FLAC</span>
|
||||
<span className="rounded-full bg-[#38455d] px-3 py-1 text-sm font-semibold text-white">{formatQuality(track)}</span>
|
||||
</div>
|
||||
<div className="grid place-items-center text-slate-500">
|
||||
<FavoriteToggle active={favoriteTrackIds.has(track.id)} entityId={track.id} entityType="track" size={16} />
|
||||
@@ -96,7 +96,61 @@ function HeaderSearch({ onClick }: { onClick: () => void }) {
|
||||
}
|
||||
|
||||
function formatDuration(durationSeconds: number) {
|
||||
if (!durationSeconds) {
|
||||
return '—'
|
||||
}
|
||||
const minutes = Math.floor(durationSeconds / 60)
|
||||
const seconds = durationSeconds % 60
|
||||
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,
|
||||
isPlaying: queue.length > 0,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
}),
|
||||
playTrack: (currentTrack, queue) =>
|
||||
set((state) => ({
|
||||
@@ -54,6 +55,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
queue: queue ?? state.queue,
|
||||
isPlaying: true,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
})),
|
||||
togglePlayback: () => set((state) => ({ isPlaying: !state.isPlaying })),
|
||||
playNext: () =>
|
||||
@@ -61,26 +63,20 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
if (!state.currentTrack || state.queue.length === 0) {
|
||||
return state
|
||||
}
|
||||
if (state.repeatMode === 'one') {
|
||||
return {
|
||||
currentTrack: state.currentTrack,
|
||||
isPlaying: true,
|
||||
currentTime: 0,
|
||||
seekRequest: 0,
|
||||
}
|
||||
}
|
||||
const index = state.queue.findIndex((track) => track.id === state.currentTrack?.id)
|
||||
const currentIndex = index >= 0 ? index : 0
|
||||
let nextTrack: Track | null = null
|
||||
if (state.shuffle && state.queue.length > 1) {
|
||||
const candidates = state.queue.filter((track) => track.id !== state.currentTrack?.id)
|
||||
nextTrack = candidates[Math.floor(Math.random() * candidates.length)] ?? state.queue[0] ?? null
|
||||
} else {
|
||||
nextTrack = state.queue[index + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
|
||||
nextTrack = state.queue[currentIndex + 1] ?? (state.repeatMode === 'all' ? state.queue[0] ?? null : null)
|
||||
}
|
||||
return {
|
||||
currentTrack: nextTrack,
|
||||
isPlaying: !!nextTrack,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
}
|
||||
}),
|
||||
playPrevious: () =>
|
||||
@@ -89,11 +85,13 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
return state
|
||||
}
|
||||
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 {
|
||||
currentTrack: previousTrack,
|
||||
isPlaying: !!previousTrack,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
}
|
||||
}),
|
||||
playAtIndex: (index) =>
|
||||
@@ -101,6 +99,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
currentTrack: state.queue[index] ?? state.currentTrack,
|
||||
isPlaying: !!state.queue[index],
|
||||
currentTime: 0,
|
||||
duration: state.queue[index] ? 0 : state.duration,
|
||||
})),
|
||||
removeFromQueue: (trackId) =>
|
||||
set((state) => {
|
||||
@@ -125,10 +124,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
clearSeekRequest: () => set({ seekRequest: null }),
|
||||
handleTrackEnded: () => {
|
||||
const state = get()
|
||||
if (state.repeatMode === 'one') {
|
||||
set({ currentTime: 0, seekRequest: 0, isPlaying: true })
|
||||
return
|
||||
}
|
||||
state.playNext()
|
||||
},
|
||||
setFullPlayerOpen: (fullPlayerOpen) => set({ fullPlayerOpen }),
|
||||
|
||||
@@ -171,11 +171,17 @@ func (a app) me(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())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load home"})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -195,6 +201,11 @@ func (a app) recentlyPlayed(w http.ResponseWriter, r *http.Request) {
|
||||
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})
|
||||
}
|
||||
|
||||
@@ -221,6 +232,7 @@ func (a app) albums(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"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
@@ -230,19 +242,31 @@ func (a app) albumByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load album"})
|
||||
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)
|
||||
}
|
||||
|
||||
func (a app) tracks(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
items, err := a.library.Tracks(r.Context(), 200)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load 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 tracks"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUserFromContext(r)
|
||||
item, err := a.library.TrackByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
if errors.Is(err, library.ErrNotFound) {
|
||||
@@ -252,6 +276,14 @@ func (a app) trackByID(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load track"})
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ type Track struct {
|
||||
FilePath string `json:"filePath"`
|
||||
ContentType string `json:"contentType"`
|
||||
CoverArtID string `json:"coverArtId"`
|
||||
PlayCount int `json:"playCount"`
|
||||
LastPlayedAt string `json:"lastPlayedAt"`
|
||||
}
|
||||
|
||||
type HomePayload struct {
|
||||
@@ -491,6 +493,60 @@ func (s *Service) RecordPlayEvent(ctx context.Context, userID, trackID, eventTyp
|
||||
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) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
|
||||
Reference in New Issue
Block a user