diff --git a/README.md b/README.md index d7d2be9..8f85065 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Scotty transfers your listens/scrobbles and favorite tracks between various musi - Transfer loved tracks from Funkwhale to ListenBrainz - Submit listens stored in a Rockbox `.scrobbler.log` file to ListenBrainz, Last.fm or Maloja - Store your favorite tracks from Deezer as a JSPF playlist +- Backup your listening history from ListenBrainz or Last.fm ## Installation @@ -44,7 +45,7 @@ deezer | ✓ | ⨯ | ✓ | - dump | ⨯ | ✓ | ⨯ | ✓ funkwhale | ✓ | ⨯ | ✓ | - jspf | - | ✓ | - | ✓ -lastfm | - | - | ✓ | ✓ +lastfm | ✓ | ✓ | ✓ | ✓ listenbrainz | ✓ | ✓ | ✓ | ✓ maloja | ✓ | ✓ | ⨯ | ⨯ scrobbler-log | ✓ | ✓ | ⨯ | ⨯ diff --git a/internal/backends/lastfm/lastfm.go b/internal/backends/lastfm/lastfm.go index e9cdf2d..e79c82c 100644 --- a/internal/backends/lastfm/lastfm.go +++ b/internal/backends/lastfm/lastfm.go @@ -31,8 +31,10 @@ import ( ) const ( - MaxItemsPerGet = 1000 - MaxListensPerRequest = 50 + MaxItemsPerGet = 1000 + MaxListensPerGet = 200 + MaxListensPerSubmission = 50 + MaxPage = 1000000 ) type LastfmApiBackend struct { @@ -69,10 +71,98 @@ func (b *LastfmApiBackend) OAuth2Setup(token oauth2.TokenSource) error { return nil } +func (b *LastfmApiBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { + page := MaxPage + minTime := oldestTimestamp + perPage := MaxItemsPerGet + + defer close(results) + + // We need to gather the full list of listens in order to sort them + p := models.Progress{Total: int64(page)} + +out: + for page > 0 { + args := lastfm.P{ + "user": b.username, + "limit": MaxListensPerGet, + // last.fm includes the listen with the exact timestamp in the result + "from": oldestTimestamp.Add(time.Second).Unix(), + "page": page, + } + result, err := b.client.User.GetRecentTracks(args) + if err != nil { + results <- models.ListensResult{Error: err} + progress <- p.Complete() + return + } + + count := len(result.Tracks) + if count == 0 { + // The page was outside of the result range, adjust and request again + if page > result.TotalPages { + page = result.TotalPages + continue + } + break + } + + listens := make(models.ListensList, 0, 2*perPage) + for _, scrobble := range result.Tracks { + timestamp, err := strconv.ParseInt(scrobble.Date.Uts, 10, 64) + if err != nil { + results <- models.ListensResult{Error: err} + progress <- p.Complete() + break out + } + if timestamp > oldestTimestamp.Unix() { + p.Elapsed += 1 + listen := models.Listen{ + ListenedAt: time.Unix(timestamp, 0), + UserName: b.username, + Track: models.Track{ + TrackName: scrobble.Name, + ArtistNames: []string{}, + ReleaseName: scrobble.Album.Name, + RecordingMbid: models.MBID(scrobble.Mbid), + ArtistMbids: []models.MBID{}, + ReleaseMbid: models.MBID(scrobble.Album.Mbid), + }, + } + if scrobble.Artist.Name != "" { + listen.Track.ArtistNames = []string{scrobble.Artist.Name} + } + if scrobble.Artist.Mbid != "" { + listen.Track.ArtistMbids = []models.MBID{models.MBID(scrobble.Artist.Mbid)} + } + listens = append(listens, listen) + } else { + break out + } + } + + sort.Sort(listens) + minTime = listens[len(listens)-1].ListenedAt + page -= 1 + + results <- models.ListensResult{ + Listens: listens, + Total: result.Total, + OldestTimestamp: minTime, + } + p.Total = int64(result.TotalPages) + p.Elapsed = int64(result.TotalPages - page) + progress <- p + } + + results <- models.ListensResult{OldestTimestamp: minTime} + progress <- p.Complete() +} + func (b *LastfmApiBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { total := len(export.Listens) - for i := 0; i < total; i += MaxListensPerRequest { - listens := export.Listens[i:min(i+MaxListensPerRequest, total)] + for i := 0; i < total; i += MaxListensPerSubmission { + listens := export.Listens[i:min(i+MaxListensPerSubmission, total)] count := len(listens) if count == 0 { break