diff --git a/internal/backends/jspf/jspf.go b/internal/backends/jspf/jspf.go index 619371b..f08a225 100644 --- a/internal/backends/jspf/jspf.go +++ b/internal/backends/jspf/jspf.go @@ -18,12 +18,12 @@ Scotty. If not, see . package jspf import ( - "encoding/json" "os" "time" "github.com/spf13/viper" "go.uploadedlobster.com/scotty/internal/models" + "go.uploadedlobster.com/scotty/pkg/jspf" ) type JSPFBackend struct { @@ -31,7 +31,7 @@ type JSPFBackend struct { title string creator string identifier string - tracks []Track + tracks []jspf.Track } func (b *JSPFBackend) Name() string { return "jspf" } @@ -41,7 +41,7 @@ func (b *JSPFBackend) FromConfig(config *viper.Viper) models.Backend { b.title = config.GetString("title") b.creator = config.GetString("username") b.identifier = config.GetString("identifier") - b.tracks = make([]Track, 0) + b.tracks = make([]jspf.Track, 0) return b } @@ -75,13 +75,13 @@ func (b *JSPFBackend) ImportLoves(export models.LovesResult, importResult models return importResult, nil } -func listenAsTrack(l models.Listen) Track { +func listenAsTrack(l models.Listen) jspf.Track { l.FillAdditionalInfo() track := trackAsTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.ListenedAt extension.AddedBy = l.UserName - track.Extension[MusicBrainzTrackExtensionId] = extension + track.Extension[jspf.MusicBrainzTrackExtensionId] = extension if l.RecordingMbid != "" { track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid)) @@ -90,13 +90,13 @@ func listenAsTrack(l models.Listen) Track { return track } -func loveAsTrack(l models.Love) Track { +func loveAsTrack(l models.Love) jspf.Track { l.FillAdditionalInfo() track := trackAsTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName - track.Extension[MusicBrainzTrackExtensionId] = extension + track.Extension[jspf.MusicBrainzTrackExtensionId] = extension if l.RecordingMbid != "" { track.Identifier = append(track.Identifier, "https://musicbrainz.org/recording/"+string(l.RecordingMbid)) @@ -105,8 +105,8 @@ func loveAsTrack(l models.Love) Track { return track } -func trackAsTrack(t models.Track) Track { - track := Track{ +func trackAsTrack(t models.Track) jspf.Track { + track := jspf.Track{ Title: t.TrackName, Album: t.ReleaseName, Creator: t.ArtistName(), @@ -117,8 +117,8 @@ func trackAsTrack(t models.Track) Track { return track } -func makeMusicBrainzExtension(t models.Track) MusicBrainzTrackExtension { - extension := MusicBrainzTrackExtension{ +func makeMusicBrainzExtension(t models.Track) jspf.MusicBrainzTrackExtension { + extension := jspf.MusicBrainzTrackExtension{ AdditionalMetadata: t.AdditionalInfo, ArtistIdentifiers: make([]string, len(t.ArtistMbids)), } @@ -137,9 +137,9 @@ func makeMusicBrainzExtension(t models.Track) MusicBrainzTrackExtension { return extension } -func (b JSPFBackend) writeJSPF(tracks []Track) error { - playlist := JSPF{ - Playlist: Playlist{ +func (b JSPFBackend) writeJSPF(tracks []jspf.Track) error { + playlist := jspf.JSPF{ + Playlist: jspf.Playlist{ Title: b.title, Creator: b.creator, Identifier: b.identifier, @@ -154,11 +154,5 @@ func (b JSPFBackend) writeJSPF(tracks []Track) error { } defer file.Close() - jspfJson, err := json.MarshalIndent(playlist, "", "\t") - if err != nil { - return err - } - - _, err = file.Write(jspfJson) - return err + return playlist.Write(file) } diff --git a/pkg/jspf/COPYING b/pkg/jspf/COPYING new file mode 100644 index 0000000..f2bd793 --- /dev/null +++ b/pkg/jspf/COPYING @@ -0,0 +1,21 @@ +The MIT License (MIT) + +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/pkg/jspf/extensions.go b/pkg/jspf/extensions.go new file mode 100644 index 0000000..fe5fef2 --- /dev/null +++ b/pkg/jspf/extensions.go @@ -0,0 +1,86 @@ +/* +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package jspf + +import "time" + +const ( + // The identifier for the MusicBrainz / ListenBrainz JSPF playlist extension + MusicBrainzPlaylistExtensionId = "https://musicbrainz.org/doc/jspf#playlist" + // The identifier for the MusicBrainz / ListenBrainz JSPF track extension + MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track" +) + +// MusicBrainz / ListenBrainz JSPF track extension +// +// This extension allows storing additional metadata for playlists. +// +// See "https://musicbrainz.org/doc/jspf#playlist" +type MusicBrainzPlaylistExtension struct { + // The ListenBrainz user who created this playlist. + Creator string `json:"added_by,omitempty"` + // Which ListenBrainz user was the playlist generated for? This is for music + // recommendation bots generating playlists for users. + CreatedFor string `json:"created_for,omitempty"` + // Who are the ListenBrainz users who have access to edit this playlist? + Collaborators []string `json:"collaborators,omitempty"` + // This field identifies a playlist, using the identifier syntax, + // from which this playlist was copied from. + CopiedFrom string `json:"copied_from,omitempty"` + // If the source playlist that this playlist has been copied from has been + // deleted, this field will be set to true and the copied_from field will not + // be returned. + CopiedFromDeleted bool `json:"copied_from_deleted,omitempty"` + // Indicates if this playlist is public or private. Must contain the value + // "true" or "false". + Public string `json:"public,omitempty"` + // The timestamp for when this playlist was last modified. + LastModifiedAt time.Time `json:"last_modified_at,omitempty"` + // This dict allows a playlist creator to submit additional track metadata + // that may be used by playlist generation tools. The content of this field + // is defined by the playlist generation tools and is beyond the scope of + // this document. + AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"` +} + +// MusicBrainz / ListenBrainz JSPF track extension +// +// This extension allows storing additional metadata for tracks. +// +// See "https://musicbrainz.org/doc/jspf#track" +type MusicBrainzTrackExtension struct { + // The timestamp for when this track was added to the playlist. + AddedAt time.Time `json:"added_at,omitempty"` + // The ListenBrainz user who added this track. + AddedBy string `json:"added_by,omitempty"` + // The MusicBrainz ID URI for the release that contained this track. + ReleaseIdentifier string `json:"release_identifier,omitempty"` + // A list of MusicBrainz Artist URIs that identify the artist that are part + // of the MusicBrainz artist credit id for this track. + ArtistIdentifiers []string `json:"artist_identifiers,omitempty"` + // This dict allows a playlist creator to submit additional track metadata + // that may be used by playlist generation tools. The content of this field + // is defined by the playlist generation tools and is beyond the scope of + // this document. + AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"` +} diff --git a/pkg/jspf/extensions_test.go b/pkg/jspf/extensions_test.go new file mode 100644 index 0000000..8d8653d --- /dev/null +++ b/pkg/jspf/extensions_test.go @@ -0,0 +1,74 @@ +/* +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package jspf_test + +import ( + "bytes" + "fmt" + "log" + "time" + + "go.uploadedlobster.com/scotty/pkg/jspf" +) + +func ExampleMusicBrainzTrackExtension() { + pl := jspf.JSPF{ + Playlist: jspf.Playlist{ + Date: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), + Tracks: []jspf.Track{ + { + Title: "Oweynagat", + Extension: map[string]any{ + jspf.MusicBrainzTrackExtensionId: jspf.MusicBrainzTrackExtension{ + AddedAt: time.Date(2023, 11, 24, 07, 47, 50, 0, time.UTC), + AddedBy: "scotty", + }, + }, + }, + }, + }, + } + buf := bytes.NewBuffer(make([]byte, 0, 10)) + err := pl.Write(buf) + if err != nil { + log.Fatal(err) + } + fmt.Print(buf.String()) + // Output: + // { + // "playlist": { + // "date": "2023-11-24T07:47:50Z", + // "track": [ + // { + // "title": "Oweynagat", + // "extension": { + // "https://musicbrainz.org/doc/jspf#track": { + // "added_at": "2023-11-24T07:47:50Z", + // "added_by": "scotty" + // } + // } + // } + // ] + // } + // } +} diff --git a/pkg/jspf/io.go b/pkg/jspf/io.go new file mode 100644 index 0000000..f25f6a6 --- /dev/null +++ b/pkg/jspf/io.go @@ -0,0 +1,52 @@ +/* +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Package for reading and writing music playlists in the JSPF format. +// +// JSPF is the JSON representation of XSPF, see https://www.xspf.org/jspf +package jspf + +import ( + "encoding/json" + "io" +) + +// Reads the JSPF playlist in JSON format from the given io.Reader +func (j *JSPF) Read(in io.Reader) error { + bytes, err := io.ReadAll(in) + if err != nil { + return err + } + err = json.Unmarshal(bytes, j) + return err +} + +// Writes the JSPF playlist in JSON format to the given io.Writer +func (j *JSPF) Write(out io.Writer) error { + jspfJson, err := json.MarshalIndent(j, "", "\t") + if err != nil { + return err + } + + _, err = out.Write(jspfJson) + return err +} diff --git a/pkg/jspf/io_test.go b/pkg/jspf/io_test.go new file mode 100644 index 0000000..e18bbce --- /dev/null +++ b/pkg/jspf/io_test.go @@ -0,0 +1,86 @@ +/* +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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +package jspf_test + +import ( + "bytes" + "fmt" + "log" + "os" + "time" + + "go.uploadedlobster.com/scotty/pkg/jspf" +) + +func ExampleJSPF_Read() { + file, err := os.Open("testdata/simple.jspf") + if err != nil { + log.Fatal(err) + } + + defer file.Close() + + pl := jspf.JSPF{} + err = pl.Read(file) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Playlist '%v' with %v tracks", + pl.Playlist.Title, len(pl.Playlist.Tracks)) + // Output: Playlist 'Two Songs From Thriller' with 2 tracks +} + +func ExampleJSPF_Write() { + date, _ := time.Parse(time.RFC3339, "2023-11-23T23:56:00Z") + pl := jspf.JSPF{ + Playlist: jspf.Playlist{ + Title: "The Playlist", + Creator: "Scotty", + Date: date, + Tracks: []jspf.Track{ + { + Title: "The Track", + }, + }, + }, + } + buf := bytes.NewBuffer(make([]byte, 0, 10)) + err := pl.Write(buf) + if err != nil { + log.Fatal(err) + } + fmt.Print(buf.String()) + // Output: + // { + // "playlist": { + // "title": "The Playlist", + // "creator": "Scotty", + // "date": "2023-11-23T23:56:00Z", + // "track": [ + // { + // "title": "The Track" + // } + // ] + // } + // } +} diff --git a/internal/backends/jspf/models.go b/pkg/jspf/models.go similarity index 59% rename from internal/backends/jspf/models.go rename to pkg/jspf/models.go index c286b96..d910367 100644 --- a/internal/backends/jspf/models.go +++ b/pkg/jspf/models.go @@ -1,24 +1,31 @@ /* Copyright © 2023 Philipp Wolfer -This file is part of Scotty. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -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. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -Scotty is distributed in the hope that it will be useful, but WITHOUT ANY -WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -A PARTICULAR PURPOSE. See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License along with -Scotty. If not, see . +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ package jspf import "time" +// A JSPF playlist +// // See https://xspf.org/jspf type JSPF struct { Playlist Playlist `json:"playlist"` @@ -61,15 +68,3 @@ type Attribution map[string]string type Link map[string]string type Meta map[string]string - -// Extension for "https://musicbrainz.org/doc/jspf#track" -// as used by ListenBrainz. -const MusicBrainzTrackExtensionId = "https://musicbrainz.org/doc/jspf#track" - -type MusicBrainzTrackExtension struct { - AddedAt time.Time `json:"added_at,omitempty"` - AddedBy string `json:"added_by,omitempty"` - ReleaseIdentifier string `json:"release_identifier,omitempty"` - ArtistIdentifiers []string `json:"artist_identifiers,omitempty"` - AdditionalMetadata map[string]any `json:"additional_metadata,omitempty"` -} diff --git a/internal/backends/jspf/models_test.go b/pkg/jspf/models_test.go similarity index 71% rename from internal/backends/jspf/models_test.go rename to pkg/jspf/models_test.go index ddd7c01..c83c24f 100644 --- a/internal/backends/jspf/models_test.go +++ b/pkg/jspf/models_test.go @@ -1,25 +1,29 @@ /* Copyright © 2023 Philipp Wolfer -This file is part of Scotty. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -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. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -Scotty is distributed in the hope that it will be useful, but WITHOUT ANY -WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR -A PARTICULAR PURPOSE. See the GNU General Public License for more details. - -You should have received a copy of the GNU General Public License along with -Scotty. If not, see . +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ package jspf_test import ( "encoding/json" - "fmt" "io" "os" "testing" @@ -27,7 +31,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uploadedlobster.com/scotty/internal/backends/jspf" + "go.uploadedlobster.com/scotty/pkg/jspf" ) func TestUnmarshalSimple(t *testing.T) { @@ -82,7 +86,6 @@ func TestUnmarshalListenBrainzPlaylist(t *testing.T) { assert.Equal( "https://musicbrainz.org/recording/3f2bdbbd-063e-478c-a394-6da0cb303302", track1.Identifier[0]) - fmt.Printf("Ext: %v\n", track1.Extension["https://musicbrainz.org/doc/jspf#track"]) extension := track1.Extension["https://musicbrainz.org/doc/jspf#track"].(map[string]any) assert.NotNil(extension) assert.Equal("outsidecontext", extension["added_by"]) diff --git a/internal/backends/jspf/testdata/comprehensive.jspf b/pkg/jspf/testdata/comprehensive.jspf similarity index 100% rename from internal/backends/jspf/testdata/comprehensive.jspf rename to pkg/jspf/testdata/comprehensive.jspf diff --git a/internal/backends/jspf/testdata/lb-playlist.jspf b/pkg/jspf/testdata/lb-playlist.jspf similarity index 100% rename from internal/backends/jspf/testdata/lb-playlist.jspf rename to pkg/jspf/testdata/lb-playlist.jspf diff --git a/internal/backends/jspf/testdata/simple.jspf b/pkg/jspf/testdata/simple.jspf similarity index 100% rename from internal/backends/jspf/testdata/simple.jspf rename to pkg/jspf/testdata/simple.jspf