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 }