feat: add recently played and scrobble flow

This commit is contained in:
2026-04-03 02:15:57 +03:00
parent 2bbf52a41b
commit 4d44632fbf
7 changed files with 320 additions and 54 deletions

View File

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

View File

@@ -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 {

View File

@@ -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
}