From a5d08602929383d177b9ae31ec40af5fe4c53e31 Mon Sep 17 00:00:00 2001 From: riwiwa Date: Sat, 28 Feb 2026 00:46:30 -0800 Subject: [PATCH] fix spotify scrobbling --- db/db.go | 26 ++++++++++- scrobble/spotify.go | 107 ++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 6 deletions(-) diff --git a/db/db.go b/db/db.go index aa5723c..cdb3e61 100644 --- a/db/db.go +++ b/db/db.go @@ -18,7 +18,10 @@ func CreateAllTables() error { if err := CreateUsersTable(); err != nil { return err } - return CreateSessionsTable() + if err := CreateSessionsTable(); err != nil { + return err + } + return CreateSpotifyLastTrackTable() } func GetDbUrl(dbName bool) string { @@ -134,7 +137,26 @@ func CleanupExpiredSessions() error { _, err := Pool.Exec(context.Background(), "DELETE FROM sessions WHERE expires_at < NOW();") if err != nil { - fmt.Fprintf(os.Stderr, "Error cleaning up expired sessions: %v\n", err) + fmt.Fprintf(os.Stderr, "Error cleaning up sessions: %v\n", err) + return err + } + return nil +} + +func CreateSpotifyLastTrackTable() error { + _, err := Pool.Exec(context.Background(), + `CREATE TABLE IF NOT EXISTS spotify_last_track ( + user_id INTEGER PRIMARY KEY REFERENCES users(pk) ON DELETE CASCADE, + track_id TEXT NOT NULL, + song_name TEXT NOT NULL, + artist TEXT NOT NULL, + album_name TEXT, + duration_ms INTEGER NOT NULL, + progress_ms INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ DEFAULT NOW() + );`) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating spotify_last_track table: %v\n", err) return err } return nil diff --git a/scrobble/spotify.go b/scrobble/spotify.go index f2fec22..9cb5464 100644 --- a/scrobble/spotify.go +++ b/scrobble/spotify.go @@ -1,6 +1,7 @@ package scrobble import ( + "context" "encoding/json" "fmt" "io" @@ -10,6 +11,8 @@ import ( "strings" "sync" "time" + + "muzi/db" ) const SpotifyTokenURL = "https://accounts.spotify.com/api/token" @@ -44,6 +47,7 @@ type SpotifyCurrentlyPlaying struct { } type SpotifyTrack struct { + Id string `json:"id"` Name string `json:"name"` DurationMs int `json:"duration_ms"` Artists []SpotifyArtist `json:"artists"` @@ -64,9 +68,8 @@ type SpotifyRecentPlays struct { } type SpotifyPlayItem struct { - Track SpotifyTrack `json:"track"` - PlayedAt string `json:"played_at"` - PlayedAtMs int64 `json:"played_at_ms"` + Track SpotifyTrack `json:"track"` + PlayedAt string `json:"played_at"` } type SpotifyCursors struct { @@ -317,6 +320,8 @@ func checkCurrentlyPlaying(userId int, accessToken string) error { artistName = playing.Item.Artists[0].Name } + checkAndScrobbleHalfway(userId, &playing.Item, playing.ProgressMs) + UpdateNowPlaying(NowPlaying{ UserId: userId, SongName: playing.Item.Name, @@ -363,9 +368,15 @@ func checkRecentPlays(userId int, accessToken string) error { artistName = item.Track.Artists[0].Name } + ts, err := time.Parse(time.RFC3339, item.PlayedAt) + if err != nil { + fmt.Fprintf(os.Stderr, " -> failed to parse timestamp %s: %v\n", item.PlayedAt, err) + continue + } + scrobbles = append(scrobbles, Scrobble{ UserId: userId, - Timestamp: time.Unix(item.PlayedAtMs/1000, 0).UTC(), + Timestamp: ts, SongName: item.Track.Name, Artist: artistName, Album: item.Track.Album.Name, @@ -409,3 +420,91 @@ func GetSpotifyAuthURL(userId int, baseURL string) (string, error) { return fmt.Sprintf("%s?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%d", SpotifyAuthURL, url.QueryEscape(clientId), url.QueryEscape(redirectURI), url.QueryEscape(scope), userId), nil } + +type LastTrack struct { + UserId int + TrackId string + SongName string + Artist string + AlbumName string + DurationMs int + ProgressMs int + UpdatedAt time.Time +} + +func GetLastTrack(userId int) (*LastTrack, error) { + var track LastTrack + err := db.Pool.QueryRow(context.Background(), + `SELECT user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at + FROM spotify_last_track WHERE user_id = $1`, + userId).Scan(&track.UserId, &track.TrackId, &track.SongName, &track.Artist, + &track.AlbumName, &track.DurationMs, &track.ProgressMs, &track.UpdatedAt) + if err != nil { + return nil, err + } + return &track, nil +} + +func SetLastTrack(userId int, trackId, songName, artist, albumName string, durationMs, progressMs int) error { + _, err := db.Pool.Exec(context.Background(), + `INSERT INTO spotify_last_track (user_id, track_id, song_name, artist, album_name, duration_ms, progress_ms, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + track_id = $2, song_name = $3, artist = $4, album_name = $5, duration_ms = $6, progress_ms = $7, updated_at = NOW()`, + userId, trackId, songName, artist, albumName, durationMs, progressMs) + if err != nil { + fmt.Fprintf(os.Stderr, "Error saving last track: %v\n", err) + return err + } + return nil +} + +func checkAndScrobbleHalfway(userId int, currentTrack *SpotifyTrack, progressMs int) { + if currentTrack.Id == "" || currentTrack.DurationMs == 0 { + return + } + + lastTrack, err := GetLastTrack(userId) + if err != nil { + if err.Error() == "no rows in result set" { + SetLastTrack(userId, currentTrack.Id, currentTrack.Name, + getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs) + } + return + } + + if lastTrack.TrackId != currentTrack.Id { + if lastTrack.DurationMs > 0 { + percentagePlayed := float64(lastTrack.ProgressMs) / float64(lastTrack.DurationMs) + if percentagePlayed >= 0.5 || lastTrack.ProgressMs >= 240000 { + msPlayed := lastTrack.ProgressMs + if msPlayed > lastTrack.DurationMs { + msPlayed = lastTrack.DurationMs + } + scrobble := Scrobble{ + UserId: userId, + Timestamp: lastTrack.UpdatedAt, + SongName: lastTrack.SongName, + Artist: lastTrack.Artist, + Album: lastTrack.AlbumName, + MsPlayed: msPlayed, + Platform: "spotify", + } + SaveScrobble(scrobble) + } + } + + SetLastTrack(userId, currentTrack.Id, currentTrack.Name, + getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs) + } else { + SetLastTrack(userId, currentTrack.Id, currentTrack.Name, + getArtistName(currentTrack.Artists), currentTrack.Album.Name, currentTrack.DurationMs, progressMs) + } +} + +func getArtistName(artists []SpotifyArtist) string { + if len(artists) > 0 { + return artists[0].Name + } + return "" +}