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 @@ -[![npm version](https://img.shields.io/docker/pulls/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty) -[![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 Hardened -![spooty logo](assets/logo.svg) -# 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(); }