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/README.md b/README.md
index ba6001b..5c61585 100644
--- a/README.md
+++ b/README.md
@@ -4,23 +4,29 @@
+> [!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
```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.
diff --git a/flake.nix b/flake.nix
index a9ef144..2869cd3 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,16 +48,14 @@
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: {
- inherit pname version;
- src = ./.;
- vendorHash = "sha256-EpGgfiCqJjHEOphV2x8FmXeIFls7eq2NVxb/or4NLUo=";
+ pname = "spotiflac-cli";
+ version = "2.0.0";
- dependencies = if withAdditionalServices then [ pkgs.ffmpeg_7 ] else [ ];
+ src = ./.;
+ vendorHash = "sha256-o7NSSe1PGuASh3Whi+VrdPtLluiU5DaG5EtmLkEGJRw=";
nativeBuildInputs = with pkgs; [
installShellFiles
@@ -83,13 +65,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) \
diff --git a/go.mod b/go.mod
index 24bbd7c..86dec54 100644
--- a/go.mod
+++ b/go.mod
@@ -2,28 +2,14 @@ module github.com/Superredstone/spotiflac-cli
go 1.24.4
-replace github.com/Superredstone/spotiflac-cli/app => ./app
+replace github.com/Superredstone/spotiflac-cli/lib => ./lib
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/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
diff --git a/go.sum b/go.sum
index f444d55..52b029e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,80 +1,23 @@
-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/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=
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=
diff --git a/lib/app.go b/lib/app.go
new file mode 100644
index 0000000..9f6bb53
--- /dev/null
+++ b/lib/app.go
@@ -0,0 +1,34 @@
+package lib
+
+type App struct {
+ SelectedTidalApiUrl string
+ Verbose bool
+ SpotifyClient *SpotifyClient
+ ApiInterval int // How many ms to wait between one call to apis and the other
+ NoFallback bool
+ StopOnFail bool
+ OverrideDownload bool
+}
+
+func NewApp() App {
+ return App{
+ 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
+ }
+
+ return nil
+}
diff --git a/lib/download.go b/lib/download.go
new file mode 100644
index 0000000..0689a16
--- /dev/null
+++ b/lib/download.go
@@ -0,0 +1,249 @@
+package lib
+
+import (
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path"
+ "strconv"
+ "time"
+)
+
+const (
+ DEFAULT_DOWNLOAD_SERVICE = "tidal"
+ DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "."
+)
+
+type DownloadRequest struct {
+ Service AvailableServices
+ Track string
+ Artist string
+ Album string
+ Artists string
+ ReleaseDate string
+ Cover string
+ OutputDir string
+ SpotifyID string
+}
+
+func (app *App) Download(url string, outputFile string, service string, quality string) error {
+ if service == "" {
+ service = DEFAULT_DOWNLOAD_SERVICE
+ }
+
+ urlType, err := ParseUrlType(url)
+ if err != nil {
+ return err
+ }
+
+ switch urlType {
+ case UrlTypeTrack:
+ metadata, err := app.GetTrackMetadata(url)
+ if err != nil {
+ return err
+ }
+
+ 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
+ }
+
+ return nil
+ case UrlTypePlaylist:
+ if err := app.DownloadPlaylist(url, outputFile, service, quality); err != nil {
+ return err
+ }
+
+ return nil
+ }
+
+ return errors.New("Invalid URL type.")
+}
+
+func (app *App) DownloadPlaylist(url string, outputFile string, service string, quality string) error {
+ playlist, err := app.GetPlaylistMetadata(url)
+ 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")
+
+ fmt.Println("[" + strconv.Itoa(idx+1) + "/" + strconv.Itoa(trackListSize) + "] " + item.Item.Data.IdentityTrait.Name + " - " + artists)
+
+ songExists, err := FileExists(fileName)
+ if err != nil {
+ if app.StopOnFail {
+ return err
+ }
+ continue
+ }
+
+ if songExists && !app.OverrideDownload {
+ app.log("Song " + fileName + " already exists")
+ continue
+ }
+
+ 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 {
+ return err
+ }
+
+ app.log("Failed download")
+ }
+
+ // Avoid getting rate limited
+ time.Sleep(time.Duration(app.ApiInterval) * time.Millisecond)
+ }
+
+ return nil
+}
+
+func (app *App) GetDownloadUrlOrFallback(askedService string, quality string, songlink SongLinkResponse) (string, error) {
+ servicesToTry := []string{}
+
+ switch askedService {
+ default:
+ case "tidal":
+ servicesToTry = []string{"tidal"}
+ 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 {
+ if idx > 0 {
+ 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
+ }
+
+ downloadUrl, err = app.GetTidalDownloadUrl(songId, quality)
+ if err != nil {
+ lastError = err
+ continue
+ }
+
+ break
+ }
+ }
+
+ 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, metadata TrackMetadata) error {
+ songlink, err := app.ConvertSongUrl(url)
+ if err != nil {
+ return err
+ }
+
+ downloadUrl, err := app.GetDownloadUrlOrFallback(service, quality, songlink)
+ if err != nil {
+ return err
+ }
+
+ extension, err := GetFormatFromQuality(quality)
+ if err != nil {
+ return err
+ }
+
+ if downloadInFolder {
+ 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)
+ if err != nil {
+ return err
+ }
+
+ if fileExists && !app.OverrideDownload {
+ app.log("File " + outputFile + " already exists")
+ return nil
+ }
+
+ err = app.DownloadFromUrl(downloadUrl, outputFile)
+ if err != nil {
+ return err
+ }
+
+ err = app.EmbedMetadata(outputFile, metadata)
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (app *App) DownloadFromUrl(url string, outputFilePath string) error {
+ app.log("Downloading " + outputFilePath)
+
+ 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
+ }
+
+ app.log("Download completed")
+
+ return nil
+}
diff --git a/lib/metadata.go b/lib/metadata.go
new file mode 100644
index 0000000..23fd934
--- /dev/null
+++ b/lib/metadata.go
@@ -0,0 +1,189 @@
+package lib
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/go-flac/flacpicture/v2"
+ "github.com/go-flac/flacvorbis/v2"
+ "github.com/go-flac/go-flac/v2"
+)
+
+func (app *App) GetPlaylistMetadata(url string) (PlaylistMetadata, error) {
+ app.log("Fetching playlist metadata")
+
+ 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
+ }
+
+ byteMetadata, err := json.Marshal(rawMetadata)
+ if err != nil {
+ 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
+ }
+
+ payload := BuildSpotifyReqPayloadTrack(trackId)
+
+ rawMetadata, err := app.SpotifyClient.Query(payload)
+ if err != nil {
+ return result, err
+ }
+
+ byteMetadata, err := json.Marshal(rawMetadata)
+ err = json.Unmarshal(byteMetadata, &result)
+ if err != nil {
+ return result, err
+ }
+
+ return result, nil
+}
+
+func (app *App) PrintMetadata(url string) error {
+ 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 {
+ app.log("Embedding metadata")
+
+ file, err := flac.ParseFile(fileName)
+ if err != nil {
+ return err
+ }
+
+ artists, err := GetArtists(metadata)
+ if err != nil {
+ return err
+ }
+
+ 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)
+
+ 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/services.go b/lib/services.go
new file mode 100644
index 0000000..de53e9e
--- /dev/null
+++ b/lib/services.go
@@ -0,0 +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.")
+}
+
diff --git a/lib/songlink.go b/lib/songlink.go
new file mode 100644
index 0000000..6fcb40a
--- /dev/null
+++ b/lib/songlink.go
@@ -0,0 +1,59 @@
+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 {
+ Tidal *LinkByPlatform `json:"tidal,omitempty"`
+}
+
+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
+
+ app.log("Searching " + url)
+
+ 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
+}
diff --git a/lib/spotfetch.go b/lib/spotfetch.go
new file mode 100644
index 0000000..c1345cb
--- /dev/null
+++ b/lib/spotfetch.go
@@ -0,0 +1,1877 @@
+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
+}
+
+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/tidal.go b/lib/tidal.go
new file mode 100644
index 0000000..ec5adfe
--- /dev/null
+++ b/lib/tidal.go
@@ -0,0 +1,126 @@
+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.GetAvailableTidalApis() {
+ 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) GetAvailableTidalApis() []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",
+ }
+}
+
+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)
+
+ rawResponse, err := http.Get(url)
+ 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) GetIdFromSonglink(songlink SongLinkResponse) (string, error) {
+ if songlink.LinksByPlatform.Tidal != nil {
+ return ParseTrackId(songlink.LinksByPlatform.Tidal.Url)
+ }
+
+ return "", errors.New("No link found.")
+}
diff --git a/lib/types.go b/lib/types.go
new file mode 100644
index 0000000..3a73556
--- /dev/null
+++ b/lib/types.go
@@ -0,0 +1,182 @@
+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 []struct {
+ Height int `json:"height"`
+ Width int `json:"width"`
+ Url string `json:"url"`
+ } `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 Associations 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 []ArtistItems `json:"items"`
+ TotalCount int64 `json:"totalCount"`
+}
+
+type ArtistItems struct {
+ Profile struct {
+ Name string `json:"name"`
+ } `json:"profile"`
+}
+
+type OtherArtists struct {
+ Items []ArtistItems `json:"items"`
+}
+
+type Playability struct {
+ Playable bool `json:"playable"`
+ Reason string `json:"reason"`
+}
+
+type TrackUnion struct {
+ 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 {
+ TrackUnion TrackUnion `json:"trackUnion"`
+}
+
+type TrackMetadata struct {
+ Data Data `json:"data"`
+}
+
+type PlaylistMetadata struct {
+ Data struct {
+ Playlist struct {
+ Name string `json:"name"`
+ Uri string `json:"uri"`
+ Content struct {
+ TotalCount int `json:"totalCount"`
+ Items []PlaylistItems `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"`
+}
+
+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
new file mode 100644
index 0000000..4f89206
--- /dev/null
+++ b/lib/utils.go
@@ -0,0 +1,159 @@
+package lib
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path"
+ "strings"
+)
+
+type UrlType int
+
+const (
+ UrlTypeTrack UrlType = iota
+ 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
+ }
+
+ if strings.Contains(url, "https://open.spotify.com/playlist") {
+ return UrlTypePlaylist, nil
+ }
+
+ return UrlTypeTrack, errors.New("Invalid URL, not a playlist nor a track.")
+}
+
+func ParseTrackId(url string) (string, error) {
+ tmp := strings.Split(url, "/")
+
+ if len(tmp) == 0 {
+ return "", errors.New("Invalid URL.")
+ }
+
+ tmp2 := strings.Split(tmp[len(tmp)-1], "?")
+ if len(tmp2) == 0 {
+ return tmp[len(tmp)-1], nil
+ }
+
+ return tmp2[0], nil
+}
+
+const FILE_NAME_FORMAT = "%s - %s.%s"
+
+func BuildFileName(metadata TrackMetadata, extension string) (string, error) {
+ var result string
+
+ artists, err := GetArtists(metadata)
+ if err != nil {
+ return result, err
+ }
+
+ result = fmt.Sprintf(FILE_NAME_FORMAT, 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.")
+ }
+ result = metadata.Data.TrackUnion.FirstArtist.Items[firstArtistLen-1].Profile.Name
+
+ for _, artist := range metadata.Data.TrackUnion.OtherArtists.Items {
+ result += ", " + artist.Profile.Name
+ }
+
+ return result, nil
+}
+
+func BuildFileOutput(outputFile string, extension string, metadata TrackMetadata) (string, error) {
+ var result string
+
+ fileName, err := BuildFileName(metadata, extension)
+ if err != nil {
+ return result, err
+ }
+
+ if outputFile == "" {
+ result = path.Join(DEFAULT_DOWNLOAD_OUTPUT_FOLDER, fileName)
+ } else {
+ result = outputFile
+ }
+
+ 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.")
+ }
+}
+
+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
+}
+
+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
+}
+
+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
+}
+
+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 f696874..3bf0903 100644
--- a/main.go
+++ b/main.go
@@ -4,16 +4,24 @@ import (
"context"
"log"
"os"
+ "strconv"
+ "strings"
- "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
+ outputFolder := ""
+ service := ""
- application := app.NewApp()
+ app := lib.NewApp()
+ err := app.Init()
+
+ // Ignore this check for nix builds
+ if err != nil && !strings.Contains(os.Args[0], "/nix/store/") {
+ log.Fatal(err)
+ }
cmd := &cli.Command{
Name: "spotiflac-cli",
@@ -29,19 +37,43 @@ func main() {
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
- Usage: "set output folder",
- Destination: &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",
+ DefaultText: strconv.Itoa(app.ApiInterval),
+ Destination: &app.ApiInterval,
+ },
+ &cli.BoolFlag{
+ Name: "no-fallback",
+ Usage: "do not fallback in case a source is not found",
+ Destination: &app.NoFallback,
+ },
+ &cli.BoolFlag{
+ Name: "stop-on-fail",
+ Usage: "stop on download failure",
+ 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()
- err := pkg.Download(application, song_url, output_folder, service)
+ quality := "LOSSLESS"
+ err := app.Download(song_url, outputFolder, service, quality)
return err
},
},
@@ -51,10 +83,18 @@ 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 app.PrintMetadata(url)
},
},
},
+ 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)
diff --git a/pkg/download.go b/pkg/download.go
deleted file mode 100644
index 92a8d28..0000000
--- a/pkg/download.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package pkg
-
-import (
- "errors"
- "fmt"
- "strconv"
-
- "github.com/Superredstone/spotiflac-cli/app"
-)
-
-const (
- DEFAULT_DOWNLOAD_SERVICE = "tidal"
- DEFAULT_DOWNLOAD_OUTPUT_FOLDER = "."
-)
-
-func Download(application *app.App, url string, output_folder string, service string) error {
- if output_folder == "" {
- output_folder = DEFAULT_DOWNLOAD_OUTPUT_FOLDER
- }
-
- if service == "" {
- service = DEFAULT_DOWNLOAD_SERVICE
- }
-
- if service == "amazon" || service == "qobuz" {
- isInstalled, err := application.CheckFFmpegInstalled()
- if err != nil {
- return err
- }
-
- if !isInstalled {
- return errors.New("FFmpeg is not installed.")
- }
- }
-
- url_type := GetUrlType(url)
-
- switch url_type {
- case UrlTypeTrack:
- metadata, err := GetMetadata[MetadataSong](application, url)
- if err != nil {
- return err
- }
-
- track := metadata.Track
- downloadRequest := app.DownloadRequest{
- Service: service,
- TrackName: track.Name,
- ArtistName: track.Artists,
- AlbumName: track.AlbumName,
- AlbumArtist: track.AlbumArtist,
- ReleaseDate: track.ReleaseDate,
- CoverURL: track.Images,
- OutputDir: output_folder,
- SpotifyID: track.SpotifyID,
- }
-
- _, err = application.DownloadTrack(downloadRequest)
- return err
- case UrlTypePlaylist:
- metadata, err := GetMetadata[MetadataPlaylist](application, 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 := app.DownloadRequest{
- Service: service,
- TrackName: track.Name,
- ArtistName: track.Artists,
- AlbumName: track.AlbumName,
- AlbumArtist: track.AlbumArtist,
- ReleaseDate: track.ReleaseDate,
- CoverURL: track.Images,
- OutputDir: output_folder,
- SpotifyID: track.SpotifyID,
- PlaylistName: metadata.Info.Owner.Name,
- }
-
- _, err = application.DownloadTrack(downloadRequest)
- if err != nil {
- fmt.Println("Unable to download " + track.Name + " - " + track.Artists)
- }
- }
-
- return nil
- }
-
- return errors.New("Invalid URL.")
-}
diff --git a/pkg/metadata.go b/pkg/metadata.go
deleted file mode 100644
index 35633a0..0000000
--- a/pkg/metadata.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package pkg
-
-import (
- "encoding/json"
- "errors"
- "fmt"
-
- "github.com/Superredstone/spotiflac-cli/app"
-)
-
-type MetadataSong struct {
- Track MetadataTrack `json:"track"`
-}
-
-type MetadataTrack 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"`
-}
-
-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](application *app.App, url string) (T, error) {
- var result T
-
- metadataRequest := app.SpotifyMetadataRequest{
- URL: url,
- Delay: 0,
- Timeout: 5,
- }
-
- metadata, err := application.GetSpotifyMetadata(metadataRequest)
- if err != nil {
- return result, err
- }
-
- err = json.Unmarshal([]byte(metadata), &result)
- if err != nil {
- return result, nil
- }
-
- return result, nil
-}
-
-func PrintMetadata(application *app.App, url string) error {
- switch GetUrlType(url) {
- case UrlTypeTrack:
- metadata, err := GetMetadata[MetadataSong](application, 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](application, 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.")
-}
-
diff --git a/pkg/utils.go b/pkg/utils.go
deleted file mode 100644
index 9cce6f2..0000000
--- a/pkg/utils.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package pkg
-
-import (
- "strings"
-)
-
-type UrlType int
-
-const (
- UrlTypeTrack UrlType = iota
- UrlTypePlaylist
- UrlTypeInvalid
-)
-
-func GetUrlType(url string) UrlType {
- if strings.Contains(url, "https://open.spotify.com/track") {
- return UrlTypeTrack
- }
-
- if strings.Contains(url, "https://open.spotify.com/playlist") {
- return UrlTypePlaylist
- }
-
- return UrlTypeInvalid
-}
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
-