From a645ec5c78aa3c0921405f6cad776ade85bf5dd6 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 1 May 2025 15:10:00 +0200 Subject: [PATCH] JSPF: Implemented export as loves and listens --- CHANGES.md | 4 + README.md | 2 +- internal/backends/jspf/jspf.go | 143 ++++++++++++++++++++++++++++++--- pkg/jspf/extensions.go | 33 +++++++- pkg/jspf/extensions_test.go | 31 ++++++- pkg/jspf/models.go | 54 ++++++------- 6 files changed, 225 insertions(+), 42 deletions(-) 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..97e6120 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 | ✓ | ✓ | ⨯ | ⨯ diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index f98349a..a9e5c90 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,35 @@ 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, addedAt, err := jspfTrackAsTrack(t) + if err != nil { + return nil, err + } + + listen := models.Listen{ + ListenedAt: *addedAt, + 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,25 +205,62 @@ 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, addedAt, err := jspfTrackAsTrack(t) + if err != nil { + return nil, err + } + + love := models.Love{ + Created: *addedAt, + RecordingMBID: track.RecordingMBID, + Track: *track, + } + 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, Duration: t.Duration.Milliseconds(), - Extension: map[string]any{}, + Extension: jspf.ExtensionMap{}, } return track } +func jspfTrackAsTrack(t jspf.Track) (*models.Track, *time.Time, 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):]) + } + } + + addedAt, err := readMusicBrainzExtension(t, &track) + if err != nil { + return nil, nil, err + } + + return &track, addedAt, nil +} + func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { extension := jspf.MusicBrainzTrackExtension{ AdditionalMetadata: t.AdditionalInfo, @@ -164,11 +268,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 @@ -177,6 +281,25 @@ func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { return extension } +func readMusicBrainzExtension(jspfTrack jspf.Track, outputTrack *models.Track) (*time.Time, 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.AddedAt, nil +} + func (b *JSPFBackend) readJSPF() error { if b.append { file, err := os.Open(b.filePath) 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 d7a540c..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 int64 `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