From 281aec29ca1c243544ced8833abae5b56e677308 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 16:55:51 +0100 Subject: [PATCH 01/39] build: remove spotiflac dependency --- flake.nix | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/flake.nix b/flake.nix index a9ef144..b45e38e 100644 --- a/flake.nix +++ b/flake.nix @@ -7,8 +7,6 @@ { self, ... }@inputs: let - pname = "spotiflac-cli"; - version = "7.0.9"; goVersion = 24; # Change this to update the whole stack supportedSystems = [ @@ -28,18 +26,6 @@ }; } ); - - pkgs = import inputs.nixpkgs { - system = "x86_64-linux"; - overlays = [ inputs.self.overlays.default ]; - }; - - spotiflac = pkgs.fetchFromGitHub { - owner = "afkarxyz"; - repo = "SpotiFLAC"; - tag = "v${version}"; - hash = "sha256-VHYof17C+eRoZfssXRQpbB8GXlcfPhyRiWltM6yDqe0="; - }; in { overlays.default = final: prev: { @@ -55,8 +41,6 @@ go # Required for .deb build dpkg - - ffmpeg_7 ]; }; } @@ -64,8 +48,6 @@ packages = forEachSupportedSystem ( { pkgs, - # Set this to true to ship with FFmpeg which is required to download songs from amazon and qobuz - withAdditionalServices ? false, }: { default = pkgs.buildGoModule (finalAttrs: { @@ -73,8 +55,6 @@ src = ./.; vendorHash = "sha256-EpGgfiCqJjHEOphV2x8FmXeIFls7eq2NVxb/or4NLUo="; - dependencies = if withAdditionalServices then [ pkgs.ffmpeg_7 ] else [ ]; - nativeBuildInputs = with pkgs; [ installShellFiles ]; @@ -83,13 +63,6 @@ "." ]; - postPatch = '' - cp -r ${spotiflac} ./SpotiFLAC/ - sed -i "s/git clone https:\/\/github.com\/afkarxyz\/SpotiFLAC.git//g" ./tools/fetch_spotiflac_backend.sh - sed -i "s/rm -rf SpotiFLAC//g" ./tools/fetch_spotiflac_backend.sh - ./tools/fetch_spotiflac_backend.sh - ''; - postInstall = '' installShellCompletion --cmd spotiflac-cli \ --bash <($out/bin/spotiflac-cli completion bash) \ From 10cc4227f594bfcfa0b906fb73bc77975bf70401 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 16:56:40 +0100 Subject: [PATCH 02/39] build: remove old tooling --- tools/fetch_spotiflac_backend.sh | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100755 tools/fetch_spotiflac_backend.sh diff --git a/tools/fetch_spotiflac_backend.sh b/tools/fetch_spotiflac_backend.sh deleted file mode 100755 index 7fcb85f..0000000 --- a/tools/fetch_spotiflac_backend.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh - -set -e - -FOLDER=lib - -rm -rf lib/ -git clone https://github.com/afkarxyz/SpotiFLAC.git -cp -r SpotiFLAC/backend/ lib/ -mkdir -p app/ -cp SpotiFLAC/app.go app/app.go -rm -rf SpotiFLAC - -sed -i "s/package main/package app/g" app/app.go -sed -i "s/\"spotiflac\/backend\"/backend \"github.com\/Superredstone\/spotiflac-cli\/lib\"/g" app/app.go - -# Nix shenanigans -chmod -R 777 lib - -for i in $(ls lib/); do - sed -i "s/package backend/package $FOLDER/g" $FOLDER/$i -done - From 30c86752b618856b3002466d9cb35c99a4047df4 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 16:59:31 +0100 Subject: [PATCH 03/39] build: fix missing pname and version --- flake.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b45e38e..ec53008 100644 --- a/flake.nix +++ b/flake.nix @@ -51,7 +51,9 @@ }: { default = pkgs.buildGoModule (finalAttrs: { - inherit pname version; + pname = "spotiflac-cli"; + version = "2.0.0"; + src = ./.; vendorHash = "sha256-EpGgfiCqJjHEOphV2x8FmXeIFls7eq2NVxb/or4NLUo="; From fc4b29b3ebc5d6208d5ab756716dbda33a639741 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 17:10:08 +0100 Subject: [PATCH 04/39] fix: remove spotiflac libraries --- .gitignore | 2 -- go.mod | 2 -- lib/app.go | 7 +++++++ {pkg => lib}/download.go | 14 ++++++-------- {pkg => lib}/metadata.go | 12 +++++------- {pkg => lib}/utils.go | 0 main.go | 9 ++++----- 7 files changed, 22 insertions(+), 24 deletions(-) create mode 100644 lib/app.go rename {pkg => lib}/download.go (79%) rename {pkg => lib}/metadata.go (85%) rename {pkg => lib}/utils.go (100%) diff --git a/.gitignore b/.gitignore index b061f66..5484fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -lib/ -app/ build/ downloads/ Unknown/ diff --git a/go.mod b/go.mod index 24bbd7c..2b06d36 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/Superredstone/spotiflac-cli go 1.24.4 -replace github.com/Superredstone/spotiflac-cli/app => ./app - require ( github.com/bogem/id3v2/v2 v2.1.4 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect diff --git a/lib/app.go b/lib/app.go new file mode 100644 index 0000000..8574bdf --- /dev/null +++ b/lib/app.go @@ -0,0 +1,7 @@ +package pkg + +type App struct { + +} + + diff --git a/pkg/download.go b/lib/download.go similarity index 79% rename from pkg/download.go rename to lib/download.go index 92a8d28..9688424 100644 --- a/pkg/download.go +++ b/lib/download.go @@ -4,8 +4,6 @@ import ( "errors" "fmt" "strconv" - - "github.com/Superredstone/spotiflac-cli/app" ) const ( @@ -13,7 +11,7 @@ const ( DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "." ) -func Download(application *app.App, url string, output_folder string, service string) error { +func Download(app *App, url string, output_folder string, service string) error { if output_folder == "" { output_folder = DEFAULT_DOWNLOAD_OUTPUT_FOLDER } @@ -23,7 +21,7 @@ func Download(application *app.App, url string, output_folder string, service st } if service == "amazon" || service == "qobuz" { - isInstalled, err := application.CheckFFmpegInstalled() + isInstalled, err := app.CheckFFmpegInstalled() if err != nil { return err } @@ -37,7 +35,7 @@ func Download(application *app.App, url string, output_folder string, service st switch url_type { case UrlTypeTrack: - metadata, err := GetMetadata[MetadataSong](application, url) + metadata, err := GetMetadata[MetadataSong](app, url) if err != nil { return err } @@ -55,10 +53,10 @@ func Download(application *app.App, url string, output_folder string, service st SpotifyID: track.SpotifyID, } - _, err = application.DownloadTrack(downloadRequest) + _, err = app.DownloadTrack(downloadRequest) return err case UrlTypePlaylist: - metadata, err := GetMetadata[MetadataPlaylist](application, url) + metadata, err := GetMetadata[MetadataPlaylist](app, url) if err != nil { return err } @@ -80,7 +78,7 @@ func Download(application *app.App, url string, output_folder string, service st PlaylistName: metadata.Info.Owner.Name, } - _, err = application.DownloadTrack(downloadRequest) + _, err = app.DownloadTrack(downloadRequest) if err != nil { fmt.Println("Unable to download " + track.Name + " - " + track.Artists) } diff --git a/pkg/metadata.go b/lib/metadata.go similarity index 85% rename from pkg/metadata.go rename to lib/metadata.go index 35633a0..725efc1 100644 --- a/pkg/metadata.go +++ b/lib/metadata.go @@ -4,8 +4,6 @@ import ( "encoding/json" "errors" "fmt" - - "github.com/Superredstone/spotiflac-cli/app" ) type MetadataSong struct { @@ -53,7 +51,7 @@ type MetadataPlaylistOwner struct { Images string `json:"images"` } -func GetMetadata[T MetadataPlaylist | MetadataSong](application *app.App, url string) (T, error) { +func GetMetadata[T MetadataPlaylist | MetadataSong](app *App, url string) (T, error) { var result T metadataRequest := app.SpotifyMetadataRequest{ @@ -62,7 +60,7 @@ func GetMetadata[T MetadataPlaylist | MetadataSong](application *app.App, url st Timeout: 5, } - metadata, err := application.GetSpotifyMetadata(metadataRequest) + metadata, err := app.GetSpotifyMetadata(metadataRequest) if err != nil { return result, err } @@ -75,10 +73,10 @@ func GetMetadata[T MetadataPlaylist | MetadataSong](application *app.App, url st return result, nil } -func PrintMetadata(application *app.App, url string) error { +func PrintMetadata(app *App, url string) error { switch GetUrlType(url) { case UrlTypeTrack: - metadata, err := GetMetadata[MetadataSong](application, url) + metadata, err := GetMetadata[MetadataSong](app, url) if err != nil { return err } @@ -98,7 +96,7 @@ Images: %s` return nil case UrlTypePlaylist: - metadata, err := GetMetadata[MetadataPlaylist](application, url) + metadata, err := GetMetadata[MetadataPlaylist](app, url) if err != nil { return err } diff --git a/pkg/utils.go b/lib/utils.go similarity index 100% rename from pkg/utils.go rename to lib/utils.go diff --git a/main.go b/main.go index f696874..5d5cae9 100644 --- a/main.go +++ b/main.go @@ -5,15 +5,14 @@ import ( "log" "os" - "github.com/Superredstone/spotiflac-cli/app" - "github.com/Superredstone/spotiflac-cli/pkg" + "github.com/Superredstone/spotiflac-cli/lib" "github.com/urfave/cli/v3" ) func main() { var output_folder, service string - application := app.NewApp() + app := lib.NewApp() cmd := &cli.Command{ Name: "spotiflac-cli", @@ -41,7 +40,7 @@ func main() { }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() - err := pkg.Download(application, song_url, output_folder, service) + err := pkg.Download(app, song_url, output_folder, service) return err }, }, @@ -51,7 +50,7 @@ func main() { Usage: "view song metadata", Action: func(ctx context.Context, cmd *cli.Command) error { url := cmd.Args().First() - return pkg.PrintMetadata(application, url) + return pkg.PrintMetadata(app, url) }, }, }, From af20e29064cd48bc2c51e486f919e3614257f2d1 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 17:23:56 +0100 Subject: [PATCH 05/39] fix: change package names --- lib/app.go | 7 ++++--- lib/download.go | 53 ++++++++++++++++++++++++++++++++----------------- lib/metadata.go | 5 ++--- lib/services.go | 1 + lib/utils.go | 2 +- main.go | 8 ++++---- 6 files changed, 47 insertions(+), 29 deletions(-) create mode 100644 lib/services.go diff --git a/lib/app.go b/lib/app.go index 8574bdf..ea98230 100644 --- a/lib/app.go +++ b/lib/app.go @@ -1,7 +1,8 @@ -package pkg +package lib type App struct { - } - +func NewApp() App { + return App{} +} diff --git a/lib/download.go b/lib/download.go index 9688424..b5aac7e 100644 --- a/lib/download.go +++ b/lib/download.go @@ -1,4 +1,4 @@ -package pkg +package lib import ( "errors" @@ -11,24 +11,37 @@ const ( DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "." ) -func Download(app *App, url string, output_folder string, service string) error { - if output_folder == "" { - output_folder = DEFAULT_DOWNLOAD_OUTPUT_FOLDER +type AvailableServices int + +const ( + AvailableServicesTidal = iota +) + +func ParseAvailableServices(service string) (AvailableServices, error) { + switch service { + case "tidal": + return AvailableServicesTidal, nil + break } + return AvailableServicesTidal, errors.New("Invalid service.") +} + +type DownloadRequest struct { + Service AvailableServices +} - if service == "" { - service = DEFAULT_DOWNLOAD_SERVICE +func (app *App) Download(url string, outputFolder string, serviceString string) error { + if outputFolder == "" { + outputFolder = DEFAULT_DOWNLOAD_OUTPUT_FOLDER } - if service == "amazon" || service == "qobuz" { - isInstalled, err := app.CheckFFmpegInstalled() - if err != nil { - return err - } + if serviceString == "" { + serviceString = DEFAULT_DOWNLOAD_SERVICE + } - if !isInstalled { - return errors.New("FFmpeg is not installed.") - } + service, err := ParseAvailableServices(serviceString) + if err != nil { + return err } url_type := GetUrlType(url) @@ -41,7 +54,7 @@ func Download(app *App, url string, output_folder string, service string) error } track := metadata.Track - downloadRequest := app.DownloadRequest{ + downloadRequest := DownloadRequest{ Service: service, TrackName: track.Name, ArtistName: track.Artists, @@ -49,7 +62,7 @@ func Download(app *App, url string, output_folder string, service string) error AlbumArtist: track.AlbumArtist, ReleaseDate: track.ReleaseDate, CoverURL: track.Images, - OutputDir: output_folder, + OutputDir: outputFolder, SpotifyID: track.SpotifyID, } @@ -65,7 +78,7 @@ func Download(app *App, url string, output_folder string, service string) error for idx, track := range metadata.TrackList { fmt.Println("[" + strconv.Itoa(idx+1) + "/" + trackListSize + "] " + track.Name + " - " + track.Artists) - downloadRequest := app.DownloadRequest{ + downloadRequest := DownloadRequest{ Service: service, TrackName: track.Name, ArtistName: track.Artists, @@ -73,7 +86,7 @@ func Download(app *App, url string, output_folder string, service string) error AlbumArtist: track.AlbumArtist, ReleaseDate: track.ReleaseDate, CoverURL: track.Images, - OutputDir: output_folder, + OutputDir: outputFolder, SpotifyID: track.SpotifyID, PlaylistName: metadata.Info.Owner.Name, } @@ -89,3 +102,7 @@ func Download(app *App, url string, output_folder string, service string) error return errors.New("Invalid URL.") } + +func (app *App) DownloadTrack(dr DownloadRequest) { + +} diff --git a/lib/metadata.go b/lib/metadata.go index 725efc1..43deb8a 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -1,4 +1,4 @@ -package pkg +package lib import ( "encoding/json" @@ -73,7 +73,7 @@ func GetMetadata[T MetadataPlaylist | MetadataSong](app *App, url string) (T, er return result, nil } -func PrintMetadata(app *App, url string) error { +func (app *App) PrintMetadata(url string) error { switch GetUrlType(url) { case UrlTypeTrack: metadata, err := GetMetadata[MetadataSong](app, url) @@ -117,4 +117,3 @@ Cover: %s` return errors.New("Invalid URL.") } - diff --git a/lib/services.go b/lib/services.go new file mode 100644 index 0000000..55c21f8 --- /dev/null +++ b/lib/services.go @@ -0,0 +1 @@ +package lib diff --git a/lib/utils.go b/lib/utils.go index 9cce6f2..64c243b 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -1,4 +1,4 @@ -package pkg +package lib import ( "strings" diff --git a/main.go b/main.go index 5d5cae9..71acc13 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,7 @@ import ( ) func main() { - var output_folder, service string + var outputFolder, service string app := lib.NewApp() @@ -29,7 +29,7 @@ func main() { Name: "output", Aliases: []string{"o"}, Usage: "set output folder", - Destination: &output_folder, + Destination: &outputFolder, }, &cli.StringFlag{ Name: "service", @@ -40,7 +40,7 @@ func main() { }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() - err := pkg.Download(app, song_url, output_folder, service) + err := app.Download(song_url, outputFolder, service) return err }, }, @@ -50,7 +50,7 @@ func main() { Usage: "view song metadata", Action: func(ctx context.Context, cmd *cli.Command) error { url := cmd.Args().First() - return pkg.PrintMetadata(app, url) + return app.PrintMetadata(url) }, }, }, From 9cdaf9851c4d2bcc119952cc1eef831253ad290b Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 17:25:55 +0100 Subject: [PATCH 06/39] style: move services into separated file --- lib/download.go | 15 --------------- lib/services.go | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/lib/download.go b/lib/download.go index b5aac7e..10e4558 100644 --- a/lib/download.go +++ b/lib/download.go @@ -11,21 +11,6 @@ const ( DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "." ) -type AvailableServices int - -const ( - AvailableServicesTidal = iota -) - -func ParseAvailableServices(service string) (AvailableServices, error) { - switch service { - case "tidal": - return AvailableServicesTidal, nil - break - } - return AvailableServicesTidal, errors.New("Invalid service.") -} - type DownloadRequest struct { Service AvailableServices } diff --git a/lib/services.go b/lib/services.go index 55c21f8..de53e9e 100644 --- a/lib/services.go +++ b/lib/services.go @@ -1 +1,18 @@ package lib + +import "errors" + +type AvailableServices int + +const ( + AvailableServicesTidal = iota +) + +func ParseAvailableServices(service string) (AvailableServices, error) { + switch service { + case "tidal": + return AvailableServicesTidal, nil + } + return AvailableServicesTidal, errors.New("Invalid service.") +} + From 1ce9feb5e6be31465e147046d0a745e2aefd347c Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 17:34:48 +0100 Subject: [PATCH 07/39] fix: first compilable version --- lib/download.go | 77 +++++++---------------------------------- lib/metadata.go | 92 ++----------------------------------------------- 2 files changed, 15 insertions(+), 154 deletions(-) diff --git a/lib/download.go b/lib/download.go index 10e4558..08d6084 100644 --- a/lib/download.go +++ b/lib/download.go @@ -2,8 +2,6 @@ package lib import ( "errors" - "fmt" - "strconv" ) const ( @@ -12,7 +10,15 @@ const ( ) type DownloadRequest struct { - Service AvailableServices + Service AvailableServices + Track string + Artist string + Album string + Artists string + ReleaseDate string + Cover string + OutputDir string + SpotifyID string } func (app *App) Download(url string, outputFolder string, serviceString string) error { @@ -24,70 +30,11 @@ func (app *App) Download(url string, outputFolder string, serviceString string) serviceString = DEFAULT_DOWNLOAD_SERVICE } - service, err := ParseAvailableServices(serviceString) - if err != nil { - return err - } - - url_type := GetUrlType(url) - - switch url_type { - case UrlTypeTrack: - metadata, err := GetMetadata[MetadataSong](app, url) - if err != nil { - return err - } - - track := metadata.Track - downloadRequest := DownloadRequest{ - Service: service, - TrackName: track.Name, - ArtistName: track.Artists, - AlbumName: track.AlbumName, - AlbumArtist: track.AlbumArtist, - ReleaseDate: track.ReleaseDate, - CoverURL: track.Images, - OutputDir: outputFolder, - SpotifyID: track.SpotifyID, - } - - _, err = app.DownloadTrack(downloadRequest) - return err - case UrlTypePlaylist: - metadata, err := GetMetadata[MetadataPlaylist](app, url) - if err != nil { - return err - } - - trackListSize := strconv.Itoa(len(metadata.TrackList)) - for idx, track := range metadata.TrackList { - fmt.Println("[" + strconv.Itoa(idx+1) + "/" + trackListSize + "] " + track.Name + " - " + track.Artists) - - downloadRequest := DownloadRequest{ - Service: service, - TrackName: track.Name, - ArtistName: track.Artists, - AlbumName: track.AlbumName, - AlbumArtist: track.AlbumArtist, - ReleaseDate: track.ReleaseDate, - CoverURL: track.Images, - OutputDir: outputFolder, - SpotifyID: track.SpotifyID, - PlaylistName: metadata.Info.Owner.Name, - } - - _, err = app.DownloadTrack(downloadRequest) - if err != nil { - fmt.Println("Unable to download " + track.Name + " - " + track.Artists) - } - } - - return nil - } + _ = GetUrlType(url) return errors.New("Invalid URL.") } -func (app *App) DownloadTrack(dr DownloadRequest) { - +func (app *App) DownloadTrack(dr DownloadRequest) (bool, error) { + return false, nil } diff --git a/lib/metadata.go b/lib/metadata.go index 43deb8a..d91ce0b 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -1,16 +1,10 @@ package lib import ( - "encoding/json" "errors" - "fmt" ) -type MetadataSong struct { - Track MetadataTrack `json:"track"` -} - -type MetadataTrack struct { +type Metadata struct { SpotifyID string `json:"spotify_id"` Artists string `json:"artists"` Name string `json:"name"` @@ -30,90 +24,10 @@ type MetadataTrack struct { IsExplicit bool `json:"is_explicit"` } -type MetadataPlaylist struct { - TrackList []MetadataTrack `json:"track_list"` - Info MetadataPlaylistInfo `json:"playlist_info"` -} - -type MetadataPlaylistInfo struct { - Owner MetadataPlaylistOwner `json:"owner"` - Tracks MetadataPlaylistTracks `json:"tracks"` - Cover string `json:"cover"` -} - -type MetadataPlaylistTracks struct { - Total int `json:"total"` -} - -type MetadataPlaylistOwner struct { - Name string `json:"name"` // Playlist name, this makes no sense - Owner string `json:"display_name"` // Playlist owner - Images string `json:"images"` -} - -func GetMetadata[T MetadataPlaylist | MetadataSong](app *App, url string) (T, error) { - var result T - - metadataRequest := app.SpotifyMetadataRequest{ - URL: url, - Delay: 0, - Timeout: 5, - } - - metadata, err := app.GetSpotifyMetadata(metadataRequest) - if err != nil { - return result, err - } - - err = json.Unmarshal([]byte(metadata), &result) - if err != nil { - return result, nil - } - - return result, nil +func (app *App) GetMetadata(url string) (Metadata, error) { + return Metadata{}, nil } func (app *App) PrintMetadata(url string) error { - switch GetUrlType(url) { - case UrlTypeTrack: - metadata, err := GetMetadata[MetadataSong](app, url) - if err != nil { - return err - } - - unformatted := `Name: %s -Artist: %s -Album: %s -Release date: %s -Images: %s` - msg := fmt.Sprintf(unformatted, - metadata.Track.Name, - metadata.Track.Artists, - metadata.Track.AlbumName, - metadata.Track.ReleaseDate, - metadata.Track.Images) - fmt.Println(msg) - - return nil - case UrlTypePlaylist: - metadata, err := GetMetadata[MetadataPlaylist](app, url) - if err != nil { - return err - } - - unformatted := `Name: %s -Owner: %s -Tracks: %d -Cover: %s` - msg := fmt.Sprintf(unformatted, - metadata.Info.Owner.Name, - metadata.Info.Owner.Owner, - metadata.Info.Tracks.Total, - metadata.Info.Cover) - fmt.Println(msg) - - return nil - } - return errors.New("Invalid URL.") } From 5b557f1b1f66b7cc61eaea9182f71dd49e4d0f21 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sun, 15 Feb 2026 18:13:08 +0100 Subject: [PATCH 08/39] feat: add first metadata implementation --- lib/download.go | 7 +- lib/metadata.go | 37 +- lib/spotfetch.go | 1837 ++++++++++++++++++++++++++++++++++++++++++++++ lib/utils.go | 13 +- 4 files changed, 1890 insertions(+), 4 deletions(-) create mode 100644 lib/spotfetch.go diff --git a/lib/download.go b/lib/download.go index 08d6084..168bc9a 100644 --- a/lib/download.go +++ b/lib/download.go @@ -30,7 +30,12 @@ func (app *App) Download(url string, outputFolder string, serviceString string) serviceString = DEFAULT_DOWNLOAD_SERVICE } - _ = GetUrlType(url) + _, err := app.GetMetadata(url) + if err != nil { + return err + } + + _ = ParseUrlType(url) return errors.New("Invalid URL.") } diff --git a/lib/metadata.go b/lib/metadata.go index d91ce0b..751c271 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -1,6 +1,7 @@ package lib import ( + "encoding/json" "errors" ) @@ -25,9 +26,41 @@ type Metadata struct { } func (app *App) GetMetadata(url string) (Metadata, error) { - return Metadata{}, nil + urlType := ParseUrlType(url) + + switch urlType { + case UrlTypeTrack: + app.GetTrackMetadata(url) + } + + return Metadata{}, errors.New("Invalid URL.") +} + +func (app *App) GetTrackMetadata(url string) error { + client := NewSpotifyClient() + + err := client.Initialize() + if err != nil { + return errors.New("Unable to fetch Spotify metadata.") + } + + trackId, err := ParseTrackId(url) + if err != nil { + return err + } + + payload := BuildSpotifyReqPayloadTrack(trackId) + + rawMetadata, err := client.Query(payload) + if err != nil { + return err + } + a, err := json.Marshal(rawMetadata) + println(string(a)) + + return nil } func (app *App) PrintMetadata(url string) error { - return errors.New("Invalid URL.") + return errors.New("Unimplemented.") } diff --git a/lib/spotfetch.go b/lib/spotfetch.go new file mode 100644 index 0000000..f4a5fd2 --- /dev/null +++ b/lib/spotfetch.go @@ -0,0 +1,1837 @@ +package lib + +import ( + "bytes" + "encoding/base32" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "sort" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +var SpotifyError = errors.New("spotify error") + +type SpotifyClient struct { + client *http.Client + accessToken string + clientToken string + clientID string + deviceID string + clientVersion string + cookies map[string]string +} + +func NewSpotifyClient() *SpotifyClient { + return &SpotifyClient{ + client: &http.Client{Timeout: 30 * time.Second}, + cookies: make(map[string]string), + } +} + +func (c *SpotifyClient) getTOTPSecret() (int, []byte) { + secrets := map[int][]byte{ + 59: {123, 105, 79, 70, 110, 59, 52, 125, 60, 49, 80, 70, 89, 75, 80, 86, 63, 53, 123, 37, 117, 49, 52, 93, 77, 62, 47, 86, 48, 104, 68, 72}, + 60: {79, 109, 69, 123, 90, 65, 46, 74, 94, 34, 58, 48, 70, 71, 92, 85, 122, 63, 91, 64, 87, 87}, + 61: {44, 55, 47, 42, 70, 40, 34, 114, 76, 74, 50, 111, 120, 97, 75, 76, 94, 102, 43, 69, 49, 120, 118, 80, 64, 78}, + } + + version := 61 + secretList := secrets[version] + return version, secretList +} + +func (c *SpotifyClient) generateTOTP() (string, int, error) { + version, secretList := c.getTOTPSecret() + + transformed := make([]byte, len(secretList)) + for i, b := range secretList { + transformed[i] = b ^ byte((i%33)+9) + } + + var joined strings.Builder + for _, b := range transformed { + joined.WriteString(strconv.Itoa(int(b))) + } + + hexStr := hex.EncodeToString([]byte(joined.String())) + hexBytes, err := hex.DecodeString(hexStr) + if err != nil { + return "", 0, err + } + + secret := base32Encode(hexBytes) + secret = strings.TrimRight(secret, "=") + + key, err := otp.NewKeyFromURL(fmt.Sprintf("otpauth://totp/secret?secret=%s", secret)) + if err != nil { + return "", 0, err + } + + totpCode, err := totp.GenerateCode(key.Secret(), time.Now()) + if err != nil { + return "", 0, err + } + + return totpCode, version, nil +} + +func base32Encode(data []byte) string { + b32 := base32.StdEncoding.WithPadding(base32.NoPadding) + return b32.EncodeToString(data) +} + +func (c *SpotifyClient) getAccessToken() error { + totpCode, version, err := c.generateTOTP() + if err != nil { + return err + } + + req, err := http.NewRequest("GET", "https://open.spotify.com/api/token", nil) + if err != nil { + return err + } + + q := req.URL.Query() + q.Add("reason", "init") + q.Add("productType", "web-player") + q.Add("totp", totpCode) + q.Add("totpVer", strconv.Itoa(version)) + q.Add("totpServer", totpCode) + req.URL.RawQuery = q.Encode() + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + req.Header.Set("Content-Type", "application/json;charset=UTF-8") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("%w: access token request failed: HTTP %d", SpotifyError, resp.StatusCode) + } + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return err + } + + c.accessToken = getString(data, "accessToken") + c.clientID = getString(data, "clientId") + + for _, cookie := range resp.Cookies() { + if cookie.Name == "sp_t" { + c.deviceID = cookie.Value + } + c.cookies[cookie.Name] = cookie.Value + } + + return nil +} + +func (c *SpotifyClient) getSessionInfo() error { + req, err := http.NewRequest("GET", "https://open.spotify.com", nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + + for name, value := range c.cookies { + req.AddCookie(&http.Cookie{Name: name, Value: value}) + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("%w: session initialization failed: HTTP %d", SpotifyError, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + re := regexp.MustCompile(``) + matches := re.FindStringSubmatch(string(body)) + if len(matches) > 1 { + decoded, err := base64.StdEncoding.DecodeString(matches[1]) + if err == nil { + var cfg map[string]interface{} + if json.Unmarshal(decoded, &cfg) == nil { + c.clientVersion = getString(cfg, "clientVersion") + } + } + } + + for _, cookie := range resp.Cookies() { + if cookie.Name == "sp_t" { + c.deviceID = cookie.Value + } + c.cookies[cookie.Name] = cookie.Value + } + + return nil +} + +func (c *SpotifyClient) getClientToken() error { + if c.clientID == "" || c.deviceID == "" || c.clientVersion == "" { + if err := c.getSessionInfo(); err != nil { + return err + } + if err := c.getAccessToken(); err != nil { + return err + } + } + + payload := map[string]interface{}{ + "client_data": map[string]interface{}{ + "client_version": c.clientVersion, + "client_id": c.clientID, + "js_sdk_data": map[string]interface{}{ + "device_brand": "unknown", + "device_model": "unknown", + "os": "windows", + "os_version": "NT 10.0", + "device_id": c.deviceID, + "device_type": "computer", + }, + }, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", "https://clienttoken.spotify.com/v1/clienttoken", bytes.NewBuffer(jsonData)) + if err != nil { + return err + } + + req.Header.Set("Authority", "clienttoken.spotify.com") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("%w: client token request failed: HTTP %d", SpotifyError, resp.StatusCode) + } + + var data map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return err + } + + if getString(data, "response_type") != "RESPONSE_GRANTED_TOKEN_RESPONSE" { + return fmt.Errorf("%w: invalid client token response type", SpotifyError) + } + + grantedToken := getMap(data, "granted_token") + c.clientToken = getString(grantedToken, "token") + + return nil +} + +func (c *SpotifyClient) Initialize() error { + if err := c.getSessionInfo(); err != nil { + return err + } + if err := c.getAccessToken(); err != nil { + return err + } + return c.getClientToken() +} + +func (c *SpotifyClient) Query(payload SpotifyPayload) (map[string]interface{}, error) { + if c.accessToken == "" || c.clientToken == "" { + if err := c.Initialize(); err != nil { + return nil, err + } + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", "https://api-partner.spotify.com/pathfinder/v2/query", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+c.accessToken) + req.Header.Set("Client-Token", c.clientToken) + req.Header.Set("Spotify-App-Version", c.clientVersion) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode != 200 { + errorText := string(body) + if len(errorText) > 200 { + errorText = errorText[:200] + } + return nil, fmt.Errorf("%w: API query failed: HTTP %d | %s", SpotifyError, resp.StatusCode, errorText) + } + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + return result, nil +} + +func getString(m map[string]interface{}, key string) string { + if val, ok := m[key].(string); ok { + return val + } + return "" +} + +func getMap(m map[string]interface{}, key string) map[string]interface{} { + if val, ok := m[key].(map[string]interface{}); ok { + return val + } + return make(map[string]interface{}) +} + +func getSlice(m map[string]interface{}, key string) []interface{} { + if val, ok := m[key].([]interface{}); ok { + return val + } + return nil +} + +func getFloat64(m map[string]interface{}, key string) float64 { + if val, ok := m[key].(float64); ok { + return val + } + return 0 +} + +func getInt(m map[string]interface{}, key string) int { + if val, ok := m[key].(int); ok { + return val + } + if val, ok := m[key].(float64); ok { + return int(val) + } + return 0 +} + +func getBool(m map[string]interface{}, key string) bool { + if val, ok := m[key].(bool); ok { + return val + } + return false +} + +func extractArtists(artistsData map[string]interface{}) []map[string]interface{} { + items := getSlice(artistsData, "items") + + artists := []map[string]interface{}{} + for _, item := range items { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + profile := getMap(itemMap, "profile") + artistInfo := map[string]interface{}{ + "name": getString(profile, "name"), + } + artists = append(artists, artistInfo) + } + return artists +} + +func extractCoverImage(coverData map[string]interface{}) map[string]interface{} { + if len(coverData) == 0 { + return nil + } + + var sources []interface{} + if srcs, ok := coverData["sources"].([]interface{}); ok { + sources = srcs + } else if squareImg, ok := coverData["squareCoverImage"].(map[string]interface{}); ok { + if img, ok := squareImg["image"].(map[string]interface{}); ok { + if data, ok := img["data"].(map[string]interface{}); ok { + if srcs, ok := data["sources"].([]interface{}); ok { + sources = srcs + } + } + } + } + + if len(sources) == 0 { + return nil + } + + type sourceInfo struct { + url string + width float64 + height float64 + } + + filteredSources := []sourceInfo{} + for _, s := range sources { + sMap, ok := s.(map[string]interface{}) + if !ok { + continue + } + url := getString(sMap, "url") + if url == "" { + continue + } + + width := getFloat64(sMap, "width") + if width == 0 { + width = getFloat64(sMap, "maxWidth") + } + height := getFloat64(sMap, "height") + if height == 0 { + height = getFloat64(sMap, "maxHeight") + } + + if (width > 64 && height > 64) || (width == 0 && height == 0 && url != "") { + filteredSources = append(filteredSources, sourceInfo{url: url, width: width, height: height}) + } + } + + if len(filteredSources) == 0 { + return nil + } + + sort.Slice(filteredSources, func(i, j int) bool { + return filteredSources[i].width < filteredSources[j].width + }) + + var smallURL, mediumURL, imageID, fallbackURL string + + for _, source := range filteredSources { + if source.width == 300 { + smallURL = source.url + } else if source.width == 640 { + mediumURL = source.url + } else if source.width == 0 { + fallbackURL = source.url + } + + if imageID == "" && source.url != "" { + if strings.Contains(source.url, "ab67616d0000b273") { + parts := strings.Split(source.url, "ab67616d0000b273") + if len(parts) > 1 { + imageID = parts[len(parts)-1] + } + } else if strings.Contains(source.url, "ab67616d00001e02") { + parts := strings.Split(source.url, "ab67616d00001e02") + if len(parts) > 1 { + imageID = parts[len(parts)-1] + } + } else if strings.Contains(source.url, "/image/") { + parts := strings.Split(source.url, "/image/") + if len(parts) > 1 { + imagePart := strings.Split(parts[len(parts)-1], "?")[0] + if len(imagePart) > 20 { + prefixes := []string{"ab67616d0000b273", "ab67616d00001e02", "ab67616d00004851"} + for _, prefix := range prefixes { + if strings.Contains(imagePart, prefix) { + subParts := strings.Split(imagePart, prefix) + if len(subParts) > 1 { + imageID = subParts[len(subParts)-1] + break + } + } + } + } + } + } + } + } + + largeURL := "" + if imageID != "" { + largeURL = "https://i.scdn.co/image/ab67616d000082c1" + imageID + } + + result := map[string]interface{}{} + if smallURL != "" { + result["small"] = smallURL + } + if mediumURL != "" { + result["medium"] = mediumURL + } + if largeURL != "" { + result["large"] = largeURL + } + + if len(result) == 0 && fallbackURL != "" { + result["small"] = fallbackURL + result["medium"] = fallbackURL + result["large"] = fallbackURL + } + + if len(result) == 0 { + return nil + } + return result +} + +func extractDuration(ms float64) map[string]interface{} { + totalSeconds := int(ms) / 1000 + minutes := totalSeconds / 60 + seconds := totalSeconds % 60 + return map[string]interface{}{ + "formatted": fmt.Sprintf("%d:%02d", minutes, seconds), + } +} + +func FilterTrack(data map[string]interface{}, albumFetchData ...map[string]interface{}) map[string]interface{} { + dataMap := getMap(data, "data") + trackData := getMap(dataMap, "trackUnion") + if len(trackData) == 0 { + return make(map[string]interface{}) + } + + var albumFetchDataMap map[string]interface{} + if len(albumFetchData) > 0 { + albumFetchDataMap = albumFetchData[0] + } + + artists := extractArtists(getMap(trackData, "artists")) + + if len(artists) == 0 { + artists = []map[string]interface{}{} + firstArtistItems := getSlice(getMap(trackData, "firstArtist"), "items") + for _, item := range firstArtistItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if profile, exists := itemMap["profile"]; exists { + profileMap, ok := profile.(map[string]interface{}) + if ok { + artistInfo := map[string]interface{}{ + "name": getString(profileMap, "name"), + } + artists = append(artists, artistInfo) + } + } + } + + otherArtistItems := getSlice(getMap(trackData, "otherArtists"), "items") + for _, item := range otherArtistItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if profile, exists := itemMap["profile"]; exists { + profileMap, ok := profile.(map[string]interface{}) + if ok { + artistInfo := map[string]interface{}{ + "name": getString(profileMap, "name"), + } + artists = append(artists, artistInfo) + } + } + } + } + + if len(artists) == 0 { + albumData := getMap(trackData, "albumOfTrack") + if len(albumData) > 0 { + artists = extractArtists(getMap(albumData, "artists")) + } + } + + albumData := getMap(trackData, "albumOfTrack") + var albumInfo map[string]interface{} + copyrightInfo := []map[string]interface{}{} + discInfo := map[string]interface{}{ + "discNumber": getFloat64(trackData, "discNumber"), + "totalDiscs": nil, + } + + if len(albumData) > 0 { + copyrightData := getMap(albumData, "copyright") + if len(copyrightData) > 0 { + copyrightItems := getSlice(copyrightData, "items") + if copyrightItems != nil { + for _, item := range copyrightItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + if getString(itemMap, "type") != "P" { + copyrightInfo = append(copyrightInfo, map[string]interface{}{ + "text": getString(itemMap, "text"), + }) + } + } + } + } + + tracksData := getMap(albumData, "tracks") + if len(tracksData) > 0 { + discNumbers := make(map[int]bool) + trackItems := getSlice(tracksData, "items") + if trackItems != nil { + for _, item := range trackItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + trackItem := getMap(itemMap, "track") + if len(trackItem) > 0 { + discNum := int(getFloat64(trackItem, "discNumber")) + if discNum == 0 { + discNum = 1 + } + discNumbers[discNum] = true + } + } + } + if len(discNumbers) > 0 { + maxDisc := 1 + for discNum := range discNumbers { + if discNum > maxDisc { + maxDisc = discNum + } + } + discInfo["totalDiscs"] = maxDisc + } + } + + dateInfo := getMap(albumData, "date") + releaseDate := getString(dateInfo, "isoString") + var releaseYear interface{} + if releaseDate == "" && len(dateInfo) > 0 { + yearStr := getString(dateInfo, "year") + monthStr := getString(dateInfo, "month") + dayStr := getString(dateInfo, "day") + if yearStr != "" { + year, err := strconv.Atoi(yearStr) + if err == nil { + releaseYear = year + if monthStr != "" && dayStr != "" { + month, _ := strconv.Atoi(monthStr) + day, _ := strconv.Atoi(dayStr) + releaseDate = fmt.Sprintf("%s-%02d-%02d", yearStr, month, day) + } else { + releaseDate = yearStr + } + } + } + } else if releaseDate != "" { + parts := strings.Split(releaseDate, "T") + if len(parts) > 0 { + releaseDate = parts[0] + } else { + parts = strings.Split(releaseDate, " ") + if len(parts) > 0 { + releaseDate = parts[0] + } + } + dateParts := strings.Split(releaseDate, "-") + if len(dateParts) > 0 && dateParts[0] != "" { + year, err := strconv.Atoi(dateParts[0]) + if err == nil { + releaseYear = year + } + } + } + + tracksTotalCount := float64(0) + if len(tracksData) > 0 { + tracksTotalCount = getFloat64(tracksData, "totalCount") + } + + albumID := getString(albumData, "id") + if albumID == "" { + albumURI := getString(albumData, "uri") + if strings.Contains(albumURI, ":") { + parts := strings.Split(albumURI, ":") + albumID = parts[len(parts)-1] + } + } + + albumArtistsString := "" + albumLabel := "" + if albumFetchDataMap != nil && len(albumFetchDataMap) > 0 { + albumUnionData := getMap(getMap(albumFetchDataMap, "data"), "albumUnion") + if len(albumUnionData) > 0 { + albumArtists := extractArtists(getMap(albumUnionData, "artists")) + if len(albumArtists) > 0 { + albumArtistNames := []string{} + for _, artist := range albumArtists { + albumArtistNames = append(albumArtistNames, getString(artist, "name")) + } + albumArtistsString = strings.Join(albumArtistNames, ", ") + } + if albumArtistsString == "" { + albumArtistsString = getString(albumUnionData, "artists") + } + albumLabel = getString(albumUnionData, "label") + } + } + + if albumArtistsString == "" { + albumArtists := extractArtists(getMap(albumData, "artists")) + if len(albumArtists) > 0 { + albumArtistNames := []string{} + for _, artist := range albumArtists { + albumArtistNames = append(albumArtistNames, getString(artist, "name")) + } + albumArtistsString = strings.Join(albumArtistNames, ", ") + } + } + + albumInfo = map[string]interface{}{ + "id": albumID, + "name": getString(albumData, "name"), + "released": releaseDate, + "year": releaseYear, + "tracks": int(tracksTotalCount), + } + + if albumArtistsString != "" { + albumInfo["artists"] = albumArtistsString + } + + if albumLabel != "" { + albumInfo["label"] = albumLabel + } + } + + cover := extractCoverImage(getMap(trackData, "visualIdentity")) + if cover == nil && len(albumData) > 0 { + cover = extractCoverImage(getMap(albumData, "coverArt")) + } + + durationMs := getFloat64(getMap(trackData, "duration"), "totalMilliseconds") + durationObj := extractDuration(durationMs) + durationString := getString(durationObj, "formatted") + + artistNames := []string{} + for _, artist := range artists { + artistNames = append(artistNames, getString(artist, "name")) + } + artistsString := strings.Join(artistNames, ", ") + + copyrightTexts := []string{} + for _, item := range copyrightInfo { + copyrightTexts = append(copyrightTexts, getString(item, "text")) + } + copyrightString := strings.Join(copyrightTexts, ", ") + + discNumber := int(getFloat64(trackData, "discNumber")) + if discNumber == 0 { + discNumber = 1 + } + + maxDiscFromAlbum := 0 + totalDiscsFromAlbum := 0 + + if len(albumFetchData) > 0 && albumFetchData[0] != nil { + albumUnion := getMap(getMap(albumFetchData[0], "data"), "albumUnion") + if len(albumUnion) > 0 { + discsData := getMap(albumUnion, "discs") + if len(discsData) > 0 { + totalDiscsFromAlbum = int(getFloat64(discsData, "totalCount")) + } + + albumTracks := getMap(albumUnion, "tracks") + if len(albumTracks) > 0 { + albumTrackItems := getSlice(albumTracks, "items") + currentTrackID := getString(trackData, "id") + for idx, item := range albumTrackItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + trackItem := getMap(itemMap, "track") + if len(trackItem) > 0 { + dNum := int(getFloat64(trackItem, "discNumber")) + if dNum > maxDiscFromAlbum { + maxDiscFromAlbum = dNum + } + + trackURI := getString(trackItem, "uri") + if strings.Contains(trackURI, currentTrackID) || getString(trackItem, "id") == currentTrackID { + if dNum > 0 { + discNumber = dNum + } + } + + trackNum := int(getFloat64(trackData, "trackNumber")) + itemTrackNum := idx + 1 + if trackNum == itemTrackNum && dNum > 0 { + } + } + } + } + } + } + + totalDiscs := 1 + if totalDiscsFromAlbum > 0 { + totalDiscs = totalDiscsFromAlbum + } else if maxDiscFromAlbum > 0 { + totalDiscs = maxDiscFromAlbum + } else if discInfo["totalDiscs"] != nil { + totalDiscs = discInfo["totalDiscs"].(int) + } + + contentRating := getMap(trackData, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + + filtered := map[string]interface{}{ + "id": getString(trackData, "id"), + "name": getString(trackData, "name"), + "artists": artistsString, + "album": albumInfo, + "duration": durationString, + "track": int(getFloat64(trackData, "trackNumber")), + "disc": discNumber, + "discs": totalDiscs, + "copyright": copyrightString, + "plays": getString(trackData, "playcount"), + "cover": cover, + "is_explicit": isExplicit, + } + + return filtered +} + +func FilterAlbum(data map[string]interface{}) map[string]interface{} { + dataMap := getMap(data, "data") + albumData := getMap(dataMap, "albumUnion") + if len(albumData) == 0 { + return make(map[string]interface{}) + } + + artists := extractArtists(getMap(albumData, "artists")) + artistNames := []string{} + for _, artist := range artists { + artistNames = append(artistNames, getString(artist, "name")) + } + albumArtistsString := strings.Join(artistNames, ", ") + + coverObj := extractCoverImage(getMap(albumData, "coverArt")) + var cover interface{} + if coverObj != nil { + + cover = getString(coverObj, "small") + if cover == "" { + cover = getString(coverObj, "medium") + } + if cover == "" { + cover = getString(coverObj, "large") + } + } + + tracks := []map[string]interface{}{} + tracksData := getMap(albumData, "tracksV2") + trackItems := getSlice(tracksData, "items") + if trackItems != nil { + for _, item := range trackItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + track := getMap(itemMap, "track") + if len(track) == 0 { + continue + } + + artistsData := getMap(track, "artists") + trackArtists := extractArtists(artistsData) + trackDurationMs := getFloat64(getMap(track, "duration"), "totalMilliseconds") + durationObj := extractDuration(trackDurationMs) + durationString := getString(durationObj, "formatted") + + trackArtistNames := []string{} + artistIDs := []string{} + + artistItems := getSlice(artistsData, "items") + if artistItems != nil { + for _, artistItem := range artistItems { + artistItemMap, ok := artistItem.(map[string]interface{}) + if !ok { + continue + } + artistURI := getString(artistItemMap, "uri") + if artistURI != "" && strings.Contains(artistURI, ":") { + parts := strings.Split(artistURI, ":") + if len(parts) > 0 { + artistID := parts[len(parts)-1] + if artistID != "" { + artistIDs = append(artistIDs, artistID) + } + } + } + } + } + + for _, artist := range trackArtists { + trackArtistNames = append(trackArtistNames, getString(artist, "name")) + } + trackArtistsString := strings.Join(trackArtistNames, ", ") + + trackURI := getString(track, "uri") + trackID := "" + if strings.Contains(trackURI, ":") { + parts := strings.Split(trackURI, ":") + trackID = parts[len(parts)-1] + } + + contentRating := getMap(track, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + + discNumber := int(getFloat64(track, "discNumber")) + if discNumber == 0 { + discNumber = 1 + } + + trackInfo := map[string]interface{}{ + "id": trackID, + "name": getString(track, "name"), + "artists": trackArtistsString, + "artistIds": artistIDs, + "duration": durationString, + "plays": getString(track, "playcount"), + "is_explicit": isExplicit, + "disc_number": discNumber, + } + tracks = append(tracks, trackInfo) + } + } + + dateInfo := getMap(albumData, "date") + releaseDate := getString(dateInfo, "isoString") + if releaseDate != "" && strings.Contains(releaseDate, "T") { + parts := strings.Split(releaseDate, "T") + releaseDate = parts[0] + } + + albumURI := getString(albumData, "uri") + albumID := "" + if strings.Contains(albumURI, ":") { + parts := strings.Split(albumURI, ":") + albumID = parts[len(parts)-1] + } + + totalDiscs := 1 + discsData := getMap(albumData, "discs") + if len(discsData) > 0 { + totalDiscs = int(getFloat64(discsData, "totalCount")) + } + + filtered := map[string]interface{}{ + "id": albumID, + "name": getString(albumData, "name"), + "artists": albumArtistsString, + "cover": cover, + "releaseDate": releaseDate, + "count": len(tracks), + "tracks": tracks, + "discs": map[string]interface{}{ + "totalCount": totalDiscs, + }, + "label": getString(albumData, "label"), + } + + return filtered +} + +func FilterPlaylist(data map[string]interface{}) map[string]interface{} { + dataMap := getMap(data, "data") + playlistData := getMap(dataMap, "playlistV2") + if len(playlistData) == 0 { + return make(map[string]interface{}) + } + + ownerData := getMap(getMap(playlistData, "ownerV2"), "data") + var ownerInfo map[string]interface{} + if len(ownerData) > 0 { + var avatarURL interface{} + avatarData := getMap(ownerData, "avatar") + if len(avatarData) > 0 { + sources := getSlice(avatarData, "sources") + if sources != nil { + for _, source := range sources { + sourceMap, ok := source.(map[string]interface{}) + if !ok { + continue + } + if getFloat64(sourceMap, "width") == 300 { + avatarURL = getString(sourceMap, "url") + break + } + } + if avatarURL == nil && len(sources) > 0 { + if firstSource, ok := sources[0].(map[string]interface{}); ok { + avatarURL = getString(firstSource, "url") + } + } + } + } + + ownerInfo = map[string]interface{}{ + "name": getString(ownerData, "name"), + "avatar": avatarURL, + } + } + + imagesData := getMap(playlistData, "images") + if len(imagesData) == 0 { + imagesData = getMap(playlistData, "imagesV2") + } + var cover interface{} + if len(imagesData) > 0 { + imageItems := getSlice(imagesData, "items") + if imageItems != nil && len(imageItems) > 0 { + if firstImage, ok := imageItems[0].(map[string]interface{}); ok { + firstSources := getSlice(firstImage, "sources") + if firstSources != nil && len(firstSources) > 0 { + if firstSource, ok := firstSources[0].(map[string]interface{}); ok { + sourceURL := getString(firstSource, "url") + if sourceURL != "" { + cover = sourceURL + } + } + } + } + } + if cover == nil { + imageSources := getSlice(imagesData, "sources") + if imageSources != nil && len(imageSources) > 0 { + if firstSource, ok := imageSources[0].(map[string]interface{}); ok { + sourceURL := getString(firstSource, "url") + if sourceURL != "" { + cover = sourceURL + } + } + } + } + } + + tracks := []map[string]interface{}{} + content := getMap(playlistData, "content") + contentItems := getSlice(content, "items") + if contentItems != nil { + for _, item := range contentItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + trackData := getMap(getMap(itemMap, "itemV2"), "data") + if len(trackData) == 0 { + continue + } + + var rank interface{} + var status interface{} + attributes := getSlice(itemMap, "attributes") + if attributes != nil { + for _, attr := range attributes { + attrMap, ok := attr.(map[string]interface{}) + if !ok { + continue + } + key := getString(attrMap, "key") + if key == "rank" { + rank = getString(attrMap, "value") + } else if key == "status" { + status = getString(attrMap, "value") + } + } + } + + artistsData := getMap(trackData, "artists") + trackArtists := extractArtists(artistsData) + trackArtistNames := []string{} + artistIDs := []string{} + + artistItems := getSlice(artistsData, "items") + if artistItems != nil { + for _, artistItem := range artistItems { + artistItemMap, ok := artistItem.(map[string]interface{}) + if !ok { + continue + } + artistURI := getString(artistItemMap, "uri") + if artistURI != "" && strings.Contains(artistURI, ":") { + parts := strings.Split(artistURI, ":") + if len(parts) > 0 { + artistID := parts[len(parts)-1] + if artistID != "" { + artistIDs = append(artistIDs, artistID) + } + } + } + } + } + + for _, artist := range trackArtists { + trackArtistNames = append(trackArtistNames, getString(artist, "name")) + } + artistsString := strings.Join(trackArtistNames, ", ") + + trackDurationMs := getFloat64(getMap(trackData, "trackDuration"), "totalMilliseconds") + durationObj := extractDuration(trackDurationMs) + durationString := getString(durationObj, "formatted") + + trackURI := getString(trackData, "uri") + trackID := getString(trackData, "id") + if trackID == "" { + if strings.Contains(trackURI, ":") { + parts := strings.Split(trackURI, ":") + trackID = parts[len(parts)-1] + } + } + + albumData := getMap(trackData, "albumOfTrack") + albumName := "" + albumID := "" + albumArtistsString := "" + var trackCover interface{} + + if len(albumData) > 0 { + albumName = getString(albumData, "name") + albumURI := getString(albumData, "uri") + if strings.Contains(albumURI, ":") { + parts := strings.Split(albumURI, ":") + albumID = parts[len(parts)-1] + } + coverObj := extractCoverImage(getMap(albumData, "coverArt")) + if coverObj != nil { + + trackCover = getString(coverObj, "small") + if trackCover == "" { + trackCover = getString(coverObj, "medium") + } + if trackCover == "" { + trackCover = getString(coverObj, "large") + } + } + + albumArtists := extractArtists(getMap(albumData, "artists")) + if len(albumArtists) > 0 { + albumArtistNames := []string{} + for _, artist := range albumArtists { + albumArtistNames = append(albumArtistNames, getString(artist, "name")) + } + albumArtistsString = strings.Join(albumArtistNames, ", ") + } + } + + contentRating := getMap(trackData, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + + trackName := getString(trackData, "name") + if trackName == "" { + continue + } + + trackInfo := map[string]interface{}{ + "id": trackID, + "cover": trackCover, + "title": trackName, + "artist": artistsString, + "artistIds": artistIDs, + "plays": rank, + "status": status, + "album": albumName, + "albumArtist": albumArtistsString, + "albumId": albumID, + "duration": durationString, + "is_explicit": isExplicit, + "disc_number": int(getFloat64(trackData, "discNumber")), + } + tracks = append(tracks, trackInfo) + } + } + + followersData, exists := playlistData["followers"] + var followersCount interface{} + if exists { + if followersMap, ok := followersData.(map[string]interface{}); ok { + followersCount = getFloat64(followersMap, "totalCount") + } else if count, ok := followersData.(float64); ok { + followersCount = count + } else if count, ok := followersData.(int); ok { + followersCount = float64(count) + } else { + followersCount = float64(0) + } + } else { + followersCount = float64(0) + } + + playlistURI := getString(playlistData, "uri") + playlistID := "" + if strings.Contains(playlistURI, ":") { + parts := strings.Split(playlistURI, ":") + playlistID = parts[len(parts)-1] + } + + totalCount := getFloat64(content, "totalCount") + count := len(tracks) + if totalCount > 0 { + count = int(totalCount) + } + + filtered := map[string]interface{}{ + "id": playlistID, + "name": getString(playlistData, "name"), + "description": getString(playlistData, "description"), + "owner": ownerInfo, + "cover": cover, + "count": count, + "tracks": tracks, + "followers": followersCount, + } + + return filtered +} + +func extractRelease(release map[string]interface{}) map[string]interface{} { + if len(release) == 0 { + return nil + } + + dateInfo := getMap(release, "date") + releaseDate := getString(dateInfo, "isoString") + if releaseDate == "" && len(dateInfo) > 0 { + yearStr := getString(dateInfo, "year") + monthStr := getString(dateInfo, "month") + dayStr := getString(dateInfo, "day") + if yearStr != "" { + if monthStr != "" && dayStr != "" { + month, _ := strconv.Atoi(monthStr) + day, _ := strconv.Atoi(dayStr) + releaseDate = fmt.Sprintf("%s-%02d-%02d", yearStr, month, day) + } else { + releaseDate = yearStr + } + } + } else if releaseDate != "" && strings.Contains(releaseDate, "T") { + parts := strings.Split(releaseDate, "T") + releaseDate = parts[0] + } + + coverObj := extractCoverImage(getMap(release, "coverArt")) + var cover interface{} + if coverObj != nil { + cover = getString(coverObj, "medium") + } + + releaseID := getString(release, "id") + if releaseID == "" { + releaseURI := getString(release, "uri") + if strings.Contains(releaseURI, ":") { + parts := strings.Split(releaseURI, ":") + releaseID = parts[len(parts)-1] + } + } + + var year interface{} + if yearVal, exists := dateInfo["year"]; exists { + year = yearVal + } + + var totalTracks int + tracksInfo := getMap(release, "tracks") + if tracksInfo != nil { + totalTracks = int(getFloat64(tracksInfo, "totalCount")) + } + + return map[string]interface{}{ + "id": releaseID, + "name": getString(release, "name"), + "cover": cover, + "date": releaseDate, + "year": year, + "total_tracks": totalTracks, + "type": getString(release, "type"), + } +} + +func extractDiscographyItems(itemsData map[string]interface{}) []map[string]interface{} { + items := []map[string]interface{}{} + dataItems := getSlice(itemsData, "items") + if dataItems != nil { + for _, item := range dataItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + releases := getMap(itemMap, "releases") + var release map[string]interface{} + if len(releases) > 0 { + releaseItems := getSlice(releases, "items") + if releaseItems != nil && len(releaseItems) > 0 { + if releaseMap, ok := releaseItems[0].(map[string]interface{}); ok { + release = releaseMap + } + } + } else { + release = getMap(itemMap, "album") + } + + if len(release) > 0 { + extracted := extractRelease(release) + if extracted != nil { + items = append(items, extracted) + } + } + } + } + return items +} + +func stripHTMLTags(s string) string { + re := regexp.MustCompile(`<[^>]*>`) + return re.ReplaceAllString(s, "") +} + +func FilterArtist(data map[string]interface{}) map[string]interface{} { + dataMap := getMap(data, "data") + artistData := getMap(dataMap, "artistUnion") + if len(artistData) == 0 { + return make(map[string]interface{}) + } + + profileRaw := getMap(artistData, "profile") + profile := make(map[string]interface{}) + if len(profileRaw) > 0 { + if biography, exists := profileRaw["biography"]; exists { + biographyMap, ok := biography.(map[string]interface{}) + if ok { + biographyText := getString(biographyMap, "text") + if biographyText != "" { + profile["biography"] = html.UnescapeString(stripHTMLTags(biographyText)) + } + } + } + if _, exists := profileRaw["name"]; exists { + profile["name"] = getString(profileRaw, "name") + } + if _, exists := profileRaw["verified"]; exists { + profile["verified"] = getBool(profileRaw, "verified") + } + } + + headerImageData := getMap(artistData, "headerImage") + var headerImage interface{} + if len(headerImageData) > 0 { + headerData := getMap(headerImageData, "data") + if len(headerData) > 0 { + sources := getSlice(headerData, "sources") + if sources != nil && len(sources) > 0 { + if firstSource, ok := sources[0].(map[string]interface{}); ok { + headerImage = getString(firstSource, "url") + } + } + } + } + + statsRaw := getMap(artistData, "stats") + stats := make(map[string]interface{}) + if len(statsRaw) > 0 { + if _, exists := statsRaw["followers"]; exists { + stats["followers"] = getFloat64(statsRaw, "followers") + } + if _, exists := statsRaw["monthlyListeners"]; exists { + stats["listeners"] = getFloat64(statsRaw, "monthlyListeners") + } + if _, exists := statsRaw["worldRank"]; exists { + stats["rank"] = getFloat64(statsRaw, "worldRank") + } + } + + discography := getMap(artistData, "discography") + discographyResult := make(map[string]interface{}) + + allData := getMap(discography, "all") + if len(allData) > 0 { + discographyResult["all"] = extractDiscographyItems(allData) + if totalCount, exists := allData["totalCount"]; exists { + var total float64 + if tc, ok := totalCount.(float64); ok { + total = tc + } else if tc, ok := totalCount.(int); ok { + total = float64(tc) + } else if tc, ok := totalCount.(int64); ok { + total = float64(tc) + } + discographyResult["total"] = total + } + } + + visualsData := getMap(artistData, "visuals") + galleryData := getMap(visualsData, "gallery") + gallery := []interface{}{} + if len(galleryData) > 0 { + galleryItems := getSlice(galleryData, "items") + if galleryItems != nil { + for _, item := range galleryItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + sources := getSlice(itemMap, "sources") + if sources != nil && len(sources) > 0 { + if firstSource, ok := sources[0].(map[string]interface{}); ok { + galleryURL := getString(firstSource, "url") + if galleryURL != "" { + gallery = append(gallery, galleryURL) + } + } + } + } + } + } + + avatarObj := extractCoverImage(getMap(visualsData, "avatarImage")) + var avatar interface{} + if avatarObj != nil { + if mediumURL, ok := avatarObj["medium"].(string); ok && mediumURL != "" { + avatar = mediumURL + } else if smallURL, ok := avatarObj["small"].(string); ok && smallURL != "" { + avatar = smallURL + } + } + + artistURI := getString(artistData, "uri") + artistID := "" + if strings.Contains(artistURI, ":") { + parts := strings.Split(artistURI, ":") + artistID = parts[len(parts)-1] + } + + filtered := map[string]interface{}{ + "id": artistID, + "name": getString(profile, "name"), + "profile": profile, + "avatar": avatar, + "header": headerImage, + "stats": stats, + "gallery": gallery, + "discography": discographyResult, + } + + return filtered +} + +func FilterSearch(data map[string]interface{}) map[string]interface{} { + dataMap := getMap(data, "data") + searchData := getMap(dataMap, "searchV2") + if len(searchData) == 0 { + return make(map[string]interface{}) + } + + results := map[string]interface{}{ + "tracks": []map[string]interface{}{}, + "albums": []map[string]interface{}{}, + "artists": []map[string]interface{}{}, + "playlists": []map[string]interface{}{}, + } + + tracksData := getMap(searchData, "tracksV2") + if len(tracksData) == 0 { + tracksData = getMap(searchData, "tracks") + } + trackItems := getSlice(tracksData, "items") + if trackItems != nil { + for _, item := range trackItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + var track map[string]interface{} + if itemData, exists := itemMap["item"]; exists { + itemDataMap, ok := itemData.(map[string]interface{}) + if ok { + track = getMap(itemDataMap, "data") + } + } else if trackData, exists := itemMap["track"]; exists { + if trackMap, ok := trackData.(map[string]interface{}); ok { + track = trackMap + } + } + + if len(track) == 0 { + continue + } + + trackArtists := extractArtists(getMap(track, "artists")) + trackDurationMs := getFloat64(getMap(track, "duration"), "totalMilliseconds") + if trackDurationMs == 0 { + trackDurationMs = getFloat64(getMap(track, "trackDuration"), "totalMilliseconds") + } + trackDuration := extractDuration(trackDurationMs) + + albumData := getMap(track, "albumOfTrack") + var albumInfo map[string]interface{} + if len(albumData) > 0 { + albumURI := getString(albumData, "uri") + albumID := getString(albumData, "id") + if albumID == "" { + if strings.Contains(albumURI, ":") { + parts := strings.Split(albumURI, ":") + albumID = parts[len(parts)-1] + } + } + albumInfo = map[string]interface{}{ + "name": getString(albumData, "name"), + "uri": albumURI, + "id": albumID, + } + } + + trackURI := getString(track, "uri") + trackID := getString(track, "id") + if trackID == "" { + if strings.Contains(trackURI, ":") { + parts := strings.Split(trackURI, ":") + trackID = parts[len(parts)-1] + } + } + + coverObj := extractCoverImage(getMap(albumData, "coverArt")) + var cover interface{} + if coverObj != nil { + cover = getString(coverObj, "medium") + } + + trackName := getString(track, "name") + if trackName == "" { + continue + } + + trackArtistNames := []string{} + for _, artist := range trackArtists { + trackArtistNames = append(trackArtistNames, getString(artist, "name")) + } + trackArtistsString := strings.Join(trackArtistNames, ", ") + + durationString := getString(trackDuration, "formatted") + + albumName := "" + if albumInfo != nil { + albumName = getString(albumInfo, "name") + } + + contentRating := getMap(track, "contentRating") + isExplicit := getString(contentRating, "label") == "EXPLICIT" + + trackResults := results["tracks"].([]map[string]interface{}) + trackResults = append(trackResults, map[string]interface{}{ + "id": trackID, + "name": trackName, + "artists": trackArtistsString, + "album": albumName, + "duration": durationString, + "cover": cover, + "is_explicit": isExplicit, + }) + results["tracks"] = trackResults + } + } + + albumsData := getMap(searchData, "albumsV2") + if len(albumsData) == 0 { + albumsData = getMap(searchData, "albums") + } + albumItems := getSlice(albumsData, "items") + if albumItems != nil { + for _, item := range albumItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + var album map[string]interface{} + if itemData, exists := itemMap["data"]; exists { + if albumMap, ok := itemData.(map[string]interface{}); ok { + album = albumMap + } + } else if albumData, exists := itemMap["album"]; exists { + if albumMap, ok := albumData.(map[string]interface{}); ok { + album = albumMap + } + } + + if len(album) == 0 { + continue + } + + albumArtists := extractArtists(getMap(album, "artists")) + albumURI := getString(album, "uri") + albumID := getString(album, "id") + if albumID == "" { + if strings.Contains(albumURI, ":") { + parts := strings.Split(albumURI, ":") + albumID = parts[len(parts)-1] + } + } + + coverObj := extractCoverImage(getMap(album, "coverArt")) + var cover interface{} + if coverObj != nil { + cover = getString(coverObj, "medium") + } + + albumArtistNames := []string{} + for _, artist := range albumArtists { + albumArtistNames = append(albumArtistNames, getString(artist, "name")) + } + albumArtistsString := strings.Join(albumArtistNames, ", ") + + dateInfo := getMap(album, "date") + var year interface{} + if len(dateInfo) > 0 { + if yearVal, exists := dateInfo["year"]; exists { + year = yearVal + } + } + + albumName := getString(album, "name") + if albumName == "" || albumArtistsString == "" { + continue + } + + albumResult := map[string]interface{}{ + "id": albumID, + "name": albumName, + "artists": albumArtistsString, + "cover": cover, + } + + if year != nil { + albumResult["year"] = year + } + + albumResults := results["albums"].([]map[string]interface{}) + albumResults = append(albumResults, albumResult) + results["albums"] = albumResults + } + } + + artistsData := getMap(searchData, "artistsV2") + if len(artistsData) == 0 { + artistsData = getMap(searchData, "artists") + } + artistItems := getSlice(artistsData, "items") + if artistItems != nil { + for _, item := range artistItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + var artist map[string]interface{} + if itemData, exists := itemMap["data"]; exists { + if artistMap, ok := itemData.(map[string]interface{}); ok { + artist = artistMap + } + } else if artistData, exists := itemMap["artist"]; exists { + if artistMap, ok := artistData.(map[string]interface{}); ok { + artist = artistMap + } + } + + if len(artist) == 0 { + continue + } + + artistURI := getString(artist, "uri") + artistID := "" + if strings.Contains(artistURI, ":") { + parts := strings.Split(artistURI, ":") + artistID = parts[len(parts)-1] + } + + coverObj := extractCoverImage(getMap(artist, "visualIdentity")) + if coverObj == nil { + visuals := getMap(artist, "visuals") + if len(visuals) > 0 { + coverObj = extractCoverImage(getMap(visuals, "avatarImage")) + } + } + + var cover interface{} + if coverObj != nil { + cover = getString(coverObj, "medium") + } + + artistName := getString(getMap(artist, "profile"), "name") + if artistName == "" { + artistName = getString(artist, "name") + } + + if artistName == "" { + continue + } + + artistResults := results["artists"].([]map[string]interface{}) + artistResults = append(artistResults, map[string]interface{}{ + "id": artistID, + "name": artistName, + "cover": cover, + }) + results["artists"] = artistResults + } + } + + playlistsData := getMap(searchData, "playlistsV2") + if len(playlistsData) == 0 { + playlistsData = getMap(searchData, "playlists") + } + playlistItems := getSlice(playlistsData, "items") + if playlistItems != nil { + for _, item := range playlistItems { + itemMap, ok := item.(map[string]interface{}) + if !ok { + continue + } + var playlist map[string]interface{} + if itemData, exists := itemMap["data"]; exists { + if playlistMap, ok := itemData.(map[string]interface{}); ok { + playlist = playlistMap + } + } else if playlistData, exists := itemMap["playlist"]; exists { + if playlistMap, ok := playlistData.(map[string]interface{}); ok { + playlist = playlistMap + } + } + + if len(playlist) == 0 { + continue + } + + playlistURI := getString(playlist, "uri") + playlistID := "" + if strings.Contains(playlistURI, ":") { + parts := strings.Split(playlistURI, ":") + playlistID = parts[len(parts)-1] + } + + playlistImages := getMap(playlist, "images") + if len(playlistImages) == 0 { + playlistImages = getMap(playlist, "imagesV2") + } + var playlistCoverObj map[string]interface{} + if len(playlistImages) > 0 { + imageItems := getSlice(playlistImages, "items") + if imageItems != nil && len(imageItems) > 0 { + if firstImage, ok := imageItems[0].(map[string]interface{}); ok { + firstSources := getSlice(firstImage, "sources") + if firstSources != nil { + playlistCoverObj = extractCoverImage(map[string]interface{}{"sources": firstSources}) + } + } + } + if playlistCoverObj == nil { + playlistCoverObj = extractCoverImage(playlistImages) + } + } + + var playlistCover interface{} + if playlistCoverObj != nil { + playlistCover = getString(playlistCoverObj, "medium") + } + + ownerData := getMap(getMap(playlist, "ownerV2"), "data") + ownerName := getString(ownerData, "name") + + playlistName := getString(playlist, "name") + if playlistName == "" { + continue + } + + playlistResult := map[string]interface{}{ + "id": playlistID, + "name": playlistName, + "cover": playlistCover, + } + + if ownerName != "" { + playlistResult["owner"] = ownerName + } + + playlistResults := results["playlists"].([]map[string]interface{}) + playlistResults = append(playlistResults, playlistResult) + results["playlists"] = playlistResults + } + } + + tracks := results["tracks"].([]map[string]interface{}) + albums := results["albums"].([]map[string]interface{}) + artists := results["artists"].([]map[string]interface{}) + playlists := results["playlists"].([]map[string]interface{}) + + return map[string]interface{}{ + "results": results, + "totalResults": map[string]interface{}{ + "tracks": len(tracks), + "albums": len(albums), + "artists": len(artists), + "playlists": len(playlists), + }, + } +} + +type SpotifyPayload map[string]interface{} + +func BuildSpotifyReqPayloadTrack(trackId string) SpotifyPayload { + payload := map[string]interface{}{ + "variables": map[string]interface{}{ + "uri": fmt.Sprintf("spotify:track:%s", trackId), + }, + "operationName": "getTrack", + "extensions": map[string]interface{}{ + "persistedQuery": map[string]interface{}{ + "version": 1, + "sha256Hash": "612585ae06ba435ad26369870deaae23b5c8800a256cd8a57e08eddc25a37294", + }, + }, + } + + return payload +} diff --git a/lib/utils.go b/lib/utils.go index 64c243b..5deac84 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -1,6 +1,7 @@ package lib import ( + "errors" "strings" ) @@ -12,7 +13,7 @@ const ( UrlTypeInvalid ) -func GetUrlType(url string) UrlType { +func ParseUrlType(url string) UrlType { if strings.Contains(url, "https://open.spotify.com/track") { return UrlTypeTrack } @@ -23,3 +24,13 @@ func GetUrlType(url string) UrlType { return UrlTypeInvalid } + +func ParseTrackId(url string) (string, error) { + tmp := strings.Split(url, "/") + + if len(tmp) == 0 { + return "", errors.New("Invalid URL.") + } + + return tmp[len(tmp)-1], nil +} From 1504e29919b21bab99ea5e78a35daa130cb8abb1 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Tue, 17 Feb 2026 11:18:53 +0100 Subject: [PATCH 09/39] feat: improve metadata implementation for track --- lib/download.go | 16 ++++--- lib/metadata.go | 50 +++++---------------- lib/types.go | 115 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/utils.go | 9 ++-- 4 files changed, 140 insertions(+), 50 deletions(-) create mode 100644 lib/types.go diff --git a/lib/download.go b/lib/download.go index 168bc9a..c407948 100644 --- a/lib/download.go +++ b/lib/download.go @@ -1,9 +1,5 @@ package lib -import ( - "errors" -) - const ( DEFAULT_DOWNLOAD_SERVICE = "tidal" DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "." @@ -30,14 +26,20 @@ func (app *App) Download(url string, outputFolder string, serviceString string) serviceString = DEFAULT_DOWNLOAD_SERVICE } - _, err := app.GetMetadata(url) + urlType, err := ParseUrlType(url) if err != nil { return err } - _ = ParseUrlType(url) + switch urlType { + case UrlTypeTrack: + _, err := app.GetTrackMetadata(url) + if err != nil { + return err + } + } - return errors.New("Invalid URL.") + return nil } func (app *App) DownloadTrack(dr DownloadRequest) (bool, error) { diff --git a/lib/metadata.go b/lib/metadata.go index 751c271..270539f 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -5,60 +5,34 @@ import ( "errors" ) -type Metadata struct { - SpotifyID string `json:"spotify_id"` - Artists string `json:"artists"` - Name string `json:"name"` - AlbumName string `json:"album_name"` - AlbumArtist string `json:"album_artist"` - DurationMS int `json:"duration_ms"` - Images string `json:"images"` - ReleaseDate string `json:"release_date"` - TrackNumber int `json:"track_number"` - TotalTracks int `json:"total_tracks"` - DiscNumber int `json:"disc_number"` - TotalDiscs int `json:"total_discs"` - ExternalURLs string `json:"external_urls"` - Copyright string `json:"copyright"` - Publisher string `json:"publisher"` - Plays string `json:"plays"` - IsExplicit bool `json:"is_explicit"` -} - -func (app *App) GetMetadata(url string) (Metadata, error) { - urlType := ParseUrlType(url) - - switch urlType { - case UrlTypeTrack: - app.GetTrackMetadata(url) - } - - return Metadata{}, errors.New("Invalid URL.") -} - -func (app *App) GetTrackMetadata(url string) error { +func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { client := NewSpotifyClient() + var result TrackMetadata err := client.Initialize() if err != nil { - return errors.New("Unable to fetch Spotify metadata.") + return result, errors.New("Unable to fetch Spotify metadata.") } trackId, err := ParseTrackId(url) if err != nil { - return err + return result, err } payload := BuildSpotifyReqPayloadTrack(trackId) rawMetadata, err := client.Query(payload) if err != nil { - return err + return result, err + } + + byteMetadata, err := json.Marshal(rawMetadata) + err = json.Unmarshal(byteMetadata, &result) + if err != nil { + return result, err } - a, err := json.Marshal(rawMetadata) - println(string(a)) - return nil + return result, nil } func (app *App) PrintMetadata(url string) error { diff --git a/lib/types.go b/lib/types.go new file mode 100644 index 0000000..0fecf31 --- /dev/null +++ b/lib/types.go @@ -0,0 +1,115 @@ +package lib + +import "time" + +type Copyright struct { + Items []map[string]interface{} `json:"items"` + TotalCount int64 `json:"totalCount"` +} + +type ColorRaw struct { + Hex string `json:"hex"` +} + +type ExtractedColors struct { + ColorRaw ColorRaw `json:"colorRaw"` +} + +type CoverArt struct { + ExtractedColors ExtractedColors `json:"extractedColors"` + Sources []map[string]interface{} `json:"sources"` +} + +type Date struct { + IsoString time.Time `json:"isoString"` + Precision string `json:"precision"` + Year int64 `json:"year"` +} + +type SharingInfo struct { + ShareId string `json:"shareId"` + ShareUrl string `json:"shareUrl"` +} + +type Tracks struct { + Items []map[string]interface{} `json:"items"` + TotalCount int64 `json:"totalCount"` +} + +type AlbumOfTrack struct { + Copyright Copyright `json:"copyright"` + CourtesyLine string `json:"courtesyLine"` + CoverArt CoverArt `json:"coverArt"` + Date Date `json:"date"` + Id string `json:"id"` + Name string `json:"name"` + Playability Playability `json:"playability"` + SharingInfo SharingInfo `json:"sharingInfo"` + Tracks Tracks `json:"tracks"` + Type string `json:"type"` + Uri string `json:"uri"` +} + +type AudioAssociations struct { + TypeName string `json:"__typename"` + Items []interface{} `json:"items"` +} + +type VideoAssociations struct { + TotalCount int64 `json:"totalCount"` +} + +type AssociationsV3 struct { + AudioAssociations AudioAssociations `json:"audioAssociations"` + VideoAssociations VideoAssociations `json:"videoAssociations"` +} + +type ContentRating struct { + Label string `json:"label"` +} + +type Duration struct { + TotalMilliseconds int64 `json:"totalMilliseconds"` +} + +type FirstArtist struct { + Items []map[string]interface{} `json:"items"` + TotalCount int64 `json:"totalCount"` +} + +type OtherArtists struct { + Items []interface{} `json:"items"` +} + +type Playability struct { + Playable bool `json:"playable"` + Reason string `json:"reason"` +} + +type TrackUnion struct { + TypeName string `json:"__typename"` + AlbumOfTrack AlbumOfTrack `json:"albumOfTrack"` + AssociationsV3 AssociationsV3 `json:"associationsV3"` + ContentRating ContentRating `json:"contentRating"` + Duration Duration `json:"duration"` + FirstArtist FirstArtist `json:"firstArtist"` + Id string `json:"id"` + MediaType string `json:"mediaType"` + Name string `json:"name"` + OtherArtists OtherArtists `json:"otherArtists"` + Playability Playability `json:"playability"` + Playcount string `json:"playcount"` + Saved bool `json:"saved"` + SharingInfo interface{} `json:"sharingInfo"` + TrackNumber int64 `json:"trackNumber"` + Uri string `json:"uri"` + VisualIdentity interface{} `json:"visualIdentity"` +} + +type Data struct { + TrackUnion TrackUnion `json:"trackUnion"` +} + +type TrackMetadata struct { + Data Data `json:"data"` +} diff --git a/lib/utils.go b/lib/utils.go index 5deac84..59a2464 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -10,19 +10,18 @@ type UrlType int const ( UrlTypeTrack UrlType = iota UrlTypePlaylist - UrlTypeInvalid ) -func ParseUrlType(url string) UrlType { +func ParseUrlType(url string) (UrlType, error) { if strings.Contains(url, "https://open.spotify.com/track") { - return UrlTypeTrack + return UrlTypeTrack, nil } if strings.Contains(url, "https://open.spotify.com/playlist") { - return UrlTypePlaylist + return UrlTypePlaylist, nil } - return UrlTypeInvalid + return UrlTypeTrack, errors.New("Invalid URL, not a playlist nor a track.") } func ParseTrackId(url string) (string, error) { From e5a6db72b3422caa0075c713602aadfaf82479f3 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Tue, 17 Feb 2026 16:33:57 +0100 Subject: [PATCH 10/39] feat: add song link conversion --- lib/download.go | 10 +++++++-- lib/songlink.go | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 lib/songlink.go diff --git a/lib/download.go b/lib/download.go index c407948..34e521f 100644 --- a/lib/download.go +++ b/lib/download.go @@ -33,9 +33,15 @@ func (app *App) Download(url string, outputFolder string, serviceString string) switch urlType { case UrlTypeTrack: - _, err := app.GetTrackMetadata(url) + // metadata, err := app.GetTrackMetadata(url) + // if err != nil { + // return err + // } + + // println(metadata.Data.TrackUnion.Id) + _, err := app.ConvertSongUrl(url) if err != nil { - return err + return err } } diff --git a/lib/songlink.go b/lib/songlink.go new file mode 100644 index 0000000..a0f1c62 --- /dev/null +++ b/lib/songlink.go @@ -0,0 +1,58 @@ +package lib + +import ( + "encoding/json" + "errors" + "io" + "net/http" +) + +const ( + SONGLINK_API_BASE_URL = "https://api.song.link/v1-alpha.1/links?url=" + RATE_LIMITED_RETURN_CODE = 429 +) + +type SongLinkResponse struct { + EntityUniqueId string `json:"entityUniqueId"` + UserCountry string `json:"userCountry"` + PageUrl string `json:"pageUrl"` + LinksByPlatform LinksByPlatform `json:"linksByPlatform"` +} + +type LinksByPlatform struct { + Deezer LinkByPlatform `json:"deezer"` + Tidal LinkByPlatform `json:"tidal"` +} + +type LinkByPlatform struct { + Country string `json:"country"` + Url string `json:"url"` + EntityUniqueId string `json:"entityUniqueId"` +} + +func (app *App) ConvertSongUrl(url string) (SongLinkResponse, error) { + var result SongLinkResponse + + rawResponse, err := http.Get(SONGLINK_API_BASE_URL + url) + if err != nil { + return result, err + } + + if rawResponse.StatusCode == RATE_LIMITED_RETURN_CODE { + return result, errors.New("You have been rate limited by song.link, try again later.") + } + + defer rawResponse.Body.Close() + + response, err := io.ReadAll(rawResponse.Body) + if err != nil { + return result, err + } + + err = json.Unmarshal(response, &result) + if err != nil { + return result, err + } + + return result, nil +} From 4cb04ca9731379a40bb960018be8c891ed77c0d1 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Tue, 17 Feb 2026 16:38:18 +0100 Subject: [PATCH 11/39] build: fix build hash --- flake.nix | 2 +- go.mod | 25 +++--------------- go.sum | 77 +++++-------------------------------------------------- 3 files changed, 12 insertions(+), 92 deletions(-) diff --git a/flake.nix b/flake.nix index ec53008..9ff5665 100644 --- a/flake.nix +++ b/flake.nix @@ -55,7 +55,7 @@ version = "2.0.0"; src = ./.; - vendorHash = "sha256-EpGgfiCqJjHEOphV2x8FmXeIFls7eq2NVxb/or4NLUo="; + vendorHash = "sha256-zU6wXQt7Vk8ks/LKx7pPmoJGBwRicUOmNI0c9byuTKI="; nativeBuildInputs = with pkgs; [ installShellFiles diff --git a/go.mod b/go.mod index 2b06d36..11f910b 100644 --- a/go.mod +++ b/go.mod @@ -3,25 +3,8 @@ module github.com/Superredstone/spotiflac-cli go 1.24.4 require ( - github.com/bogem/id3v2/v2 v2.1.4 // indirect - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/go-flac/flacpicture v0.3.0 // indirect - github.com/go-flac/flacvorbis v0.2.0 // indirect - github.com/go-flac/go-flac v1.0.0 // indirect - github.com/icza/bitio v1.1.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/leaanthony/slicer v1.6.0 // indirect - github.com/leaanthony/u v1.1.1 // indirect - github.com/mewkiz/flac v1.0.13 // indirect - github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect - github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect - github.com/pquerna/otp v1.5.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/ulikunitz/xz v0.5.15 // indirect - github.com/urfave/cli/v3 v3.6.2 // indirect - github.com/wailsapp/wails/v2 v2.11.0 // indirect - go.etcd.io/bbolt v1.4.3 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.22.0 // indirect + github.com/pquerna/otp v1.5.0 + github.com/urfave/cli/v3 v3.6.2 ) + +require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect diff --git a/go.sum b/go.sum index f444d55..eac4cd7 100644 --- a/go.sum +++ b/go.sum @@ -1,80 +1,17 @@ -github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= -github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-flac/flacpicture v0.3.0 h1:LkmTxzFLIynwfhHiZsX0s8xcr3/u33MzvV89u+zOT8I= -github.com/go-flac/flacpicture v0.3.0/go.mod h1:DPbrzVYQ3fJcvSgLFp9HXIrEQEdfdk/+m0nQCzwodZI= -github.com/go-flac/flacvorbis v0.2.0 h1:KH0xjpkNTXFER4cszH4zeJxYcrHbUobz/RticWGOESs= -github.com/go-flac/flacvorbis v0.2.0/go.mod h1:uIysHOtuU7OLGoCRG92bvnkg7QEqHx19qKRV6K1pBrI= -github.com/go-flac/go-flac v1.0.0 h1:6qI9XOVLcO50xpzm3nXvO31BgDgHhnr/p/rER/K/doY= -github.com/go-flac/go-flac v1.0.0/go.mod h1:WnZhcpmq4u1UdZMNn9LYSoASpWOCMOoxXxcWEHSzkW8= -github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= -github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= -github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= -github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= -github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mewkiz/flac v1.0.13 h1:6wF8rRQKBFW159Daqx6Ro7K5ZnlVhHUKfS5aTsC4oXs= -github.com/mewkiz/flac v1.0.13/go.mod h1:HfPYDA+oxjyuqMu2V+cyKcxF51KM6incpw5eZXmfA6k= -github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d h1:IL2tii4jXLdhCeQN69HNzYYW1kl0meSG0wt5+sLwszU= -github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d/go.mod h1:SIpumAnUWSy0q9RzKD3pyH3g1t5vdawUAPcW5tQrUtI= -github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1QJU3VXFKXHDZxr4TXRPGeBa8= -github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= -github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= -github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 22fe398c6cdcd1d3607ed89d89b0f1cb87d98080 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Tue, 17 Feb 2026 18:48:56 +0100 Subject: [PATCH 12/39] fix: add edge case to id parsing in url --- lib/utils.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/utils.go b/lib/utils.go index 59a2464..1c4006a 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -31,5 +31,10 @@ func ParseTrackId(url string) (string, error) { return "", errors.New("Invalid URL.") } - return tmp[len(tmp)-1], nil + tmp2 := strings.Split(tmp[len(tmp)-1], "?") + if len(tmp2) == 0 { + return tmp[len(tmp)-1], nil + } + + return tmp2[0], nil } From f79836b11ea04926a1e12f4ca8fc132aee5ffeb9 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Tue, 17 Feb 2026 19:04:47 +0100 Subject: [PATCH 13/39] feat: add tidal download url finder --- lib/app.go | 15 ++++- lib/download.go | 14 ++++- lib/tidal.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 1 + 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 lib/tidal.go diff --git a/lib/app.go b/lib/app.go index ea98230..5d14f44 100644 --- a/lib/app.go +++ b/lib/app.go @@ -1,8 +1,21 @@ package lib type App struct { + UserAgent string // User agent used for scraping requests + SelectedTidalApiUrl string } func NewApp() App { - return App{} + return App{ + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + } +} + +func (app *App) Init() error { + err := app.LoadTidalApis() + if err != nil { + return err + } + + return nil } diff --git a/lib/download.go b/lib/download.go index 34e521f..e070e10 100644 --- a/lib/download.go +++ b/lib/download.go @@ -39,10 +39,22 @@ func (app *App) Download(url string, outputFolder string, serviceString string) // } // println(metadata.Data.TrackUnion.Id) - _, err := app.ConvertSongUrl(url) + songlink, err := app.ConvertSongUrl(url) if err != nil { return err } + + tidalId, err := app.GetTidalIdFromSonglink(songlink) + if err != nil { + return err + } + + // err = app.DownloadFromTidal(tidalId) + url, err = app.GetTidalDownloadUrl(tidalId, "LOSSLESS") + if err != nil { + return err + } + println(url) } return nil diff --git a/lib/tidal.go b/lib/tidal.go new file mode 100644 index 0000000..fe5e2f0 --- /dev/null +++ b/lib/tidal.go @@ -0,0 +1,148 @@ +package lib + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +func (app *App) LoadTidalApis() error { + var found bool + + for _, url := range app.GetAvailableApis() { + res, err := http.Get(url) + if err != nil { + continue + } + + if res.StatusCode == http.StatusOK { + app.SelectedTidalApiUrl = url + found = true + break + } + } + + if !found { + return errors.New("No available Tidal APIs found.") + } + + return nil +} + +func (app *App) GetAvailableApis() []string { + // TODO: Make this load from a JSON file inside of $HOME/.config/spotiflac-cli/apis.json + return []string{ + "https://triton.squid.wtf", + "https://hifi-one.spotisaver.net", + "https://hifi-two.spotisaver.net", + "https://tidal.kinoplus.online", + "https://tidal-api.binimum.org", + } +} + +func (app *App) DownloadFromTidal(tidalId string) error { + // url, err := app.GetTidalDownloadUrl(tidalId) + // if err != nil { + // return err + // } + // rawResponse, err := http.Get(tidalUrl) + // if err != nil { + // return err + // } + // defer rawResponse.Body.Close() + // + // _, err = io.ReadAll(rawResponse.Body) + // if err != nil { + // return err + // } + + return nil +} + +type TidalAPIResponseV2 struct { + Version string `json:"version"` + Data struct { + TrackID int64 `json:"trackId"` + AssetPresentation string `json:"assetPresentation"` + AudioMode string `json:"audioMode"` + AudioQuality string `json:"audioQuality"` + ManifestMimeType string `json:"manifestMimeType"` + ManifestHash string `json:"manifestHash"` + Manifest string `json:"manifest"` + BitDepth int `json:"bitDepth"` + SampleRate int `json:"sampleRate"` + } `json:"data"` +} + +func (app *App) GetTidalDownloadUrl(tidalId string, quality string) (string, error) { + url := fmt.Sprintf("%s/track/?id=%s&quality=%s", app.SelectedTidalApiUrl, tidalId, quality) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + + req.Header.Set("User-Agent", app.UserAgent) + + rawResponse, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer rawResponse.Body.Close() + + body, err := io.ReadAll(rawResponse.Body) + if err != nil { + return "", err + } + + var response TidalAPIResponseV2 + err = json.Unmarshal(body, &response) + if err != nil { + return "", err + } + + if response.Data.Manifest != "" { + manifest, err := app.ParseTidalManifestFromBase64(response.Data.Manifest) + if err != nil { + return "", err + } + + if len(manifest.Urls) == 0 { + return "", errors.New("No download URL found inside of Tidal APIs manifest.") + } + + return manifest.Urls[0], nil + } + + return "", errors.New("Unimplemented download from API v1.") +} + +type TidalManifest struct { + MimeType string `json:"mimeType"` + Codecs string `json:"codecs"` + Urls []string `json:"urls"` +} + +func (app *App) ParseTidalManifestFromBase64(manifestBase64 string) (TidalManifest, error) { + var result TidalManifest + + manifestDecoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(manifestBase64)) + if err != nil { + return result, err + } + + err = json.Unmarshal(manifestDecoded, &result) + if err != nil { + return result, err + } + + return result, nil +} + +func (app *App) GetTidalIdFromSonglink(songlink SongLinkResponse) (string, error) { + return ParseTrackId(songlink.LinksByPlatform.Tidal.Url) +} diff --git a/main.go b/main.go index 71acc13..99e97d9 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ func main() { var outputFolder, service string app := lib.NewApp() + app.Init() cmd := &cli.Command{ Name: "spotiflac-cli", From 1026868e019b1dcd5ba34e8b19d77999b8da4830 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 13:18:47 +0100 Subject: [PATCH 14/39] feat: download implementation --- lib/download.go | 61 ++++++++++++++++++++++++++++++++++++------------- lib/types.go | 48 +++++++++++++++++++++----------------- lib/utils.go | 38 ++++++++++++++++++++++++++++++ main.go | 3 ++- 4 files changed, 112 insertions(+), 38 deletions(-) diff --git a/lib/download.go b/lib/download.go index e070e10..941ed43 100644 --- a/lib/download.go +++ b/lib/download.go @@ -1,5 +1,11 @@ package lib +import ( + "io" + "net/http" + "os" +) + const ( DEFAULT_DOWNLOAD_SERVICE = "tidal" DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "." @@ -17,10 +23,9 @@ type DownloadRequest struct { SpotifyID string } -func (app *App) Download(url string, outputFolder string, serviceString string) error { - if outputFolder == "" { - outputFolder = DEFAULT_DOWNLOAD_OUTPUT_FOLDER - } +func (app *App) Download(url string, outputFile string, serviceString string, quality string) error { + var downloadUrl string + var fileName string if serviceString == "" { serviceString = DEFAULT_DOWNLOAD_SERVICE @@ -33,15 +38,9 @@ func (app *App) Download(url string, outputFolder string, serviceString string) switch urlType { case UrlTypeTrack: - // metadata, err := app.GetTrackMetadata(url) - // if err != nil { - // return err - // } - - // println(metadata.Data.TrackUnion.Id) songlink, err := app.ConvertSongUrl(url) if err != nil { - return err + return err } tidalId, err := app.GetTidalIdFromSonglink(songlink) @@ -49,17 +48,47 @@ func (app *App) Download(url string, outputFolder string, serviceString string) return err } - // err = app.DownloadFromTidal(tidalId) - url, err = app.GetTidalDownloadUrl(tidalId, "LOSSLESS") + downloadUrl, err = app.GetTidalDownloadUrl(tidalId, quality) if err != nil { return err } - println(url) + } + + metadata, err := app.GetTrackMetadata(url) + if err != nil { + return err + } + + outputFile, err = BuildFileOutput(outputFile, fileName, metadata) + if err != nil { + return err + } + + err = app.DownloadFromUrl(downloadUrl, outputFile) + if err != nil { + return err } return nil } -func (app *App) DownloadTrack(dr DownloadRequest) (bool, error) { - return false, nil +func (app *App) DownloadFromUrl(url string, outputFilePath string) error { + outputFile, err := os.Create(outputFilePath) + if err != nil { + return err + } + defer outputFile.Close() + + res, err := http.Get(url) + if err != nil { + return err + } + defer res.Body.Close() + + _, err = io.Copy(outputFile, res.Body) + if err != nil { + return err + } + + return nil } diff --git a/lib/types.go b/lib/types.go index 0fecf31..0045098 100644 --- a/lib/types.go +++ b/lib/types.go @@ -59,7 +59,7 @@ type VideoAssociations struct { TotalCount int64 `json:"totalCount"` } -type AssociationsV3 struct { +type Associations struct { AudioAssociations AudioAssociations `json:"audioAssociations"` VideoAssociations VideoAssociations `json:"videoAssociations"` } @@ -73,12 +73,18 @@ type Duration struct { } type FirstArtist struct { - Items []map[string]interface{} `json:"items"` - TotalCount int64 `json:"totalCount"` + Items []ArtistItems `json:"items"` + TotalCount int64 `json:"totalCount"` +} + +type ArtistItems struct { + Profile struct { + Name string `json:"name"` + } `json:"profile"` } type OtherArtists struct { - Items []interface{} `json:"items"` + Items []ArtistItems `json:"items"` } type Playability struct { @@ -87,23 +93,23 @@ type Playability struct { } type TrackUnion struct { - TypeName string `json:"__typename"` - AlbumOfTrack AlbumOfTrack `json:"albumOfTrack"` - AssociationsV3 AssociationsV3 `json:"associationsV3"` - ContentRating ContentRating `json:"contentRating"` - Duration Duration `json:"duration"` - FirstArtist FirstArtist `json:"firstArtist"` - Id string `json:"id"` - MediaType string `json:"mediaType"` - Name string `json:"name"` - OtherArtists OtherArtists `json:"otherArtists"` - Playability Playability `json:"playability"` - Playcount string `json:"playcount"` - Saved bool `json:"saved"` - SharingInfo interface{} `json:"sharingInfo"` - TrackNumber int64 `json:"trackNumber"` - Uri string `json:"uri"` - VisualIdentity interface{} `json:"visualIdentity"` + TypeName string `json:"__typename"` + AlbumOfTrack AlbumOfTrack `json:"albumOfTrack"` + Associations Associations `json:"associationsV3"` + ContentRating ContentRating `json:"contentRating"` + Duration Duration `json:"duration"` + FirstArtist FirstArtist `json:"firstArtist"` + Id string `json:"id"` + MediaType string `json:"mediaType"` + Name string `json:"name"` + OtherArtists OtherArtists `json:"otherArtists"` + Playability Playability `json:"playability"` + Playcount string `json:"playcount"` + Saved bool `json:"saved"` + SharingInfo interface{} `json:"sharingInfo"` + TrackNumber int64 `json:"trackNumber"` + Uri string `json:"uri"` + VisualIdentity interface{} `json:"visualIdentity"` } type Data struct { diff --git a/lib/utils.go b/lib/utils.go index 1c4006a..56b9cee 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -2,6 +2,8 @@ package lib import ( "errors" + "fmt" + "path" "strings" ) @@ -38,3 +40,39 @@ func ParseTrackId(url string) (string, error) { return tmp2[0], nil } + +func BuildFileName(metadata TrackMetadata) (string, error) { + var result string + var artists string + + firstArtistLen := len(metadata.Data.TrackUnion.FirstArtist.Items) + if firstArtistLen == 0 { + return result, errors.New("What? This should never happen.") + } + artists = metadata.Data.TrackUnion.FirstArtist.Items[firstArtistLen-1].Profile.Name + + for _, artist := range(metadata.Data.TrackUnion.OtherArtists.Items) { + artists += ", " + artist.Profile.Name + } + + result = fmt.Sprintf("%s - %s", metadata.Data.TrackUnion.Name, artists) + + return result, nil +} + +func BuildFileOutput(outputFile string, fileName string, metadata TrackMetadata) (string, error) { + var result string + + fileName, err := BuildFileName(metadata) + if err != nil { + return result, err + } + + if outputFile == "" { + result = path.Join(DEFAULT_DOWNLOAD_OUTPUT_FOLDER, fileName) + } else { + result = outputFile + } + + return result, nil +} diff --git a/main.go b/main.go index 99e97d9..23e469c 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,8 @@ func main() { }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() - err := app.Download(song_url, outputFolder, service) + quality := "LOSSLESS" + err := app.Download(song_url, outputFolder, service, quality) return err }, }, From 80e2d868d23239d1321862ccba28b5beca7c3a2c Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 13:43:07 +0100 Subject: [PATCH 15/39] feat: add filename extension and verbose flag --- lib/app.go | 2 ++ lib/download.go | 11 ++++++++++- lib/metadata.go | 2 ++ lib/songlink.go | 2 ++ lib/tidal.go | 19 ------------------- lib/utils.go | 31 ++++++++++++++++++++++++++----- main.go | 8 ++++++++ 7 files changed, 50 insertions(+), 25 deletions(-) diff --git a/lib/app.go b/lib/app.go index 5d14f44..9d15798 100644 --- a/lib/app.go +++ b/lib/app.go @@ -3,11 +3,13 @@ package lib type App struct { UserAgent string // User agent used for scraping requests SelectedTidalApiUrl string + Verbose bool } func NewApp() App { return App{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", + Verbose: false, } } diff --git a/lib/download.go b/lib/download.go index 941ed43..83588c4 100644 --- a/lib/download.go +++ b/lib/download.go @@ -59,7 +59,12 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu return err } - outputFile, err = BuildFileOutput(outputFile, fileName, metadata) + extension, err := GetFormatFromQuality(quality) + if err != nil { + return err + } + + outputFile, err = BuildFileOutput(outputFile, fileName, extension, metadata) if err != nil { return err } @@ -73,6 +78,8 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu } func (app *App) DownloadFromUrl(url string, outputFilePath string) error { + app.log("Downloading " + outputFilePath) + outputFile, err := os.Create(outputFilePath) if err != nil { return err @@ -90,5 +97,7 @@ func (app *App) DownloadFromUrl(url string, outputFilePath string) error { return err } + app.log("Download completed") + return nil } diff --git a/lib/metadata.go b/lib/metadata.go index 270539f..3892fbd 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -6,6 +6,8 @@ import ( ) func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { + app.log("Getting metadata for " + url) + client := NewSpotifyClient() var result TrackMetadata diff --git a/lib/songlink.go b/lib/songlink.go index a0f1c62..565da10 100644 --- a/lib/songlink.go +++ b/lib/songlink.go @@ -33,6 +33,8 @@ type LinkByPlatform struct { func (app *App) ConvertSongUrl(url string) (SongLinkResponse, error) { var result SongLinkResponse + app.log("Searching " + url) + rawResponse, err := http.Get(SONGLINK_API_BASE_URL + url) if err != nil { return result, err diff --git a/lib/tidal.go b/lib/tidal.go index fe5e2f0..0534e25 100644 --- a/lib/tidal.go +++ b/lib/tidal.go @@ -44,25 +44,6 @@ func (app *App) GetAvailableApis() []string { } } -func (app *App) DownloadFromTidal(tidalId string) error { - // url, err := app.GetTidalDownloadUrl(tidalId) - // if err != nil { - // return err - // } - // rawResponse, err := http.Get(tidalUrl) - // if err != nil { - // return err - // } - // defer rawResponse.Body.Close() - // - // _, err = io.ReadAll(rawResponse.Body) - // if err != nil { - // return err - // } - - return nil -} - type TidalAPIResponseV2 struct { Version string `json:"version"` Data struct { diff --git a/lib/utils.go b/lib/utils.go index 56b9cee..14fe4a3 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -41,7 +41,7 @@ func ParseTrackId(url string) (string, error) { return tmp2[0], nil } -func BuildFileName(metadata TrackMetadata) (string, error) { +func BuildFileName(metadata TrackMetadata, extension string) (string, error) { var result string var artists string @@ -51,19 +51,19 @@ func BuildFileName(metadata TrackMetadata) (string, error) { } artists = metadata.Data.TrackUnion.FirstArtist.Items[firstArtistLen-1].Profile.Name - for _, artist := range(metadata.Data.TrackUnion.OtherArtists.Items) { + for _, artist := range metadata.Data.TrackUnion.OtherArtists.Items { artists += ", " + artist.Profile.Name } - result = fmt.Sprintf("%s - %s", metadata.Data.TrackUnion.Name, artists) + result = fmt.Sprintf("%s - %s.%s", metadata.Data.TrackUnion.Name, artists, extension) return result, nil } -func BuildFileOutput(outputFile string, fileName string, metadata TrackMetadata) (string, error) { +func BuildFileOutput(outputFile string, fileName string, extension string, metadata TrackMetadata) (string, error) { var result string - fileName, err := BuildFileName(metadata) + fileName, err := BuildFileName(metadata, extension) if err != nil { return result, err } @@ -76,3 +76,24 @@ func BuildFileOutput(outputFile string, fileName string, metadata TrackMetadata) return result, nil } + +func (app *App) log(message string) { + if app.Verbose { + fmt.Println(message) + } +} + +func GetFormatFromQuality(quality string) (string, error) { + switch quality { + case "LOW": + return "aac", nil + case "HIGH": + return "aac", nil + case "LOSSLESS": + return "flac", nil + case "HI_RES_LOSSLESS": + return "flac", nil + default: + return "", errors.New("Invalid quality.") + } +} diff --git a/main.go b/main.go index 23e469c..082b67c 100644 --- a/main.go +++ b/main.go @@ -56,6 +56,14 @@ func main() { }, }, }, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "verbose output", + Destination: &app.Verbose, + }, + }, } if err := cmd.Run(context.Background(), os.Args); err != nil { log.Fatal(err) From ecc4564ff7d2d81f10df7e53d1a1f3d22b15db15 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 16:01:56 +0100 Subject: [PATCH 16/39] feat: add file exists check --- lib/download.go | 10 ++++++++++ lib/utils.go | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/lib/download.go b/lib/download.go index 83588c4..6406d4f 100644 --- a/lib/download.go +++ b/lib/download.go @@ -69,6 +69,16 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu return err } + fileExists, err := FileExists(outputFile) + if err != nil { + return err + } + + if fileExists { + app.log("File " + outputFile + " already exists") + return nil + } + err = app.DownloadFromUrl(downloadUrl, outputFile) if err != nil { return err diff --git a/lib/utils.go b/lib/utils.go index 14fe4a3..424a28f 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -3,6 +3,7 @@ package lib import ( "errors" "fmt" + "os" "path" "strings" ) @@ -97,3 +98,16 @@ func GetFormatFromQuality(quality string) (string, error) { return "", errors.New("Invalid quality.") } } + +func FileExists(file string) (bool, error) { + _, err := os.Stat(file) + if err == nil { + return true, nil + } + + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, err +} From 41e2484b2a677ef5d75fabc3060eca0f433be749 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 16:24:28 +0100 Subject: [PATCH 17/39] feat: add metadata embedding --- go.mod | 6 +++++- go.sum | 27 +++++++++++++++++++++++++++ lib/download.go | 9 +++++++-- lib/metadata.go | 25 +++++++++++++++++++++++++ lib/utils.go | 20 +++++++++++++++----- 5 files changed, 79 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 11f910b..052d9ca 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,12 @@ module github.com/Superredstone/spotiflac-cli go 1.24.4 require ( + github.com/bogem/id3v2/v2 v2.1.4 github.com/pquerna/otp v1.5.0 github.com/urfave/cli/v3 v3.6.2 ) -require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect +require ( + github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum index eac4cd7..1e0d80e 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= +github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,5 +15,30 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/download.go b/lib/download.go index 6406d4f..30c98b1 100644 --- a/lib/download.go +++ b/lib/download.go @@ -69,9 +69,9 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu return err } - fileExists, err := FileExists(outputFile) + fileExists, err := FileExists(outputFile) if err != nil { - return err + return err } if fileExists { @@ -84,6 +84,11 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu return err } + err = app.EmbedMetadata(outputFile, metadata) + if err != nil { + return err + } + return nil } diff --git a/lib/metadata.go b/lib/metadata.go index 3892fbd..f5fed2c 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -3,6 +3,8 @@ package lib import ( "encoding/json" "errors" + + id3v2 "github.com/bogem/id3v2/v2" ) func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { @@ -40,3 +42,26 @@ func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { func (app *App) PrintMetadata(url string) error { return errors.New("Unimplemented.") } + +func (app *App) EmbedMetadata(file string, metadata TrackMetadata) error { + tag, err := id3v2.Open(file, id3v2.Options{Parse: true}) + if err != nil { + return err + } + + artists, err := GetArtists(metadata) + if err != nil { + return err + } + + tag.SetArtist(artists) + tag.SetTitle(metadata.Data.TrackUnion.Name) + tag.SetYear(string(metadata.Data.TrackUnion.AlbumOfTrack.Date.Year)) + tag.SetAlbum(metadata.Data.TrackUnion.AlbumOfTrack.Name) + + if err = tag.Save(); err != nil { + return err + } + + return nil +} diff --git a/lib/utils.go b/lib/utils.go index 424a28f..8e823ab 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -44,20 +44,30 @@ func ParseTrackId(url string) (string, error) { func BuildFileName(metadata TrackMetadata, extension string) (string, error) { var result string - var artists string + + artists, err := GetArtists(metadata) + if err != nil { + return result, err + } + + result = fmt.Sprintf("%s - %s.%s", metadata.Data.TrackUnion.Name, artists, extension) + + return result, nil +} + +func GetArtists(metadata TrackMetadata) (string, error) { + var result string firstArtistLen := len(metadata.Data.TrackUnion.FirstArtist.Items) if firstArtistLen == 0 { return result, errors.New("What? This should never happen.") } - artists = metadata.Data.TrackUnion.FirstArtist.Items[firstArtistLen-1].Profile.Name + result = metadata.Data.TrackUnion.FirstArtist.Items[firstArtistLen-1].Profile.Name for _, artist := range metadata.Data.TrackUnion.OtherArtists.Items { - artists += ", " + artist.Profile.Name + result += ", " + artist.Profile.Name } - result = fmt.Sprintf("%s - %s.%s", metadata.Data.TrackUnion.Name, artists, extension) - return result, nil } From a85812a1b83be818de29d50d17631e4283cce821 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 16:44:54 +0100 Subject: [PATCH 18/39] refactor: move track download to a different function --- lib/download.go | 39 ++++++++++++++++++++++----------------- lib/metadata.go | 2 ++ lib/utils.go | 2 +- 3 files changed, 25 insertions(+), 18 deletions(-) diff --git a/lib/download.go b/lib/download.go index 30c98b1..8bf2a95 100644 --- a/lib/download.go +++ b/lib/download.go @@ -23,12 +23,9 @@ type DownloadRequest struct { SpotifyID string } -func (app *App) Download(url string, outputFile string, serviceString string, quality string) error { - var downloadUrl string - var fileName string - - if serviceString == "" { - serviceString = DEFAULT_DOWNLOAD_SERVICE +func (app *App) Download(url string, outputFile string, service string, quality string) error { + if service == "" { + service = DEFAULT_DOWNLOAD_SERVICE } urlType, err := ParseUrlType(url) @@ -38,20 +35,28 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu switch urlType { case UrlTypeTrack: - songlink, err := app.ConvertSongUrl(url) - if err != nil { + if err := app.DownloadTrack(url, outputFile, service, quality); err != nil { return err } + } - tidalId, err := app.GetTidalIdFromSonglink(songlink) - if err != nil { - return err - } + return nil +} - downloadUrl, err = app.GetTidalDownloadUrl(tidalId, quality) - if err != nil { - return err - } +func (app *App) DownloadTrack(url string, outputFile, service string, quality string) error { + songlink, err := app.ConvertSongUrl(url) + if err != nil { + return err + } + + tidalId, err := app.GetTidalIdFromSonglink(songlink) + if err != nil { + return err + } + + downloadUrl, err := app.GetTidalDownloadUrl(tidalId, quality) + if err != nil { + return err } metadata, err := app.GetTrackMetadata(url) @@ -64,7 +69,7 @@ func (app *App) Download(url string, outputFile string, serviceString string, qu return err } - outputFile, err = BuildFileOutput(outputFile, fileName, extension, metadata) + outputFile, err = BuildFileOutput(outputFile, extension, metadata) if err != nil { return err } diff --git a/lib/metadata.go b/lib/metadata.go index f5fed2c..0efc077 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -44,6 +44,8 @@ func (app *App) PrintMetadata(url string) error { } func (app *App) EmbedMetadata(file string, metadata TrackMetadata) error { + app.log("Embedding metadata") + tag, err := id3v2.Open(file, id3v2.Options{Parse: true}) if err != nil { return err diff --git a/lib/utils.go b/lib/utils.go index 8e823ab..900c2f6 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -71,7 +71,7 @@ func GetArtists(metadata TrackMetadata) (string, error) { return result, nil } -func BuildFileOutput(outputFile string, fileName string, extension string, metadata TrackMetadata) (string, error) { +func BuildFileOutput(outputFile string, extension string, metadata TrackMetadata) (string, error) { var result string fileName, err := BuildFileName(metadata, extension) From 1944db8b81a5c0aaefefac05bdf7c799496ef9a6 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 16:54:41 +0100 Subject: [PATCH 19/39] fix: go modules --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index 052d9ca..f1a7d8a 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/Superredstone/spotiflac-cli go 1.24.4 +replace github.com/Superredstone/spotiflac-cli/lib => ./lib + require ( github.com/bogem/id3v2/v2 v2.1.4 github.com/pquerna/otp v1.5.0 From 129695f8237efba4176f97631ce2f91c0cff4f52 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 16:57:17 +0100 Subject: [PATCH 20/39] build: fix build hash --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 9ff5665..94ba569 100644 --- a/flake.nix +++ b/flake.nix @@ -55,7 +55,7 @@ version = "2.0.0"; src = ./.; - vendorHash = "sha256-zU6wXQt7Vk8ks/LKx7pPmoJGBwRicUOmNI0c9byuTKI="; + vendorHash = "sha256-cDJ2cu9lGyJanGYcnj6MGch+d2xfYTFgHfxnb450K8E="; nativeBuildInputs = with pkgs; [ installShellFiles From 6d27cc45020c3ae3d7b8129dc52b0d9c9fc6361d Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 18:49:55 +0100 Subject: [PATCH 21/39] feat: fix metadata embedding --- go.mod | 9 +++-- go.sum | 33 ++++-------------- lib/app.go | 7 +++- lib/download.go | 12 ++++++- lib/metadata.go | 88 +++++++++++++++++++++++++++++++++++++++--------- lib/spotfetch.go | 40 ++++++++++++++++++++++ lib/types.go | 16 +++++++-- lib/utils.go | 10 ++++++ 8 files changed, 164 insertions(+), 51 deletions(-) diff --git a/go.mod b/go.mod index f1a7d8a..86dec54 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,11 @@ go 1.24.4 replace github.com/Superredstone/spotiflac-cli/lib => ./lib require ( - github.com/bogem/id3v2/v2 v2.1.4 + github.com/go-flac/flacpicture/v2 v2.0.2 + github.com/go-flac/flacvorbis/v2 v2.0.2 + github.com/go-flac/go-flac/v2 v2.0.4 github.com/pquerna/otp v1.5.0 github.com/urfave/cli/v3 v3.6.2 ) -require ( - github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - golang.org/x/text v0.3.8 // indirect -) +require github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect diff --git a/go.sum b/go.sum index 1e0d80e..52b029e 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,14 @@ -github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= -github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= +github.com/go-flac/flacpicture/v2 v2.0.2/go.mod h1:DMZBPWPAmdLqNhqFSy5ZBs9wyBzOekXutGfP7/TFCuo= +github.com/go-flac/flacvorbis/v2 v2.0.2 h1:xCL3OhxrxWkHrbWUBvGNe+6FQ03yLmBbz0v5z4V2PoQ= +github.com/go-flac/flacvorbis/v2 v2.0.2/go.mod h1:SwTB5gs13VaM/N7rstwPoUsPibiMKklgwybYP9dYo2g= +github.com/go-flac/go-flac/v2 v2.0.4 h1:atf/kFa8U9idtkA//NO22XGr+MzQLeXZecnmP9sYBf0= +github.com/go-flac/go-flac/v2 v2.0.4/go.mod h1:sYOlTKxutMW0RDYF+KlD6Zn+VOCZlIFQG/r/usPveCs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -15,30 +19,5 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli/v3 v3.6.2 h1:lQuqiPrZ1cIz8hz+HcrG0TNZFxU70dPZ3Yl+pSrH9A8= github.com/urfave/cli/v3 v3.6.2/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/app.go b/lib/app.go index 9d15798..2a2c45a 100644 --- a/lib/app.go +++ b/lib/app.go @@ -4,12 +4,13 @@ type App struct { UserAgent string // User agent used for scraping requests SelectedTidalApiUrl string Verbose bool + SpotifyClient *SpotifyClient } func NewApp() App { return App{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", - Verbose: false, + Verbose: false, } } @@ -19,5 +20,9 @@ func (app *App) Init() error { return err } + if err := app.InitSpotifyClient(); err != nil { + return err + } + return nil } diff --git a/lib/download.go b/lib/download.go index 8bf2a95..bc6921a 100644 --- a/lib/download.go +++ b/lib/download.go @@ -1,6 +1,7 @@ package lib import ( + "errors" "io" "net/http" "os" @@ -38,9 +39,18 @@ func (app *App) Download(url string, outputFile string, service string, quality if err := app.DownloadTrack(url, outputFile, service, quality); err != nil { return err } + + return nil + case UrlTypePlaylist: + _, err := app.GetPlaylistMetadata(url) + if err != nil { + return err + } + + return nil } - return nil + return errors.New("Invalid URL type.") } func (app *App) DownloadTrack(url string, outputFile, service string, quality string) error { diff --git a/lib/metadata.go b/lib/metadata.go index 0efc077..684ae58 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -3,21 +3,47 @@ package lib import ( "encoding/json" "errors" + "io" + "net/http" - id3v2 "github.com/bogem/id3v2/v2" + "github.com/go-flac/flacpicture/v2" + "github.com/go-flac/flacvorbis/v2" + "github.com/go-flac/go-flac/v2" ) -func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { - app.log("Getting metadata for " + url) +func (app *App) GetPlaylistMetadata(url string) (PlaylistMetadata, error) { + app.log("Fetching playlist metadata") - client := NewSpotifyClient() - var result TrackMetadata + var result PlaylistMetadata + playlistId, err := ParseTrackId(url) + if err != nil { + return result, err + } + + payload := BuildSpotifyReqPayloadPlaylist(playlistId) + + rawMetadata, err := app.SpotifyClient.Query(payload) + if err != nil { + return result, err + } - err := client.Initialize() + byteMetadata, err := json.Marshal(rawMetadata) if err != nil { - return result, errors.New("Unable to fetch Spotify metadata.") + return result, err } + if err := json.Unmarshal(byteMetadata, &result); err != nil { + return result, err + } + + return result, nil +} + +func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { + app.log("Fetching metadata for " + url) + + var result TrackMetadata + trackId, err := ParseTrackId(url) if err != nil { return result, err @@ -25,7 +51,7 @@ func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { payload := BuildSpotifyReqPayloadTrack(trackId) - rawMetadata, err := client.Query(payload) + rawMetadata, err := app.SpotifyClient.Query(payload) if err != nil { return result, err } @@ -43,10 +69,10 @@ func (app *App) PrintMetadata(url string) error { return errors.New("Unimplemented.") } -func (app *App) EmbedMetadata(file string, metadata TrackMetadata) error { +func (app *App) EmbedMetadata(fileName string, metadata TrackMetadata) error { app.log("Embedding metadata") - tag, err := id3v2.Open(file, id3v2.Options{Parse: true}) + file, err := flac.ParseFile(fileName) if err != nil { return err } @@ -56,14 +82,46 @@ func (app *App) EmbedMetadata(file string, metadata TrackMetadata) error { return err } - tag.SetArtist(artists) - tag.SetTitle(metadata.Data.TrackUnion.Name) - tag.SetYear(string(metadata.Data.TrackUnion.AlbumOfTrack.Date.Year)) - tag.SetAlbum(metadata.Data.TrackUnion.AlbumOfTrack.Name) + cmt := flacvorbis.New() + cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Data.TrackUnion.AlbumOfTrack.Name) + cmt.Add(flacvorbis.FIELD_DATE, string(metadata.Data.TrackUnion.AlbumOfTrack.Date.IsoString.Year())) + cmt.Add(flacvorbis.FIELD_ARTIST, artists) + cmt.Add(flacvorbis.FIELD_TITLE, metadata.Data.TrackUnion.Name) + cmtBlock := cmt.Marshal() + file.Meta = append(file.Meta, &cmtBlock) - if err = tag.Save(); err != nil { + cover, err := app.GetAlbumCover(metadata) + if err != nil { return err } + picture, err := flacpicture.NewFromImageData( + flacpicture.PictureTypeFrontCover, "Front cover", cover, "image/jpeg") + + pictureMeta := picture.Marshal() + file.Meta = append(file.Meta, &pictureMeta) + file.Save(fileName) + return nil } + +func (app *App) GetAlbumCover(metadata TrackMetadata) ([]byte, error) { + app.log("Embedding cover") + + for _, source := range metadata.Data.TrackUnion.AlbumOfTrack.CoverArt.Sources { + rawResponse, err := http.Get(source.Url) + if err != nil { + continue + } + defer rawResponse.Body.Close() + + response, err := io.ReadAll(rawResponse.Body) + if err != nil { + continue + } + + return response, nil + } + + return []byte{}, errors.New("Unable to download album cover for " + metadata.Data.TrackUnion.Name + ".") +} diff --git a/lib/spotfetch.go b/lib/spotfetch.go index f4a5fd2..c1345cb 100644 --- a/lib/spotfetch.go +++ b/lib/spotfetch.go @@ -1835,3 +1835,43 @@ func BuildSpotifyReqPayloadTrack(trackId string) SpotifyPayload { return payload } + +func BuildSpotifyReqPayloadPlaylist(playlistId string) SpotifyPayload { + payload := map[string]interface{}{ + "variables": map[string]interface{}{ + "uri": fmt.Sprintf("spotify:playlist:%s", playlistId), + "offset": 0, // No one wants to download from their playlists starting from song 158th, right? + "limit": 5000, // Hope that this does not limit anyone + "enableWatchFeedEntrypoint": false, + }, + "operationName": "fetchPlaylist", + "extensions": map[string]interface{}{ + "persistedQuery": map[string]interface{}{ + "version": 1, + "sha256Hash": "bb67e0af06e8d6f52b531f97468ee4acd44cd0f82b988e15c2ea47b1148efc77", + }, + }, + } + + return payload +} + +func BuildSpotifyReqPayloadAlbum(albumId string) SpotifyPayload { + payload := map[string]interface{}{ + "variables": map[string]interface{}{ + "uri": fmt.Sprintf("spotify:album:%s", albumId), + "locale": "", + "offset": 0, // No one wants to download from an album from song number 9 + "limit": 5000, // No album will ever have more than 5000 songs, i hope + }, + "operationName": "getAlbum", + "extensions": map[string]interface{}{ + "persistedQuery": map[string]interface{}{ + "version": 1, + "sha256Hash": "b9bfabef66ed756e5e13f68a942deb60bd4125ec1f1be8cc42769dc0259b4b10", + }, + }, + } + + return payload +} diff --git a/lib/types.go b/lib/types.go index 0045098..9715208 100644 --- a/lib/types.go +++ b/lib/types.go @@ -16,8 +16,12 @@ type ExtractedColors struct { } type CoverArt struct { - ExtractedColors ExtractedColors `json:"extractedColors"` - Sources []map[string]interface{} `json:"sources"` + ExtractedColors ExtractedColors `json:"extractedColors"` + Sources []struct { + Height int `json:"height"` + Width int `json:"width"` + Url string `json:"url"` + } `json:"sources"` } type Date struct { @@ -119,3 +123,11 @@ type Data struct { type TrackMetadata struct { Data Data `json:"data"` } + +type PlaylistMetadata struct { + Data struct { + Playlist struct { + Name string `json:"name"` + } `json:"playlistV2"` + } `json:"data"` +} diff --git a/lib/utils.go b/lib/utils.go index 900c2f6..d47b497 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -121,3 +121,13 @@ func FileExists(file string) (bool, error) { return false, err } + +func (app *App) InitSpotifyClient() error { + app.SpotifyClient = NewSpotifyClient() + + if err := app.SpotifyClient.Initialize(); err != nil { + return errors.New("Unable to fetch Spotify metadata.") + } + + return nil +} From 777fab8c9a0105e6f5ff243a88812579d9fddc37 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Thu, 19 Feb 2026 18:52:58 +0100 Subject: [PATCH 22/39] fix: go modules --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 94ba569..2869cd3 100644 --- a/flake.nix +++ b/flake.nix @@ -55,7 +55,7 @@ version = "2.0.0"; src = ./.; - vendorHash = "sha256-cDJ2cu9lGyJanGYcnj6MGch+d2xfYTFgHfxnb450K8E="; + vendorHash = "sha256-o7NSSe1PGuASh3Whi+VrdPtLluiU5DaG5EtmLkEGJRw="; nativeBuildInputs = with pkgs; [ installShellFiles From e4083c65c06414c4ae33f2283ae16246588a29e3 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Fri, 20 Feb 2026 08:05:05 +0100 Subject: [PATCH 23/39] feat: add playlist download --- lib/download.go | 66 +++++++++++++++++++++++++++++++++++++++++++------ lib/types.go | 14 ++++++++++- lib/utils.go | 14 +++++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/lib/download.go b/lib/download.go index bc6921a..a6b74b2 100644 --- a/lib/download.go +++ b/lib/download.go @@ -2,9 +2,13 @@ package lib import ( "errors" + "fmt" "io" "net/http" "os" + "path" + "strconv" + "time" ) const ( @@ -36,14 +40,13 @@ func (app *App) Download(url string, outputFile string, service string, quality switch urlType { case UrlTypeTrack: - if err := app.DownloadTrack(url, outputFile, service, quality); err != nil { + if err := app.DownloadTrack(url, outputFile, service, quality, false); err != nil { return err } return nil case UrlTypePlaylist: - _, err := app.GetPlaylistMetadata(url) - if err != nil { + if err := app.DownloadPlaylist(url, outputFile, service, quality); err != nil { return err } @@ -53,7 +56,48 @@ func (app *App) Download(url string, outputFile string, service string, quality return errors.New("Invalid URL type.") } -func (app *App) DownloadTrack(url string, outputFile, service string, quality string) error { +func (app *App) DownloadPlaylist(url string, outputFile string, service string, quality string) error { + playlist, err := app.GetPlaylistMetadata(url) + if err != nil { + return err + } + + var urls []string + for _, item := range playlist.Data.Playlist.Content.Items { + url, err := SpotifyUriToLink(item.Item.Data.Uri) + if err != nil { + return err + } + + urls = append(urls, url) + } + + trackListSize := len(urls) + for idx, url := range urls { + metadata, err := app.GetTrackMetadata(url) + if err != nil { + return err + } + + artists, err := GetArtists(metadata) + if err != nil { + return err + } + + fmt.Println("[" + strconv.Itoa(idx+1) + "/" + strconv.Itoa(trackListSize) + "] " + metadata.Data.TrackUnion.Name + " - " + artists) + + if err := app.DownloadTrack(url, outputFile+"/", service, quality, true); err != nil { + return err + } + + // Avoid getting rate limited + time.Sleep(800 * time.Millisecond) + } + + return nil +} + +func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadFromPlaylist bool) error { songlink, err := app.ConvertSongUrl(url) if err != nil { return err @@ -79,9 +123,17 @@ func (app *App) DownloadTrack(url string, outputFile, service string, quality st return err } - outputFile, err = BuildFileOutput(outputFile, extension, metadata) - if err != nil { - return err + if downloadFromPlaylist { + fileName, err := BuildFileName(metadata, extension) + if err != nil { + return err + } + outputFile = path.Join(outputFile, fileName) + } else { + outputFile, err = BuildFileOutput(outputFile, extension, metadata) + if err != nil { + return err + } } fileExists, err := FileExists(outputFile) diff --git a/lib/types.go b/lib/types.go index 9715208..5acc756 100644 --- a/lib/types.go +++ b/lib/types.go @@ -127,7 +127,19 @@ type TrackMetadata struct { type PlaylistMetadata struct { Data struct { Playlist struct { - Name string `json:"name"` + Name string `json:"name"` + Content struct { + Items []struct { + Item struct { + Data struct { + IdentityTrait struct { + Name string `json:"name"` + } `json:"identityTrait"` + Uri string `json:"uri"` + } `json:"data"` + } `json:"itemV3"` + } `json:"items"` + } `json:"content"` } `json:"playlistV2"` } `json:"data"` } diff --git a/lib/utils.go b/lib/utils.go index d47b497..63cc5eb 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -15,6 +15,10 @@ const ( UrlTypePlaylist ) +const ( + BASE_SPOTIFY_TRACK_URL = "https://open.spotify.com/track/" +) + func ParseUrlType(url string) (UrlType, error) { if strings.Contains(url, "https://open.spotify.com/track") { return UrlTypeTrack, nil @@ -131,3 +135,13 @@ func (app *App) InitSpotifyClient() error { return nil } + +func SpotifyUriToLink(uri string) (string, error) { + spotifyId := strings.Split(uri, ":") + + if len(spotifyId) != 3 { + return "", errors.New("Invalid URI parsed.") + } + + return BASE_SPOTIFY_TRACK_URL + spotifyId[2], nil +} From 33668ca82a4c209126ee19224c4d836f7f602774 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Fri, 20 Feb 2026 08:47:10 +0100 Subject: [PATCH 24/39] fix: output file/dir for track download --- lib/download.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/download.go b/lib/download.go index a6b74b2..e8693ab 100644 --- a/lib/download.go +++ b/lib/download.go @@ -40,7 +40,11 @@ func (app *App) Download(url string, outputFile string, service string, quality switch urlType { case UrlTypeTrack: - if err := app.DownloadTrack(url, outputFile, service, quality, false); err != nil { + outputFileRune := []rune(outputFile) + lastCharacter := string(outputFileRune[len(outputFileRune)-1:]) + downloadInFolder := lastCharacter == "/" + + if err := app.DownloadTrack(url, outputFile, service, quality, downloadInFolder); err != nil { return err } @@ -97,7 +101,7 @@ func (app *App) DownloadPlaylist(url string, outputFile string, service string, return nil } -func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadFromPlaylist bool) error { +func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadInFolder bool) error { songlink, err := app.ConvertSongUrl(url) if err != nil { return err @@ -123,7 +127,7 @@ func (app *App) DownloadTrack(url string, outputFile string, service string, qua return err } - if downloadFromPlaylist { + if downloadInFolder { fileName, err := BuildFileName(metadata, extension) if err != nil { return err From c87f1987b56c153d2720dd12a07eba197a9282a7 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Fri, 20 Feb 2026 08:54:04 +0100 Subject: [PATCH 25/39] docs: update readme --- README.md | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ba6001b..2c25937 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,18 @@ Spotify downloader with playlist sync in mind. ## Usage ```bash -spotiflac-cli download [URL] +spotiflac-cli download [URL] -v -o ~/Music/song.flac ``` -## How to install +## How to build 1) Clone the repo ```bash -git clone https://github.com/Superredstone/spotiflac-cli +git clone https://github.com/Superredstone/spotiflac-cli && cd spotiflac-cli/ ``` -2) Download the required libraries +2) Build ```bash -./tools/fetch_spotiflac_backend.sh +go build . ``` -3) Go get all the dependencies ## License This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. From 36030f9f4c625b0eb443573d520a6f01a4f4940e Mon Sep 17 00:00:00 2001 From: Superredstone Date: Fri, 20 Feb 2026 09:32:59 +0100 Subject: [PATCH 26/39] feat: implement metadata fetch command --- lib/metadata.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++++- lib/types.go | 14 ++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/lib/metadata.go b/lib/metadata.go index 684ae58..23fd934 100644 --- a/lib/metadata.go +++ b/lib/metadata.go @@ -3,8 +3,10 @@ package lib import ( "encoding/json" "errors" + "fmt" "io" "net/http" + "strconv" "github.com/go-flac/flacpicture/v2" "github.com/go-flac/flacvorbis/v2" @@ -66,7 +68,67 @@ func (app *App) GetTrackMetadata(url string) (TrackMetadata, error) { } func (app *App) PrintMetadata(url string) error { - return errors.New("Unimplemented.") + urlType, err := ParseUrlType(url) + if err != nil { + return err + } + + switch urlType { + case UrlTypeTrack: + metadata, err := app.GetTrackMetadata(url) + if err != nil { + return err + } + + if err = PrintTrackMetadata(metadata); err != nil { + return err + } + + return nil + case UrlTypePlaylist: + metadata, err := app.GetPlaylistMetadata(url) + if err != nil { + return err + } + + var members, owner string + for _, member := range metadata.Data.Playlist.Members.Items { + if member.IsOwner { + owner = member.User.Data.Name + continue + } + + members += member.User.Data.Name + " " + } + + fmt.Println( + "Name: " + metadata.Data.Playlist.Name + "\n" + + "Owner: " + owner + "\n" + + "Members: " + members + "\n" + + "Tracks: " + strconv.Itoa(metadata.Data.Playlist.Content.TotalCount), + ) + + return nil + } + + return errors.New("Invalid URL type.") +} + +func PrintTrackMetadata(metadata TrackMetadata) error { + artists, err := GetArtists(metadata) + if err != nil { + return err + } + + fmt.Println( + "Name:\t\t" + metadata.Data.TrackUnion.Name + "\n" + + "Artists:\t" + artists + "\n" + + "Album:\t\t" + metadata.Data.TrackUnion.AlbumOfTrack.Name + "\n" + + "Year:\t\t" + strconv.FormatInt(metadata.Data.TrackUnion.AlbumOfTrack.Date.Year, 10) + "\n" + + "Spotify ID:\t" + metadata.Data.TrackUnion.Id, + ) + + return nil } func (app *App) EmbedMetadata(fileName string, metadata TrackMetadata) error { diff --git a/lib/types.go b/lib/types.go index 5acc756..6bd864f 100644 --- a/lib/types.go +++ b/lib/types.go @@ -128,8 +128,10 @@ type PlaylistMetadata struct { Data struct { Playlist struct { Name string `json:"name"` + Uri string `json:"uri"` Content struct { - Items []struct { + TotalCount int `json:"totalCount"` + Items []struct { Item struct { Data struct { IdentityTrait struct { @@ -140,6 +142,16 @@ type PlaylistMetadata struct { } `json:"itemV3"` } `json:"items"` } `json:"content"` + Members struct { + Items []struct { + IsOwner bool `json:"isOwner"` + User struct { + Data struct { + Name string `json:"name"` + } `json:"data"` + } `json:"user"` + } `json:"items"` + } `json:"members"` } `json:"playlistV2"` } `json:"data"` } From e666e1b4f892763f2c352eaefcc138c5e718e802 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Fri, 20 Feb 2026 15:08:53 +0100 Subject: [PATCH 27/39] feat: add download fallback --- lib/download.go | 55 +++++++++++++++++++++++++++++++++++++++++++------ lib/songlink.go | 4 ++-- lib/tidal.go | 8 ++++++- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/lib/download.go b/lib/download.go index e8693ab..83023fb 100644 --- a/lib/download.go +++ b/lib/download.go @@ -101,18 +101,61 @@ func (app *App) DownloadPlaylist(url string, outputFile string, service string, return nil } -func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadInFolder bool) error { - songlink, err := app.ConvertSongUrl(url) - if err != nil { - return err +func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, songlink SongLinkResponse) (string, error) { + servicesToTry := []string{} + + switch askedService { + default: + case "tidal": + servicesToTry = []string{"tidal", "amazon", "qoboz"} + break + case "amazon": + servicesToTry = []string{"amazon", "tidal", "qoboz"} + break + case "quoboz": + servicesToTry = []string{"quoboz", "tidal", "amazon"} + break + } + + var downloadUrl string + var lastError error + for _, service := range servicesToTry { + switch service { + case "tidal": + if songlink.LinksByPlatform.Tidal == nil { + continue + } + + tidalId, err := app.GetTidalIdFromSonglink(songlink) + if err != nil { + lastError = err + continue + } + + downloadUrl, err = app.GetTidalDownloadUrl(tidalId, quality) + if err != nil { + lastError = err + continue + } + + break + } } - tidalId, err := app.GetTidalIdFromSonglink(songlink) + if lastError != nil || downloadUrl == "" { + return "", errors.New("Unable to download from any source.") + } + + return downloadUrl, nil +} + +func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadInFolder bool) error { + songlink, err := app.ConvertSongUrl(url) if err != nil { return err } - downloadUrl, err := app.GetTidalDownloadUrl(tidalId, quality) + downloadUrl, err := app.GetDownloadUrlOrFallback(service, quality, songlink) if err != nil { return err } diff --git a/lib/songlink.go b/lib/songlink.go index 565da10..ea34c70 100644 --- a/lib/songlink.go +++ b/lib/songlink.go @@ -20,8 +20,8 @@ type SongLinkResponse struct { } type LinksByPlatform struct { - Deezer LinkByPlatform `json:"deezer"` - Tidal LinkByPlatform `json:"tidal"` + Deezer *LinkByPlatform `json:"deezer,omitempty"` + Tidal *LinkByPlatform `json:"tidal,omitempty"` } type LinkByPlatform struct { diff --git a/lib/tidal.go b/lib/tidal.go index 0534e25..994d02e 100644 --- a/lib/tidal.go +++ b/lib/tidal.go @@ -10,6 +10,8 @@ import ( "strings" ) +var ErrTidalUrlNotFound = errors.New("Tidal URL not found.") + func (app *App) LoadTidalApis() error { var found bool @@ -119,11 +121,15 @@ func (app *App) ParseTidalManifestFromBase64(manifestBase64 string) (TidalManife err = json.Unmarshal(manifestDecoded, &result) if err != nil { return result, err - } + } return result, nil } func (app *App) GetTidalIdFromSonglink(songlink SongLinkResponse) (string, error) { + if songlink.LinksByPlatform.Tidal == nil { + return "", ErrTidalUrlNotFound + } + return ParseTrackId(songlink.LinksByPlatform.Tidal.Url) } From 7a080cd46dbc7774d2a532cf917f75e5d2ebbe7e Mon Sep 17 00:00:00 2001 From: Superredstone Date: Wed, 25 Feb 2026 18:05:44 +0100 Subject: [PATCH 28/39] feat: add logging to know which source is going to fallback to the download --- lib/download.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/download.go b/lib/download.go index 83023fb..546a5ce 100644 --- a/lib/download.go +++ b/lib/download.go @@ -119,7 +119,11 @@ func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, so var downloadUrl string var lastError error - for _, service := range servicesToTry { + for idx, service := range servicesToTry { + if idx > 0 { + app.log("Falling back to " + service) + } + switch service { case "tidal": if songlink.LinksByPlatform.Tidal == nil { From 8b00b0e197a5836c599b09c9505ad32317160d18 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Wed, 25 Feb 2026 18:10:07 +0100 Subject: [PATCH 29/39] fix: wrong string for service parameter --- lib/download.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/download.go b/lib/download.go index 546a5ce..d67b3c6 100644 --- a/lib/download.go +++ b/lib/download.go @@ -112,8 +112,8 @@ func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, so case "amazon": servicesToTry = []string{"amazon", "tidal", "qoboz"} break - case "quoboz": - servicesToTry = []string{"quoboz", "tidal", "amazon"} + case "qoboz": + servicesToTry = []string{"qoboz", "tidal", "amazon"} break } From d479ba02d2915f6804ae460000b7da61e843f46e Mon Sep 17 00:00:00 2001 From: Superredstone Date: Wed, 25 Feb 2026 18:17:14 +0100 Subject: [PATCH 30/39] fix: for now download only from tidal --- lib/download.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/lib/download.go b/lib/download.go index d67b3c6..7241d02 100644 --- a/lib/download.go +++ b/lib/download.go @@ -107,13 +107,7 @@ func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, so switch askedService { default: case "tidal": - servicesToTry = []string{"tidal", "amazon", "qoboz"} - break - case "amazon": - servicesToTry = []string{"amazon", "tidal", "qoboz"} - break - case "qoboz": - servicesToTry = []string{"qoboz", "tidal", "amazon"} + servicesToTry = []string{"tidal"} break } From a0d76a6b01b529b358edd3a38f17b849d3178305 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Wed, 25 Feb 2026 18:25:08 +0100 Subject: [PATCH 31/39] fix: add default empty string --- main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index 082b67c..ee534bb 100644 --- a/main.go +++ b/main.go @@ -10,7 +10,8 @@ import ( ) func main() { - var outputFolder, service string + outputFolder := "" + service := "" app := lib.NewApp() app.Init() From 2fea9a79df94d9b710affd77d2bb9652a7dc8a6d Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 28 Feb 2026 10:50:29 +0100 Subject: [PATCH 32/39] feat: add --interval flag --- lib/app.go | 2 ++ lib/download.go | 2 +- main.go | 16 ++++++++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/app.go b/lib/app.go index 2a2c45a..94171ff 100644 --- a/lib/app.go +++ b/lib/app.go @@ -5,12 +5,14 @@ type App struct { SelectedTidalApiUrl string Verbose bool SpotifyClient *SpotifyClient + ApiInterval int // How many ms to wait between one call to apis and the other } func NewApp() App { return App{ UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", Verbose: false, + ApiInterval: 800, } } diff --git a/lib/download.go b/lib/download.go index 7241d02..73a727a 100644 --- a/lib/download.go +++ b/lib/download.go @@ -95,7 +95,7 @@ func (app *App) DownloadPlaylist(url string, outputFile string, service string, } // Avoid getting rate limited - time.Sleep(800 * time.Millisecond) + time.Sleep(time.Duration(app.ApiInterval) * time.Millisecond) } return nil diff --git a/main.go b/main.go index ee534bb..586b667 100644 --- a/main.go +++ b/main.go @@ -4,13 +4,14 @@ import ( "context" "log" "os" + "strconv" "github.com/Superredstone/spotiflac-cli/lib" "github.com/urfave/cli/v3" ) func main() { - outputFolder := "" + outputFolder := "" service := "" app := lib.NewApp() @@ -39,6 +40,13 @@ func main() { Usage: "set service to tidal/amazon/qobuz (FFmpeg is required for amazon and qobuz)", Destination: &service, }, + &cli.IntFlag{ + Name: "interval", + Aliases: []string{"i"}, + Usage: "interval between api requests in milliseconds", + DefaultText: strconv.Itoa(app.ApiInterval), + Destination: &app.ApiInterval, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() @@ -59,9 +67,9 @@ func main() { }, Flags: []cli.Flag{ &cli.BoolFlag{ - Name: "verbose", - Aliases: []string{"v"}, - Usage: "verbose output", + Name: "verbose", + Aliases: []string{"v"}, + Usage: "verbose output", Destination: &app.Verbose, }, }, From fcae48753b78045440b874cf255ce13110af336c Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 28 Feb 2026 16:18:50 +0100 Subject: [PATCH 33/39] fix: various bugs and general code improvements --- lib/app.go | 10 ++++++---- lib/download.go | 45 +++++++++++++++++++++++++++------------------ lib/songlink.go | 3 +-- lib/tidal.go | 23 +++++++---------------- lib/utils.go | 12 +++++++++++- main.go | 22 ++++++++++++++++------ 6 files changed, 68 insertions(+), 47 deletions(-) diff --git a/lib/app.go b/lib/app.go index 94171ff..738827d 100644 --- a/lib/app.go +++ b/lib/app.go @@ -1,27 +1,29 @@ package lib type App struct { - UserAgent string // User agent used for scraping requests SelectedTidalApiUrl string Verbose bool SpotifyClient *SpotifyClient - ApiInterval int // How many ms to wait between one call to apis and the other + ApiInterval int // How many ms to wait between one call to apis and the other + NoFallback bool } func NewApp() App { return App{ - UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36", - Verbose: false, + Verbose: false, ApiInterval: 800, + NoFallback: false, } } func (app *App) Init() error { + app.log("Initializing Tidal") err := app.LoadTidalApis() if err != nil { return err } + app.log("Initializing Spotify") if err := app.InitSpotifyClient(); err != nil { return err } diff --git a/lib/download.go b/lib/download.go index 73a727a..ae66f62 100644 --- a/lib/download.go +++ b/lib/download.go @@ -40,11 +40,20 @@ func (app *App) Download(url string, outputFile string, service string, quality switch urlType { case UrlTypeTrack: - outputFileRune := []rune(outputFile) - lastCharacter := string(outputFileRune[len(outputFileRune)-1:]) - downloadInFolder := lastCharacter == "/" + metadata, err := app.GetTrackMetadata(url) + if err != nil { + return err + } - if err := app.DownloadTrack(url, outputFile, service, quality, downloadInFolder); err != nil { + isDir := IsPathDirectory(outputFile) + if outputFile == "" && !isDir { + outputFile, err = BuildFileName(metadata, "flac") + if err != nil { + return err + } + } + + if err := app.DownloadTrack(url, outputFile, service, quality, isDir, metadata); err != nil { return err } @@ -90,7 +99,7 @@ func (app *App) DownloadPlaylist(url string, outputFile string, service string, fmt.Println("[" + strconv.Itoa(idx+1) + "/" + strconv.Itoa(trackListSize) + "] " + metadata.Data.TrackUnion.Name + " - " + artists) - if err := app.DownloadTrack(url, outputFile+"/", service, quality, true); err != nil { + if err := app.DownloadTrack(url, outputFile+"/", service, quality, true, metadata); err != nil { return err } @@ -111,6 +120,11 @@ func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, so break } + // This could have been implemented in a more clear way + if app.NoFallback { + servicesToTry = []string{servicesToTry[0]} + } + var downloadUrl string var lastError error for idx, service := range servicesToTry { @@ -118,19 +132,19 @@ func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, so app.log("Falling back to " + service) } + songId, err := app.GetIdFromSonglink(songlink) + if err != nil { + lastError = err + continue + } + switch service { case "tidal": if songlink.LinksByPlatform.Tidal == nil { continue } - tidalId, err := app.GetTidalIdFromSonglink(songlink) - if err != nil { - lastError = err - continue - } - - downloadUrl, err = app.GetTidalDownloadUrl(tidalId, quality) + downloadUrl, err = app.GetTidalDownloadUrl(songId, quality) if err != nil { lastError = err continue @@ -147,7 +161,7 @@ func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, so return downloadUrl, nil } -func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadInFolder bool) error { +func (app *App) DownloadTrack(url string, outputFile string, service string, quality string, downloadInFolder bool, metadata TrackMetadata) error { songlink, err := app.ConvertSongUrl(url) if err != nil { return err @@ -158,11 +172,6 @@ func (app *App) DownloadTrack(url string, outputFile string, service string, qua return err } - metadata, err := app.GetTrackMetadata(url) - if err != nil { - return err - } - extension, err := GetFormatFromQuality(quality) if err != nil { return err diff --git a/lib/songlink.go b/lib/songlink.go index ea34c70..6fcb40a 100644 --- a/lib/songlink.go +++ b/lib/songlink.go @@ -20,8 +20,7 @@ type SongLinkResponse struct { } type LinksByPlatform struct { - Deezer *LinkByPlatform `json:"deezer,omitempty"` - Tidal *LinkByPlatform `json:"tidal,omitempty"` + Tidal *LinkByPlatform `json:"tidal,omitempty"` } type LinkByPlatform struct { diff --git a/lib/tidal.go b/lib/tidal.go index 994d02e..ec5adfe 100644 --- a/lib/tidal.go +++ b/lib/tidal.go @@ -10,12 +10,10 @@ import ( "strings" ) -var ErrTidalUrlNotFound = errors.New("Tidal URL not found.") - func (app *App) LoadTidalApis() error { var found bool - for _, url := range app.GetAvailableApis() { + for _, url := range app.GetAvailableTidalApis() { res, err := http.Get(url) if err != nil { continue @@ -35,7 +33,7 @@ func (app *App) LoadTidalApis() error { return nil } -func (app *App) GetAvailableApis() []string { +func (app *App) GetAvailableTidalApis() []string { // TODO: Make this load from a JSON file inside of $HOME/.config/spotiflac-cli/apis.json return []string{ "https://triton.squid.wtf", @@ -64,14 +62,7 @@ type TidalAPIResponseV2 struct { func (app *App) GetTidalDownloadUrl(tidalId string, quality string) (string, error) { url := fmt.Sprintf("%s/track/?id=%s&quality=%s", app.SelectedTidalApiUrl, tidalId, quality) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "", err - } - - req.Header.Set("User-Agent", app.UserAgent) - - rawResponse, err := http.DefaultClient.Do(req) + rawResponse, err := http.Get(url) if err != nil { return "", err } @@ -126,10 +117,10 @@ func (app *App) ParseTidalManifestFromBase64(manifestBase64 string) (TidalManife return result, nil } -func (app *App) GetTidalIdFromSonglink(songlink SongLinkResponse) (string, error) { - if songlink.LinksByPlatform.Tidal == nil { - return "", ErrTidalUrlNotFound +func (app *App) GetIdFromSonglink(songlink SongLinkResponse) (string, error) { + if songlink.LinksByPlatform.Tidal != nil { + return ParseTrackId(songlink.LinksByPlatform.Tidal.Url) } - return ParseTrackId(songlink.LinksByPlatform.Tidal.Url) + return "", errors.New("No link found.") } diff --git a/lib/utils.go b/lib/utils.go index 63cc5eb..7fa86b8 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -137,7 +137,7 @@ func (app *App) InitSpotifyClient() error { } func SpotifyUriToLink(uri string) (string, error) { - spotifyId := strings.Split(uri, ":") + spotifyId := strings.Split(uri, ":") if len(spotifyId) != 3 { return "", errors.New("Invalid URI parsed.") @@ -145,3 +145,13 @@ func SpotifyUriToLink(uri string) (string, error) { return BASE_SPOTIFY_TRACK_URL + spotifyId[2], nil } + +func IsPathDirectory(path string) bool { + pathRune := []rune(path) + if len(pathRune) == 0 { + return false + } + + lastCharacter := string(pathRune[len(pathRune)-1:]) + return lastCharacter == "/" +} diff --git a/main.go b/main.go index 586b667..696ec50 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,10 @@ func main() { service := "" app := lib.NewApp() - app.Init() + err := app.Init() + if err != nil { + log.Fatal(err) + } cmd := &cli.Command{ Name: "spotiflac-cli", @@ -31,22 +34,29 @@ func main() { &cli.StringFlag{ Name: "output", Aliases: []string{"o"}, - Usage: "set output folder", + Usage: "set output folder/file", + DefaultText: outputFolder, Destination: &outputFolder, }, &cli.StringFlag{ Name: "service", Aliases: []string{"s"}, - Usage: "set service to tidal/amazon/qobuz (FFmpeg is required for amazon and qobuz)", + Usage: "set default service (only tidal is supported at the moment)", Destination: &service, }, &cli.IntFlag{ - Name: "interval", - Aliases: []string{"i"}, - Usage: "interval between api requests in milliseconds", + Name: "interval", + Aliases: []string{"i"}, + Usage: "interval between api requests in milliseconds", DefaultText: strconv.Itoa(app.ApiInterval), Destination: &app.ApiInterval, }, + &cli.BoolFlag{ + Name: "no-fallback", + Usage: "do not fallback in case a source is not found", + DefaultText: strconv.FormatBool(app.NoFallback), + Destination: &app.NoFallback, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() From 420429112aadec32bfcf7ced819124336cc614b3 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 28 Feb 2026 16:34:53 +0100 Subject: [PATCH 34/39] feat: add --stop-on-fail flag --- lib/app.go | 1 + lib/download.go | 6 +++++- main.go | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/app.go b/lib/app.go index 738827d..a4544e5 100644 --- a/lib/app.go +++ b/lib/app.go @@ -6,6 +6,7 @@ type App struct { SpotifyClient *SpotifyClient ApiInterval int // How many ms to wait between one call to apis and the other NoFallback bool + StopOnFail bool } func NewApp() App { diff --git a/lib/download.go b/lib/download.go index ae66f62..4d2ad8e 100644 --- a/lib/download.go +++ b/lib/download.go @@ -100,7 +100,11 @@ func (app *App) DownloadPlaylist(url string, outputFile string, service string, fmt.Println("[" + strconv.Itoa(idx+1) + "/" + strconv.Itoa(trackListSize) + "] " + metadata.Data.TrackUnion.Name + " - " + artists) if err := app.DownloadTrack(url, outputFile+"/", service, quality, true, metadata); err != nil { - return err + if app.StopOnFail { + return err + } + + app.log("Failed download") } // Avoid getting rate limited diff --git a/main.go b/main.go index 696ec50..cce48a8 100644 --- a/main.go +++ b/main.go @@ -57,6 +57,12 @@ func main() { DefaultText: strconv.FormatBool(app.NoFallback), Destination: &app.NoFallback, }, + &cli.BoolFlag{ + Name: "stop-on-fail", + Usage: "continue on download failure", + DefaultText: strconv.FormatBool(app.StopOnFail), + Destination: &app.StopOnFail, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() From 9d8a9538be60c5eacab410ba2abaf357344cf130 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 28 Feb 2026 16:39:51 +0100 Subject: [PATCH 35/39] feat: add --override flag --- lib/app.go | 1 + lib/download.go | 2 +- main.go | 7 +++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/app.go b/lib/app.go index a4544e5..9f6bb53 100644 --- a/lib/app.go +++ b/lib/app.go @@ -7,6 +7,7 @@ type App struct { ApiInterval int // How many ms to wait between one call to apis and the other NoFallback bool StopOnFail bool + OverrideDownload bool } func NewApp() App { diff --git a/lib/download.go b/lib/download.go index 4d2ad8e..7121dfd 100644 --- a/lib/download.go +++ b/lib/download.go @@ -199,7 +199,7 @@ func (app *App) DownloadTrack(url string, outputFile string, service string, qua return err } - if fileExists { + if fileExists && !app.OverrideDownload { app.log("File " + outputFile + " already exists") return nil } diff --git a/main.go b/main.go index cce48a8..e31fc94 100644 --- a/main.go +++ b/main.go @@ -54,15 +54,18 @@ func main() { &cli.BoolFlag{ Name: "no-fallback", Usage: "do not fallback in case a source is not found", - DefaultText: strconv.FormatBool(app.NoFallback), Destination: &app.NoFallback, }, &cli.BoolFlag{ Name: "stop-on-fail", Usage: "continue on download failure", - DefaultText: strconv.FormatBool(app.StopOnFail), Destination: &app.StopOnFail, }, + &cli.BoolFlag{ + Name: "override", + Usage: "override already downloaded songs", + Destination: &app.OverrideDownload, + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { song_url := cmd.Args().First() From a21832d39c8e9cc49ee03453752ef038356d6d94 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 28 Feb 2026 17:06:22 +0100 Subject: [PATCH 36/39] build: fix nix build --- main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/main.go b/main.go index e31fc94..f212b91 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "strconv" + "strings" "github.com/Superredstone/spotiflac-cli/lib" "github.com/urfave/cli/v3" @@ -16,7 +17,9 @@ func main() { app := lib.NewApp() err := app.Init() - if err != nil { + + // Ignore this check for nix builds + if err != nil && !strings.Contains(os.Args[0], "/nix/store/") { log.Fatal(err) } From 195d783bcf05dff3ad6aaf6d931a25d8b9150b86 Mon Sep 17 00:00:00 2001 From: Patrick Canal <56410215+Superredstone@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:33:24 +0100 Subject: [PATCH 37/39] docs: add disclaimer to readme Clarified project origins and limitations regarding Amazon and Qobuz downloads. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 2c25937..5c61585 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@

