From c1fd39c3773ba7b0aecf34e45a04a042100c4bd4 Mon Sep 17 00:00:00 2001
From: Tor Wingalen
Date: Thu, 14 May 2026 19:40:27 +0200
Subject: [PATCH] Add Spotify OAuth and large playlist support
---
CHANGELOG.md | 199 +++-----
README.md | 447 +++++++++++++-----
src/backend/src/environmentEnum.ts | 2 +
src/backend/src/shared/auth.guard.ts | 7 +-
.../controllers/spotify-auth.controller.ts | 38 ++
src/backend/src/shared/shared.module.ts | 3 +-
src/backend/src/shared/spotify-api.service.ts | 337 +++++++++----
src/frontend/src/app/app.component.html | 7 +
src/frontend/src/app/app.component.ts | 14 +
9 files changed, 727 insertions(+), 327 deletions(-)
create mode 100644 src/backend/src/shared/controllers/spotify-auth.controller.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2188d0a..e714779 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
-
-
-
-### 2.0.0
-- refactor(backend): queue system presented
-
-### 1.0.0
-- initial release
\ No newline at end of file
+- Initial release
diff --git a/README.md b/README.md
index b0543d5..92bb5bb 100644
--- a/README.md
+++ b/README.md
@@ -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
+The maintainers are not responsible for misuse.
+
+---
+
+# Supported URLs
+
+- Spotify playlists
+- Spotify tracks
+
+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
+EOF
+
+sudo chown root:$USER /etc/tokens/spotify.env
+sudo chmod 640 /etc/tokens/spotify.env
+~~~
+
+Never commit this file.
+
+---
+
+# Docker 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=change_this_token \
+ -e YT_SEARCH_DELAY_MS=7000 \
+ -e YT_DOWNLOADS_PER_MINUTE=3 \
+ -v "$PWD/downloads:/spooty/backend/downloads" \
+ spootyfy-hardened:local
+~~~
+
+Open:
+
+ http://127.0.0.1:3000/?token=change_this_token
+
+Then:
+
+1. Click "Connect Spotify"
+2. Login to Spotify
+3. Approve access
+4. Paste playlist URL
+5. Download
+
+---
+
+# Docker Compose
+
+~~~yaml
services:
spooty:
- image: raiper34/spooty:latest
+ image: spootyfy-hardened:local
container_name: spooty
restart: unless-stopped
+
ports:
- "3000:3000"
- volumes:
- - /path/to/downloads:/spooty/backend/downloads
- - /path/to/cookies.txt:/spooty/config/cookies.txt
+
+ env_file:
+ - /etc/tokens/spotify.env
+
environment:
- - SPOTIFY_CLIENT_ID=your_client_id
- - SPOTIFY_CLIENT_SECRET=your_client_secret
- # Configure other environment variables if needed
-```
+ SPOTIFY_REDIRECT_URI: "http://127.0.0.1:3000/api/spotify/callback"
+ AUTH_ENABLED: "true"
+ SPOOTY_AUTH_TOKEN: "change_this_token"
-### Build from source
+ YT_SEARCH_DELAY_MS: "7000"
+ YT_DOWNLOADS_PER_MINUTE: "3"
-Spooty can be also build from source files on your own.
+ volumes:
+ - ./downloads:/spooty/backend/downloads
+~~~
-#### 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
+---
+
+# Build From Source
+
+## Requirements
+
+- Node.js 20.20.0
+- Docker
+- ffmpeg
- Python3
+- Redis
-#### 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:
- ```
- 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`
+---
-### Environment variables
+## Build
-Some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
+~~~bash
+npm install
+npm run build
+~~~
- 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
+## 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
+npm run start
+~~~
-#### `YT_COOKIES` — browser-based cookies (non-Docker)
+---
-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`.
+# Environment Variables
-```
-YT_COOKIES=chrome
-```
+| 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 |
-> [!NOTE]
-> This only works when Spooty runs on the same machine as your browser (i.e. not in Docker, where no browser is present).
+---
-#### `YT_COOKIES_FILE` — cookies file (recommended for Docker)
+# YouTube Cookies
-Export your YouTube cookies as a Netscape `cookies.txt` file and provide its path. This is the recommended approach for Docker deployments.
+YouTube may throttle or block requests without cookies.
-**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`.
+Two supported methods exist.
-**Docker usage:**
+---
-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.
+## Browser Cookies (Native Install Only)
-> [!NOTE]
-> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set.
+~~~bash
+YT_COOKIES=firefox
+~~~
-# ⚖️ License
-[MIT](https://choosealicense.com/licenses/mit/)
+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
diff --git a/src/backend/src/environmentEnum.ts b/src/backend/src/environmentEnum.ts
index 8208771..61caf30 100644
--- a/src/backend/src/environmentEnum.ts
+++ b/src/backend/src/environmentEnum.ts
@@ -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',
}
diff --git a/src/backend/src/shared/auth.guard.ts b/src/backend/src/shared/auth.guard.ts
index 50fd671..9e7f9c8 100644
--- a/src/backend/src/shared/auth.guard.ts
+++ b/src/backend/src/shared/auth.guard.ts
@@ -11,7 +11,12 @@ export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
- 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;
}
diff --git a/src/backend/src/shared/controllers/spotify-auth.controller.ts b/src/backend/src/shared/controllers/spotify-auth.controller.ts
new file mode 100644
index 0000000..cf4014b
--- /dev/null
+++ b/src/backend/src/shared/controllers/spotify-auth.controller.ts
@@ -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 {
+ 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');
+ }
+}
diff --git a/src/backend/src/shared/shared.module.ts b/src/backend/src/shared/shared.module.ts
index 752e8e9..a19186f 100644
--- a/src/backend/src/shared/shared.module.ts
+++ b/src/backend/src/shared/shared.module.ts
@@ -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 {}
diff --git a/src/backend/src/shared/spotify-api.service.ts b/src/backend/src/shared/spotify-api.service.ts
index c28e731..120f15f 100644
--- a/src/backend/src/shared/spotify-api.service.ts
+++ b/src/backend/src/shared/spotify-api.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ private async getClientCredentialsAccessToken(): Promise {
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}`);
@@ -155,86 +317,93 @@ 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;
let hasMoreTracks = true;
while (hasMoreTracks) {
- this.logger.debug(
- `Fetching tracks from Spotify API with offset ${offset}`,
- );
+ this.logger.debug(
+ `Fetching tracks from Spotify API with offset ${offset}`,
+ );
- 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`,
- {
- headers: {
- Authorization: `Bearer ${accessToken}`,
- },
+ const response = await fetch(
+ `https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100`,
+ {
+ headers: {
+ Authorization: `Bearer ${accessToken}`,
},
+ },
+ );
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ this.logger.error(
+ `Spotify API error: ${response.status} ${errorText}`,
);
- if (!response.ok) {
- const errorText = await response.text();
- 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}`);
}
- const data = await response.json();
-
- if (!data.items || data.items.length === 0) {
- this.logger.debug('No more tracks to fetch from Spotify API');
- hasMoreTracks = false;
- continue;
- }
-
- const pageTracks = data.items
- .map(
- (item: {
- track: {
- id: string;
- name: any;
- artists: any[];
- preview_url: any;
- album: { images: any[] };
- };
- }) => {
- if (!item.track) 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,
- };
- },
- )
- .filter((track) => track !== null);
-
- this.logger.debug(
- `Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`,
- );
-
- if (pageTracks.length > 0) {
- allTracks.push(...pageTracks);
- }
-
- if (!data.next) {
- hasMoreTracks = false;
- } else {
- offset += 100;
- }
+ throw new Error(`Failed to fetch tracks: ${response.status}`);
}
+ const data = await response.json();
+
+ if (!data.items || data.items.length === 0) {
+ this.logger.debug('No more tracks to fetch from Spotify API');
+ hasMoreTracks = false;
+ continue;
+ }
+
+ const pageTracks = data.items
+ .map(
+ (item: {
+ item: {
+ id: string;
+ name: any;
+ artists: any[];
+ preview_url: any;
+ album: { images: any[] };
+ };
+ }) => {
+ if (!item.item) return null;
+
+ return {
+ 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,
+ };
+ },
+ )
+ .filter((track) => track !== null);
+
this.logger.debug(
- `Total tracks retrieved from Spotify API: ${allTracks.length}`,
+ `Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`,
);
- return allTracks;
+
+ if (pageTracks.length > 0) {
+ allTracks.push(...pageTracks);
+ }
+
+ if (!data.next) {
+ hasMoreTracks = false;
+ } else {
+ offset += 100;
+ }
+ }
+
+ this.logger.debug(
+ `Total tracks retrieved from Spotify API: ${allTracks.length}`,
+ );
+ return allTracks;
} catch (error) {
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
throw error;
diff --git a/src/frontend/src/app/app.component.html b/src/frontend/src/app/app.component.html
index 97d2ea3..3dc769c 100644
--- a/src/frontend/src/app/app.component.html
+++ b/src/frontend/src/app/app.component.html
@@ -6,6 +6,13 @@
v{{version}}
Self-hosted spotify downloader
+
diff --git a/src/frontend/src/app/app.component.ts b/src/frontend/src/app/app.component.ts
index 8a9d0d7..95e7b3c 100644
--- a/src/frontend/src/app/app.component.ts
+++ b/src/frontend/src/app/app.component.ts
@@ -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();
}