feat: add recently played and scrobble flow
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user