diff --git a/CHANGES.md b/CHANGES.md index 5ac83ab..5dfd892 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Scotty Changelog +## 0.6.0 - WIP +- JSPF: Implemented export as loves and listens + + ## 0.5.2 - 2025-05-01 - ListenBrainz: fixed loves export not considering latest timestamp diff --git a/README.md b/README.md index 9f9f5c9..c764730 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Backend | Listens Export | Listens Import | Loves Export | Loves Import ----------------|----------------|----------------|--------------|------------- deezer | ✓ | ⨯ | ✓ | - funkwhale | ✓ | ⨯ | ✓ | - -jspf | - | ✓ | - | ✓ +jspf | ✓ | ✓ | ✓ | ✓ lastfm | ✓ | ✓ | ✓ | ✓ listenbrainz | ✓ | ✓ | ✓ | ✓ maloja | ✓ | ✓ | ⨯ | ⨯ @@ -145,7 +145,7 @@ You can help translate this project into your language with [Weblate](https://tr ## License -Scotty © 2023-2024 Philipp Wolfer +Scotty © 2023-2025 Philipp Wolfer Scotty is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 3e6866d..6551f15 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2024 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. @@ -18,15 +18,25 @@ Scotty. If not, see . package jspf import ( + "errors" "os" + "sort" + "strings" "time" + "go.uploadedlobster.com/mbtypes" "go.uploadedlobster.com/scotty/internal/config" "go.uploadedlobster.com/scotty/internal/i18n" "go.uploadedlobster.com/scotty/internal/models" "go.uploadedlobster.com/scotty/pkg/jspf" ) +const ( + artistMBIDPrefix = "https://musicbrainz.org/artist/" + recordingMBIDPrefix = "https://musicbrainz.org/recording/" + releaseMBIDPrefix = "https://musicbrainz.org/release/" +) + type JSPFBackend struct { filePath string playlist jspf.Playlist @@ -68,7 +78,7 @@ func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { Creator: config.GetString("username"), Identifier: config.GetString("identifier"), Tracks: make([]jspf.Track, 0), - Extension: map[string]any{ + Extension: jspf.ExtensionMap{ jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{ LastModifiedAt: time.Now(), Public: true, @@ -86,6 +96,28 @@ func (b *JSPFBackend) FinishImport() error { return b.writeJSPF() } +func (b *JSPFBackend) ExportListens(oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.Progress) { + defer close(results) + + err := b.readJSPF() + if err != nil { + progress <- models.Progress{}.Complete() + results <- models.ListensResult{Error: err} + return + } + + listens := make(models.ListensList, 0, len(b.playlist.Tracks)) + for _, track := range b.playlist.Tracks { + listen, err := trackAsListen(track) + if err == nil && listen != nil && listen.ListenedAt.After(oldestTimestamp) { + listens = append(listens, *listen) + } + } + sort.Sort(listens) + progress <- models.Progress{Total: int64(len(listens))}.Complete() + results <- models.ListensResult{Items: listens} +} + func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { for _, listen := range export.Items { track := listenAsTrack(listen) @@ -98,6 +130,28 @@ func (b *JSPFBackend) ImportListens(export models.ListensResult, importResult mo return importResult, nil } +func (b *JSPFBackend) ExportLoves(oldestTimestamp time.Time, results chan models.LovesResult, progress chan models.Progress) { + defer close(results) + + err := b.readJSPF() + if err != nil { + progress <- models.Progress{}.Complete() + results <- models.LovesResult{Error: err} + return + } + + loves := make(models.LovesList, 0, len(b.playlist.Tracks)) + for _, track := range b.playlist.Tracks { + love, err := trackAsLove(track) + if err == nil && love != nil && love.Created.After(oldestTimestamp) { + loves = append(loves, *love) + } + } + sort.Sort(loves) + progress <- models.Progress{Total: int64(len(loves))}.Complete() + results <- models.LovesResult{Items: loves} +} + func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models.ImportResult, progress chan models.Progress) (models.ImportResult, error) { for _, love := range export.Items { track := loveAsTrack(love) @@ -112,22 +166,36 @@ func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models func listenAsTrack(l models.Listen) jspf.Track { l.FillAdditionalInfo() - track := trackAsTrack(l.Track) + track := trackAsJSPFTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.ListenedAt extension.AddedBy = l.UserName track.Extension[jspf.MusicBrainzTrackExtensionID] = extension if l.RecordingMBID != "" { - track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMBID)) + track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(l.RecordingMBID)) } return track } +func trackAsListen(t jspf.Track) (*models.Listen, error) { + track, ext, err := jspfTrackAsTrack(t) + if err != nil { + return nil, err + } + + listen := models.Listen{ + ListenedAt: ext.AddedAt, + UserName: ext.AddedBy, + Track: *track, + } + return &listen, err +} + func loveAsTrack(l models.Love) jspf.Track { l.FillAdditionalInfo() - track := trackAsTrack(l.Track) + track := trackAsJSPFTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName @@ -138,24 +206,69 @@ func loveAsTrack(l models.Love) jspf.Track { recordingMBID = l.RecordingMBID } if recordingMBID != "" { - track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID)) + track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(recordingMBID)) } return track } -func trackAsTrack(t models.Track) jspf.Track { +func trackAsLove(t jspf.Track) (*models.Love, error) { + track, ext, err := jspfTrackAsTrack(t) + if err != nil { + return nil, err + } + + love := models.Love{ + Created: ext.AddedAt, + UserName: ext.AddedBy, + RecordingMBID: track.RecordingMBID, + Track: *track, + } + + recordingMSID, ok := track.AdditionalInfo["recording_msid"].(string) + if ok { + love.RecordingMSID = mbtypes.MBID(recordingMSID) + } + + return &love, err +} + +func trackAsJSPFTrack(t models.Track) jspf.Track { track := jspf.Track{ Title: t.TrackName, Album: t.ReleaseName, Creator: t.ArtistName(), TrackNum: t.TrackNumber, - Extension: map[string]any{}, + Duration: t.Duration.Milliseconds(), + Extension: jspf.ExtensionMap{}, } return track } +func jspfTrackAsTrack(t jspf.Track) (*models.Track, *jspf.MusicBrainzTrackExtension, error) { + track := models.Track{ + ArtistNames: []string{t.Creator}, + ReleaseName: t.Album, + TrackName: t.Title, + TrackNumber: t.TrackNum, + Duration: time.Duration(t.Duration) * time.Millisecond, + } + + for _, id := range t.Identifier { + if strings.HasPrefix(id, recordingMBIDPrefix) { + track.RecordingMBID = mbtypes.MBID(id[len(recordingMBIDPrefix):]) + } + } + + ext, err := readMusicBrainzExtension(t, &track) + if err != nil { + return nil, nil, err + } + + return &track, ext, nil +} + func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { extension := jspf.MusicBrainzTrackExtension{ AdditionalMetadata: t.AdditionalInfo, @@ -163,11 +276,11 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { } for i, mbid := range t.ArtistMBIDs { - extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid) + extension.ArtistIdentifiers[i] = artistMBIDPrefix + string(mbid) } if t.ReleaseMBID != "" { - extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID) + extension.ReleaseIdentifier = releaseMBIDPrefix + string(t.ReleaseMBID) } // The tracknumber tag would be redundant @@ -176,6 +289,25 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { return extension } +func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*jspf.MusicBrainzTrackExtension, error) { + ext := jspf.MusicBrainzTrackExtension{} + err := jspfTrack.Extension.Get(jspf.MusicBrainzTrackExtensionID, &ext) + if err != nil { + return nil, errors.New("missing MusicBrainz track extension") + } + + outputTrack.AdditionalInfo = ext.AdditionalMetadata + outputTrack.ReleaseMBID = mbtypes.MBID(ext.ReleaseIdentifier) + outputTrack.ArtistMBIDs = make([]mbtypes.MBID, len(ext.ArtistIdentifiers)) + for i, mbid := range ext.ArtistIdentifiers { + if strings.HasPrefix(mbid, artistMBIDPrefix) { + outputTrack.ArtistMBIDs[i] = mbtypes.MBID(mbid[len(artistMBIDPrefix):]) + } + } + + return &ext, nil +} + func (b *JSPFBackend) readJSPF() error { if b.append { file, err := os.Open(b.filePath) diff --git a/internal/backends/scrobblerlog/scrobblerlog.go b/internal/backends/scrobblerlog/scrobblerlog.go index 19ed30b..7955c15 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -151,9 +151,12 @@ func (b *ScrobblerLogBackend) ExportListens(oldestTimestamp time.Time, results c listens := make(models.ListensList, 0, len(b.log.Records)) client := strings.Split(b.log.Client, " ")[0] for _, record := range b.log.Records { - listens = append(listens, recordToListen(record, client)) + listen := recordToListen(record, client) + if listen.ListenedAt.After(oldestTimestamp) { + listens = append(listens, recordToListen(record, client)) + } } - sort.Sort(listens.NewerThan(oldestTimestamp)) + sort.Sort(listens) progress <- models.Progress{Total: int64(len(listens))}.Complete() results <- models.ListensResult{Items: listens} } diff --git a/pkg/jspf/extensions.go b/pkg/jspf/extensions.go index 0f521c4..7cf99d3 100644 --- a/pkg/jspf/extensions.go +++ b/pkg/jspf/extensions.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -22,7 +22,28 @@ THE SOFTWARE. package jspf -import "time" +import ( + "encoding/json" + "fmt" + "time" +) + +// Represents a JSPF extension +type Extension any + +// A map of JSPF extensions +type ExtensionMap map[string]Extension + +// Parses the extension with the given ID and unmarshals it into "v". +// If the extensions is not found or the data cannot be unmarshalled, +// an error is returned. +func (e ExtensionMap) Get(id string, v any) error { + ext, ok := e[id] + if !ok { + return fmt.Errorf("extension %q not found", id) + } + return unmarshalExtension(ext, v) +} const ( // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension @@ -83,3 +104,11 @@ type MusicBrainzTrackExtension struct { // this document. AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"` } + +func unmarshalExtension(ext Extension, v any) error { + asJson, err := json.Marshal(ext) + if err != nil { + return err + } + return json.Unmarshal(asJson, v) +} diff --git a/pkg/jspf/extensions_test.go b/pkg/jspf/extensions_test.go index 883301d..49d1bd5 100644 --- a/pkg/jspf/extensions_test.go +++ b/pkg/jspf/extensions_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -26,6 +26,7 @@ import ( "bytes" "fmt" "log" + "testing" "time" "go.uploadedlobster.com/scotty/pkg/jspf" @@ -38,7 +39,7 @@ func ExampleMusicBrainzTrackExtension() { Tracks: []jspf.Track{ { Title: "Oweynagat", - Extension: map[string]any{ + Extension: jspf.ExtensionMap{ jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), AddedBy: "scotty", @@ -72,3 +73,29 @@ func ExampleMusicBrainzTrackExtension() { // } // } } + +func TestExtensionMapGet(t *testing.T) { + ext := jspf.ExtensionMap{ + jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ + AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), + AddedBy: "scotty", + }, + } + var trackExt jspf.MusicBrainzTrackExtension + err := ext.Get(jspf.MusicBrainzTrackExtensionID, &trackExt) + if err != nil { + t.Fatal(err) + } + if trackExt.AddedBy != "scotty" { + t.Fatalf("expected 'scotty', got '%s'", trackExt.AddedBy) + } +} + +func TestExtensionMapGetNotFound(t *testing.T) { + ext := jspf.ExtensionMap{} + var trackExt jspf.MusicBrainzTrackExtension + err := ext.Get(jspf.MusicBrainzTrackExtensionID, &trackExt) + if err == nil { + t.Fatal("expected ExtensionMap.Get to return an error") + } +} diff --git a/pkg/jspf/models.go b/pkg/jspf/models.go index d910367..829e922 100644 --- a/pkg/jspf/models.go +++ b/pkg/jspf/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Philipp Wolfer +Copyright © 2023-2025 Philipp Wolfer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -32,35 +32,35 @@ type JSPF struct { } type Playlist struct { - Title string `json:"title,omitempty"` - Creator string `json:"creator,omitempty"` - Annotation string `json:"annotation,omitempty"` - Info string `json:"info,omitempty"` - Location string `json:"location,omitempty"` - Identifier string `json:"identifier,omitempty"` - Image string `json:"image,omitempty"` - Date time.Time `json:"date,omitempty"` - License string `json:"license,omitempty"` - Attribution []Attribution `json:"attribution,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension map[string]any `json:"extension,omitempty"` - Tracks []Track `json:"track"` + Title string `json:"title,omitempty"` + Creator string `json:"creator,omitempty"` + Annotation string `json:"annotation,omitempty"` + Info string `json:"info,omitempty"` + Location string `json:"location,omitempty"` + Identifier string `json:"identifier,omitempty"` + Image string `json:"image,omitempty"` + Date time.Time `json:"date,omitempty"` + License string `json:"license,omitempty"` + Attribution []Attribution `json:"attribution,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension ExtensionMap `json:"extension,omitempty"` + Tracks []Track `json:"track"` } type Track struct { - Location []string `json:"location,omitempty"` - Identifier []string `json:"identifier,omitempty"` - Title string `json:"title,omitempty"` - Creator string `json:"creator,omitempty"` - Annotation string `json:"annotation,omitempty"` - Info string `json:"info,omitempty"` - Album string `json:"album,omitempty"` - TrackNum int `json:"trackNum,omitempty"` - Duration int `json:"duration,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension map[string]any `json:"extension,omitempty"` + Location []string `json:"location,omitempty"` + Identifier []string `json:"identifier,omitempty"` + Title string `json:"title,omitempty"` + Creator string `json:"creator,omitempty"` + Annotation string `json:"annotation,omitempty"` + Info string `json:"info,omitempty"` + Album string `json:"album,omitempty"` + TrackNum int `json:"trackNum,omitempty"` + Duration int64 `json:"duration,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension ExtensionMap `json:"extension,omitempty"` } type Attribution map[string]string diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go index d355c62..6b9d1ba 100644 --- a/pkg/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -20,11 +20,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Package to parse and writer .scrobbler.log files as written by Rockbox. +// Package to parse and write .scrobbler.log files as written by Rockbox. +// +// The parser supports reading version 1.1 and 1.0 of the scrobbler log file +// format. The latter is only supported if encoded in UTF-8. +// +// When written it always writes version 1.1 of the scrobbler log file format, +// which includes the MusicBrainz recording ID as the last field of each row. // // See // - https://www.rockbox.org/wiki/LastFMLog // - https://git.rockbox.org/cgit/rockbox.git/tree/apps/plugins/lastfm_scrobbler.c +// - https://web.archive.org/web/20110110053056/http://www.audioscrobbler.net/wiki/Portable_Player_Logging package scrobblerlog import ( @@ -79,6 +86,10 @@ type ScrobblerLog struct { FallbackTimezone *time.Location } +// Parses a scrobbler log file from the given reader. +// +// The reader must provide a valid scrobbler log file with a valid header. +// This function implicitly calls [ScrobblerLog.ReadHeader]. func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { l.Records = make([]Record, 0) @@ -106,6 +117,7 @@ func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { // fmt.Printf("row: %v\n", row) // We consider only the last field (recording MBID) optional + // This was added in the 1.1 file format. if len(row) < 7 { line, _ := tsvReader.FieldPos(0) return fmt.Errorf("invalid record in scrobblerlog line %v", line) @@ -126,6 +138,11 @@ func (l *ScrobblerLog) Parse(data io.Reader, ignoreSkipped bool) error { return nil } +// Append writes the given records to the writer. +// +// The writer should be for an existing scrobbler log file or +// [ScrobblerLog.WriteHeader] should be called before this function. +// Returns the last timestamp of the records written. func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp time.Time, err error) { tsvWriter := csv.NewWriter(data) tsvWriter.Comma = '\t' @@ -153,6 +170,9 @@ func (l *ScrobblerLog) Append(data io.Writer, records []Record) (lastTimestamp t return } +// Parses just the header of a scrobbler log file from the given reader. +// +// This function sets [ScrobblerLog.TZ] and [ScrobblerLog.Client]. func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error { // Skip header for i := 0; i < 3; i++ { @@ -191,6 +211,7 @@ func (l *ScrobblerLog) ReadHeader(reader *bufio.Reader) error { return nil } +// Writes the header of a scrobbler log file to the given writer. func (l *ScrobblerLog) WriteHeader(writer io.Writer) error { headers := []string{ "#AUDIOSCROBBLER/1.1\n",