+> [!IMPORTANT] +> This project was originally based on [SpotiFLAC](github.com/afkarxyz/SpotiFLAC) which did not integrate a CLI and [the developer is not willing to add support for it](https://github.com/afkarxyz/SpotiFLAC/pull/381#issuecomment-3888433673). +> +> The code was a mess and not modular at all, which made adding new features painful. It was full of bad design choices. One of those bad design choices was not informing users how songs were actually downloaded. Tracks from Amazon were downloaded using what I later discovered to be closed-source APIs as M4A files, and then converted to FLAC with FFmpeg, which significantly reduced the quality. Qobuz as a source never existed in the first place, everything was downloaded either from Tidal or Amazon. +> +> For these reasons, I'm not going to support Amazon and Qobuz downloads until someone finds a better way to handle this. + Spotify downloader with playlist sync in mind. ## Usage From aef6e80c7704fff3ce4b5ea2e5c5f8926c628c4e Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 28 Feb 2026 17:43:21 +0100 Subject: [PATCH 38/39] docs: fix incorrect usage description for --stop-on-fail flag --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index f212b91..3bf0903 100644 --- a/main.go +++ b/main.go @@ -61,7 +61,7 @@ func main() { }, &cli.BoolFlag{ Name: "stop-on-fail", - Usage: "continue on download failure", + Usage: "stop on download failure", Destination: &app.StopOnFail, }, &cli.BoolFlag{ From 3d3f57db046926b374830dfdb4a6f7d037329489 Mon Sep 17 00:00:00 2001 From: Superredstone Date: Sat, 14 Mar 2026 11:07:57 +0100 Subject: [PATCH 39/39] fix: check file existance before fetching metadata --- lib/download.go | 38 ++++++++++++++++++++++---------------- lib/types.go | 47 ++++++++++++++++++++++++++++++++++++----------- lib/utils.go | 4 +++- 3 files changed, 61 insertions(+), 28 deletions(-) diff --git a/lib/download.go b/lib/download.go index 7121dfd..0689a16 100644 --- a/lib/download.go +++ b/lib/download.go @@ -75,29 +75,35 @@ func (app *App) DownloadPlaylist(url string, outputFile string, service string, return err } - var urls []string - for _, item := range playlist.Data.Playlist.Content.Items { - url, err := SpotifyUriToLink(item.Item.Data.Uri) - if err != nil { - return err - } + playlistItems := playlist.GetPlaylistItems() + trackListSize := len(playlistItems) + for idx, item := range playlistItems { + artists := item.GetArtists() + fileName := outputFile + fmt.Sprintf(FILE_NAME_FORMAT, item.Item.Data.IdentityTrait.Name, artists, "flac") - urls = append(urls, url) - } + fmt.Println("[" + strconv.Itoa(idx+1) + "/" + strconv.Itoa(trackListSize) + "] " + item.Item.Data.IdentityTrait.Name + " - " + artists) - trackListSize := len(urls) - for idx, url := range urls { - metadata, err := app.GetTrackMetadata(url) + songExists, err := FileExists(fileName) if err != nil { - return err + if app.StopOnFail { + return err + } + continue } - artists, err := GetArtists(metadata) - if err != nil { - return err + if songExists && !app.OverrideDownload { + app.log("Song " + fileName + " already exists") + continue } - fmt.Println("[" + strconv.Itoa(idx+1) + "/" + strconv.Itoa(trackListSize) + "] " + metadata.Data.TrackUnion.Name + " - " + artists) + url, err := SpotifyUriToLink(item.Item.Data.Uri) + metadata, err := app.GetTrackMetadata(url) + if err != nil { + if app.StopOnFail { + return err + } + continue + } if err := app.DownloadTrack(url, outputFile+"/", service, quality, true, metadata); err != nil { if app.StopOnFail { diff --git a/lib/types.go b/lib/types.go index 6bd864f..3a73556 100644 --- a/lib/types.go +++ b/lib/types.go @@ -130,17 +130,8 @@ type PlaylistMetadata struct { Name string `json:"name"` Uri string `json:"uri"` Content struct { - TotalCount int `json:"totalCount"` - Items []struct { - Item struct { - Data struct { - IdentityTrait struct { - Name string `json:"name"` - } `json:"identityTrait"` - Uri string `json:"uri"` - } `json:"data"` - } `json:"itemV3"` - } `json:"items"` + TotalCount int `json:"totalCount"` + Items []PlaylistItems `json:"items"` } `json:"content"` Members struct { Items []struct { @@ -155,3 +146,37 @@ type PlaylistMetadata struct { } `json:"playlistV2"` } `json:"data"` } + +type PlaylistItems struct { + Item struct { + Data struct { + IdentityTrait struct { + Name string `json:"name"` + Contributors struct { + Items []struct { + Name string `json:"name"` + } + } `json:"contributors"` + } `json:"identityTrait"` + Uri string `json:"uri"` + } `json:"data"` + } `json:"itemV3"` +} + +func (metadata *PlaylistMetadata) GetPlaylistItems() []PlaylistItems { + return metadata.Data.Playlist.Content.Items +} + +func (playlistItem *PlaylistItems) GetArtists() string { + var result = "" + for idx, artist := range playlistItem.Item.Data.IdentityTrait.Contributors.Items { + if idx == len(playlistItem.Item.Data.IdentityTrait.Contributors.Items)-1 { + result += artist.Name + continue + } + + result += artist.Name + ", " + } + + return result +} diff --git a/lib/utils.go b/lib/utils.go index 7fa86b8..4f89206 100644 --- a/lib/utils.go +++ b/lib/utils.go @@ -46,6 +46,8 @@ func ParseTrackId(url string) (string, error) { return tmp2[0], nil } +const FILE_NAME_FORMAT = "%s - %s.%s" + func BuildFileName(metadata TrackMetadata, extension string) (string, error) { var result string @@ -54,7 +56,7 @@ func BuildFileName(metadata TrackMetadata, extension string) (string, error) { return result, err } - result = fmt.Sprintf("%s - %s.%s", metadata.Data.TrackUnion.Name, artists, extension) + result = fmt.Sprintf(FILE_NAME_FORMAT, metadata.Data.TrackUnion.Name, artists, extension) return result, nil }