Author SHA1 Message Date
TorMatzAndren a71a47f76a Document Jarri Spooty hardening updates 2026-05-16 13:10:00 +02:00
TorMatzAndren d83b0e6e1e Normalize Jarri Spooty naming 2026-05-16 13:01:28 +02:00
TorMatzAndren 1f3131d99a Shape track websocket payloads 2026-05-16 12:45:02 +02:00
TorMatzAndren c3d2f39b82 Harden yt-dlp process lifecycle 2026-05-16 11:42:16 +02:00
TorMatzAndren 09bbffa6e8 Harden cover art fetching 2026-05-16 11:11:59 +02:00
TorMatzAndren 38ca4d6c8d Harden download filename sanitization 2026-05-16 11:08:26 +02:00
TorMatzAndren 0cd5294944 Remove unused downloader dependencies 2026-05-16 11:04:38 +02:00
Tor Wingalen 5d20972c2d Update frontend retry calls to POST 2026-05-15 23:55:10 +02:00
Tor Wingalen 5dc428b148 Use POST for retry state-changing endpoints 2026-05-15 23:49:54 +02:00
Tor Wingalen be11bb94d7 Replace Redis shell exec with spawn 2026-05-15 23:45:59 +02:00
Tor Wingalen f64cc97475 Replace yt-search dependency with yt-dlp search 2026-05-15 23:36:57 +02:00
Tor Wingalen 005e47bf6e Remove unused fluent-ffmpeg dependency 2026-05-15 23:17:51 +02:00
Tor Wingalen 9e83001b73 Update Angular toolchain patch versions 2026-05-15 22:11:01 +02:00
Tor Wingalen 5f4d7c9c4e Document deterministic yt-dlp hardening and Docker persistence 2026-05-15 21:07:01 +02:00
Tor Wingalen 48517a5a85 Use direct yt-dlp CLI with deterministic fallback handling 2026-05-15 20:47:25 +02:00
Tor Wingalen ca388a8fc8 Improve deterministic YouTube matching and playlist handling 2026-05-14 21:52:58 +02:00
Tor Wingalen c1fd39c377 Add Spotify OAuth and large playlist support 2026-05-14 19:40:27 +02:00
Tor Wingalen 0034b28f1a Improve large playlist stability and Spotify item endpoint 2026-05-14 19:07:53 +02:00
Tor Wingalen a1772c7670 Make cover art non-fatal and disable critical CSS inlining 2026-05-14 18:37:15 +02:00
Tor Wingalen 64160fe21a Ignore local test downloads 2026-05-14 18:20:58 +02:00
Tor Wingalen 20632c6f9f Add deterministic YouTube matching and fix download path guard 2026-05-14 18:18:37 +02:00
Tor Wingalen 4bb61f5ebe Add deterministic YouTube source scoring 2026-05-14 18:00:53 +02:00
Tor Matz Andrén d1b040a8b1 Allow frontend auth token bootstrap from URL 2026-05-14 17:36:44 +02:00
Tor Matz Andrén abc63c0cec Add public health endpoint for Docker healthcheck 2026-05-14 17:17:51 +02:00
Tor Matz Andrén 33f077ed1e Reduce Docker build context and harden runtime image 2026-05-14 17:07:22 +02:00
37 changed files with 4183 additions and 2526 deletions
+26
View File
@@ -0,0 +1,26 @@
node_modules
dist
.git
.github
coverage
.vscode
*.log
downloads
config
.env
.env.*
README.md
assets
# Local runtime/download artifacts
test-downloads
downloads
config
*.sqlite
*.sqlite3
*.db
*.mp3
*.m4a
*.webm
*.part
spooty-config
+3
View File
@@ -4,3 +4,6 @@
**/*.sqlite
# Local Netlify folder
.netlify
test-downloads/
spooty-config
+124 -127
View File
@@ -1,171 +1,168 @@
### Changelog
# Changelog
All notable changes to Jarri Spooty are documented here.
This project follows a practical chronological changelog rather than autogenerated dependency noise.
---
# Unreleased
## Added
### Spotify
- Added Spotify OAuth login flow
- Added `/api/spotify/login`
- Added `/api/spotify/callback`
- Added `/api/spotify/status`
- Added persistent Spotify user token storage
- Added authenticated Spotify playlist retrieval
- Added OAuth frontend bootstrap flow
- Added support for playlists exceeding 300 tracks
- Added deterministic playlist pagination handling
All notable changes to this project will be documented in this file. Dates are displayed in UTC.
### YouTube
#### [2.4.2](https://github.com/Raiper34/spooty/compare/2.4.1...2.4.2)
- Added deterministic YouTube candidate scoring
- Added duration-aware YouTube matching
- Added failed-candidate rejection memory
- Added automatic retry/fallback handling
- Added explicit YouTube candidate exclusion support
- Added detailed YouTube candidate debug logging
- Added explicit yt-dlp CLI execution
- Added deterministic yt-dlp stderr capture
- Added age-gated video detection
- Added invalid-format detection
- Added Docker-compatible cookie handling
- Added temporary cookie-copy isolation
- Added retry-aware queue coordination
- 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)
### Queue Hardening
#### [2.4.1](https://github.com/Raiper34/spooty/compare/2.4.0...2.4.1)
- Added configurable YouTube search pacing
- Added configurable YouTube download pacing
- Added sequential queue coordination
- Added retry collision prevention
- Added deterministic queue replacement handling
- Added explicit retry-state transitions
- Reduced aggressive request bursts toward YouTube
> 7 February 2026
### Docker
- fix(docker): fix docker node version [`a14f5ea`](https://github.com/Raiper34/spooty/commit/a14f5eab6805acdf4755dbd8f37a289a0d675c12)
- Added persistent SQLite bind-mount recommendations
- Added persistent runtime config recommendations
- Added yt-dlp installation to runtime container
- Improved Docker runtime observability
- Improved hardened Docker startup flow
- Improved deployment documentation
#### [2.4.0](https://github.com/Raiper34/spooty/compare/2.3.4...2.4.0)
### Security
> 7 February 2026
- Added token-based frontend access mode
- Added external secret handling recommendations
- Added isolated Spotify credential storage
- Added safer cookie handling flow
- Improved Docker secret isolation documentation
- Removed unused downloader dependencies to reduce transitive attack surface
- Hardened download filename sanitization
- Hardened remote cover-art fetching with protocol, MIME, timeout, and size checks
- Added yt-dlp subprocess timeout and single-settlement handling
- Shaped track websocket payloads to avoid leaking internal entity fields
- Normalized visible project naming to Jarri Spooty
- 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)
### Frontend
#### [2.3.4](https://github.com/Raiper34/spooty/compare/2.3.3...2.3.4)
- Added Spotify login integration
- Added auth token bootstrap from URL
- Improved playlist rendering behavior
- Improved large playlist progress tracking
> 4 February 2026
## Improved
- fix(ytdl): Upgrade ytdl package (automated) [`ef32347`](https://github.com/Raiper34/spooty/commit/ef32347903d7caa2cc72c9c54341195b2ac0bb96)
- Improved YouTube throttling resistance
- Improved age-gated video recovery behavior
- Improved operational observability
- Improved cover-art embedding resilience
- Improved queue stability during large playlist imports
- Improved deterministic runtime behavior
- Improved Docker persistence handling
#### [2.3.3](https://github.com/Raiper34/spooty/compare/2.3.2...2.3.3)
## Fixed
> 30 January 2026
- Fixed Spotify playlist truncation at 100 tracks
- Fixed incomplete playlist retrieval behavior
- Fixed repeated retry loops against failed YouTube videos
- Fixed queue deadlocks during retries
- Fixed retry-button queue collisions
- Fixed container database loss on restart
- Fixed yt-dlp wrapper opacity issues
- Fixed hidden yt-dlp stderr failures
- Fixed multiple age-gated fallback failure paths
- Fixed invalid YouTube candidate reuse
- Fixed several Docker persistence edge cases
- fix(ytdl): Upgrade ytdl package (automated) [`cc552d1`](https://github.com/Raiper34/spooty/commit/cc552d1def309a3633aa67ca7f3659eb44ef47b2)
---
#### [2.3.2](https://github.com/Raiper34/spooty/compare/2.3.1...2.3.2)
# 2.4.2 (Original Spooty Endpoint)
> 26 January 2026
## Fixed
- fix(ytdl): Upgrade ytdl package (automated) [`531730b`](https://github.com/Raiper34/spooty/commit/531730bc57f9ab1339277ad995f37611a9f55aa8)
- Fixed Spotify playlist pagination using `data.next`
- Fixed incomplete playlist retrieval behavior
#### [2.3.1](https://github.com/Raiper34/spooty/compare/2.3.0...2.3.1)
---
> 23 January 2026
# 2.4.1
- 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
#### [2.3.0](https://github.com/Raiper34/spooty/compare/2.2.1...2.3.0)
- Fixed Docker Node.js version compatibility
> 29 December 2025
---
- 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)
# 2.4.0
#### [2.2.1](https://github.com/Raiper34/spooty/compare/2.2.0...2.2.1)
## Added
> 15 November 2025
- Added configurable audio quality selection
- 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)
## Fixed
#### [2.2.0](https://github.com/Raiper34/spooty/compare/2.1.1...2.2.0)
- Fixed special character download issues
- Fixed EJS runtime issue
> 17 October 2025
---
- 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)
# 2.3.0
#### [2.1.1](https://github.com/Raiper34/spooty/compare/2.1.0...2.1.1)
## Added
> 13 August 2025
- Added individual track downloads
- Added cover art support
- 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.1.0](https://github.com/Raiper34/spooty/compare/2.0.11...2.1.0)
# 2.2.0
> 12 August 2025
## Added
- feat(track): add track cover art into downloaded file [`#24`](https://github.com/Raiper34/spooty/issues/24)
- Added Spotify API integration
- Added YouTube cookie support
- Added download timeout handling
#### [2.0.11](https://github.com/Raiper34/spooty/compare/2.0.10...2.0.11)
---
> 14 June 2025
# 2.0.0
- fix(ytdl): Upgrade ytdl package (automated) [`fe79302`](https://github.com/Raiper34/spooty/commit/fe79302c2a747093df39405e42c06c08957acc3c)
## Added
#### [2.0.10](https://github.com/Raiper34/spooty/compare/2.0.9...2.0.10)
- Introduced queue-based backend architecture
> 4 June 2025
---
- fix(ytdl): Upgrade ytdl package (automated) [`f74a4d2`](https://github.com/Raiper34/spooty/commit/f74a4d2ed9da50e29f4e44b0027b2891c7473ec6)
# 1.0.0
#### [2.0.9](https://github.com/Raiper34/spooty/compare/2.0.8...2.0.9)
## Added
> 8 May 2025
- fix(ytdl): Upgrade ytdl package (automated) [`cc6a201`](https://github.com/Raiper34/spooty/commit/cc6a20196d474152eecaeb9862edfb3505ed3106)
#### [2.0.8](https://github.com/Raiper34/spooty/compare/2.0.7...2.0.8)
> 27 April 2025
- docs(readme): remove docs, netlify and docsify, keep only readme docs for now [`bc9482c`](https://github.com/Raiper34/spooty/commit/bc9482c6cb9a47aa0d96fa34866546ec552d1e3c)
- fix(ytdl): Upgrade ytdl package (automated) [`17b875e`](https://github.com/Raiper34/spooty/commit/17b875e2825cb739767565be5d86aba3a48a9208)
#### [2.0.7](https://github.com/Raiper34/spooty/compare/2.0.6...2.0.7)
> 5 April 2025
- fix(ytdl): Upgrade ytdl package (automated) [`9653db5`](https://github.com/Raiper34/spooty/commit/9653db54ea48e93526f2124b11b8012d987b7bcc)
#### [2.0.6](https://github.com/Raiper34/spooty/compare/2.0.5...2.0.6)
> 1 April 2025
- ci(netlify): remove netlify cli from deps [`e4cde85`](https://github.com/Raiper34/spooty/commit/e4cde8585ba0dfc422cec89360612947ebe92ff1)
- fix(ytdl): Upgrade ytdl package (automated) [`5f30f31`](https://github.com/Raiper34/spooty/commit/5f30f3116f34ceb48d00e3abd5c606e8f9b7caba)
- feat(names): strip special characters from file and folder names [`6f39c1e`](https://github.com/Raiper34/spooty/commit/6f39c1e5dc4b8f87917a18d2a23cec6b5cd9ca60)
- ci(github-actions): fix github actions update ytdl script [`04d8987`](https://github.com/Raiper34/spooty/commit/04d8987aa51a8887bf1b8b41e8680c2db9530bef)
#### [2.0.5](https://github.com/Raiper34/spooty/compare/2.0.4...2.0.5)
> 20 March 2025
- ci(docker): fix docker buildx in release-it for github actions [`db65823`](https://github.com/Raiper34/spooty/commit/db65823ce0794280cb373a4381e24ad704a4db56)
- fix(ytdl): Upgrade ytdl package (automated) [`16c57aa`](https://github.com/Raiper34/spooty/commit/16c57aaefd599f04b0a88723d55751f4ba27383a)
#### [2.0.4](https://github.com/Raiper34/spooty/compare/2.0.3...2.0.4)
> 15 March 2025
- fix(ytdl): Upgrade ytdl package (automated) [`b960dc8`](https://github.com/Raiper34/spooty/commit/b960dc88db2a44c5865cb5c6f5964e4b4ffedc3f)
#### [2.0.3](https://github.com/Raiper34/spooty/compare/2.0.2...2.0.3)
> 15 March 2025
- fix(ytdl): Upgrade ytdl package (automated) [`844d026`](https://github.com/Raiper34/spooty/commit/844d02668512cb8446f27f5629412fb5331b1298)
#### [2.0.2](https://github.com/Raiper34/spooty/compare/2.0.1...2.0.2)
> 15 March 2025
- fix(ytdl): Upgrade ytdl package (automated) [`cf559eb`](https://github.com/Raiper34/spooty/commit/cf559eb41a2eb84cd29d4a732d68885f4ebcf348)
#### [2.0.1](https://github.com/Raiper34/spooty/compare/2.0.0...2.0.1)
> 23 February 2025
- build(ytdl): Upgrade ytdl package (automated) [`#20`](https://github.com/Raiper34/spooty/pull/20)
- refactor(backend): queue system presented [`#5`](https://github.com/Raiper34/spooty/issues/5)
- fix(utdl): upgrade ytdl package [`66b55b3`](https://github.com/Raiper34/spooty/commit/66b55b39432f2da71bd401fd65af6c0a207dd6c2)
- ci(github-actions): add automatic ytdl update into github actions [`2ca165b`](https://github.com/Raiper34/spooty/commit/2ca165b59a76147439d4d00055b84102cf7a7317)
- ci(github-actions): add automatic ytdl update into github actions [`9baf3b9`](https://github.com/Raiper34/spooty/commit/9baf3b9eb7b093029300614bc9c1a913a73fb003)
- ci(github-actions): add automatic ytdl update into github actions [`b2fbb01`](https://github.com/Raiper34/spooty/commit/b2fbb01a7415c4027aa50dd2a1773d0b1e0dde3a)
- docs(website): change default environment variable for REDIS_RUN var [`f539a22`](https://github.com/Raiper34/spooty/commit/f539a227575569ce5641f5dfec6fcf50598a491d)
- ci(github-actions): add automatic ytdl update into github actions [`cbe2c9f`](https://github.com/Raiper34/spooty/commit/cbe2c9fbdc8322881b6ae3fd90586b7656d155ec)
- ci(github-actions): add automatic ytdl update into github actions [`a55ebf6`](https://github.com/Raiper34/spooty/commit/a55ebf659974137f96a465b74a8a0c8b649b3399)
- ci(github-actions): add automatic ytdl update into github actions [`66516e6`](https://github.com/Raiper34/spooty/commit/66516e6916af1b1e29df13d870f775f5c7ce43c2)
<!-- auto-changelog-above -->
### 2.0.0
- refactor(backend): queue system presented
### 1.0.0
- initial release
- Initial release
+22 -10
View File
@@ -6,15 +6,27 @@ RUN npm run build
FROM node:20.20.0-alpine
WORKDIR /spooty
COPY --from=builder /spooty/dist .
COPY --from=builder /spooty/src ./src
COPY --from=builder /spooty/package.json ./package.json
COPY --from=builder /spooty/package-lock.json ./package-lock.json
COPY --from=builder /spooty/src/backend/.env.docker ./.env
RUN npm prune --production
RUN rm -rf src package.json package-lock.json
RUN apk add --no-cache ffmpeg
RUN apk add --no-cache redis
RUN apk add --no-cache python3 py3-pip
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
&& addgroup -S spooty \
&& adduser -S spooty -G spooty
COPY --from=builder --chown=spooty:spooty /spooty/dist .
COPY --from=builder --chown=spooty:spooty /spooty/src ./src
COPY --from=builder --chown=spooty:spooty /spooty/package.json ./package.json
COPY --from=builder --chown=spooty:spooty /spooty/package-lock.json ./package-lock.json
COPY --from=builder --chown=spooty:spooty /spooty/src/backend/.env.docker ./.env
RUN npm prune --production \
&& rm -rf src package.json package-lock.json \
&& mkdir -p /spooty/backend/downloads /spooty/backend/config /spooty/config \
&& chown -R spooty:spooty /spooty
USER spooty
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "backend/main.js"]
+470 -120
View File
@@ -1,157 +1,507 @@
[![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)
# Jarri Spooty
![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.
Jarri 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
- Deterministic YouTube fallback handling
- Explicit yt-dlp CLI execution
- Automatic failed-candidate rejection
- Improved age-gated video handling
- Persistent SQLite state across container restarts
- Deterministic Docker config persistence
- Improved operational observability
- Reduced unused dependency surface
- Hardened filename, cover-art, subprocess, and websocket boundaries
### 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
- Automatic YouTube retry/fallback handling
- Failed YouTube candidate rejection memory
- Deterministic yt-dlp error classification
- Hardened cover-art validation and embedding
- Explicit client-facing websocket payload shaping
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.
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
```
#### Docker compose
Never commit this file.
---
## 3. Export YouTube Cookies (Recommended)
YouTube increasingly rate-limits or age-gates anonymous downloads.
Export a Netscape-format `cookies.txt` from a logged-in browser session.
Recommended storage:
```bash
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
sudo chown root:$USER /etc/tokens/youtube.cookies.txt
sudo chmod 640 /etc/tokens/youtube.cookies.txt
```
---
# 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=6 \
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt \
-v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
jarri-spooty: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
container_name: spooty
image: jarri-spooty:local
container_name: jarri-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"
YT_SEARCH_DELAY_MS: "7000"
YT_DOWNLOADS_PER_MINUTE: "6"
YT_COOKIES_FILE: "/spooty/config/youtube.cookies.txt"
volumes:
- ./downloads:/spooty/backend/downloads
- ./spooty-config:/spooty/backend/config
- /etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro
```
### Build from source
---
Spooty can be also build from source files on your own.
# Build From Source
#### 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
## Requirements
- Node.js 20.20.0
- Docker
- ffmpeg
- Python3
- Redis
- yt-dlp
#### 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.
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
YouTube may block or throttle downloads without authentication cookies. Spooty supports two ways to provide them — use the one that fits your setup.
#### `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`.
```
YT_COOKIES=chrome
```bash
npm install
npm run build
docker build -t jarri-spooty:local .
```
> [!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)
## Run
Export your YouTube cookies as a Netscape `cookies.txt` file and provide its path. This is the recommended approach for Docker deployments.
```bash
npm run start
```
**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`.
---
**Docker usage:**
# Environment Variables
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.
| 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 | `7000` | Delay between YouTube searches |
| YT_DOWNLOADS_PER_MINUTE | `6` | Download pacing |
| YT_COOKIES | unset | Browser cookie extraction |
| YT_COOKIES_FILE | unset | Netscape cookies.txt path |
> [!NOTE]
> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set.
---
# ⚖️ License
[MIT](https://choosealicense.com/licenses/mit/)
# 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/youtube.cookies.txt:ro
```
Then:
```bash
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt
```
---
# Deterministic YouTube Fallback Handling
The hardened branch now uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions.
This provides:
- Explicit stderr visibility
- Better Docker compatibility
- Deterministic retry handling
- Automatic failed-candidate rejection
- Better age-gated video handling
- Improved operational observability
If a YouTube candidate fails:
1. The failed URL is recorded
2. The candidate is rejected
3. A new YouTube search is performed
4. The next-best valid candidate is attempted automatically
This prevents infinite retry loops against dead or restricted videos.
---
# Persistent Docker State
The hardened Docker flow strongly recommends persistent bind mounts for:
- downloads
- SQLite database
- runtime config
Recommended:
```bash
-v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config"
```
This prevents:
- database resets on container recreation
- queue state loss
- retry-state loss
- playlist metadata loss
---
# 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=6
```
The hardened branch also includes additional internal pacing and retry coordination to reduce:
- repeated failed candidate loops
- aggressive retry bursts
- queue collisions
- YouTube anti-bot triggers
---
# yt-dlp Notes
Modern YouTube behavior changes frequently.
The hardened branch now:
- Uses explicit yt-dlp CLI execution
- Captures stderr deterministically
- Detects age-gated failures
- Detects missing downloadable formats
- Handles retry/fallback selection explicitly
Some videos may still fail due to:
- regional restrictions
- removed videos
- YouTube anti-bot measures
- invalid cookies
- unavailable formats
Recommended:
- fresh cookies.txt exports
- authenticated YouTube sessions
- moderate pacing settings
---
# Security Notes
Never commit:
- Spotify secrets
- OAuth tokens
- cookies.txt
- downloaded music
- local databases
- spooty-config/
Recommended `.gitignore` additions:
```gitignore
downloads/
config/
spooty-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 deterministic YouTube candidate scoring
- Added duration-aware YouTube matching
- Added failed-candidate rejection memory
- Added automatic retry/fallback handling
- Added explicit YouTube candidate exclusion support
- Added detailed YouTube candidate debug logging
- Added explicit yt-dlp CLI execution
- Added deterministic yt-dlp stderr capture
- Added age-gated video detection
- Added invalid-format detection
- Added Docker-compatible cookie handling
- Added temporary cookie-copy isolation
- Added retry-aware queue coordination
### 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
- Added deterministic retry-state handling
### 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=6 \
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt \
-v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
jarri-spooty:local
```
---
# License
MIT
+1834 -1983
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -17,8 +17,7 @@
"clean": "rimraf dist",
"changelog": "auto-changelog -p",
"release": "release-it",
"commit": "cz",
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
"commit": "cz"
},
"devDependencies": {
"@release-it/bumper": "^7.0.1",
+1 -6
View File
@@ -31,18 +31,13 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.10",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/yt-search": "^2.10.3",
"bullmq": "^5.31.2",
"fluent-ffmpeg": "^2.1.3",
"isomorphic-unfetch": "^4.0.2",
"node-id3": "^0.2.9",
"reflect-metadata": "^0.2.0",
"rxjs": "7.8.0",
"spotify-url-info": "^3.2.18",
"sqlite3": "^5.1.7",
"yt-search": "^2.12.1",
"ytdlp-nodejs": "^3.4.4"
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
+5 -2
View File
@@ -2,10 +2,13 @@ import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
constructor() {}
@Get()
getHello(): string {
return 'ONLINE';
}
@Get('health')
getHealth(): { status: string } {
return { status: 'ok' };
}
}
+2
View File
@@ -12,4 +12,6 @@ export enum EnvironmentEnum {
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
CORS_ORIGIN = 'CORS_ORIGIN',
NODE_ENV = 'NODE_ENV',
SPOTIFY_REDIRECT_URI = 'SPOTIFY_REDIRECT_URI',
SPOTIFY_TOKEN_PATH = 'SPOTIFY_TOKEN_PATH',
}
+11 -2
View File
@@ -4,7 +4,7 @@ import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
import { AppModule } from './app.module';
import * as fs from 'fs';
import { resolve } from 'path';
import { exec } from 'child_process';
import { spawn } from 'child_process';
import helmet from 'helmet';
import { EnvironmentEnum } from './environmentEnum';
@@ -27,7 +27,16 @@ async function bootstrap() {
try {
// Convenience mode for the single-container Docker image.
// Hardened deployments should prefer an external Redis service.
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`);
const redisProcess = spawn(
'redis-server',
['--port', String(process.env.REDIS_PORT || 6379)],
{
stdio: 'ignore',
detached: true,
},
);
redisProcess.unref();
} catch (e) {
console.log('Unable to run redis server from app');
console.log(e);
@@ -40,7 +40,7 @@ export class PlaylistController {
return this.service.remove(id);
}
@Get('retry/:id')
@Post('retry/:id')
retryFailedOfPlaylist(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
@@ -51,9 +51,12 @@ export class PlaylistService {
async create(playlist: PlaylistEntity): Promise<void> {
// Detect if URL is for a single track or a playlist and route accordingly
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
const isArtist = this.spotifyService.isArtistUrl(playlist.spotifyUrl);
if (isTrack) {
await this.createSingleTrack(playlist);
} else if (isArtist) {
await this.createArtistLibrary(playlist);
} else {
await this.createPlaylist(playlist);
}
@@ -105,6 +108,107 @@ export class PlaylistService {
}
}
private async createArtistLibrary(playlist: PlaylistEntity): Promise<void> {
let detail: { tracks: any[]; name: string; image: string };
let playlist2Save: PlaylistEntity;
try {
detail = await this.spotifyService.getArtistLibrary(playlist.spotifyUrl);
this.logger.debug(
`Artist library retrieved with ${detail.tracks?.length || 0} tracks`,
);
playlist2Save = {
...playlist,
name: detail.name,
coverUrl: detail.image,
active: false,
};
this.createPlaylistFolderStructure(playlist2Save.name);
} catch (err) {
this.logger.error(`Error getting artist library details: ${err}`);
playlist2Save = {
...playlist,
name: 'Failed artist library import',
error: toSafeErrorMessage(err),
active: false,
};
}
const savedPlaylist = await this.save(playlist2Save);
if (detail?.tracks && detail.tracks.length > 0) {
await this.createTracksForSavedPlaylist(detail.tracks, savedPlaylist);
} else {
this.logger.warn(`No tracks found for artist library ${savedPlaylist.name}`);
}
}
private async createTracksForSavedPlaylist(
tracks: any[],
savedPlaylist: PlaylistEntity,
): Promise<void> {
this.logger.debug(
`Starting to process ${tracks.length} tracks for ${savedPlaylist.name}`,
);
let processedCount = 0;
let skippedCount = 0;
let errorCount = 0;
for (const track of tracks) {
try {
if (!track.artist || !track.name) {
this.logger.warn(
`Skipping track ${processedCount + skippedCount + 1}: Missing artist or name information`,
);
skippedCount++;
continue;
}
if (track.unavailable === true) {
this.logger.warn(
`Skipping unavailable track ${processedCount + skippedCount + 1}: ${track.artist} - ${track.name}`,
);
skippedCount++;
continue;
}
await this.trackService.create(
{
artist: track.artist,
name: track.name,
spotifyUrl: track.previewUrl || null,
coverUrl: track.coverUrl || savedPlaylist.coverUrl,
durationMs: track.durationMs,
},
savedPlaylist,
);
processedCount++;
if (processedCount % 100 === 0) {
this.logger.debug(
`Processed ${processedCount} tracks so far for ${savedPlaylist.name}`,
);
}
} catch (error) {
this.logger.error(
`Error creating track "${
track?.artist || 'Unknown'
} - ${track?.name || 'Unknown'}": ${error.message}`,
);
errorCount++;
}
}
this.logger.debug(
`Finished processing ${savedPlaylist.name}: ` +
`${processedCount} tracks processed, ${skippedCount} skipped, ${errorCount} errors`,
);
}
private async createPlaylist(playlist: PlaylistEntity): Promise<void> {
let detail: { tracks: any; name: any; image: any };
let playlist2Save: PlaylistEntity;
@@ -162,6 +266,7 @@ export class PlaylistService {
name: track.name,
spotifyUrl: track.previewUrl || null,
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
durationMs: track.durationMs,
},
savedPlaylist,
);
+12 -2
View File
@@ -9,6 +9,17 @@ import type { Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
if (
request.path === '/api/health' ||
request.path === '/api/spotify/login' ||
request.path === '/api/spotify/callback' ||
request.path === '/api/spotify/status'
) {
return true;
}
if (!this.authEnabled()) {
return true;
}
@@ -20,11 +31,10 @@ export class AuthGuard implements CanActivate {
);
}
const request = context.switchToHttp().getRequest<Request>();
const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) {
throw new UnauthorizedException('Invalid or missing Spooty auth token');
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
}
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 { 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 {}
+472 -84
View File
@@ -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,203 @@ export class SpotifyApiService {
}
}
isArtistUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.pathname.includes('/artist/');
} catch {
return false;
}
}
private getArtistId(url: string): string {
try {
const urlObj = new URL(url);
const pathParts = urlObj.pathname.split('/');
const artistIndex = pathParts.findIndex((part) => part === 'artist');
if (artistIndex >= 0 && pathParts.length > artistIndex + 1) {
return pathParts[artistIndex + 1].split('?')[0];
}
throw new Error('Invalid Spotify artist URL');
} catch (error) {
this.logger.error(`Failed to extract artist ID: ${error.message}`);
throw error;
}
}
private getClientId(): string {
const clientId = process.env.SPOTIFY_CLIENT_ID;
if (!clientId) {
throw new Error('Missing SPOTIFY_CLIENT_ID');
}
return clientId;
}
private getClientSecret(): string {
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientSecret) {
throw new Error('Missing SPOTIFY_CLIENT_SECRET');
}
return clientSecret;
}
private getRedirectUri(): string {
return (
process.env[EnvironmentEnum.SPOTIFY_REDIRECT_URI] ||
'http://127.0.0.1:3000/api/spotify/callback'
);
}
private getTokenPath(): string {
if (process.env[EnvironmentEnum.SPOTIFY_TOKEN_PATH]) {
return process.env[EnvironmentEnum.SPOTIFY_TOKEN_PATH];
}
const dbPath = process.env[EnvironmentEnum.DB_PATH] || './config/db.sqlite';
return resolve(dirname(resolve(__dirname, dbPath)), 'spotify-user-token.json');
}
private getBasicAuthHeader(): string {
const credentials = Buffer.from(
`${this.getClientId()}:${this.getClientSecret()}`,
).toString('base64');
return `Basic ${credentials}`;
}
private readUserToken(): StoredSpotifyUserToken | null {
const tokenPath = this.getTokenPath();
if (!fs.existsSync(tokenPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
} catch (error) {
this.logger.error(`Failed to read Spotify user token: ${error.message}`);
return null;
}
}
private writeUserToken(token: StoredSpotifyUserToken): void {
const tokenPath = this.getTokenPath();
fs.mkdirSync(dirname(tokenPath), { recursive: true });
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
}
async hasUserToken(): Promise<boolean> {
return !!this.readUserToken();
}
getAuthorizationUrl(): string {
const url = new URL('https://accounts.spotify.com/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.getClientId());
url.searchParams.set(
'scope',
[
'playlist-read-private',
'playlist-read-collaborative',
'user-read-private',
].join(' '),
);
url.searchParams.set('redirect_uri', this.getRedirectUri());
url.searchParams.set('show_dialog', 'true');
return url.toString();
}
async exchangeAuthorizationCode(code: string): Promise<void> {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.getRedirectUri(),
}).toString(),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Failed to exchange Spotify authorization code: ${errorData}`);
}
const data = await response.json();
this.writeUserToken({
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000 - 60_000,
scope: data.scope,
token_type: data.token_type,
});
this.logger.debug('Stored Spotify user access token');
}
private async refreshUserAccessToken(
token: StoredSpotifyUserToken,
): Promise<StoredSpotifyUserToken> {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: token.refresh_token,
}).toString(),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Failed to refresh Spotify user token: ${errorData}`);
}
const data = await response.json();
const refreshed = {
...token,
access_token: data.access_token,
refresh_token: data.refresh_token || token.refresh_token,
expires_at: Date.now() + data.expires_in * 1000 - 60_000,
scope: data.scope || token.scope,
token_type: data.token_type || token.token_type,
};
this.writeUserToken(refreshed);
return refreshed;
}
private async getUserAccessToken(): Promise<string> {
const token = this.readUserToken();
if (!token) {
throw new Error('Spotify user is not connected. Open /api/spotify/login first.');
}
if (Date.now() < token.expires_at) {
return token.access_token;
}
return (await this.refreshUserAccessToken(token)).access_token;
}
async getTrackMetadata(
spotifyUrl: string,
): Promise<{ name: string; artist: string; image: string }> {
): Promise<{ name: string; artist: string; image: string; durationMs?: number }> {
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}`,
@@ -78,6 +277,7 @@ export class SpotifyApiService {
name: data.name,
artist: data.artists.map((a) => a.name).join(', '),
image: data.album.images[0]?.url || '',
durationMs: data.duration_ms,
};
} catch (error) {
this.logger.error(`Failed to get track metadata: ${error.message}`);
@@ -102,31 +302,18 @@ export class SpotifyApiService {
}
}
private async getAccessToken(): Promise<string> {
private async getClientCredentialsAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiry) {
return this.accessToken;
}
try {
this.logger.debug('Getting new Spotify access token');
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error(
'Missing Spotify credentials. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env file',
);
}
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
'base64',
);
this.logger.debug('Getting new Spotify client credentials access token');
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
@@ -141,7 +328,7 @@ export class SpotifyApiService {
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
this.logger.debug('Successfully obtained Spotify access token');
this.logger.debug('Successfully obtained Spotify client credentials access token');
return this.accessToken;
} catch (error) {
this.logger.error(`Error getting Spotify access token: ${error.message}`);
@@ -156,88 +343,289 @@ 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}/tracks?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[] };
duration_ms?: number;
};
}) => {
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,
durationMs: item.item.duration_ms,
};
},
)
.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;
}
}
async getArtistLibrary(
spotifyUrl: string,
): Promise<{ name: string; image: string; tracks: any[] }> {
try {
this.logger.debug(`Getting artist library for ${spotifyUrl}`);
const artistId = this.getArtistId(spotifyUrl);
const accessToken = await this.getUserAccessToken();
const artistResponse = await fetch(
`https://api.spotify.com/v1/artists/${artistId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!artistResponse.ok) {
const errorText = await artistResponse.text();
throw new Error(`Failed to fetch artist metadata: ${artistResponse.status} ${errorText}`);
}
const artist = await artistResponse.json();
const albums = await this.getAllArtistAlbums(artistId, accessToken);
const tracks = await this.getTracksForAlbums(albums, accessToken, artist.name);
return {
name: `Artist Library: ${artist.name}`,
image: artist.images?.[0]?.url || '',
tracks,
};
} catch (error) {
this.logger.error(`Failed to get artist library: ${error.message}`);
throw error;
}
}
private async getAllArtistAlbums(artistId: string, accessToken: string): Promise<any[]> {
const includeGroups = process.env.SPOTIFY_ARTIST_INCLUDE_GROUPS || 'album,single';
const albumByKey = new Map<string, any>();
const artistAlbumPageLimit = 20;
let offset = 0;
let hasMoreAlbums = true;
while (hasMoreAlbums) {
this.logger.debug(
`Fetching artist albums from Spotify API with offset ${offset}`,
);
const albumUrl = new URL(
`https://api.spotify.com/v1/artists/${artistId}/albums`,
);
albumUrl.searchParams.set('include_groups', includeGroups);
albumUrl.searchParams.set('offset', String(offset));
this.logger.debug(`Spotify artist albums URL: ${albumUrl.toString()}`);
const response = await fetch(albumUrl.toString(), {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch artist albums: ${response.status} ${errorText}`);
}
const data = await response.json();
for (const album of data.items || []) {
const key = this.normalizeLibraryKey(`${album.name}|${album.release_date}|${album.total_tracks}`);
if (!albumByKey.has(key)) {
albumByKey.set(key, album);
}
}
if (!data.next) {
hasMoreAlbums = false;
} else {
offset += artistAlbumPageLimit;
}
}
const albums = [...albumByKey.values()];
this.logger.debug(`Retrieved ${albums.length} deduplicated artist albums/singles`);
return albums;
}
private async getTracksForAlbums(
albums: any[],
accessToken: string,
artistName: string,
): Promise<any[]> {
const trackByKey = new Map<string, any>();
const maxTracks = Number(process.env.SPOTIFY_ARTIST_LIBRARY_MAX_TRACKS || 1000);
for (const album of albums) {
let offset = 0;
let hasMoreTracks = true;
while (hasMoreTracks) {
const response = await fetch(
`https://api.spotify.com/v1/albums/${album.id}/tracks?limit=50&offset=${offset}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch album tracks: ${response.status} ${errorText}`);
}
const data = await response.json();
for (const item of data.items || []) {
if (!item?.name || !item?.artists?.length) {
continue;
}
const fullTrack = await this.getTrackById(item.id, accessToken);
const isrc = fullTrack?.external_ids?.isrc;
const durationMs = fullTrack?.duration_ms || item.duration_ms;
const artist = item.artists.map((a) => a.name).join(', ');
const key = isrc
? `isrc:${isrc}`
: this.normalizeLibraryKey(`${artist}|${item.name}|${Math.round((durationMs || 0) / 2000)}`);
if (!trackByKey.has(key)) {
trackByKey.set(key, {
id: item.id,
name: item.name,
artist,
previewUrl: item.external_urls?.spotify || null,
coverUrl: album.images?.[0]?.url || null,
durationMs,
albumName: album.name,
primaryArtist: artistName,
});
}
if (trackByKey.size >= maxTracks) {
this.logger.warn(`Artist library max track limit reached: ${maxTracks}`);
return [...trackByKey.values()];
}
}
if (!data.next) {
hasMoreTracks = false;
} else {
offset += 50;
}
}
}
const tracks = [...trackByKey.values()];
this.logger.debug(`Retrieved ${tracks.length} deduplicated artist library tracks`);
return tracks;
}
private async getTrackById(trackId: string, accessToken: string): Promise<any> {
const response = await fetch(
`https://api.spotify.com/v1/tracks/${trackId}`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!response.ok) {
return null;
}
return response.json();
}
private normalizeLibraryKey(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
}
+11
View File
@@ -16,6 +16,10 @@ export class SpotifyService {
return this.spotifyApiService.isTrackUrl(url);
}
isArtistUrl(url: string): boolean {
return this.spotifyApiService.isArtistUrl(url);
}
async getTrackDetail(
spotifyUrl: string,
): Promise<{ name: string; artist: string; image: string }> {
@@ -61,6 +65,13 @@ export class SpotifyService {
}
}
async getArtistLibrary(
spotifyUrl: string,
): Promise<{ name: string; tracks: any[]; image: string }> {
this.logger.debug(`Get artist library ${spotifyUrl} on Spotify`);
return this.spotifyApiService.getArtistLibrary(spotifyUrl);
}
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
try {
+17 -3
View File
@@ -22,11 +22,11 @@ export class UtilsService {
}
ensureInsideDownloadsRoot(candidatePath: string): string {
const root = this.getRootDownloadsPath();
const root = resolve(this.getRootDownloadsPath());
const resolvedCandidate = resolve(candidatePath);
const rel = relative(root, resolvedCandidate);
if (rel === '' || (!rel.startsWith('..') && !resolve(rel).startsWith('/'))) {
if (rel === '' || (!rel.startsWith('..') && !relative('', rel).startsWith('..'))) {
return resolvedCandidate;
}
@@ -34,6 +34,20 @@ export class UtilsService {
}
stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled';
const sanitized = String(text || '')
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
.trim();
const fallback = sanitized || 'untitled';
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
return `_${fallback}`;
}
return fallback.slice(0, 180);
}
}
+480 -37
View File
@@ -3,9 +3,8 @@ import { TrackEntity } from '../track/track.entity';
import { EnvironmentEnum } from '../environmentEnum';
import { TrackService } from '../track/track.service';
import { ConfigService } from '@nestjs/config';
import { YtDlp } from 'ytdlp-nodejs';
import * as yts from 'yt-search';
import * as fs from 'fs';
import { spawn } from 'child_process';
const NodeID3 = require('node-id3');
const HEADERS = {
@@ -21,26 +20,314 @@ const ALLOWED_YOUTUBE_HOSTS = new Set([
'youtu.be',
]);
const BAD_MATCH_TERMS = [
'live',
'ao vivo',
'acústico',
'acustico',
'acoustic',
'concert',
'festival',
'session',
'cover',
'karaoke',
'remix',
'sped up',
'slowed',
'nightcore',
'reaction',
'tutorial',
'instrumental',
];
const OFFICIALISH_TERMS = [
'official audio',
'official video',
'official music video',
'lyric video',
'lyrics',
'vevo',
'- topic',
];
export interface YoutubeMatch {
url: string;
title: string;
author: string;
score: number;
reason: string;
}
interface CandidateScore extends YoutubeMatch {
rejected: boolean;
}
interface YtDlpSearchVideo {
url?: string;
webpage_url?: string;
id?: string;
title?: string;
uploader?: string;
channel?: string;
duration?: number;
}
@Injectable()
export class YoutubeService {
private readonly logger = new Logger(TrackService.name);
constructor(private readonly configService: ConfigService) {}
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
async findBestYoutubeMatch(
artist: string,
name: string,
durationMs?: number,
excludedUrls: string[] = [],
): Promise<YoutubeMatch> {
const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query);
const firstVideo = result.videos?.[0];
const videos = await this.searchYoutubeVideos(query);
const excludedVideoIds = new Set(
excludedUrls
.map((url) => this.getYoutubeVideoId(url))
.filter((id): id is string => !!id),
);
if (!firstVideo?.url) {
throw new Error(`No YouTube result found for: ${query}`);
const candidates = videos
.filter((video: any) => !!video?.url && !!video?.title)
.filter((video: any) => {
const videoId = this.getYoutubeVideoId(String(video.url));
return !videoId || !excludedVideoIds.has(videoId);
})
.slice(0, 15)
.map((video: any) => this.scoreCandidate(video, artist, name, durationMs))
.filter((match: CandidateScore) => {
try {
this.assertValidYoutubeUrl(match.url);
return true;
} catch {
return false;
}
})
.sort((a, b) => b.score - a.score);
this.logger.debug(
`YouTube candidates for "${query}": ` +
candidates
.slice(0, 5)
.map(
(c) =>
`[${c.score}${c.rejected ? ' rejected' : ''}] ${c.title} by ${c.author} (${c.reason})`,
)
.join(' | '),
);
const accepted = candidates.find((candidate) => !candidate.rejected);
if (!accepted) {
throw new Error(
`No acceptable YouTube result found for: ${query}` +
(excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''),
);
}
this.assertValidYoutubeUrl(firstVideo.url);
this.logger.debug(`Found ${query} on ${firstVideo.url}`);
return firstVideo.url;
this.logger.debug(
`Selected YouTube match for "${query}": ${accepted.title} by ${accepted.author} (${accepted.score})`,
);
return accepted;
}
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--flat-playlist',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
searchTarget,
];
const output = await this.runYtDlpForOutput(args);
let parsed: any;
try {
parsed = JSON.parse(output);
} catch (error) {
throw new Error(`Failed to parse yt-dlp search output: ${(error as Error).message}`);
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
return entries
.filter((entry: YtDlpSearchVideo) => !!entry?.title && (!!entry?.url || !!entry?.webpage_url || !!entry?.id))
.map((entry: YtDlpSearchVideo) => ({
...entry,
url: this.normalizeYtDlpVideoUrl(entry),
author: entry.uploader || entry.channel || '',
seconds: typeof entry.duration === 'number' ? entry.duration : undefined,
}));
}
private normalizeYtDlpVideoUrl(video: YtDlpSearchVideo): string {
const rawUrl = String(video.webpage_url || video.url || '');
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return rawUrl;
}
const id = String(video.id || rawUrl || '').trim();
if (!id) {
return '';
}
return `https://www.youtube.com/watch?v=${id}`;
}
private scoreCandidate(
video: any,
artist: string,
name: string,
durationMs?: number,
): CandidateScore {
const title = String(video.title || '');
const author = String(video.author?.name || video.author || '');
const url = String(video.url || '');
const seconds = this.getVideoSeconds(video);
const normalizedTitle = this.normalize(title);
const normalizedAuthor = this.normalize(author);
const normalizedArtist = this.normalize(artist);
const normalizedName = this.normalize(name);
let score = 0;
const reasons: string[] = [];
let rejected = false;
if (normalizedTitle.includes(normalizedName)) {
score += 45;
reasons.push('title=45');
}
for (const artistPart of this.artistParts(normalizedArtist)) {
if (artistPart.length >= 3 && (normalizedTitle.includes(artistPart) || normalizedAuthor.includes(artistPart))) {
score += 20;
reasons.push(`artist=${artistPart}:20`);
break;
}
}
const officialish = OFFICIALISH_TERMS.find((term) =>
`${normalizedTitle} ${normalizedAuthor}`.includes(this.normalize(term)),
);
if (officialish) {
score += 12;
reasons.push(`officialish=${officialish}:12`);
}
const badTerm = BAD_MATCH_TERMS.find((term) =>
normalizedTitle.includes(this.normalize(term)),
);
if (badTerm) {
score -= 25;
reasons.push(`bad=${badTerm}:-25`);
}
if (durationMs && seconds) {
const spotifySeconds = Math.round(durationMs / 1000);
const diff = Math.abs(seconds - spotifySeconds);
const ratio = diff / Math.max(spotifySeconds, 1);
if (diff <= 8 || ratio <= 0.05) {
score += 40;
reasons.push(`duration=excellent:${diff}s:+40`);
} else if (diff <= 15 || ratio <= 0.10) {
score += 30;
reasons.push(`duration=good:${diff}s:+30`);
} else if (diff <= 30 || ratio <= 0.15) {
score += 18;
reasons.push(`duration=ok:${diff}s:+18`);
} else if (diff <= 45 || ratio <= 0.25) {
score += 5;
reasons.push(`duration=weak:${diff}s:+5`);
} else if (diff > 60 && ratio > 0.40) {
score -= 80;
rejected = true;
reasons.push(`duration=reject:${diff}s:-80`);
} else {
score -= 35;
reasons.push(`duration=bad:${diff}s:-35`);
}
} else {
reasons.push('duration=unknown');
}
return {
url,
title,
author,
score,
reason: reasons.join(', '),
rejected,
};
}
private normalize(value: string): string {
return value
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
private artistParts(normalizedArtist: string): string[] {
return normalizedArtist
.split(/\s+(and|feat|featuring|ft|x|,|with)\s+|,/)
.map((part) => part.trim())
.filter((part) => part && !['and', 'feat', 'featuring', 'ft', 'x', 'with'].includes(part));
}
private getVideoSeconds(video: any): number | undefined {
if (typeof video.seconds === 'number' && Number.isFinite(video.seconds)) {
return video.seconds;
}
const timestamp = String(video.timestamp || video.duration || '');
const parts = timestamp
.split(':')
.map((part) => Number(part))
.filter((part) => Number.isFinite(part));
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
if (parts.length === 3) {
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
return undefined;
}
private getYoutubeVideoId(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.hostname === 'youtu.be') {
return parsed.pathname.replace(/^\//, '') || null;
}
return parsed.searchParams.get('v');
} catch {
return null;
}
}
assertValidYoutubeUrl(url: string): void {
@@ -61,25 +348,132 @@ export class YoutubeService {
}
}
private getCookiesOptions(): {
cookiesFromBrowser?: string;
cookies?: string;
} {
const cookiesBrowser = this.configService.get<string>(
EnvironmentEnum.YT_COOKIES,
);
if (cookiesBrowser) {
this.logger.debug(`Using cookies from browser: ${cookiesBrowser}`);
return { cookiesFromBrowser: cookiesBrowser };
}
private getCookiesFile(): string | null {
const cookiesFile = this.configService.get<string>(
EnvironmentEnum.YT_COOKIES_FILE,
);
if (cookiesFile && fs.existsSync(cookiesFile)) {
this.logger.debug(`Using cookies file: ${cookiesFile}`);
return { cookies: cookiesFile };
return cookiesFile;
}
return {};
return null;
}
private classifyYtDlpError(output: string): string {
const text = output || '';
if (text.includes('Sign in to confirm your age')) {
return 'YouTube age-gated: cookies required or invalid';
}
if (text.includes('Only images are available')) {
return 'YouTube unavailable: no downloadable audio/video formats';
}
if (text.includes('Requested format is not available')) {
return 'YouTube unavailable: requested audio format is not available';
}
if (text.includes('Private video')) {
return 'YouTube unavailable: private video';
}
if (text.includes('Video unavailable')) {
return 'YouTube unavailable';
}
if (text.includes('Unable to download webpage')) {
return 'YouTube unavailable: unable to download webpage';
}
return text.trim().slice(0, 1200) || 'Unknown yt-dlp error';
}
private prepareWritableCookiesFile(cookiesFile: string | null): string | null {
if (!cookiesFile) {
return null;
}
const tempCookiesFile = `/tmp/jarri-spooty-youtube-${process.pid}.cookies.txt`;
fs.copyFileSync(cookiesFile, tempCookiesFile);
return tempCookiesFile;
}
private runYtDlpForOutput(args: string[]): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn('yt-dlp', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeoutMs = 10 * 60 * 1000;
let settled = false;
const settleResolve = (value: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolvePromise(value);
};
const settleReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rejectPromise(error);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
settleReject(
new Error(`yt-dlp exceeded maximum runtime of ${timeoutMs}ms`),
);
}, timeoutMs);
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
settleReject(new Error(`Failed to start yt-dlp: ${error.message}`));
});
child.on('close', (code) => {
if (settled) {
return;
}
if (code === 0) {
settleResolve(stdout);
return;
}
settleReject(
new Error(
`yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`,
),
);
});
});
}
private async runYtDlp(args: string[]): Promise<void> {
await this.runYtDlpForOutput(args);
}
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
@@ -93,18 +487,32 @@ export class YoutubeService {
this.assertValidYoutubeUrl(track.youtubeUrl);
const ytdlp = new YtDlp();
await ytdlp.downloadAudio(
track.youtubeUrl,
this.configService.get<'m4a'>(EnvironmentEnum.FORMAT),
{
output,
...this.getCookiesOptions(),
headers: HEADERS,
jsRuntime: 'node',
audioQuality: this.configService.get<string>('QUALITY'),
},
);
const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
const quality = this.configService.get<string>('QUALITY') || '0';
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
const args = [
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--extract-audio',
'--audio-format',
format,
'--audio-quality',
quality,
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
'-o',
output,
];
if (cookiesFile) {
args.push('--cookies', cookiesFile);
}
args.push(track.youtubeUrl);
await this.runYtDlp(args);
this.logger.debug(
`Downloaded ${track.artist} - ${track.name} to ${output}`,
@@ -121,12 +529,47 @@ export class YoutubeService {
return;
}
const res = await fetch(coverUrl);
let parsedCoverUrl: URL;
try {
parsedCoverUrl = new URL(coverUrl);
} catch {
throw new Error('Invalid cover art URL');
}
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
throw new Error('Rejected non-HTTP cover art URL');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let res: Response;
try {
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
throw new Error(`Failed to fetch cover art: ${res.status}`);
}
const contentType = res.headers.get('content-type')?.split(';')[0].toLowerCase() || '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
throw new Error(`Rejected unsupported cover art type: ${contentType || 'unknown'}`);
}
const maxCoverBytes = 5 * 1024 * 1024;
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${contentLength} bytes`);
}
const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
}
const imageBuffer = Buffer.from(arrayBuf);
NodeID3.write(
@@ -134,7 +577,7 @@ export class YoutubeService {
title,
artist,
APIC: {
mime: 'image/jpeg',
mime: contentType,
type: { id: 3, name: 'front cover' },
description: 'cover',
imageBuffer,
@@ -4,8 +4,18 @@ import { Job } from 'bullmq';
import { TrackService } from './track.service';
import { TrackEntity } from './track.entity';
function getSearchDelayMs(): number {
const parsed = Number(process.env.YT_SEARCH_DELAY_MS || 5000);
if (!Number.isFinite(parsed) || parsed < 0) {
return 5000;
}
return Math.max(1000, Math.min(30000, Math.floor(parsed)));
}
@Processor('track-search-processor', {
concurrency: 2,
concurrency: 1,
})
export class TrackSearchProcessor extends WorkerHost {
private readonly logger = new Logger(TrackSearchProcessor.name);
@@ -14,13 +24,14 @@ export class TrackSearchProcessor extends WorkerHost {
super();
}
async process(job: Job<TrackEntity, void, string>): Promise<void> {
if (!job.data?.id) {
this.logger.warn(`Skipping malformed search job ${job.id}`);
return;
}
async process(job: Job<{ id: number }, void, string>): Promise<void> {
const delayMs = getSearchDelayMs();
this.logger.debug(`Processing search job ${job.id} for track ${job.data.id}`);
await this.trackService.findOnYoutube(job.data);
this.logger.debug(
`Processing search job ${job.id} for track ${job.data.id}; pacing ${delayMs}ms`,
);
await new Promise((res) => setTimeout(res, delayMs));
await this.trackService.findOnYoutube({ id: job.data.id } as TrackEntity);
}
}
+2 -1
View File
@@ -2,6 +2,7 @@ import {
Controller,
Delete,
Get,
Post,
NotFoundException,
Param,
ParseIntPipe,
@@ -66,7 +67,7 @@ export class TrackController {
return this.service.remove(id);
}
@Get('retry/:id')
@Post('retry/:id')
retry(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
+9
View File
@@ -27,6 +27,12 @@ export class TrackEntity {
@Column({ nullable: true })
youtubeUrl?: string;
@Column({ nullable: true, type: 'text' })
rejectedYoutubeUrls?: string;
@Column({ default: 0 })
downloadAttemptCount?: number;
@Column({ default: TrackStatusEnum.New })
status?: TrackStatusEnum;
@@ -36,6 +42,9 @@ export class TrackEntity {
@Column({ nullable: true })
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
@Column({ nullable: true })
durationMs?: number;
@Column({ default: Date.now() })
createdAt?: number;
+115 -16
View File
@@ -56,18 +56,51 @@ export class TrackService {
this.io.emit(WsTrackOperation.Delete, { id });
}
private toClientTrack(track: TrackEntity): Partial<TrackEntity> {
return {
id: track.id,
artist: track.artist,
name: track.name,
spotifyUrl: track.spotifyUrl,
youtubeUrl: track.youtubeUrl,
downloadAttemptCount: track.downloadAttemptCount,
status: track.status,
error: track.error,
coverUrl: track.coverUrl,
durationMs: track.durationMs,
createdAt: track.createdAt,
};
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({ ...track, playlist });
await this.enqueueSearch(savedTrack.id);
this.io.emit(WsTrackOperation.New, {
track: savedTrack,
track: this.toClientTrack(savedTrack),
playlistId: playlist.id,
});
}
async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track);
this.io.emit(WsTrackOperation.Update, track);
this.io.emit(WsTrackOperation.Update, this.toClientTrack(track));
}
private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
if (!track.rejectedYoutubeUrls) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeUrls);
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
} catch {
return [];
}
}
private stringifyRejectedYoutubeUrls(urls: string[]): string {
return JSON.stringify([...new Set(urls)].slice(0, 20));
}
async retry(id: number): Promise<void> {
@@ -78,12 +111,22 @@ export class TrackService {
return;
}
await this.enqueueSearch(id);
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
rejectedUrls.push(track.youtubeUrl);
}
await this.update(id, {
...track,
youtubeUrl: null,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
downloadAttemptCount: 0,
error: null,
status: TrackStatusEnum.New,
});
await this.enqueueSearch(id);
}
async findOnYoutube(track: TrackEntity): Promise<void> {
@@ -102,11 +145,22 @@ export class TrackService {
});
let updatedTrack: TrackEntity;
try {
const youtubeUrl = await this.youtubeService.findOnYoutubeOne(
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
track.artist,
track.name,
track.durationMs,
rejectedUrls,
);
updatedTrack = {
...track,
youtubeUrl: youtubeMatch.url,
status: TrackStatusEnum.Queued,
};
this.logger.debug(
`YouTube match for track ${track.id}: ${youtubeMatch.title} by ${youtubeMatch.author} score=${youtubeMatch.score} reason=${youtubeMatch.reason}`,
);
updatedTrack = { ...track, youtubeUrl, status: TrackStatusEnum.Queued };
} catch (err) {
this.logger.error(err);
updatedTrack = {
@@ -134,8 +188,8 @@ export class TrackService {
const existingJob = await this.trackSearchQueue.getJob(jobId);
if (existingJob) {
this.logger.warn(`Search job already exists for track ${id}`);
return;
this.logger.warn(`Removing existing search job for track ${id} before enqueue`);
await existingJob.remove();
}
await this.trackSearchQueue.add('search-track', { id }, { jobId });
@@ -153,13 +207,23 @@ export class TrackService {
const existingJob = await this.trackDownloadQueue.getJob(jobId);
if (existingJob) {
this.logger.warn(`Download job already exists for track ${id}`);
return;
this.logger.warn(`Removing existing download job for track ${id} before enqueue`);
await existingJob.remove();
}
await this.trackDownloadQueue.add('download-track', { id }, { jobId });
}
private getMaxDownloadAttempts(): number {
const parsed = Number(process.env.YT_DOWNLOAD_FALLBACK_ATTEMPTS || 3);
if (!Number.isFinite(parsed) || parsed < 1) {
return 3;
}
return Math.max(1, Math.min(10, Math.floor(parsed)));
}
async downloadFromYoutube(track: TrackEntity): Promise<void> {
const dbTrack = await this.get(track.id);
if (!dbTrack) {
@@ -192,22 +256,57 @@ export class TrackService {
const folderName = this.getFolderName(track, track.playlist);
await this.youtubeService.downloadAndFormat(track, folderName);
if (coverUrl) {
await this.youtubeService.addImage(
folderName,
coverUrl,
track.name,
track.artist,
);
try {
await this.youtubeService.addImage(
folderName,
coverUrl,
track.name,
track.artist,
);
} catch (imageError) {
this.logger.warn(
`Cover art embed failed for track ${track.id}: ${toSafeErrorMessage(imageError)}`,
);
}
}
} catch (err) {
this.logger.error(err);
error = toSafeErrorMessage(err);
}
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
const maxAttempts = this.getMaxDownloadAttempts();
if (error && track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
rejectedUrls.push(track.youtubeUrl);
}
if (error && nextAttemptCount < maxAttempts) {
this.logger.warn(
`Download failed for track ${track.id}; rejecting current YouTube candidate and retrying search ` +
`(${nextAttemptCount}/${maxAttempts})`,
);
await this.update(track.id, {
...track,
youtubeUrl: null,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
downloadAttemptCount: nextAttemptCount,
error: null,
status: TrackStatusEnum.New,
});
await this.enqueueSearch(track.id);
return;
}
const updatedTrack = {
...track,
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
...(error ? { error } : {}),
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
downloadAttemptCount: error ? nextAttemptCount : 0,
...(error ? { error } : { error: null }),
};
await this.update(track.id, updatedTrack);
}
+1 -1
View File
@@ -1,4 +1,4 @@
# SpootyFe
# Jarri Spooty Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
+9 -1
View File
@@ -50,7 +50,15 @@
"maximumError": "4kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false
},
"fonts": true
}
},
"development": {
"optimization": false,
+11 -12
View File
@@ -11,15 +11,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.0.6",
"@angular/common": "^19.0.6",
"@angular/compiler": "^19.0.6",
"@angular/core": "^19.0.6",
"@angular/forms": "^19.0.6",
"@angular/platform-browser": "^19.0.6",
"@angular/platform-browser-dynamic": "^19.0.6",
"@angular/router": "^19.0.6",
"@distube/ytdl-core": "^4.15.9",
"@angular/animations": "^19.2.22",
"@angular/common": "^19.2.22",
"@angular/compiler": "^19.2.22",
"@angular/core": "^19.2.22",
"@angular/forms": "^19.2.22",
"@angular/platform-browser": "^19.2.22",
"@angular/platform-browser-dynamic": "^19.2.22",
"@angular/router": "^19.2.22",
"@fortawesome/fontawesome-free": "^6.5.2",
"@ngneat/elf": "^2.5.1",
"@ngneat/elf-cli-ng": "^1.0.0",
@@ -36,9 +35,9 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.7",
"@angular/cli": "^19.0.7",
"@angular/compiler-cli": "^19.0.6",
"@angular-devkit/build-angular": "^19.2.26",
"@angular/cli": "^19.2.26",
"@angular/compiler-cli": "^19.2.22",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "SpootyFe",
"short_name": "SpootyFe",
"name": "Jarri Spooty",
"short_name": "Spooty",
"description": "Self-hosted Spotify downloader",
"icons": [
{
+77 -56
View File
@@ -1,40 +1,73 @@
<section class="hero is-primary">
<div class="hero-body">
<p class="title">
<section class="jarri-hero">
<div class="hero-left">
<p class="eyebrow">Jarri subsystem · deterministic media ingestion</p>
<h1>
<i class="fa-brands fa-spotify"></i>
<span>Spooty</span>
<span class="tag is-dark is-size-7">v{{version}}</span>
Jarri Spooty
<span class="tag version-tag">v{{version}}</span>
</h1>
<p class="hero-subtitle">Spotify metadata → YouTube candidate scoring → Local archive</p>
</div>
<div class="hero-right">
<button
class="button auth-button"
[class.connected]="spotifyConnected"
(click)="connectSpotify()"
>
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
</button>
</div>
</section>
<section class="jarri-body">
<div class="intelligence-grid">
<div class="intel-card">
<span class="intel-label">Spotify</span>
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
</div>
<div class="intel-card">
<span class="intel-label">Matching</span>
<strong>Duration-aware</strong>
</div>
<div class="intel-card">
<span class="intel-label">Queue</span>
<strong>Paced</strong>
</div>
<div class="intel-card">
<span class="intel-label">Mode</span>
<strong>Archive run</strong>
</div>
</div>
<div class="box jarri-box">
<p class="subtitle">Acquire music</p>
<div class="is-flex">
<input class="input"
[class.is-danger]="url && !isValidSpotifyUrl"
type="text"
[(ngModel)]="url"
placeholder="Paste Spotify playlist or track URL"/>
<button class="button is-primary"
[class.is-loading]="(createLoading$ | async)?.isLoading"
(click)="download()"
[disabled]="!url || !isValidSpotifyUrl"
>
<i class="fa-solid fa-download"></i> Queue
</button>
</div>
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
Please enter a valid Spotify URL.
</p>
<p class="subtitle">Self-hosted spotify downloader</p>
</div>
</section>
<section class="hero">
<div class="hero-body">
<div class="box">
<p class="subtitle">Download</p>
<div class="box jarri-box">
<div class="is-flex is-justify-content-space-between">
<div>
<div class="is-flex">
<input class="input"
[class.is-danger]="url && !isValidSpotifyUrl"
type="text"
[(ngModel)]="url"
placeholder="Paste playlist/song/artist url"/>
<button class="button is-primary"
[class.is-loading]="(createLoading$ | async)?.isLoading"
(click)="download()"
[disabled]="!url || !isValidSpotifyUrl"
>
<i class="fa-solid fa-download"></i> Download
</button>
</div>
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">Please enter a valid Spotify URL (e.g. https://open.spotify.com/...)</p>
<p class="subtitle">Playlist History</p>
<p class="has-text-grey is-size-7">One collapsed row per playlist run.</p>
</div>
</div>
<hr>
<div class="box">
<div class="is-flex is-justify-content-space-between">
<p class="subtitle">Playlists</p>
<div class="buttons has-addons">
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
<i class="fa-solid fa-check"></i>
@@ -44,30 +77,18 @@
</button>
</div>
</div>
<ng-container *ngIf="playlists$ | async as playlists">
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
</ng-container>
</div>
<hr>
<div class="box">
<div class="is-flex is-justify-content-space-between">
<p class="subtitle">Songs</p>
<div class="buttons has-addons">
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
<i class="fa-solid fa-check"></i>
</button>
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<ng-container *ngIf="songs$ | async as songs">
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
</ng-container>
</div>
<ng-container *ngIf="playlists$ | async as playlists">
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
</ng-container>
</div>
<div class="box jarri-box">
<p class="subtitle">Single Songs</p>
<ng-container *ngIf="songs$ | async as songs">
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
</ng-container>
</div>
</section>
+129
View File
@@ -0,0 +1,129 @@
:host {
display: block;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(29, 185, 84, 0.18), transparent 30rem),
linear-gradient(135deg, #06110c 0%, #0a1210 45%, #020403 100%);
color: #eafff0;
}
.jarri-hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
padding: 34px 44px;
background:
linear-gradient(90deg, rgba(3, 12, 7, 0.95), rgba(29, 185, 84, 0.92));
border-bottom: 1px solid rgba(126, 255, 180, 0.25);
}
.eyebrow {
margin: 0 0 6px;
color: #9fffc3;
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.72rem;
font-weight: 800;
}
h1 {
margin: 0;
color: #f3fff7;
font-size: clamp(2.2rem, 4vw, 4.6rem);
font-weight: 900;
letter-spacing: -0.06em;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45);
}
.hero-subtitle {
margin-top: 8px;
color: #d8ffe5;
font-size: 1.05rem;
}
.version-tag {
background: #07110c;
color: #9fffc3;
vertical-align: middle;
}
.auth-button {
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.92);
color: #07110c;
font-weight: 800;
}
.auth-button.connected {
background: #9fffc3;
}
.jarri-body {
padding: 34px 44px;
}
.intelligence-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 22px;
}
.intel-card {
border: 1px solid rgba(126, 255, 180, 0.18);
border-radius: 16px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.22);
}
.intel-label {
display: block;
color: #8ea99a;
text-transform: uppercase;
letter-spacing: 0.13em;
font-size: 0.68rem;
font-weight: 800;
}
.intel-card strong {
display: block;
margin-top: 4px;
color: #f1fff5;
font-size: 1.1rem;
}
.intel-card strong.online {
color: #9fffc3;
}
.jarri-box {
border-radius: 18px;
margin-bottom: 20px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
}
.button {
border-radius: 10px;
}
@media (max-width: 900px) {
.jarri-hero,
.jarri-body {
padding: 18px;
}
.jarri-hero {
display: block;
}
.hero-right {
margin-top: 14px;
}
.intelligence-grid {
grid-template-columns: 1fr 1fr;
}
}
+28
View File
@@ -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,14 +26,41 @@ 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();
}
private bootstrapAuthTokenFromUrl(): void {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
if (!token) {
return;
}
localStorage.setItem('spooty_auth_token', token);
url.searchParams.delete('token');
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();
}
@@ -1,35 +1,65 @@
<article class="panel" [ngClass]="statusClass$ | async">
<p class="panel-heading is-flex is-justify-content-space-between">
<span>
<i class="is-clickable fa-solid"
[ngClass]="playlist.collapsed ? 'fa-caret-up' : 'fa-caret-down'"
(click)="toggleCollapse(playlist.id)"
></i>&nbsp;
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">{{playlist.error}}</span>
<ng-template #noErrorTemplate>
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
</ng-template>&nbsp;
<a [href]="playlist.spotifyUrl"
target="_blank"
class="has-text-black" title="Link to Spotify url to be download from">
<i class="fa-brands fa-spotify"></i>
</a>
</span>
<span>
<article class="panel playlist-card" [ngClass]="statusClass$ | async">
<p class="panel-heading playlist-heading is-flex is-justify-content-space-between" (click)="toggleCollapse(playlist.id)">
<span class="playlist-title">
<i class="is-clickable fa-solid"
[ngClass]="playlist.collapsed ? 'fa-caret-down' : 'fa-caret-right'"></i>&nbsp;
<span class="history-label">{{ playlist.isTrack ? 'track' : 'playlist' }}</span>&nbsp;
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">
{{playlist.error}}
</span>
<ng-template #noErrorTemplate>
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
</ng-template>
</span>
<span class="playlist-actions" (click)="$event.stopPropagation()">
<span *ngIf="!playlist.error" class="progress-text">
{{trackCompletedCount$ | async}}/{{trackCount$ | async}}
</span>
<span *ngIf="(trackErrorCount$ | async) as errors" class="error-count" [class.is-hidden]="errors === 0">
{{errors}} failed
</span>
<a [href]="playlist.spotifyUrl"
target="_blank"
class="has-text-black"
title="Open Spotify URL">
<i class="fa-brands fa-spotify"></i>
</a>
<ng-container *ngIf="!playlist.isTrack">
<i class="fa-solid is-clickable hover-icon"
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
(click)="toggleActive(playlist.id, playlist.active)"
></i>
<i>&nbsp;</i>
(click)="toggleActive(playlist.id, playlist.active)">
</i>
</ng-container>
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>&nbsp;
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>&nbsp;
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>&nbsp;
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>&nbsp;
<i class="fa-solid fa-repeat is-clickable hover-icon"
title="Retry failed tracks"
(click)="retryFailed(playlist.id)">
</i>
<i class="fa-solid fa-xmark is-clickable hover-icon"
title="Remove playlist from list"
(click)="delete(playlist.id)">
</i>
</span>
</p>
<div class="progress-shell">
<div class="progress-fill" [style.width.%]="progressPercent$ | async"></div>
</div>
<p class="progress-caption">
<span>{{progressPercent$ | async}}% complete</span>
<span *ngIf="playlist.active">subscribed</span>
</p>
<ng-container *ngIf="playlist.collapsed">
<app-track-list [playlistId]="playlist.id"></app-track-list>
</ng-container>
@@ -2,19 +2,73 @@
display: none;
}
.panel-heading {
padding: 5px 10px;
border-radius: 10px;
.playlist-card {
margin: 8px 0;
border-radius: 14px;
overflow: hidden;
background: rgba(255, 255, 255, 0.96);
}
.playlist-heading {
padding: 8px 12px;
border-radius: 0;
cursor: pointer;
&:hover .hover-icon {
display: inline;
}
&:hover .hover-icon-reversed {
display: none;
}
}
.panel {
margin: 5px 0;
.playlist-title {
min-width: 0;
overflow-wrap: anywhere;
}
.history-label {
border-radius: 999px;
background: rgba(0, 0, 0, 0.16);
padding: 2px 7px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.playlist-actions {
display: inline-flex;
align-items: center;
gap: 9px;
white-space: nowrap;
}
.progress-text {
font-weight: 700;
}
.error-count {
border-radius: 999px;
padding: 2px 7px;
background: #ff3860;
color: white;
font-size: 0.72rem;
font-weight: 700;
}
.progress-shell {
height: 7px;
background: rgba(0, 0, 0, 0.08);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #1db954, #48f59b);
transition: width 180ms ease-out;
}
.progress-caption {
display: flex;
justify-content: space-between;
margin: 0;
padding: 5px 12px 8px;
color: #667;
font-size: 0.78rem;
}
@@ -2,7 +2,7 @@ import {Component, Input} from '@angular/core';
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
import {TrackListComponent} from "../track-list/track-list.component";
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
import {Observable, map} from "rxjs";
import {Observable, combineLatest, map} from "rxjs";
import {Playlist} from "../../models/playlist";
const STATUS2CLASS = {
@@ -14,15 +14,15 @@ const STATUS2CLASS = {
}
@Component({
selector: 'app-playlist-box',
imports: [
CommonModule,
AsyncPipe,
NgIf,
TrackListComponent
],
templateUrl: './playlist-box.component.html',
styleUrl: './playlist-box.component.scss',
selector: 'app-playlist-box',
imports: [
CommonModule,
AsyncPipe,
NgIf,
TrackListComponent
],
templateUrl: './playlist-box.component.html',
styleUrl: './playlist-box.component.scss',
standalone: true
})
export class PlaylistBoxComponent {
@@ -31,16 +31,27 @@ export class PlaylistBoxComponent {
this._playlist = val;
this.trackCount$ = this.service.getTrackCount(val.id);
this.trackCompletedCount$ = this.service.getCompletedTrackCount(val.id);
this.trackErrorCount$ = this.service.getErrorTrackCount(val.id);
this.progressPercent$ = combineLatest([
this.trackCompletedCount$,
this.trackCount$,
]).pipe(
map(([completed, total]) => total ? Math.round((completed / total) * 100) : 0)
);
this.statusClass$ = this.service.getStatus$(val.id).pipe(
map(status => STATUS2CLASS[status])
);
}
get playlist(): Playlist & PlaylistUi {
return this._playlist;
}
_playlist!: Playlist & PlaylistUi;
trackCount$!: Observable<number>;
trackCompletedCount$!: Observable<number>;
trackErrorCount$!: Observable<number>;
progressPercent$!: Observable<number>;
statusClass$!: Observable<string>;
constructor(private readonly service: PlaylistService) { }
@@ -138,7 +138,7 @@ export class PlaylistService {
}
retryFailed(id: number): void {
this.http.get<void>(`${ENDPOINT}/retry/${id}`).subscribe();
this.http.post<void>(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
setActive(id: number, active: boolean): void {
@@ -57,7 +57,7 @@ export class TrackService {
}
retry(id: number): void {
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe();
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
private initWsConnection(): void {
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>SpootyFe</title>
<title>Jarri Spooty</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">