/* Copyright © 2023-2025 Philipp Wolfer This file is part of Scotty. 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. 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 . */ 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 append bool } func (b *JSPFBackend) Name() string { return "jspf" } func (b *JSPFBackend) Options() []models.BackendOption { return []models.BackendOption{{ Name: "file-path", Label: i18n.Tr("File path"), Type: models.String, }, { Name: "append", Label: i18n.Tr("Append to file"), Type: models.Bool, Default: "true", }, { Name: "title", Label: i18n.Tr("Playlist title"), Type: models.String, }, { Name: "username", Label: i18n.Tr("User name"), Type: models.String, }, { Name: "identifier", Label: i18n.Tr("Unique playlist identifier"), Type: models.String, }} } func (b *JSPFBackend) InitConfig(config *config.ServiceConfig) error { b.filePath = config.GetString("file-path") b.append = config.GetBool("append", true) b.playlist = jspf.Playlist{ Title: config.GetString("title"), Creator: config.GetString("username"), Identifier: config.GetString("identifier"), Tracks: make([]jspf.Track, 0), Extension: jspf.ExtensionMap{ jspf.MusicBrainzPlaylistExtensionID: jspf.MusicBrainzPlaylistExtension{ LastModifiedAt: time.Now(), Public: true, }, }, } return nil } func (b *JSPFBackend) StartImport() error { return b.readJSPF() } 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) b.playlist.Tracks = append(b.playlist.Tracks, track) importResult.ImportCount += 1 importResult.UpdateTimestamp(listen.ListenedAt) } progress <- models.Progress{}.FromImportResult(importResult) 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) b.playlist.Tracks = append(b.playlist.Tracks, track) importResult.ImportCount += 1 importResult.UpdateTimestamp(love.Created) } progress <- models.Progress{}.FromImportResult(importResult) return importResult, nil } func listenAsTrack(l models.Listen) jspf.Track { l.FillAdditionalInfo() 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, 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 := trackAsJSPFTrack(l.Track) extension := makeMusicBrainzExtension(l.Track) extension.AddedAt = l.Created extension.AddedBy = l.UserName track.Extension[jspf.MusicBrainzTrackExtensionID] = extension recordingMBID := l.Track.RecordingMBID if l.RecordingMBID != "" { recordingMBID = l.RecordingMBID } if recordingMBID != "" { track.Identifier = append(track.Identifier, recordingMBIDPrefix+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 { track := jspf.Track{ Title: t.TrackName, Album: t.ReleaseName, Creator: t.ArtistName(), TrackNum: t.TrackNumber, 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, ArtistIdentifiers: make([]string, len(t.ArtistMBIDs)), } for i, mbid := range t.ArtistMBIDs { extension.ArtistIdentifiers[i] = artistMBIDPrefix + string(mbid) } if t.ReleaseMBID != "" { extension.ReleaseIdentifier = releaseMBIDPrefix + string(t.ReleaseMBID) } // The tracknumber tag would be redundant delete(extension.AdditionalMetadata, "tracknumber") 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) if err != nil { return nil } defer file.Close() stat, err := file.Stat() if err != nil { return err } if stat.Size() == 0 { // Zero length file, treat as a new file return nil } else { playlist := jspf.JSPF{} err := playlist.Read(file) if err != nil { return err } b.playlist = playlist.Playlist } } return nil } func (b *JSPFBackend) writeJSPF() error { playlist := jspf.JSPF{ Playlist: b.playlist, } file, err := os.Create(b.filePath) if err != nil { return err } defer file.Close() return playlist.Write(file) }