diff --git a/CHANGES.md b/CHANGES.md index 5dfd892..5ac83ab 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,9 +1,5 @@ # 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 c764730..9f9f5c9 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-2025 Philipp Wolfer +Scotty © 2023-2024 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 6551f15..3e6866d 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023-2024 Philipp Wolfer This file is part of Scotty. @@ -18,25 +18,15 @@ 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 @@ -78,7 +68,7 @@ func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { Creator: config.GetString("username"), Identifier: config.GetString("identifier"), Tracks: make([]jspf.Track, 0), - Extension: jspf.ExtensionMap{ + Extension: map[string]any{ jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{ LastModifiedAt: time.Now(), Public: true, @@ -96,28 +86,6 @@ 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) @@ -130,28 +98,6 @@ 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) @@ -166,36 +112,22 @@ func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models func listenAsTrack(l models.Listen) jspf.Track { l.FillAdditionalInfo() - track := trackAsJSPFTrack(l.Track) + track := trackAsTrack(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, recordingMBIDPrefix+string(l.RecordingMBID)) + track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+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 := trackAsJSPFTrack(l.Track) + track := trackAsTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName @@ -206,69 +138,24 @@ func loveAsTrack(l models.Love) jspf.Track { recordingMBID = l.RecordingMBID } if recordingMBID != "" { - track.Identifier = append(track.Identifier, recordingMBIDPrefix+string(recordingMBID)) + track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(recordingMBID)) } return 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 { +func trackAsTrack(t models.Track) jspf.Track { track := jspf.Track{ Title: t.TrackName, Album: t.ReleaseName, Creator: t.ArtistName(), TrackNum: t.TrackNumber, - Duration: t.Duration.Milliseconds(), - Extension: jspf.ExtensionMap{}, + Extension: map[string]any{}, } 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, @@ -276,11 +163,11 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { } for i, mbid := range t.ArtistMBIDs { - extension.ArtistIdentifiers[i] = artistMBIDPrefix + string(mbid) + extension.ArtistIdentifiers[i] = "https://musicbrainz.org/artist/" + string(mbid) } if t.ReleaseMBID != "" { - extension.ReleaseIdentifier = releaseMBIDPrefix + string(t.ReleaseMBID) + extension.ReleaseIdentifier = "https://musicbrainz.org/release/" + string(t.ReleaseMBID) } // The tracknumber tag would be redundant @@ -289,25 +176,6 @@ 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 7955c15..19ed30b 100644 --- a/internal/backends/scrobblerlog/scrobblerlog.go +++ b/internal/backends/scrobblerlog/scrobblerlog.go @@ -151,12 +151,9 @@ 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 { - listen := recordToListen(record, client) - if listen.ListenedAt.After(oldestTimestamp) { - listens = append(listens, recordToListen(record, client)) - } + listens = append(listens, recordToListen(record, client)) } - sort.Sort(listens) + sort.Sort(listens.NewerThan(oldestTimestamp)) 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 7cf99d3..0f521c4 100644 --- a/pkg/jspf/extensions.go +++ b/pkg/jspf/extensions.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 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,28 +22,7 @@ THE SOFTWARE. package jspf -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) -} +import "time" const ( // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension @@ -104,11 +83,3 @@ 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 49d1bd5..883301d 100644 --- a/pkg/jspf/extensions_test.go +++ b/pkg/jspf/extensions_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 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,7 +26,6 @@ import ( "bytes" "fmt" "log" - "testing" "time" "go.uploadedlobster.com/scotty/pkg/jspf" @@ -39,7 +38,7 @@ func ExampleMusicBrainzTrackExtension() { Tracks: []jspf.Track{ { Title: "Oweynagat", - Extension: jspf.ExtensionMap{ + Extension: map[string]any{ jspf.MusicBrainzTrackExtensionID: jspf.MusicBrainzTrackExtension{ AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), AddedBy: "scotty", @@ -73,29 +72,3 @@ 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 829e922..d910367 100644 --- a/pkg/jspf/models.go +++ b/pkg/jspf/models.go @@ -1,5 +1,5 @@ /* -Copyright © 2023-2025 Philipp Wolfer +Copyright © 2023 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 ExtensionMap `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 map[string]any `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 int64 `json:"duration,omitempty"` - Links []Link `json:"link,omitempty"` - Meta []Meta `json:"meta,omitempty"` - Extension ExtensionMap `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 int `json:"duration,omitempty"` + Links []Link `json:"link,omitempty"` + Meta []Meta `json:"meta,omitempty"` + Extension map[string]any `json:"extension,omitempty"` } type Attribution map[string]string diff --git a/pkg/scrobblerlog/parser.go b/pkg/scrobblerlog/parser.go index 6b9d1ba..d355c62 100644 --- a/pkg/scrobblerlog/parser.go +++ b/pkg/scrobblerlog/parser.go @@ -20,18 +20,11 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// 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. +// Package to parse and writer .scrobbler.log files as written by Rockbox. // // 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 ( @@ -86,10 +79,6 @@ 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) @@ -117,7 +106,6 @@ 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) @@ -138,11 +126,6 @@ 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' @@ -170,9 +153,6 @@ 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++ { @@ -211,7 +191,6 @@ 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",