From 7fb77da135d74577f76c4e1045988197e6f55f52 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sat, 24 May 2025 17:35:19 +0200 Subject: [PATCH] Allow reading Spotify history directly from ZIP file --- config.example.toml | 8 +- internal/backends/spotifyhistory/archive.go | 82 +++++++++++++++++++ .../backends/spotifyhistory/spotifyhistory.go | 51 +++++------- 3 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 internal/backends/spotifyhistory/archive.go diff --git a/config.example.toml b/config.example.toml index ecbba9b..28c37ad 100644 --- a/config.example.toml +++ b/config.example.toml @@ -105,9 +105,11 @@ client-secret = "" [service.spotify-history] # Read listens from a Spotify extended history export backend = "spotify-history" -# Directory where the extended history JSON files are located. The files must -# follow the naming scheme "Streaming_History_Audio_*.json". -dir-path = "./my_spotify_data_extended/Spotify Extended Streaming History" +# Path to the Spotify extended history archive. This can either point directly +# to the "my_spotify_data_extended.zip" ZIP file provided by Spotify or a +# directory where this file has been extracted to. The history files are +# expected to follow the naming pattern "Streaming_History_Audio_*.json". +archive-path = "./my_spotify_data_extended.zip" # If true (default), ignore listens from a Spotify "private session". ignore-incognito = true # If true, ignore listens marked as skipped. Default is false. diff --git a/internal/backends/spotifyhistory/archive.go b/internal/backends/spotifyhistory/archive.go new file mode 100644 index 0000000..1d596bd --- /dev/null +++ b/internal/backends/spotifyhistory/archive.go @@ -0,0 +1,82 @@ +/* +Copyright © 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 spotifyhistory + +import ( + "errors" + "sort" + + "go.uploadedlobster.com/scotty/internal/archive" +) + +var historyFileGlobs = []string{ + "Spotify Extended Streaming History/Streaming_History_Audio_*.json", + "Streaming_History_Audio_*.json", +} + +// Access a Spotify history archive. +// This can be either the ZIP file as provided by Spotify +// or a directory where this was extracted to. +type HistoryArchive struct { + backend archive.Archive +} + +// Open a Spotify history archive from file path. +func OpenHistoryArchive(path string) (*HistoryArchive, error) { + backend, err := archive.OpenArchive(path) + if err != nil { + return nil, err + } + + return &HistoryArchive{backend: backend}, nil +} + +func (h *HistoryArchive) GetHistoryFiles() ([]archive.FileInfo, error) { + for _, glob := range historyFileGlobs { + files, err := h.backend.Glob(glob) + if err != nil { + return nil, err + } + + if len(files) > 0 { + sort.Slice(files, func(i, j int) bool { + return files[i].Name < files[j].Name + }) + return files, nil + } + } + + // Found no files, fail + return nil, errors.New("found no history files in archive") +} + +func readHistoryFile(f archive.OpenableFile) (StreamingHistory, error) { + file, err := f.Open() + if err != nil { + return nil, err + } + + defer file.Close() + history := StreamingHistory{} + err = history.Read(file) + if err != nil { + return nil, err + } + + return history, nil +} diff --git a/internal/backends/spotifyhistory/spotifyhistory.go b/internal/backends/spotifyhistory/spotifyhistory.go index ce470ff..90ee8ff 100644 --- a/internal/backends/spotifyhistory/spotifyhistory.go +++ b/internal/backends/spotifyhistory/spotifyhistory.go @@ -19,9 +19,6 @@ package spotifyhistory import ( "context" - "os" - "path/filepath" - "slices" "sort" "time" @@ -30,10 +27,8 @@ import ( "go.uploadedlobster.com/scotty/internal/models" ) -const historyFileGlob = "Streaming_History_Audio_*.json" - type SpotifyHistoryBackend struct { - dirPath string + archivePath string ignoreIncognito bool ignoreSkipped bool skippedMinSeconds int @@ -43,9 +38,10 @@ func (b *SpotifyHistoryBackend) Name() string { return "spotify-history" } func (b *SpotifyHistoryBackend) Options() []models.BackendOption { return []models.BackendOption{{ - Name: "dir-path", - Label: i18n.Tr("Directory path"), - Type: models.String, + Name: "archive-path", + Label: i18n.Tr("Archive path"), + Type: models.String, + Default: "./my_spotify_data_extended.zip", }, { Name: "ignore-incognito", Label: i18n.Tr("Ignore listens in incognito mode"), @@ -65,7 +61,11 @@ func (b *SpotifyHistoryBackend) Options() []models.BackendOption { } func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { - b.dirPath = config.GetString("dir-path") + b.archivePath = config.GetString("archive-path") + // Backward compatibility + if b.archivePath == "" { + b.archivePath = config.GetString("dir-path") + } b.ignoreIncognito = config.GetBool("ignore-incognito", true) b.ignoreSkipped = config.GetBool("ignore-skipped", false) b.skippedMinSeconds = config.GetInt("ignore-min-duration-seconds", 30) @@ -73,11 +73,19 @@ func (b *SpotifyHistoryBackend) InitConfig(config *config.ServiceConfig) error { } func (b *SpotifyHistoryBackend) ExportListens(ctx context.Context, oldestTimestamp time.Time, results chan models.ListensResult, progress chan models.TransferProgress) { - files, err := filepath.Glob(filepath.Join(b.dirPath, historyFileGlob)) p := models.TransferProgress{ Export: &models.Progress{}, } + archive, err := OpenHistoryArchive(b.archivePath) + if err != nil { + p.Export.Abort() + progress <- p + results <- models.ListensResult{Error: err} + return + } + + files, err := archive.GetHistoryFiles() if err != nil { p.Export.Abort() progress <- p @@ -85,10 +93,9 @@ func (b *SpotifyHistoryBackend) ExportListens(ctx context.Context, oldestTimesta return } - slices.Sort(files) fileCount := int64(len(files)) p.Export.Total = fileCount - for i, filePath := range files { + for i, f := range files { if err := ctx.Err(); err != nil { results <- models.ListensResult{Error: err} p.Export.Abort() @@ -96,7 +103,7 @@ func (b *SpotifyHistoryBackend) ExportListens(ctx context.Context, oldestTimesta return } - history, err := readHistoryFile(filePath) + history, err := readHistoryFile(f.File) if err != nil { results <- models.ListensResult{Error: err} p.Export.Abort() @@ -118,19 +125,3 @@ func (b *SpotifyHistoryBackend) ExportListens(ctx context.Context, oldestTimesta p.Export.Complete() progress <- p } - -func readHistoryFile(filePath string) (StreamingHistory, error) { - file, err := os.Open(filePath) - if err != nil { - return nil, err - } - - defer file.Close() - history := StreamingHistory{} - err = history.Read(file) - if err != nil { - return nil, err - } - - return history, nil -}