feat: add recently played and scrobble flow
This commit is contained in:
@@ -574,9 +574,9 @@ Responsibilities:
|
||||
- [x] Add delete playlist endpoint
|
||||
- [ ] Add reorder tracks endpoint
|
||||
- [x] Add add/remove track endpoints
|
||||
- [ ] Add listening history table
|
||||
- [ ] Record play/scrobble events
|
||||
- [ ] Add recently played endpoint
|
||||
- [x] Add listening history table
|
||||
- [x] Record play/scrobble events
|
||||
- [x] Add recently played endpoint
|
||||
|
||||
## Favorites
|
||||
|
||||
@@ -608,7 +608,7 @@ Responsibilities:
|
||||
- [x] Implement `star`
|
||||
- [x] Implement `unstar`
|
||||
- [x] Implement playlist endpoints
|
||||
- [ ] Implement `scrobble`
|
||||
- [x] Implement `scrobble`
|
||||
- [ ] Test against at least one existing Subsonic client
|
||||
|
||||
## Frontend Bootstrap
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
Shuffle,
|
||||
Volume2,
|
||||
} from 'lucide-react'
|
||||
import { streamUrl } from '@/lib/api'
|
||||
import { scrobbleTrack, streamUrl } from '@/lib/api'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
export function PlayerBar() {
|
||||
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 isPlaying = usePlayerStore((state) => state.isPlaying)
|
||||
const volume = usePlayerStore((state) => state.volume)
|
||||
@@ -41,6 +43,7 @@ export function PlayerBar() {
|
||||
return
|
||||
}
|
||||
audioRef.current.src = streamUrl(currentTrack.id)
|
||||
lastSubmittedTrackRef.current = null
|
||||
if (isPlaying) {
|
||||
void audioRef.current.play().catch(() => {})
|
||||
}
|
||||
@@ -66,13 +69,55 @@ export function PlayerBar() {
|
||||
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 (
|
||||
<footer className="grid grid-cols-[260px_minmax(0,1fr)_280px] items-center border-t border-[#24314f] bg-[#091228] px-4 py-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
onEnded={handleTrackEnded}
|
||||
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(() => {})
|
||||
}
|
||||
handleTrackEnded()
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ export type ScanStatus = {
|
||||
|
||||
export type HomePayload = {
|
||||
recentAlbums: Album[]
|
||||
recentTracks: Track[]
|
||||
artists: Artist[]
|
||||
}
|
||||
|
||||
@@ -70,6 +71,10 @@ export type FavoritesPayload = {
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export type TrackListPayload = {
|
||||
items: Track[]
|
||||
}
|
||||
|
||||
export type PlaylistSummary = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -143,7 +148,7 @@ export async function fetchAlbum(id: string) {
|
||||
}
|
||||
|
||||
export async function fetchTracks() {
|
||||
return request<{ items: Track[] }>('/api/tracks')
|
||||
return request<TrackListPayload>('/api/tracks')
|
||||
}
|
||||
|
||||
export async function fetchTrack(id: string) {
|
||||
@@ -206,6 +211,22 @@ export async function fetchFavorites() {
|
||||
return request<FavoritesPayload>('/api/favorites')
|
||||
}
|
||||
|
||||
export async function fetchRecentlyPlayed() {
|
||||
return request<TrackListPayload>('/api/recently-played')
|
||||
}
|
||||
|
||||
export async function scrobbleTrack(input: {
|
||||
trackId: string
|
||||
submission?: boolean
|
||||
time?: number
|
||||
clientName?: string
|
||||
}) {
|
||||
return request<{ status: string }>('/api/history/scrobble', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
}
|
||||
|
||||
export async function starFavorites(input: {
|
||||
trackIds?: string[]
|
||||
albumIds?: string[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { coverArtUrl, fetchHome, fetchTracks } from '@/lib/api'
|
||||
import { type Track, coverArtUrl, fetchHome, fetchRecentlyPlayed, fetchTracks } from '@/lib/api'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePlayerStore } from '@/stores/player-store'
|
||||
|
||||
@@ -14,9 +14,14 @@ export function HomePage() {
|
||||
queryKey: ['tracks'],
|
||||
queryFn: fetchTracks,
|
||||
})
|
||||
const recentTracksQuery = useQuery({
|
||||
queryKey: ['recently-played'],
|
||||
queryFn: fetchRecentlyPlayed,
|
||||
})
|
||||
|
||||
const heroTrack = tracksQuery.data?.items[0]
|
||||
const recentAlbums = homeQuery.data?.recentAlbums ?? []
|
||||
const recentTracks = recentTracksQuery.data?.items ?? homeQuery.data?.recentTracks ?? []
|
||||
const popularAlbums = [...recentAlbums].reverse()
|
||||
|
||||
return (
|
||||
@@ -46,12 +51,59 @@ export function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlbumRow title="Недавно прослушанные" albums={recentAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
|
||||
<TrackRow title="Недавно прослушанные" tracks={recentTracks} onPlayAll={() => setQueue(recentTracks)} />
|
||||
<AlbumRow title="Недавно добавленные" albums={recentAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
|
||||
<AlbumRow title="Наиболее прослушиваемые" albums={popularAlbums} onPlayAll={() => setQueue(tracksQuery.data?.items ?? [])} />
|
||||
</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({
|
||||
title,
|
||||
albums,
|
||||
|
||||
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);
|
||||
@@ -58,6 +58,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
private.Use(application.requireAuth)
|
||||
private.Get("/me", application.me)
|
||||
private.Get("/home", application.home)
|
||||
private.Get("/recently-played", application.recentlyPlayed)
|
||||
private.Get("/artists", application.artists)
|
||||
private.Get("/artists/{id}", application.artistByID)
|
||||
private.Get("/albums", application.albums)
|
||||
@@ -75,6 +76,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
private.Delete("/playlists/{id}", application.deletePlaylist)
|
||||
private.Get("/admin/scan-status", application.scanStatus)
|
||||
private.Post("/admin/scan", application.scanLibrary)
|
||||
private.Post("/history/scrobble", application.recordPlayEvent)
|
||||
})
|
||||
|
||||
api.Get("/cover-art/{id}", application.coverArt)
|
||||
@@ -107,6 +109,7 @@ func NewRouter(cfg config.Config, database *sql.DB, scanService *scanner.Service
|
||||
authed.Get("/startScan.view", application.subsonicStartScan)
|
||||
authed.Get("/getCoverArt.view", application.subsonicCoverArt)
|
||||
authed.Get("/stream.view", application.subsonicStream)
|
||||
authed.Get("/scrobble.view", application.subsonicScrobble)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -185,6 +188,16 @@ func (a app) artists(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a app) artistByID(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.library.ArtistByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
@@ -564,6 +577,40 @@ func (a app) scanLibrary(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
writeJSON(w, http.StatusOK, a.scanner.Status())
|
||||
}
|
||||
@@ -613,6 +660,40 @@ func (a app) subsonicStream(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
path, err := a.library.CoverArtPathByEntityID(r.Context(), id)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
@@ -42,6 +44,7 @@ type Track struct {
|
||||
|
||||
type HomePayload struct {
|
||||
RecentAlbums []Album `json:"recentAlbums"`
|
||||
RecentTracks []Track `json:"recentTracks"`
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
@@ -86,8 +89,14 @@ func (s *Service) Home(ctx context.Context) (HomePayload, error) {
|
||||
return HomePayload{}, err
|
||||
}
|
||||
|
||||
recentTracks, err := s.RecentTracks(ctx, "", 8)
|
||||
if err != nil {
|
||||
return HomePayload{}, err
|
||||
}
|
||||
|
||||
return HomePayload{
|
||||
RecentAlbums: albums,
|
||||
RecentTracks: recentTracks,
|
||||
Artists: artists,
|
||||
}, nil
|
||||
}
|
||||
@@ -406,6 +415,82 @@ func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string
|
||||
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(t.track_number, 0),
|
||||
COALESCE(t.duration_seconds, 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(t.track_number, 0),
|
||||
COALESCE(t.duration_seconds, 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) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
@@ -452,28 +537,7 @@ func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track,
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tracks []Track
|
||||
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()
|
||||
return scanTracks(rows)
|
||||
}
|
||||
|
||||
func (s *Service) searchArtists(ctx context.Context, pattern string, limit int) ([]Artist, error) {
|
||||
@@ -557,27 +621,7 @@ func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) (
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tracks []Track
|
||||
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()
|
||||
return scanTracks(rows)
|
||||
}
|
||||
|
||||
func (s *Service) updateFavorites(ctx context.Context, userID string, trackIDs, albumIDs, artistIDs []string, star bool) error {
|
||||
@@ -694,6 +738,10 @@ func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, er
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanTracks(rows)
|
||||
}
|
||||
|
||||
func scanTracks(rows *sql.Rows) ([]Track, error) {
|
||||
var tracks []Track
|
||||
for rows.Next() {
|
||||
var track Track
|
||||
@@ -710,9 +758,16 @@ func (s *Service) starredTracks(ctx context.Context, userID string) ([]Track, er
|
||||
&track.ContentType,
|
||||
&track.CoverArtID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan starred track: %w", err)
|
||||
return nil, fmt.Errorf("scan track row: %w", err)
|
||||
}
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
return tracks, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user