Files

102 lines
3.2 KiB
Go

package subsonic
import (
"database/sql"
"strconv"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
func init() {
registerExtension("playbackReport", 1)
register("reportPlayback", handleReportPlayback)
}
// resolveMediaID maps a reportPlayback/getTranscodeDecision mediaId to a
// song, honoring mediaType: "song" IDs are looked up directly; "podcast"
// IDs may name either a podcast_episode (resolved to its downloaded
// song, once one exists) or, since a downloaded episode's song ID is
// itself usable everywhere, the song directly — both are accepted so
// clients don't need to track which kind of ID they were given.
func resolveMediaID(db *sql.DB, mediaID, mediaType string) (*database.Song, error) {
if song, err := database.GetSong(db, mediaID); err == nil {
return song, nil
}
if mediaType == "podcast" {
if ep, err := database.GetPodcastEpisode(db, mediaID); err == nil && ep.MediaFileID.Valid {
if song, err := database.GetSongByMediaFileID(db, ep.MediaFileID.String); err == nil {
return song, nil
}
}
}
return nil, database.ErrNotFound
}
// handleReportPlayback records a client's playback timeline state. This
// is deliberately separate from scrobble: it's meant for frequent,
// low-stakes position pings (used to drive now-playing displays and
// resume positions) rather than "this was a real listen" submission
// events, though a state=stopped report with ignoreScrobble=false does
// also log a playback_history entry, matching how a client naturally
// signals "playback ended here" without a distinct submission call.
func handleReportPlayback(rc *Context) *apiError {
mediaID, aerr := rc.Params.Required("mediaId")
if aerr != nil {
return aerr
}
mediaType, aerr := rc.Params.Required("mediaType")
if aerr != nil {
return aerr
}
positionMs, aerr := rc.Params.RequiredInt("positionMs")
if aerr != nil {
return aerr
}
state, aerr := rc.Params.Required("state")
if aerr != nil {
return aerr
}
switch state {
case "starting", "playing", "paused", "stopped":
default:
return newError(ErrGeneric, "invalid state: "+state)
}
playbackRate := 1.0
if v := rc.Params.Get("playbackRate"); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil {
playbackRate = f
}
}
ignoreScrobble := rc.Params.Bool("ignoreScrobble", false)
client := rc.Params.Get("c")
db := rc.Server.DB
song, err := resolveMediaID(db, mediaID, mediaType)
if err != nil {
return errNotFound("Media")
}
if err := database.RecordPlaybackReport(db, rc.User.ID, song.ID, positionMs, state, client, playbackRate); err != nil {
return newError(ErrGeneric, err.Error())
}
switch state {
case "stopped":
if err := database.ClearNowPlaying(db, rc.User.ID); err != nil {
return newError(ErrGeneric, err.Error())
}
if !ignoreScrobble && positionMs > 0 {
if err := database.RecordPlayback(db, rc.User.ID, song.ID, float64(positionMs)/1000); err != nil {
return newError(ErrGeneric, err.Error())
}
}
default: // starting, playing, paused
if err := database.SetNowPlaying(db, rc.User.ID, song.ID, client, float64(positionMs)/1000); err != nil {
return newError(ErrGeneric, err.Error())
}
}
rc.Response = newResponse()
return nil
}