Add Spotify OAuth and large playlist support
This commit is contained in:
+75
-124
@@ -1,171 +1,122 @@
|
||||
### Changelog
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
|
||||
All notable changes to the hardened Spooty branch are documented here.
|
||||
|
||||
#### [2.4.2](https://github.com/Raiper34/spooty/compare/2.4.1...2.4.2)
|
||||
This project follows a practical chronological changelog rather than autogenerated dependency noise.
|
||||
|
||||
- fix(spotify): use data.next for playlist pagination instead of filtered track count [`#52`](https://github.com/Raiper34/spooty/issues/52) [`#35`](https://github.com/Raiper34/spooty/issues/35)
|
||||
---
|
||||
|
||||
#### [2.4.1](https://github.com/Raiper34/spooty/compare/2.4.0...2.4.1)
|
||||
# Unreleased
|
||||
|
||||
> 7 February 2026
|
||||
## Added
|
||||
|
||||
- fix(docker): fix docker node version [`a14f5ea`](https://github.com/Raiper34/spooty/commit/a14f5eab6805acdf4755dbd8f37a289a0d675c12)
|
||||
### Spotify OAuth Integration
|
||||
|
||||
#### [2.4.0](https://github.com/Raiper34/spooty/compare/2.3.4...2.4.0)
|
||||
- Added Spotify OAuth login flow
|
||||
- Added `/api/spotify/login`
|
||||
- Added `/api/spotify/callback`
|
||||
- Added `/api/spotify/status`
|
||||
- Added persistent Spotify user access token storage
|
||||
- Added authenticated Spotify playlist retrieval
|
||||
- Added OAuth frontend bootstrap flow
|
||||
|
||||
> 7 February 2026
|
||||
### Large Playlist Support
|
||||
|
||||
- feat(files): add possibility to set the audio quality of downloaded files [`#39`](https://github.com/Raiper34/spooty/issues/39)
|
||||
- fix(downloading): fix downloading songs from browser with special characters [`#41`](https://github.com/Raiper34/spooty/issues/41)
|
||||
- fix(downloadin): fix ejs runtime problem [`#38`](https://github.com/Raiper34/spooty/issues/38)
|
||||
- Fixed Spotify playlist truncation at 100 tracks
|
||||
- Added full Spotify pagination support
|
||||
- Verified support for playlists exceeding 300 tracks
|
||||
- Improved playlist queue handling stability
|
||||
|
||||
#### [2.3.4](https://github.com/Raiper34/spooty/compare/2.3.3...2.3.4)
|
||||
### Queue Hardening
|
||||
|
||||
> 4 February 2026
|
||||
- Added configurable YouTube search pacing
|
||||
- Added configurable YouTube download pacing
|
||||
- Added sequential search queue behavior
|
||||
- Reduced aggressive request bursts toward YouTube
|
||||
- Improved queue logging visibility
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`ef32347`](https://github.com/Raiper34/spooty/commit/ef32347903d7caa2cc72c9c54341195b2ac0bb96)
|
||||
### Security
|
||||
|
||||
#### [2.3.3](https://github.com/Raiper34/spooty/compare/2.3.2...2.3.3)
|
||||
- Added token-based frontend access mode
|
||||
- Added external secret handling recommendations
|
||||
- Added isolated Spotify credential storage
|
||||
- Improved Docker credential handling documentation
|
||||
|
||||
> 30 January 2026
|
||||
### Frontend
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`cc552d1`](https://github.com/Raiper34/spooty/commit/cc552d1def309a3633aa67ca7f3659eb44ef47b2)
|
||||
- Added Spotify login integration
|
||||
- Added auth token bootstrap from URL
|
||||
- Improved playlist rendering behavior
|
||||
- Improved large playlist progress tracking
|
||||
|
||||
#### [2.3.2](https://github.com/Raiper34/spooty/compare/2.3.1...2.3.2)
|
||||
### Docker
|
||||
|
||||
> 26 January 2026
|
||||
- Improved hardened Docker runtime flow
|
||||
- Added explicit environment variable examples
|
||||
- Added safer default pacing recommendations
|
||||
- Improved deployment documentation
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`531730b`](https://github.com/Raiper34/spooty/commit/531730bc57f9ab1339277ad995f37611a9f55aa8)
|
||||
---
|
||||
|
||||
#### [2.3.1](https://github.com/Raiper34/spooty/compare/2.3.0...2.3.1)
|
||||
# 2.4.2
|
||||
|
||||
> 23 January 2026
|
||||
## Fixed
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`08b83bf`](https://github.com/Raiper34/spooty/commit/08b83bf9de70999bddb47cd1d64d63d1ab550d88)
|
||||
- docs(readme): change redirect URI [`8162a2a`](https://github.com/Raiper34/spooty/commit/8162a2a21f762310f857dfc42cad53831769ed66)
|
||||
- Fixed Spotify playlist pagination using `data.next`
|
||||
- Fixed incomplete playlist retrieval behavior
|
||||
|
||||
#### [2.3.0](https://github.com/Raiper34/spooty/compare/2.2.1...2.3.0)
|
||||
---
|
||||
|
||||
> 29 December 2025
|
||||
# 2.4.1
|
||||
|
||||
- feat(track): allow download of individual track and fix album art [`#29`](https://github.com/Raiper34/spooty/issues/29)
|
||||
- docs(readme): remove demo image and gif from readme [`a2ac579`](https://github.com/Raiper34/spooty/commit/a2ac579854a6cbc95729d434c514fb2309f7a3bb)
|
||||
## Fixed
|
||||
|
||||
#### [2.2.1](https://github.com/Raiper34/spooty/compare/2.2.0...2.2.1)
|
||||
- Fixed Docker Node.js version compatibility
|
||||
|
||||
> 15 November 2025
|
||||
---
|
||||
|
||||
- fix(downloading): migrate from @distube/ytdl-core to ytdlp-nodejs yt downloading library [`5795f7c`](https://github.com/Raiper34/spooty/commit/5795f7cc178ab4d8a8d3d8a7e94f33743ff6e9e0)
|
||||
- fix(docker): add python3 dependency into docker image for yt download library [`46d7c66`](https://github.com/Raiper34/spooty/commit/46d7c6699bc8698896b22837e8507885677a71bd)
|
||||
- docs(readme): fix yt cookies section heading [`285578f`](https://github.com/Raiper34/spooty/commit/285578ff1ed1d7fe14d7ddd0d95a4b8079276e4e)
|
||||
- docs(readme): add docker versio badge into readme [`4be3743`](https://github.com/Raiper34/spooty/commit/4be3743f7b5a8019bd6ed0d2248f23a254a82603)
|
||||
# 2.4.0
|
||||
|
||||
#### [2.2.0](https://github.com/Raiper34/spooty/compare/2.1.1...2.2.0)
|
||||
## Added
|
||||
|
||||
> 17 October 2025
|
||||
- Added configurable audio quality selection
|
||||
|
||||
- feat(spotify): integrate Spotify API for playlist metadata and track retrieval [`de55e42`](https://github.com/Raiper34/spooty/commit/de55e42fcd246d86e8d3156d8e0ab4898b5755d6)
|
||||
- feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads [`4c05178`](https://github.com/Raiper34/spooty/commit/4c051782fd8808a808fe49fe1509ec0b0db9d05e)
|
||||
## Fixed
|
||||
|
||||
#### [2.1.1](https://github.com/Raiper34/spooty/compare/2.1.0...2.1.1)
|
||||
- Fixed special character download issues
|
||||
- Fixed EJS runtime issue
|
||||
|
||||
> 13 August 2025
|
||||
---
|
||||
|
||||
- feat(gui): add version to gui header [`#25`](https://github.com/Raiper34/spooty/issues/25)
|
||||
- fix(track): fix track missing artist and title tags [`b93993c`](https://github.com/Raiper34/spooty/commit/b93993c6cf034dccad3df08485d2126614874743)
|
||||
- style(gui): change gui primary to match spotify primary color [`5068fa8`](https://github.com/Raiper34/spooty/commit/5068fa8a4b724ef4a1060dc4d83cc80afee08bad)
|
||||
# 2.3.0
|
||||
|
||||
#### [2.1.0](https://github.com/Raiper34/spooty/compare/2.0.11...2.1.0)
|
||||
## Added
|
||||
|
||||
> 12 August 2025
|
||||
- Added individual track downloads
|
||||
- Added cover art support
|
||||
|
||||
- feat(track): add track cover art into downloaded file [`#24`](https://github.com/Raiper34/spooty/issues/24)
|
||||
---
|
||||
|
||||
#### [2.0.11](https://github.com/Raiper34/spooty/compare/2.0.10...2.0.11)
|
||||
# 2.2.0
|
||||
|
||||
> 14 June 2025
|
||||
## Added
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`fe79302`](https://github.com/Raiper34/spooty/commit/fe79302c2a747093df39405e42c06c08957acc3c)
|
||||
- Added Spotify API integration
|
||||
- Added YouTube cookie support
|
||||
- Added download timeout handling
|
||||
|
||||
#### [2.0.10](https://github.com/Raiper34/spooty/compare/2.0.9...2.0.10)
|
||||
---
|
||||
|
||||
> 4 June 2025
|
||||
# 2.0.0
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`f74a4d2`](https://github.com/Raiper34/spooty/commit/f74a4d2ed9da50e29f4e44b0027b2891c7473ec6)
|
||||
## Added
|
||||
|
||||
#### [2.0.9](https://github.com/Raiper34/spooty/compare/2.0.8...2.0.9)
|
||||
- Introduced queue-based backend architecture
|
||||
|
||||
> 8 May 2025
|
||||
---
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`cc6a201`](https://github.com/Raiper34/spooty/commit/cc6a20196d474152eecaeb9862edfb3505ed3106)
|
||||
# 1.0.0
|
||||
|
||||
#### [2.0.8](https://github.com/Raiper34/spooty/compare/2.0.7...2.0.8)
|
||||
## Added
|
||||
|
||||
> 27 April 2025
|
||||
|
||||
- docs(readme): remove docs, netlify and docsify, keep only readme docs for now [`bc9482c`](https://github.com/Raiper34/spooty/commit/bc9482c6cb9a47aa0d96fa34866546ec552d1e3c)
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`17b875e`](https://github.com/Raiper34/spooty/commit/17b875e2825cb739767565be5d86aba3a48a9208)
|
||||
|
||||
#### [2.0.7](https://github.com/Raiper34/spooty/compare/2.0.6...2.0.7)
|
||||
|
||||
> 5 April 2025
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`9653db5`](https://github.com/Raiper34/spooty/commit/9653db54ea48e93526f2124b11b8012d987b7bcc)
|
||||
|
||||
#### [2.0.6](https://github.com/Raiper34/spooty/compare/2.0.5...2.0.6)
|
||||
|
||||
> 1 April 2025
|
||||
|
||||
- ci(netlify): remove netlify cli from deps [`e4cde85`](https://github.com/Raiper34/spooty/commit/e4cde8585ba0dfc422cec89360612947ebe92ff1)
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`5f30f31`](https://github.com/Raiper34/spooty/commit/5f30f3116f34ceb48d00e3abd5c606e8f9b7caba)
|
||||
- feat(names): strip special characters from file and folder names [`6f39c1e`](https://github.com/Raiper34/spooty/commit/6f39c1e5dc4b8f87917a18d2a23cec6b5cd9ca60)
|
||||
- ci(github-actions): fix github actions update ytdl script [`04d8987`](https://github.com/Raiper34/spooty/commit/04d8987aa51a8887bf1b8b41e8680c2db9530bef)
|
||||
|
||||
#### [2.0.5](https://github.com/Raiper34/spooty/compare/2.0.4...2.0.5)
|
||||
|
||||
> 20 March 2025
|
||||
|
||||
- ci(docker): fix docker buildx in release-it for github actions [`db65823`](https://github.com/Raiper34/spooty/commit/db65823ce0794280cb373a4381e24ad704a4db56)
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`16c57aa`](https://github.com/Raiper34/spooty/commit/16c57aaefd599f04b0a88723d55751f4ba27383a)
|
||||
|
||||
#### [2.0.4](https://github.com/Raiper34/spooty/compare/2.0.3...2.0.4)
|
||||
|
||||
> 15 March 2025
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`b960dc8`](https://github.com/Raiper34/spooty/commit/b960dc88db2a44c5865cb5c6f5964e4b4ffedc3f)
|
||||
|
||||
#### [2.0.3](https://github.com/Raiper34/spooty/compare/2.0.2...2.0.3)
|
||||
|
||||
> 15 March 2025
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`844d026`](https://github.com/Raiper34/spooty/commit/844d02668512cb8446f27f5629412fb5331b1298)
|
||||
|
||||
#### [2.0.2](https://github.com/Raiper34/spooty/compare/2.0.1...2.0.2)
|
||||
|
||||
> 15 March 2025
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`cf559eb`](https://github.com/Raiper34/spooty/commit/cf559eb41a2eb84cd29d4a732d68885f4ebcf348)
|
||||
|
||||
#### [2.0.1](https://github.com/Raiper34/spooty/compare/2.0.0...2.0.1)
|
||||
|
||||
> 23 February 2025
|
||||
|
||||
- build(ytdl): Upgrade ytdl package (automated) [`#20`](https://github.com/Raiper34/spooty/pull/20)
|
||||
- refactor(backend): queue system presented [`#5`](https://github.com/Raiper34/spooty/issues/5)
|
||||
- fix(utdl): upgrade ytdl package [`66b55b3`](https://github.com/Raiper34/spooty/commit/66b55b39432f2da71bd401fd65af6c0a207dd6c2)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`2ca165b`](https://github.com/Raiper34/spooty/commit/2ca165b59a76147439d4d00055b84102cf7a7317)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`9baf3b9`](https://github.com/Raiper34/spooty/commit/9baf3b9eb7b093029300614bc9c1a913a73fb003)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`b2fbb01`](https://github.com/Raiper34/spooty/commit/b2fbb01a7415c4027aa50dd2a1773d0b1e0dde3a)
|
||||
- docs(website): change default environment variable for REDIS_RUN var [`f539a22`](https://github.com/Raiper34/spooty/commit/f539a227575569ce5641f5dfec6fcf50598a491d)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`cbe2c9f`](https://github.com/Raiper34/spooty/commit/cbe2c9fbdc8322881b6ae3fd90586b7656d155ec)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`a55ebf6`](https://github.com/Raiper34/spooty/commit/a55ebf659974137f96a465b74a8a0c8b649b3399)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`66516e6`](https://github.com/Raiper34/spooty/commit/66516e6916af1b1e29df13d870f775f5c7ce43c2)
|
||||
|
||||
<!-- auto-changelog-above -->
|
||||
|
||||
### 2.0.0
|
||||
- refactor(backend): queue system presented
|
||||
|
||||
### 1.0.0
|
||||
- initial release
|
||||
- Initial release
|
||||
|
||||
@@ -1,157 +1,370 @@
|
||||
[](https://hub.docker.com/r/raiper34/spooty)
|
||||
[](https://hub.docker.com/r/raiper34/spooty)
|
||||

|
||||
[](https://hub.docker.com/r/raiper34/spooty)
|
||||
[](https://github.com/Raiper34/spooty)
|
||||
[](https://github.com/Raiper34/spooty)
|
||||
# Spooty Hardened
|
||||
|
||||

|
||||
# Spooty - selfhosted Spotify downloader
|
||||
Spooty is a self-hosted Spotify downloader.
|
||||
It allows download track/playlist/album from the Spotify url.
|
||||
It can also subscribe to a playlist or author page and download new songs upon release.
|
||||
Spooty basically downloads nothing from Spotify, it only gets information from spotify and then finds relevant and downloadeds music on Youtube.
|
||||
The project is based on NestJS and Angular.
|
||||
Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Please do not use this tool for piracy! Download only music you own rights! Use this tool only on your responsibility.
|
||||
Spooty does not download audio from Spotify itself.
|
||||
It retrieves metadata from Spotify and locates matching audio on YouTube.
|
||||
|
||||
### Content
|
||||
- [🚀 Installation](#-installation)
|
||||
- [Spotify App Configuration](#spotify-app-configuration)
|
||||
- [Docker](#docker)
|
||||
- [Docker command](#docker-command)
|
||||
- [Docker compose](#docker-compose)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Process](#requirements)
|
||||
- [Requirements](#process)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [YouTube cookies](#youtube-cookies)
|
||||
- [⚖️ License](#-license)
|
||||
This hardened branch focuses on:
|
||||
|
||||
## 🚀 Installation
|
||||
Recommended and the easiest way how to start to use of Spooty is using docker.
|
||||
- Large playlist support (>100 tracks)
|
||||
- Spotify OAuth login flow
|
||||
- Better YouTube pacing and throttling resistance
|
||||
- Improved Docker deployment
|
||||
- Safer credential handling
|
||||
- More resilient cover-art embedding
|
||||
- Better queue stability
|
||||
|
||||
### Spotify App Configuration
|
||||
---
|
||||
|
||||
To fully use Spooty, you need to create an application in the Spotify Developer Dashboard:
|
||||
# Features
|
||||
|
||||
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
||||
2. Sign in with your Spotify account
|
||||
3. Create a new application
|
||||
4. Note your `Client ID` and `Client Secret`
|
||||
5. Configure the redirect URI to `http://127.0.0.1:3000/api/callback` (or the corresponding URL of your instance)
|
||||
- Download Spotify playlists
|
||||
- Download individual Spotify tracks
|
||||
- Playlist auto-subscription support
|
||||
- Automatic YouTube matching
|
||||
- MP3 tagging and embedded cover art
|
||||
- Docker deployment
|
||||
- Queue-based download system
|
||||
- Spotify OAuth integration
|
||||
- Large playlist pagination support
|
||||
- Download pacing controls
|
||||
- YouTube cookie support
|
||||
|
||||
These credentials will be used by Spooty to access the Spotify API.
|
||||
---
|
||||
|
||||
### Docker
|
||||
# Important Notice
|
||||
|
||||
Just run docker command or use docker compose configuration.
|
||||
For detailed configuration, see available [environment variables](#environment-variables).
|
||||
Use this software responsibly.
|
||||
|
||||
#### Docker command
|
||||
```shell
|
||||
docker run -d -p 3000:3000 \
|
||||
-v /path/to/downloads:/spooty/backend/downloads \
|
||||
-v /path/to/cookies.txt:/spooty/config/cookies.txt \
|
||||
-e SPOTIFY_CLIENT_ID=your_client_id \
|
||||
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
|
||||
raiper34/spooty:latest
|
||||
```
|
||||
Only download music you legally own or are permitted to access.
|
||||
|
||||
#### Docker compose
|
||||
```yaml
|
||||
services:
|
||||
spooty:
|
||||
image: raiper34/spooty:latest
|
||||
container_name: spooty
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- /path/to/downloads:/spooty/backend/downloads
|
||||
- /path/to/cookies.txt:/spooty/config/cookies.txt
|
||||
environment:
|
||||
- SPOTIFY_CLIENT_ID=your_client_id
|
||||
- SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
# Configure other environment variables if needed
|
||||
```
|
||||
The maintainers are not responsible for misuse.
|
||||
|
||||
### Build from source
|
||||
---
|
||||
|
||||
Spooty can be also build from source files on your own.
|
||||
# Supported URLs
|
||||
|
||||
#### Requirements
|
||||
- Node v20.20.0 (it is recommended to use `nvm` node version manager to install proper version of node)
|
||||
- Redis in memory cache
|
||||
- Ffmpeg
|
||||
- Python3
|
||||
- Spotify playlists
|
||||
- Spotify tracks
|
||||
|
||||
#### Process
|
||||
- install Node v20.20.0 using `nvm install` and use that node version `nvm use`
|
||||
- from project root install all dependencies using `npm install`
|
||||
- copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see [environment variables](#environment-variables))
|
||||
- add your Spotify application credentials to the `.env` file:
|
||||
```
|
||||
Example:
|
||||
|
||||
https://open.spotify.com/playlist/...
|
||||
https://open.spotify.com/track/...
|
||||
|
||||
---
|
||||
|
||||
# Quick Start (Recommended)
|
||||
|
||||
## 1. Create Spotify Developer App
|
||||
|
||||
Go to:
|
||||
|
||||
https://developer.spotify.com/dashboard
|
||||
|
||||
Create an application.
|
||||
|
||||
Add this Redirect URI:
|
||||
|
||||
http://127.0.0.1:3000/api/spotify/callback
|
||||
|
||||
Copy:
|
||||
|
||||
- Client ID
|
||||
- Client Secret
|
||||
|
||||
---
|
||||
|
||||
## 2. Store Credentials Outside Repository
|
||||
|
||||
Create a secure env file:
|
||||
|
||||
~~~bash
|
||||
sudo mkdir -p /etc/tokens
|
||||
|
||||
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOF'
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
- build source files `npm run build`
|
||||
- built project will be stored in `dist` folder
|
||||
- start server `npm run start`
|
||||
EOF
|
||||
|
||||
### Environment variables
|
||||
sudo chown root:$USER /etc/tokens/spotify.env
|
||||
sudo chmod 640 /etc/tokens/spotify.env
|
||||
~~~
|
||||
|
||||
Some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
|
||||
Never commit this file.
|
||||
|
||||
Name | Default | Description |
|
||||
-------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored |
|
||||
FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application |
|
||||
DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
|
||||
FORMAT | `mp3` | Format of downloaded files ('aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav', 'alac') |
|
||||
QUALITY | undefined | Audio quality (0-9 VBR or specific bitrate) of downloaded files |
|
||||
PORT | 3000 | Port of Spooty server |
|
||||
REDIS_PORT | 6379 | Port of Redis server |
|
||||
REDIS_HOST | localhost | Host of Redis server |
|
||||
RUN_REDIS | false | Whenever Redis server should be started from backend (recommended for Docker environment) |
|
||||
SPOTIFY_CLIENT_ID | your_client_id | Client ID of your Spotify application (required) |
|
||||
SPOTIFY_CLIENT_SECRET | your_client_secret | Client Secret of your Spotify application (required) |
|
||||
YT_DOWNLOADS_PER_MINUTE | 3 | Set the maximum number of YouTube downloads started per minute |
|
||||
YT_COOKIES | | Browser name to automatically extract YouTube cookies from (e.g. `chrome`, `firefox`). Only works when running Spooty natively (not in Docker). See [below](#yt_cookies---browser-based-cookies-non-docker). |
|
||||
YT_COOKIES_FILE | `./config/cookies.txt` | Path to a Netscape-format `cookies.txt` file. Recommended for Docker deployments. See [below](#yt_cookies_file---cookies-file-recommended-for-docker). |
|
||||
---
|
||||
|
||||
### YouTube cookies
|
||||
# Docker Run
|
||||
|
||||
YouTube may block or throttle downloads without authentication cookies. Spooty supports two ways to provide them — use the one that fits your setup.
|
||||
~~~bash
|
||||
docker run --rm -p 3000:3000 \
|
||||
--env-file /etc/tokens/spotify.env \
|
||||
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e SPOOTY_AUTH_TOKEN=change_this_token \
|
||||
-e YT_SEARCH_DELAY_MS=7000 \
|
||||
-e YT_DOWNLOADS_PER_MINUTE=3 \
|
||||
-v "$PWD/downloads:/spooty/backend/downloads" \
|
||||
spootyfy-hardened:local
|
||||
~~~
|
||||
|
||||
#### `YT_COOKIES` — browser-based cookies (non-Docker)
|
||||
Open:
|
||||
|
||||
Set `YT_COOKIES` to the name of your browser and yt-dlp will automatically read cookies directly from it.
|
||||
Supported values: `chrome`, `firefox`, `edge`, `safari`, `brave`, `opera`, `chromium`.
|
||||
http://127.0.0.1:3000/?token=change_this_token
|
||||
|
||||
```
|
||||
YT_COOKIES=chrome
|
||||
```
|
||||
Then:
|
||||
|
||||
> [!NOTE]
|
||||
> This only works when Spooty runs on the same machine as your browser (i.e. not in Docker, where no browser is present).
|
||||
1. Click "Connect Spotify"
|
||||
2. Login to Spotify
|
||||
3. Approve access
|
||||
4. Paste playlist URL
|
||||
5. Download
|
||||
|
||||
#### `YT_COOKIES_FILE` — cookies file (recommended for Docker)
|
||||
---
|
||||
|
||||
Export your YouTube cookies as a Netscape `cookies.txt` file and provide its path. This is the recommended approach for Docker deployments.
|
||||
# Docker Compose
|
||||
|
||||
**How to get your `cookies.txt` file:**
|
||||
1. Install a browser extension that exports cookies in Netscape format, e.g. [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) for Chrome or [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) for Firefox.
|
||||
2. Go to https://www.youtube.com and log in.
|
||||
3. Use the extension to export cookies for `youtube.com` and save the file as `cookies.txt`.
|
||||
~~~yaml
|
||||
services:
|
||||
spooty:
|
||||
image: spootyfy-hardened:local
|
||||
container_name: spooty
|
||||
restart: unless-stopped
|
||||
|
||||
**Docker usage:**
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
Bind mount the `cookies.txt` file into the container and set `YT_COOKIES_FILE` to its path inside the container. See the [Environment variables](#environment-variables) section for details.
|
||||
env_file:
|
||||
- /etc/tokens/spotify.env
|
||||
|
||||
> [!NOTE]
|
||||
> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set.
|
||||
environment:
|
||||
SPOTIFY_REDIRECT_URI: "http://127.0.0.1:3000/api/spotify/callback"
|
||||
AUTH_ENABLED: "true"
|
||||
SPOOTY_AUTH_TOKEN: "change_this_token"
|
||||
|
||||
# ⚖️ License
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
YT_SEARCH_DELAY_MS: "7000"
|
||||
YT_DOWNLOADS_PER_MINUTE: "3"
|
||||
|
||||
volumes:
|
||||
- ./downloads:/spooty/backend/downloads
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Build From Source
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20.20.0
|
||||
- Docker
|
||||
- ffmpeg
|
||||
- Python3
|
||||
- Redis
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
~~~bash
|
||||
npm install
|
||||
npm run build
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
~~~bash
|
||||
npm run start
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| DB_PATH | `./config/db.sqlite` | SQLite database path |
|
||||
| DOWNLOADS_PATH | `./downloads` | Download directory |
|
||||
| FORMAT | `mp3` | Output format |
|
||||
| REDIS_HOST | `localhost` | Redis host |
|
||||
| REDIS_PORT | `6379` | Redis port |
|
||||
| SPOTIFY_CLIENT_ID | unset | Spotify client ID |
|
||||
| SPOTIFY_CLIENT_SECRET | unset | Spotify client secret |
|
||||
| SPOTIFY_REDIRECT_URI | unset | OAuth callback URI |
|
||||
| AUTH_ENABLED | `false` | Enable frontend token auth |
|
||||
| SPOOTY_AUTH_TOKEN | unset | Frontend auth token |
|
||||
| YT_SEARCH_DELAY_MS | `5000` | Delay between YouTube searches |
|
||||
| YT_DOWNLOADS_PER_MINUTE | `3` | Download pacing |
|
||||
| YT_COOKIES | unset | Browser cookie extraction |
|
||||
| YT_COOKIES_FILE | unset | Netscape cookies.txt path |
|
||||
|
||||
---
|
||||
|
||||
# YouTube Cookies
|
||||
|
||||
YouTube may throttle or block requests without cookies.
|
||||
|
||||
Two supported methods exist.
|
||||
|
||||
---
|
||||
|
||||
## Browser Cookies (Native Install Only)
|
||||
|
||||
~~~bash
|
||||
YT_COOKIES=firefox
|
||||
~~~
|
||||
|
||||
Supported:
|
||||
|
||||
- firefox
|
||||
- chrome
|
||||
- chromium
|
||||
- edge
|
||||
- brave
|
||||
- opera
|
||||
|
||||
This does NOT work reliably in Docker.
|
||||
|
||||
---
|
||||
|
||||
## Cookies File (Recommended)
|
||||
|
||||
Export a Netscape-format `cookies.txt`.
|
||||
|
||||
Mount into container:
|
||||
|
||||
~~~bash
|
||||
-v /path/to/cookies.txt:/spooty/config/cookies.txt
|
||||
~~~
|
||||
|
||||
Then:
|
||||
|
||||
~~~bash
|
||||
-e YT_COOKIES_FILE=/spooty/config/cookies.txt
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Large Playlist Support
|
||||
|
||||
The hardened branch fixes several Spotify API limitations:
|
||||
|
||||
- Proper playlist pagination
|
||||
- >100 track playlists
|
||||
- OAuth-authenticated playlist access
|
||||
- Queue stability improvements
|
||||
|
||||
Verified working with playlists containing 300+ tracks.
|
||||
|
||||
---
|
||||
|
||||
# Queue Pacing
|
||||
|
||||
Aggressive YouTube access can trigger:
|
||||
|
||||
- HTTP 302 loops
|
||||
- CAPTCHA
|
||||
- temporary throttling
|
||||
- incomplete downloads
|
||||
|
||||
Recommended safe pacing:
|
||||
|
||||
~~~bash
|
||||
-e YT_SEARCH_DELAY_MS=7000
|
||||
-e YT_DOWNLOADS_PER_MINUTE=3
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Security Notes
|
||||
|
||||
Never commit:
|
||||
|
||||
- Spotify secrets
|
||||
- OAuth tokens
|
||||
- cookies.txt
|
||||
- downloaded music
|
||||
- local databases
|
||||
|
||||
Recommended `.gitignore` additions:
|
||||
|
||||
~~~gitignore
|
||||
downloads/
|
||||
config/
|
||||
*.sqlite
|
||||
cookies.txt
|
||||
.env
|
||||
.env.local
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Hardened Branch Changelog
|
||||
|
||||
## Unreleased Hardened Changes
|
||||
|
||||
### Spotify
|
||||
|
||||
- Added Spotify OAuth login flow
|
||||
- Added persistent Spotify user token storage
|
||||
- Added authenticated playlist retrieval
|
||||
- Fixed large playlist pagination
|
||||
- Fixed playlist truncation at 100 tracks
|
||||
- Added playlist retrieval debugging
|
||||
- Added OAuth status endpoint
|
||||
|
||||
### YouTube
|
||||
|
||||
- Added configurable search pacing
|
||||
- Added configurable download pacing
|
||||
- Reduced aggressive YouTube request bursts
|
||||
- Improved queue throttling behavior
|
||||
|
||||
### Backend
|
||||
|
||||
- Added safer queue pacing logic
|
||||
- Added explicit search processor throttling
|
||||
- Improved Docker runtime stability
|
||||
- Improved logging around Spotify retrieval
|
||||
- Added auth bootstrap flow support
|
||||
|
||||
### Frontend
|
||||
|
||||
- Added Spotify login integration
|
||||
- Added frontend auth token bootstrap
|
||||
- Fixed playlist progress handling for large playlists
|
||||
- Improved playlist rendering stability
|
||||
|
||||
### Security
|
||||
|
||||
- Moved secrets to external env files
|
||||
- Improved Docker secret handling recommendations
|
||||
- Added security documentation
|
||||
- Prevented accidental secret inclusion in repository
|
||||
|
||||
---
|
||||
|
||||
# Development Notes
|
||||
|
||||
Recommended local test run:
|
||||
|
||||
~~~bash
|
||||
docker run --rm -p 3000:3000 \
|
||||
--env-file /etc/tokens/spotify.env \
|
||||
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e SPOOTY_AUTH_TOKEN=test-token \
|
||||
-e YT_SEARCH_DELAY_MS=7000 \
|
||||
-e YT_DOWNLOADS_PER_MINUTE=3 \
|
||||
-v "$PWD/downloads:/spooty/backend/downloads" \
|
||||
spootyfy-hardened:local
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -12,4 +12,6 @@ export enum EnvironmentEnum {
|
||||
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
|
||||
CORS_ORIGIN = 'CORS_ORIGIN',
|
||||
NODE_ENV = 'NODE_ENV',
|
||||
SPOTIFY_REDIRECT_URI = 'SPOTIFY_REDIRECT_URI',
|
||||
SPOTIFY_TOKEN_PATH = 'SPOTIFY_TOKEN_PATH',
|
||||
}
|
||||
|
||||
@@ -11,7 +11,12 @@ export class AuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
if (request.path === '/api/health') {
|
||||
if (
|
||||
request.path === '/api/health' ||
|
||||
request.path === '/api/spotify/login' ||
|
||||
request.path === '/api/spotify/callback' ||
|
||||
request.path === '/api/spotify/status'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Controller, Get, Query, Res } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { SpotifyApiService } from '../spotify-api.service';
|
||||
|
||||
@Controller('spotify')
|
||||
export class SpotifyAuthController {
|
||||
constructor(private readonly spotifyApiService: SpotifyApiService) {}
|
||||
|
||||
@Get('status')
|
||||
async status(): Promise<{ connected: boolean }> {
|
||||
return { connected: await this.spotifyApiService.hasUserToken() };
|
||||
}
|
||||
|
||||
@Get('login')
|
||||
login(@Res() res: Response): void {
|
||||
res.redirect(this.spotifyApiService.getAuthorizationUrl());
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
async callback(
|
||||
@Query('code') code: string | undefined,
|
||||
@Query('error') error: string | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
if (error) {
|
||||
res.redirect(`/?spotify_error=${encodeURIComponent(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.redirect('/?spotify_error=missing_code');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.spotifyApiService.exchangeAuthorizationCode(code);
|
||||
res.redirect('/?spotify=connected');
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { SpotifyService } from './spotify.service';
|
||||
import { YoutubeService } from './youtube.service';
|
||||
import { SpotifyApiService } from './spotify-api.service';
|
||||
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||
controllers: [],
|
||||
controllers: [SpotifyAuthController],
|
||||
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { resolve, dirname } from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { EnvironmentEnum } from '../environmentEnum';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fetch = require('isomorphic-unfetch');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { getDetails } = require('spotify-url-info')(fetch);
|
||||
|
||||
interface StoredSpotifyUserToken {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SpotifyApiService {
|
||||
private readonly logger = new Logger(SpotifyApiService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor() {}
|
||||
private tokenExpiry = 0;
|
||||
|
||||
private getPlaylistId(url: string): string {
|
||||
try {
|
||||
@@ -51,13 +60,179 @@ export class SpotifyApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private getClientId(): string {
|
||||
const clientId = process.env.SPOTIFY_CLIENT_ID;
|
||||
if (!clientId) {
|
||||
throw new Error('Missing SPOTIFY_CLIENT_ID');
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
private getClientSecret(): string {
|
||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||
if (!clientSecret) {
|
||||
throw new Error('Missing SPOTIFY_CLIENT_SECRET');
|
||||
}
|
||||
return clientSecret;
|
||||
}
|
||||
|
||||
private getRedirectUri(): string {
|
||||
return (
|
||||
process.env[EnvironmentEnum.SPOTIFY_REDIRECT_URI] ||
|
||||
'http://127.0.0.1:3000/api/spotify/callback'
|
||||
);
|
||||
}
|
||||
|
||||
private getTokenPath(): string {
|
||||
if (process.env[EnvironmentEnum.SPOTIFY_TOKEN_PATH]) {
|
||||
return process.env[EnvironmentEnum.SPOTIFY_TOKEN_PATH];
|
||||
}
|
||||
|
||||
const dbPath = process.env[EnvironmentEnum.DB_PATH] || './config/db.sqlite';
|
||||
return resolve(dirname(resolve(__dirname, dbPath)), 'spotify-user-token.json');
|
||||
}
|
||||
|
||||
private getBasicAuthHeader(): string {
|
||||
const credentials = Buffer.from(
|
||||
`${this.getClientId()}:${this.getClientSecret()}`,
|
||||
).toString('base64');
|
||||
return `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
private readUserToken(): StoredSpotifyUserToken | null {
|
||||
const tokenPath = this.getTokenPath();
|
||||
|
||||
if (!fs.existsSync(tokenPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to read Spotify user token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private writeUserToken(token: StoredSpotifyUserToken): void {
|
||||
const tokenPath = this.getTokenPath();
|
||||
fs.mkdirSync(dirname(tokenPath), { recursive: true });
|
||||
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2), {
|
||||
encoding: 'utf-8',
|
||||
mode: 0o600,
|
||||
});
|
||||
}
|
||||
|
||||
async hasUserToken(): Promise<boolean> {
|
||||
return !!this.readUserToken();
|
||||
}
|
||||
|
||||
getAuthorizationUrl(): string {
|
||||
const url = new URL('https://accounts.spotify.com/authorize');
|
||||
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', this.getClientId());
|
||||
url.searchParams.set(
|
||||
'scope',
|
||||
[
|
||||
'playlist-read-private',
|
||||
'playlist-read-collaborative',
|
||||
'user-read-private',
|
||||
].join(' '),
|
||||
);
|
||||
url.searchParams.set('redirect_uri', this.getRedirectUri());
|
||||
url.searchParams.set('show_dialog', 'true');
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeAuthorizationCode(code: string): Promise<void> {
|
||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: this.getBasicAuthHeader(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: this.getRedirectUri(),
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(`Failed to exchange Spotify authorization code: ${errorData}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.writeUserToken({
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000 - 60_000,
|
||||
scope: data.scope,
|
||||
token_type: data.token_type,
|
||||
});
|
||||
|
||||
this.logger.debug('Stored Spotify user access token');
|
||||
}
|
||||
|
||||
private async refreshUserAccessToken(
|
||||
token: StoredSpotifyUserToken,
|
||||
): Promise<StoredSpotifyUserToken> {
|
||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: this.getBasicAuthHeader(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: token.refresh_token,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(`Failed to refresh Spotify user token: ${errorData}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const refreshed = {
|
||||
...token,
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || token.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000 - 60_000,
|
||||
scope: data.scope || token.scope,
|
||||
token_type: data.token_type || token.token_type,
|
||||
};
|
||||
|
||||
this.writeUserToken(refreshed);
|
||||
return refreshed;
|
||||
}
|
||||
|
||||
private async getUserAccessToken(): Promise<string> {
|
||||
const token = this.readUserToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Spotify user is not connected. Open /api/spotify/login first.');
|
||||
}
|
||||
|
||||
if (Date.now() < token.expires_at) {
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
return (await this.refreshUserAccessToken(token)).access_token;
|
||||
}
|
||||
|
||||
async getTrackMetadata(
|
||||
spotifyUrl: string,
|
||||
): Promise<{ name: string; artist: string; image: string }> {
|
||||
try {
|
||||
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
||||
const trackId = this.getTrackId(spotifyUrl);
|
||||
const accessToken = await this.getAccessToken();
|
||||
const accessToken = await this.getClientCredentialsAccessToken();
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/tracks/${trackId}`,
|
||||
@@ -102,31 +277,18 @@ export class SpotifyApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
private async getClientCredentialsAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug('Getting new Spotify access token');
|
||||
|
||||
const clientId = process.env.SPOTIFY_CLIENT_ID;
|
||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
'Missing Spotify credentials. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env file',
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
|
||||
'base64',
|
||||
);
|
||||
this.logger.debug('Getting new Spotify client credentials access token');
|
||||
|
||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Authorization: this.getBasicAuthHeader(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
@@ -141,7 +303,7 @@ export class SpotifyApiService {
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
|
||||
|
||||
this.logger.debug('Successfully obtained Spotify access token');
|
||||
this.logger.debug('Successfully obtained Spotify client credentials access token');
|
||||
return this.accessToken;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting Spotify access token: ${error.message}`);
|
||||
@@ -156,7 +318,7 @@ export class SpotifyApiService {
|
||||
const playlistId = this.getPlaylistId(spotifyUrl);
|
||||
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
||||
|
||||
const accessToken = await this.getAccessToken();
|
||||
const accessToken = await this.getUserAccessToken();
|
||||
|
||||
const allTracks = [];
|
||||
let offset = 0;
|
||||
@@ -168,7 +330,7 @@ export class SpotifyApiService {
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100&fields=items(track(id,name,artists,preview_url,album(images))),next`,
|
||||
`https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
@@ -181,6 +343,13 @@ export class SpotifyApiService {
|
||||
this.logger.error(
|
||||
`Spotify API error: ${response.status} ${errorText}`,
|
||||
);
|
||||
|
||||
if (response.status === 403) {
|
||||
throw new Error(
|
||||
'Spotify API refused playlist item access with 403 Forbidden. Connect Spotify through /api/spotify/login and ensure the authenticated user can access this playlist.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -195,7 +364,7 @@ export class SpotifyApiService {
|
||||
const pageTracks = data.items
|
||||
.map(
|
||||
(item: {
|
||||
track: {
|
||||
item: {
|
||||
id: string;
|
||||
name: any;
|
||||
artists: any[];
|
||||
@@ -203,14 +372,14 @@ export class SpotifyApiService {
|
||||
album: { images: any[] };
|
||||
};
|
||||
}) => {
|
||||
if (!item.track) return null;
|
||||
if (!item.item) return null;
|
||||
|
||||
return {
|
||||
id: item.track.id,
|
||||
name: item.track.name,
|
||||
artist: item.track.artists.map((a) => a.name).join(', '),
|
||||
previewUrl: item.track.preview_url,
|
||||
coverUrl: item.track.album?.images?.[0]?.url || null,
|
||||
id: item.item.id,
|
||||
name: item.item.name,
|
||||
artist: item.item.artists.map((a) => a.name).join(', '),
|
||||
previewUrl: item.item.preview_url,
|
||||
coverUrl: item.item.album?.images?.[0]?.url || null,
|
||||
};
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
<span class="tag is-dark is-size-7">v{{version}}</span>
|
||||
</p>
|
||||
<p class="subtitle">Self-hosted spotify downloader</p>
|
||||
<button
|
||||
class="button is-light is-small"
|
||||
[class.is-success]="spotifyConnected"
|
||||
(click)="connectSpotify()"
|
||||
>
|
||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||
import {VersionService} from "./services/version.service";
|
||||
import {map} from "rxjs";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -25,12 +26,15 @@ export class AppComponent {
|
||||
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
||||
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
||||
version = this.versionService.getVersion();
|
||||
spotifyConnected = false;
|
||||
|
||||
constructor(
|
||||
private readonly playlistService: PlaylistService,
|
||||
private readonly versionService: VersionService,
|
||||
private readonly http: HttpClient,
|
||||
) {
|
||||
this.bootstrapAuthTokenFromUrl();
|
||||
this.checkSpotifyStatus();
|
||||
this.fetchPlaylists();
|
||||
}
|
||||
|
||||
@@ -47,6 +51,16 @@ export class AppComponent {
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
|
||||
checkSpotifyStatus(): void {
|
||||
this.http
|
||||
.get<{ connected: boolean }>('/api/spotify/status')
|
||||
.subscribe((status) => (this.spotifyConnected = status.connected));
|
||||
}
|
||||
|
||||
connectSpotify(): void {
|
||||
window.location.href = '/api/spotify/login';
|
||||
}
|
||||
|
||||
fetchPlaylists(): void {
|
||||
this.playlistService.fetch();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user