Add Spotify OAuth and large playlist support

This commit is contained in:
Tor Wingalen
2026-05-14 19:40:27 +02:00
parent 0034b28f1a
commit c1fd39c377
9 changed files with 727 additions and 327 deletions
+75 -124
View File
@@ -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) - Fixed Spotify playlist truncation at 100 tracks
- fix(downloading): fix downloading songs from browser with special characters [`#41`](https://github.com/Raiper34/spooty/issues/41) - Added full Spotify pagination support
- fix(downloadin): fix ejs runtime problem [`#38`](https://github.com/Raiper34/spooty/issues/38) - 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) - Fixed Spotify playlist pagination using `data.next`
- docs(readme): change redirect URI [`8162a2a`](https://github.com/Raiper34/spooty/commit/8162a2a21f762310f857dfc42cad53831769ed66) - 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) ## Fixed
- docs(readme): remove demo image and gif from readme [`a2ac579`](https://github.com/Raiper34/spooty/commit/a2ac579854a6cbc95729d434c514fb2309f7a3bb)
#### [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) # 2.4.0
- 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.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) ## Fixed
- feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads [`4c05178`](https://github.com/Raiper34/spooty/commit/4c051782fd8808a808fe49fe1509ec0b0db9d05e)
#### [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) # 2.3.0
- 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.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 - Initial release
- 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
+336 -123
View File
@@ -1,157 +1,370 @@
[![npm version](https://img.shields.io/docker/pulls/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty) # Spooty Hardened
[![npm version](https://img.shields.io/docker/image-size/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty)
![Docker Image Version](https://img.shields.io/docker/v/raiper34/spooty)
[![npm version](https://img.shields.io/docker/stars/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty)
[![GitHub License](https://img.shields.io/github/license/raiper34/spooty)](https://github.com/Raiper34/spooty)
[![GitHub Repo stars](https://img.shields.io/github/stars/raiper34/spooty)](https://github.com/Raiper34/spooty)
![spooty logo](assets/logo.svg) Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
# 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.
> [!IMPORTANT] Spooty does not download audio from Spotify itself.
> Please do not use this tool for piracy! Download only music you own rights! Use this tool only on your responsibility. It retrieves metadata from Spotify and locates matching audio on YouTube.
### Content This hardened branch focuses on:
- [🚀 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)
## 🚀 Installation - Large playlist support (>100 tracks)
Recommended and the easiest way how to start to use of Spooty is using docker. - 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) - Download Spotify playlists
2. Sign in with your Spotify account - Download individual Spotify tracks
3. Create a new application - Playlist auto-subscription support
4. Note your `Client ID` and `Client Secret` - Automatic YouTube matching
5. Configure the redirect URI to `http://127.0.0.1:3000/api/callback` (or the corresponding URL of your instance) - 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. Use this software responsibly.
For detailed configuration, see available [environment variables](#environment-variables).
#### Docker command Only download music you legally own or are permitted to access.
```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
```
#### Docker compose The maintainers are not responsible for misuse.
```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
```
### Build from source ---
Spooty can be also build from source files on your own. # Supported URLs
#### Requirements - Spotify playlists
- Node v20.20.0 (it is recommended to use `nvm` node version manager to install proper version of node) - Spotify tracks
- Redis in memory cache
- Ffmpeg
- Python3
#### Process Example:
- install Node v20.20.0 using `nvm install` and use that node version `nvm use`
- from project root install all dependencies using `npm install` https://open.spotify.com/playlist/...
- copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see [environment variables](#environment-variables)) https://open.spotify.com/track/...
- add your Spotify application credentials to the `.env` file:
``` ---
# 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_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret SPOTIFY_CLIENT_SECRET=your_client_secret
``` EOF
- build source files `npm run build`
- built project will be stored in `dist` folder
- start server `npm run start`
### 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. http://127.0.0.1:3000/?token=change_this_token
Supported values: `chrome`, `firefox`, `edge`, `safari`, `brave`, `opera`, `chromium`.
``` Then:
YT_COOKIES=chrome
```
> [!NOTE] 1. Click "Connect Spotify"
> This only works when Spooty runs on the same machine as your browser (i.e. not in Docker, where no browser is present). 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:** ~~~yaml
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. services:
2. Go to https://www.youtube.com and log in. spooty:
3. Use the extension to export cookies for `youtube.com` and save the file as `cookies.txt`. 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] environment:
> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set. SPOTIFY_REDIRECT_URI: "http://127.0.0.1:3000/api/spotify/callback"
AUTH_ENABLED: "true"
SPOOTY_AUTH_TOKEN: "change_this_token"
# ⚖️ License YT_SEARCH_DELAY_MS: "7000"
[MIT](https://choosealicense.com/licenses/mit/) 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
+2
View File
@@ -12,4 +12,6 @@ export enum EnvironmentEnum {
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN', SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
CORS_ORIGIN = 'CORS_ORIGIN', CORS_ORIGIN = 'CORS_ORIGIN',
NODE_ENV = 'NODE_ENV', NODE_ENV = 'NODE_ENV',
SPOTIFY_REDIRECT_URI = 'SPOTIFY_REDIRECT_URI',
SPOTIFY_TOKEN_PATH = 'SPOTIFY_TOKEN_PATH',
} }
+6 -1
View File
@@ -11,7 +11,12 @@ export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>(); 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; 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');
}
}
+2 -1
View File
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
import { SpotifyService } from './spotify.service'; import { SpotifyService } from './spotify.service';
import { YoutubeService } from './youtube.service'; import { YoutubeService } from './youtube.service';
import { SpotifyApiService } from './spotify-api.service'; import { SpotifyApiService } from './spotify-api.service';
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService], providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
controllers: [], controllers: [SpotifyAuthController],
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService], exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
}) })
export class SharedModule {} export class SharedModule {}
+199 -30
View File
@@ -1,16 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'; 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 // eslint-disable-next-line @typescript-eslint/no-var-requires
const fetch = require('isomorphic-unfetch'); const fetch = require('isomorphic-unfetch');
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { getDetails } = require('spotify-url-info')(fetch); const { getDetails } = require('spotify-url-info')(fetch);
interface StoredSpotifyUserToken {
access_token: string;
refresh_token: string;
expires_at: number;
scope?: string;
token_type?: string;
}
@Injectable() @Injectable()
export class SpotifyApiService { export class SpotifyApiService {
private readonly logger = new Logger(SpotifyApiService.name); private readonly logger = new Logger(SpotifyApiService.name);
private accessToken: string | null = null; private accessToken: string | null = null;
private tokenExpiry: number = 0; private tokenExpiry = 0;
constructor() {}
private getPlaylistId(url: string): string { private getPlaylistId(url: string): string {
try { 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( async getTrackMetadata(
spotifyUrl: string, spotifyUrl: string,
): Promise<{ name: string; artist: string; image: string }> { ): Promise<{ name: string; artist: string; image: string }> {
try { try {
this.logger.debug(`Getting track metadata for ${spotifyUrl}`); this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
const trackId = this.getTrackId(spotifyUrl); const trackId = this.getTrackId(spotifyUrl);
const accessToken = await this.getAccessToken(); const accessToken = await this.getClientCredentialsAccessToken();
const response = await fetch( const response = await fetch(
`https://api.spotify.com/v1/tracks/${trackId}`, `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) { if (this.accessToken && Date.now() < this.tokenExpiry) {
return this.accessToken; return this.accessToken;
} }
try { try {
this.logger.debug('Getting new Spotify access token'); this.logger.debug('Getting new Spotify client credentials 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',
);
const response = await fetch('https://accounts.spotify.com/api/token', { const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Basic ${credentials}`, Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
}, },
body: 'grant_type=client_credentials', body: 'grant_type=client_credentials',
@@ -141,7 +303,7 @@ export class SpotifyApiService {
this.accessToken = data.access_token; this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000; 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; return this.accessToken;
} catch (error) { } catch (error) {
this.logger.error(`Error getting Spotify access token: ${error.message}`); this.logger.error(`Error getting Spotify access token: ${error.message}`);
@@ -156,7 +318,7 @@ export class SpotifyApiService {
const playlistId = this.getPlaylistId(spotifyUrl); const playlistId = this.getPlaylistId(spotifyUrl);
this.logger.debug(`Extracted playlist ID: ${playlistId}`); this.logger.debug(`Extracted playlist ID: ${playlistId}`);
const accessToken = await this.getAccessToken(); const accessToken = await this.getUserAccessToken();
const allTracks = []; const allTracks = [];
let offset = 0; let offset = 0;
@@ -168,7 +330,7 @@ export class SpotifyApiService {
); );
const response = await fetch( 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: { headers: {
Authorization: `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
@@ -181,6 +343,13 @@ export class SpotifyApiService {
this.logger.error( this.logger.error(
`Spotify API error: ${response.status} ${errorText}`, `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}`); throw new Error(`Failed to fetch tracks: ${response.status}`);
} }
@@ -195,7 +364,7 @@ export class SpotifyApiService {
const pageTracks = data.items const pageTracks = data.items
.map( .map(
(item: { (item: {
track: { item: {
id: string; id: string;
name: any; name: any;
artists: any[]; artists: any[];
@@ -203,14 +372,14 @@ export class SpotifyApiService {
album: { images: any[] }; album: { images: any[] };
}; };
}) => { }) => {
if (!item.track) return null; if (!item.item) return null;
return { return {
id: item.track.id, id: item.item.id,
name: item.track.name, name: item.item.name,
artist: item.track.artists.map((a) => a.name).join(', '), artist: item.item.artists.map((a) => a.name).join(', '),
previewUrl: item.track.preview_url, previewUrl: item.item.preview_url,
coverUrl: item.track.album?.images?.[0]?.url || null, coverUrl: item.item.album?.images?.[0]?.url || null,
}; };
}, },
) )
+7
View File
@@ -6,6 +6,13 @@
<span class="tag is-dark is-size-7">v{{version}}</span> <span class="tag is-dark is-size-7">v{{version}}</span>
</p> </p>
<p class="subtitle">Self-hosted spotify downloader</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> </div>
</section> </section>
+14
View File
@@ -5,6 +5,7 @@ import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component"; import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
import {VersionService} from "./services/version.service"; import {VersionService} from "./services/version.service";
import {map} from "rxjs"; import {map} from "rxjs";
import {HttpClient} from "@angular/common/http";
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -25,12 +26,15 @@ export class AppComponent {
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack))); playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
songs$ = 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(); version = this.versionService.getVersion();
spotifyConnected = false;
constructor( constructor(
private readonly playlistService: PlaylistService, private readonly playlistService: PlaylistService,
private readonly versionService: VersionService, private readonly versionService: VersionService,
private readonly http: HttpClient,
) { ) {
this.bootstrapAuthTokenFromUrl(); this.bootstrapAuthTokenFromUrl();
this.checkSpotifyStatus();
this.fetchPlaylists(); this.fetchPlaylists();
} }
@@ -47,6 +51,16 @@ export class AppComponent {
window.history.replaceState({}, document.title, url.toString()); 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 { fetchPlaylists(): void {
this.playlistService.fetch(); this.playlistService.fetch();
} }