Merge branch 'hardening/pass-006-source-determinism'
This commit is contained in:
@@ -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
|
||||||
@@ -5,3 +5,5 @@
|
|||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
test-downloads/
|
test-downloads/
|
||||||
|
|
||||||
|
spooty-config
|
||||||
|
|||||||
+124
-127
@@ -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)
|
### Frontend
|
||||||
- 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)
|
|
||||||
|
|
||||||
#### [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)
|
## Fixed
|
||||||
- docs(readme): change redirect URI [`8162a2a`](https://github.com/Raiper34/spooty/commit/8162a2a21f762310f857dfc42cad53831769ed66)
|
|
||||||
|
|
||||||
#### [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)
|
# 2.4.0
|
||||||
- docs(readme): remove demo image and gif from readme [`a2ac579`](https://github.com/Raiper34/spooty/commit/a2ac579854a6cbc95729d434c514fb2309f7a3bb)
|
|
||||||
|
|
||||||
#### [2.2.1](https://github.com/Raiper34/spooty/compare/2.2.0...2.2.1)
|
## 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)
|
## Fixed
|
||||||
- fix(docker): add python3 dependency into docker image for yt download library [`46d7c66`](https://github.com/Raiper34/spooty/commit/46d7c6699bc8698896b22837e8507885677a71bd)
|
|
||||||
- docs(readme): fix yt cookies section heading [`285578f`](https://github.com/Raiper34/spooty/commit/285578ff1ed1d7fe14d7ddd0d95a4b8079276e4e)
|
|
||||||
- docs(readme): add docker versio badge into readme [`4be3743`](https://github.com/Raiper34/spooty/commit/4be3743f7b5a8019bd6ed0d2248f23a254a82603)
|
|
||||||
|
|
||||||
#### [2.2.0](https://github.com/Raiper34/spooty/compare/2.1.1...2.2.0)
|
- 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)
|
# 2.3.0
|
||||||
- feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads [`4c05178`](https://github.com/Raiper34/spooty/commit/4c051782fd8808a808fe49fe1509ec0b0db9d05e)
|
|
||||||
|
|
||||||
#### [2.1.1](https://github.com/Raiper34/spooty/compare/2.1.0...2.1.1)
|
## 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
|
- Initial release
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|||||||
+22
-10
@@ -6,15 +6,27 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM node:20.20.0-alpine
|
FROM node:20.20.0-alpine
|
||||||
WORKDIR /spooty
|
WORKDIR /spooty
|
||||||
COPY --from=builder /spooty/dist .
|
|
||||||
COPY --from=builder /spooty/src ./src
|
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
|
||||||
COPY --from=builder /spooty/package.json ./package.json
|
&& addgroup -S spooty \
|
||||||
COPY --from=builder /spooty/package-lock.json ./package-lock.json
|
&& adduser -S spooty -G spooty
|
||||||
COPY --from=builder /spooty/src/backend/.env.docker ./.env
|
|
||||||
RUN npm prune --production
|
COPY --from=builder --chown=spooty:spooty /spooty/dist .
|
||||||
RUN rm -rf src package.json package-lock.json
|
COPY --from=builder --chown=spooty:spooty /spooty/src ./src
|
||||||
RUN apk add --no-cache ffmpeg
|
COPY --from=builder --chown=spooty:spooty /spooty/package.json ./package.json
|
||||||
RUN apk add --no-cache redis
|
COPY --from=builder --chown=spooty:spooty /spooty/package-lock.json ./package-lock.json
|
||||||
RUN apk add --no-cache python3 py3-pip
|
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
|
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"]
|
CMD ["node", "backend/main.js"]
|
||||||
@@ -1,157 +1,507 @@
|
|||||||
[](https://hub.docker.com/r/raiper34/spooty)
|
# Jarri Spooty
|
||||||
[](https://hub.docker.com/r/raiper34/spooty)
|
|
||||||

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

|
Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
|
||||||
# Spooty - selfhosted Spotify downloader
|
|
||||||
Spooty is a self-hosted Spotify downloader.
|
|
||||||
It allows download track/playlist/album from the Spotify url.
|
|
||||||
It can also subscribe to a playlist or author page and download new songs upon release.
|
|
||||||
Spooty basically downloads nothing from Spotify, it only gets information from spotify and then finds relevant and downloadeds music on Youtube.
|
|
||||||
The project is based on NestJS and Angular.
|
|
||||||
|
|
||||||
> [!IMPORTANT]
|
Jarri Spooty does not download audio from Spotify itself.
|
||||||
> Please do not use this tool for piracy! Download only music you own rights! Use this tool only on your responsibility.
|
It retrieves metadata from Spotify and locates matching audio on YouTube.
|
||||||
|
|
||||||
### Content
|
This hardened branch focuses on:
|
||||||
- [🚀 Installation](#-installation)
|
|
||||||
- [Spotify App Configuration](#spotify-app-configuration)
|
|
||||||
- [Docker](#docker)
|
|
||||||
- [Docker command](#docker-command)
|
|
||||||
- [Docker compose](#docker-compose)
|
|
||||||
- [Build from source](#build-from-source)
|
|
||||||
- [Process](#requirements)
|
|
||||||
- [Requirements](#process)
|
|
||||||
- [Environment variables](#environment-variables)
|
|
||||||
- [YouTube cookies](#youtube-cookies)
|
|
||||||
- [⚖️ License](#-license)
|
|
||||||
|
|
||||||
## 🚀 Installation
|
- Large playlist support (>100 tracks)
|
||||||
Recommended and the easiest way how to start to use of Spooty is using docker.
|
- Spotify OAuth login flow
|
||||||
|
- Better YouTube pacing and throttling resistance
|
||||||
|
- Improved Docker deployment
|
||||||
|
- Safer credential handling
|
||||||
|
- More resilient cover-art embedding
|
||||||
|
- Better queue stability
|
||||||
|
- 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)
|
- Download Spotify playlists
|
||||||
2. Sign in with your Spotify account
|
- Download individual Spotify tracks
|
||||||
3. Create a new application
|
- Playlist auto-subscription support
|
||||||
4. Note your `Client ID` and `Client Secret`
|
- Automatic YouTube matching
|
||||||
5. Configure the redirect URI to `http://127.0.0.1:3000/api/callback` (or the corresponding URL of your instance)
|
- MP3 tagging and embedded cover art
|
||||||
|
- Docker deployment
|
||||||
|
- Queue-based download system
|
||||||
|
- Spotify OAuth integration
|
||||||
|
- Large playlist pagination support
|
||||||
|
- Download pacing controls
|
||||||
|
- YouTube cookie support
|
||||||
|
- 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.
|
Use this software responsibly.
|
||||||
For detailed configuration, see available [environment variables](#environment-variables).
|
|
||||||
|
|
||||||
#### Docker command
|
Only download music you legally own or are permitted to access.
|
||||||
```shell
|
|
||||||
docker run -d -p 3000:3000 \
|
The maintainers are not responsible for misuse.
|
||||||
-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 \
|
# Supported URLs
|
||||||
raiper34/spooty:latest
|
|
||||||
|
- 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
|
```yaml
|
||||||
services:
|
services:
|
||||||
spooty:
|
spooty:
|
||||||
image: raiper34/spooty:latest
|
image: jarri-spooty:local
|
||||||
container_name: spooty
|
container_name: jarri-spooty
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
|
||||||
- /path/to/downloads:/spooty/backend/downloads
|
env_file:
|
||||||
- /path/to/cookies.txt:/spooty/config/cookies.txt
|
- /etc/tokens/spotify.env
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- SPOTIFY_CLIENT_ID=your_client_id
|
SPOTIFY_REDIRECT_URI: "http://127.0.0.1:3000/api/spotify/callback"
|
||||||
- SPOTIFY_CLIENT_SECRET=your_client_secret
|
AUTH_ENABLED: "true"
|
||||||
# Configure other environment variables if needed
|
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
|
## Requirements
|
||||||
- Node v20.20.0 (it is recommended to use `nvm` node version manager to install proper version of node)
|
|
||||||
- Redis in memory cache
|
- Node.js 20.20.0
|
||||||
- Ffmpeg
|
- Docker
|
||||||
|
- ffmpeg
|
||||||
- Python3
|
- 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.
|
```bash
|
||||||
|
npm install
|
||||||
Name | Default | Description |
|
npm run build
|
||||||
-------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
docker build -t jarri-spooty:local .
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!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
|
# YouTube Cookies
|
||||||
[MIT](https://choosealicense.com/licenses/mit/)
|
|
||||||
|
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
|
||||||
|
|||||||
Generated
+1893
-1985
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -17,8 +17,7 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"changelog": "auto-changelog -p",
|
"changelog": "auto-changelog -p",
|
||||||
"release": "release-it",
|
"release": "release-it",
|
||||||
"commit": "cz",
|
"commit": "cz"
|
||||||
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@release-it/bumper": "^7.0.1",
|
"@release-it/bumper": "^7.0.1",
|
||||||
@@ -30,5 +29,11 @@
|
|||||||
"release-it": "^18.1.2",
|
"release-it": "^18.1.2",
|
||||||
"release-it-docker-plugin": "^2.0.0",
|
"release-it-docker-plugin": "^2.0.0",
|
||||||
"rimraf": "^6.0.1"
|
"rimraf": "^6.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"helmet": "^8.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,13 @@ SPOTIFY_CLIENT_SECRET=your_client_secret
|
|||||||
YT_COOKIES=
|
YT_COOKIES=
|
||||||
YT_COOKIES_FILE=./config/cookies.txt
|
YT_COOKIES_FILE=./config/cookies.txt
|
||||||
YT_DOWNLOADS_PER_MINUTE=3
|
YT_DOWNLOADS_PER_MINUTE=3
|
||||||
|
|
||||||
|
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
SPOOTY_AUTH_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
|
# Optional frontend origin restriction
|
||||||
|
CORS_ORIGIN=http://localhost:4200
|
||||||
|
|
||||||
|
NODE_ENV=development
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ SPOTIFY_CLIENT_SECRET=your_client_secret
|
|||||||
|
|
||||||
YT_COOKIES=
|
YT_COOKIES=
|
||||||
YT_DOWNLOADS_PER_MINUTE=3
|
YT_DOWNLOADS_PER_MINUTE=3
|
||||||
|
|
||||||
|
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
|
||||||
|
AUTH_ENABLED=false
|
||||||
|
SPOOTY_AUTH_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
|
# Optional frontend origin restriction
|
||||||
|
CORS_ORIGIN=http://localhost:4200
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -31,18 +31,13 @@
|
|||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"@nestjs/websockets": "^10.3.10",
|
"@nestjs/websockets": "^10.3.10",
|
||||||
"@types/fluent-ffmpeg": "^2.1.24",
|
|
||||||
"@types/yt-search": "^2.10.3",
|
|
||||||
"bullmq": "^5.31.2",
|
"bullmq": "^5.31.2",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
|
||||||
"isomorphic-unfetch": "^4.0.2",
|
"isomorphic-unfetch": "^4.0.2",
|
||||||
"node-id3": "^0.2.9",
|
"node-id3": "^0.2.9",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "7.8.0",
|
"rxjs": "7.8.0",
|
||||||
"spotify-url-info": "^3.2.18",
|
"spotify-url-info": "^3.2.18",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7"
|
||||||
"yt-search": "^2.12.1",
|
|
||||||
"ytdlp-nodejs": "^3.4.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import { Controller, Get } from '@nestjs/common';
|
|||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getHello(): string {
|
getHello(): string {
|
||||||
return 'ONLINE';
|
return 'ONLINE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('health')
|
||||||
|
getHealth(): { status: string } {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { PlaylistEntity } from './playlist/playlist.entity';
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthGuard } from './shared/auth.guard';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -42,11 +45,30 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
],
|
],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 60,
|
||||||
|
},
|
||||||
|
]),
|
||||||
BullModule.forRootAsync({
|
BullModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => ({
|
useFactory: async (configService: ConfigService) => ({
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
removeOnComplete: true,
|
attempts: 2,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 30_000,
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
age: 3_600,
|
||||||
|
count: 1_000,
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
age: 86_400,
|
||||||
|
count: 1_000,
|
||||||
|
},
|
||||||
|
timeout: 10 * 60_000,
|
||||||
},
|
},
|
||||||
connection: {
|
connection: {
|
||||||
host: configService.get<string>(EnvironmentEnum.REDIS_HOST),
|
host: configService.get<string>(EnvironmentEnum.REDIS_HOST),
|
||||||
@@ -59,6 +81,15 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
PlaylistModule,
|
PlaylistModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [],
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AuthGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -8,4 +8,10 @@ export enum EnvironmentEnum {
|
|||||||
REDIS_RUN = 'REDIS_RUN',
|
REDIS_RUN = 'REDIS_RUN',
|
||||||
YT_COOKIES = 'YT_COOKIES',
|
YT_COOKIES = 'YT_COOKIES',
|
||||||
YT_COOKIES_FILE = 'YT_COOKIES_FILE',
|
YT_COOKIES_FILE = 'YT_COOKIES_FILE',
|
||||||
|
AUTH_ENABLED = 'AUTH_ENABLED',
|
||||||
|
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',
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-20
@@ -1,31 +1,84 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { exec } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import helmet from 'helmet';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
|
|
||||||
|
function envFlag(name: string): boolean {
|
||||||
|
return String(process.env[name] || '').toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) {
|
||||||
|
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderName = resolve(
|
||||||
|
__dirname,
|
||||||
|
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
||||||
|
);
|
||||||
|
fs.mkdirSync(folderName, { recursive: true });
|
||||||
|
|
||||||
|
if (envFlag(EnvironmentEnum.REDIS_RUN)) {
|
||||||
|
try {
|
||||||
|
// Convenience mode for the single-container Docker image.
|
||||||
|
// Hardened deployments should prefer an external Redis service.
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
bodyParser: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginResourcePolicy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const corsOrigin =
|
||||||
|
process.env[EnvironmentEnum.CORS_ORIGIN] || 'http://localhost:4200';
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigin,
|
||||||
|
credentials: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env[EnvironmentEnum.NODE_ENV] === 'production' &&
|
||||||
|
!envFlag(EnvironmentEnum.AUTH_ENABLED)
|
||||||
|
) {
|
||||||
|
console.warn('[SECURITY] AUTH_ENABLED is false while NODE_ENV=production');
|
||||||
|
}
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
await app.listen(process.env.PORT || 3000);
|
await app.listen(process.env.PORT || 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
||||||
if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) {
|
|
||||||
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
|
||||||
}
|
|
||||||
const folderName = resolve(
|
|
||||||
__dirname,
|
|
||||||
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
|
||||||
);
|
|
||||||
!fs.existsSync(folderName) && fs.mkdirSync(folderName);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// not good idea, but I want to keep simple Dockerfile, I know ideally should be in another container and used docker compose
|
|
||||||
Boolean(process.env[EnvironmentEnum.REDIS_RUN]) &&
|
|
||||||
exec(`redis-server --port ${process.env.REDIS_PORT}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Unable to run redis server form app');
|
|
||||||
console.log(e);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePlaylistDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
|
@IsUrl({
|
||||||
|
require_protocol: true,
|
||||||
|
protocols: ['https'],
|
||||||
|
})
|
||||||
|
spotifyUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePlaylistDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PlaylistService } from './playlist.service';
|
import { PlaylistService } from './playlist.service';
|
||||||
import { PlaylistEntity } from './playlist.entity';
|
import { PlaylistEntity } from './playlist.entity';
|
||||||
|
import { CreatePlaylistDto } from './dto/create-playlist.dto';
|
||||||
|
import { UpdatePlaylistDto } from './dto/update-playlist.dto';
|
||||||
|
|
||||||
@Controller('playlist')
|
@Controller('playlist')
|
||||||
export class PlaylistController {
|
export class PlaylistController {
|
||||||
@@ -20,25 +23,27 @@ export class PlaylistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() playlist: PlaylistEntity): Promise<void> {
|
async create(@Body() playlist: CreatePlaylistDto): Promise<void> {
|
||||||
await this.service.create(playlist);
|
await this.service.create(playlist as PlaylistEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id') id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() playlist: Partial<PlaylistEntity>,
|
@Body() playlist: UpdatePlaylistDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.service.update(id, playlist);
|
return this.service.update(id, playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: number): Promise<void> {
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Post('retry/:id')
|
||||||
retryFailedOfPlaylist(@Param('id') id: number): Promise<void> {
|
retryFailedOfPlaylist(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.retryFailedOfPlaylist(id);
|
return this.service.retryFailedOfPlaylist(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Interval } from '@nestjs/schedule';
|
|||||||
import { TrackStatusEnum } from '../track/track.entity';
|
import { TrackStatusEnum } from '../track/track.entity';
|
||||||
import { UtilsService } from '../shared/utils.service';
|
import { UtilsService } from '../shared/utils.service';
|
||||||
import { SpotifyService } from '../shared/spotify.service';
|
import { SpotifyService } from '../shared/spotify.service';
|
||||||
|
import { toSafeErrorMessage } from '../shared/errors/safe-error';
|
||||||
|
|
||||||
enum WsPlaylistOperation {
|
enum WsPlaylistOperation {
|
||||||
New = 'playlistNew',
|
New = 'playlistNew',
|
||||||
@@ -50,9 +51,12 @@ export class PlaylistService {
|
|||||||
async create(playlist: PlaylistEntity): Promise<void> {
|
async create(playlist: PlaylistEntity): Promise<void> {
|
||||||
// Detect if URL is for a single track or a playlist and route accordingly
|
// Detect if URL is for a single track or a playlist and route accordingly
|
||||||
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
|
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
|
||||||
|
const isArtist = this.spotifyService.isArtistUrl(playlist.spotifyUrl);
|
||||||
|
|
||||||
if (isTrack) {
|
if (isTrack) {
|
||||||
await this.createSingleTrack(playlist);
|
await this.createSingleTrack(playlist);
|
||||||
|
} else if (isArtist) {
|
||||||
|
await this.createArtistLibrary(playlist);
|
||||||
} else {
|
} else {
|
||||||
await this.createPlaylist(playlist);
|
await this.createPlaylist(playlist);
|
||||||
}
|
}
|
||||||
@@ -77,7 +81,11 @@ export class PlaylistService {
|
|||||||
// Don't create folder structure for individual tracks - they go in root
|
// Don't create folder structure for individual tracks - they go in root
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error getting track details: ${err}`);
|
this.logger.error(`Error getting track details: ${err}`);
|
||||||
playlist2Save = { ...playlist, error: String(err), isTrack: true };
|
playlist2Save = {
|
||||||
|
...playlist,
|
||||||
|
error: toSafeErrorMessage(err),
|
||||||
|
isTrack: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const savedPlaylist = await this.save(playlist2Save);
|
const savedPlaylist = await this.save(playlist2Save);
|
||||||
|
|
||||||
@@ -100,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> {
|
private async createPlaylist(playlist: PlaylistEntity): Promise<void> {
|
||||||
let detail: { tracks: any; name: any; image: any };
|
let detail: { tracks: any; name: any; image: any };
|
||||||
let playlist2Save: PlaylistEntity;
|
let playlist2Save: PlaylistEntity;
|
||||||
@@ -117,7 +226,10 @@ export class PlaylistService {
|
|||||||
this.createPlaylistFolderStructure(playlist2Save.name);
|
this.createPlaylistFolderStructure(playlist2Save.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error getting playlist details: ${err}`);
|
this.logger.error(`Error getting playlist details: ${err}`);
|
||||||
playlist2Save = { ...playlist, error: String(err) };
|
playlist2Save = {
|
||||||
|
...playlist,
|
||||||
|
error: toSafeErrorMessage(err),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const savedPlaylist = await this.save(playlist2Save);
|
const savedPlaylist = await this.save(playlist2Save);
|
||||||
|
|
||||||
@@ -154,6 +266,7 @@ export class PlaylistService {
|
|||||||
name: track.name,
|
name: track.name,
|
||||||
spotifyUrl: track.previewUrl || null,
|
spotifyUrl: track.previewUrl || null,
|
||||||
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
||||||
|
durationMs: track.durationMs,
|
||||||
},
|
},
|
||||||
savedPlaylist,
|
savedPlaylist,
|
||||||
);
|
);
|
||||||
@@ -207,7 +320,7 @@ export class PlaylistService {
|
|||||||
|
|
||||||
private createPlaylistFolderStructure(playlistName: string): void {
|
private createPlaylistFolderStructure(playlistName: string): void {
|
||||||
const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName);
|
const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName);
|
||||||
!fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath);
|
!fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Interval(3_600_000)
|
@Interval(3_600_000)
|
||||||
@@ -224,7 +337,10 @@ export class PlaylistService {
|
|||||||
);
|
);
|
||||||
this.createPlaylistFolderStructure(playlist.name);
|
this.createPlaylistFolderStructure(playlist.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.update(playlist.id, { ...playlist, error: String(err) });
|
await this.update(playlist.id, {
|
||||||
|
...playlist,
|
||||||
|
error: toSafeErrorMessage(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const track of tracks ?? []) {
|
for (const track of tracks ?? []) {
|
||||||
const track2Save = {
|
const track2Save = {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedToken = process.env.SPOOTY_AUTH_TOKEN;
|
||||||
|
if (!expectedToken) {
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'AUTH_ENABLED=true but SPOOTY_AUTH_TOKEN is missing',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const providedToken = this.extractToken(request);
|
||||||
|
|
||||||
|
if (providedToken !== expectedToken) {
|
||||||
|
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private authEnabled(): boolean {
|
||||||
|
return String(process.env.AUTH_ENABLED || '').toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractToken(request: Request): string | undefined {
|
||||||
|
const headerToken = request.header('x-spooty-token');
|
||||||
|
if (headerToken) {
|
||||||
|
return headerToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorization = request.header('authorization');
|
||||||
|
if (authorization?.toLowerCase().startsWith('bearer ')) {
|
||||||
|
return authorization.slice('bearer '.length).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function toSafeErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return sanitize(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return sanitize(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\/home\/[^\s]+/g, '[path]')
|
||||||
|
.replace(/\/root\/[^\s]+/g, '[path]')
|
||||||
|
.replace(/token=[^\s&]+/gi, 'token=[redacted]')
|
||||||
|
.replace(/authorization:[^\n]+/gi, 'authorization:[redacted]')
|
||||||
|
.slice(0, 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.message
|
||||||
|
: 'Internal server error';
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`${request.method} ${request.url} -> ${status}: ${String(exception)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
statusCode: status,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { SpotifyService } from './spotify.service';
|
import { SpotifyService } from './spotify.service';
|
||||||
import { YoutubeService } from './youtube.service';
|
import { YoutubeService } from './youtube.service';
|
||||||
import { SpotifyApiService } from './spotify-api.service';
|
import { SpotifyApiService } from './spotify-api.service';
|
||||||
|
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||||
controllers: [],
|
controllers: [SpotifyAuthController],
|
||||||
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||||
})
|
})
|
||||||
export class SharedModule {}
|
export class SharedModule {}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const fetch = require('isomorphic-unfetch');
|
const fetch = require('isomorphic-unfetch');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { getDetails } = require('spotify-url-info')(fetch);
|
const { getDetails } = require('spotify-url-info')(fetch);
|
||||||
|
|
||||||
|
interface StoredSpotifyUserToken {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_at: number;
|
||||||
|
scope?: string;
|
||||||
|
token_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpotifyApiService {
|
export class SpotifyApiService {
|
||||||
private readonly logger = new Logger(SpotifyApiService.name);
|
private readonly logger = new Logger(SpotifyApiService.name);
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
private tokenExpiry: number = 0;
|
private tokenExpiry = 0;
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
private getPlaylistId(url: string): string {
|
private getPlaylistId(url: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -51,13 +60,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(
|
async getTrackMetadata(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; artist: string; image: string }> {
|
): Promise<{ name: string; artist: string; image: string; durationMs?: number }> {
|
||||||
try {
|
try {
|
||||||
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
||||||
const trackId = this.getTrackId(spotifyUrl);
|
const trackId = this.getTrackId(spotifyUrl);
|
||||||
const accessToken = await this.getAccessToken();
|
const accessToken = await this.getClientCredentialsAccessToken();
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`https://api.spotify.com/v1/tracks/${trackId}`,
|
`https://api.spotify.com/v1/tracks/${trackId}`,
|
||||||
@@ -78,6 +277,7 @@ export class SpotifyApiService {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
artist: data.artists.map((a) => a.name).join(', '),
|
artist: data.artists.map((a) => a.name).join(', '),
|
||||||
image: data.album.images[0]?.url || '',
|
image: data.album.images[0]?.url || '',
|
||||||
|
durationMs: data.duration_ms,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get track metadata: ${error.message}`);
|
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) {
|
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
||||||
return this.accessToken;
|
return this.accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.debug('Getting new Spotify access token');
|
this.logger.debug('Getting new Spotify client credentials access token');
|
||||||
|
|
||||||
const clientId = process.env.SPOTIFY_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
|
||||||
throw new Error(
|
|
||||||
'Missing Spotify credentials. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env file',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
|
|
||||||
'base64',
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Basic ${credentials}`,
|
Authorization: this.getBasicAuthHeader(),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
},
|
},
|
||||||
body: 'grant_type=client_credentials',
|
body: 'grant_type=client_credentials',
|
||||||
@@ -141,7 +328,7 @@ export class SpotifyApiService {
|
|||||||
this.accessToken = data.access_token;
|
this.accessToken = data.access_token;
|
||||||
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
|
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
|
||||||
|
|
||||||
this.logger.debug('Successfully obtained Spotify access token');
|
this.logger.debug('Successfully obtained Spotify client credentials access token');
|
||||||
return this.accessToken;
|
return this.accessToken;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error getting Spotify access token: ${error.message}`);
|
this.logger.error(`Error getting Spotify access token: ${error.message}`);
|
||||||
@@ -156,88 +343,289 @@ export class SpotifyApiService {
|
|||||||
const playlistId = this.getPlaylistId(spotifyUrl);
|
const playlistId = this.getPlaylistId(spotifyUrl);
|
||||||
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
||||||
|
|
||||||
const accessToken = await this.getAccessToken();
|
const accessToken = await this.getUserAccessToken();
|
||||||
|
|
||||||
const allTracks = [];
|
const allTracks = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let hasMoreTracks = true;
|
let hasMoreTracks = true;
|
||||||
|
|
||||||
while (hasMoreTracks) {
|
while (hasMoreTracks) {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Fetching tracks from Spotify API with offset ${offset}`,
|
`Fetching tracks from Spotify API with offset ${offset}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await fetch(
|
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`,
|
`https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
this.logger.error(
|
||||||
|
`Spotify API error: ${response.status} ${errorText}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.status === 403) {
|
||||||
const errorText = await response.text();
|
throw new Error(
|
||||||
this.logger.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.',
|
||||||
`Spotify API error: ${response.status} ${errorText}`,
|
|
||||||
);
|
);
|
||||||
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
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) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
|
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
|
||||||
throw error;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export class SpotifyService {
|
|||||||
return this.spotifyApiService.isTrackUrl(url);
|
return this.spotifyApiService.isTrackUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isArtistUrl(url: string): boolean {
|
||||||
|
return this.spotifyApiService.isArtistUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
async getTrackDetail(
|
async getTrackDetail(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; artist: string; image: 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[]> {
|
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
|
||||||
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { resolve } from 'path';
|
import { relative, resolve } from 'path';
|
||||||
import { EnvironmentEnum } from '../environmentEnum';
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -16,13 +16,38 @@ export class UtilsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getPlaylistFolderPath(name: string): string {
|
getPlaylistFolderPath(name: string): string {
|
||||||
return resolve(
|
return this.ensureInsideDownloadsRoot(
|
||||||
this.getRootDownloadsPath(),
|
resolve(this.getRootDownloadsPath(), this.stripFileIllegalChars(name)),
|
||||||
this.stripFileIllegalChars(name),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureInsideDownloadsRoot(candidatePath: string): string {
|
||||||
|
const root = resolve(this.getRootDownloadsPath());
|
||||||
|
const resolvedCandidate = resolve(candidatePath);
|
||||||
|
const rel = relative(root, resolvedCandidate);
|
||||||
|
|
||||||
|
if (rel === '' || (!rel.startsWith('..') && !relative('', rel).startsWith('..'))) {
|
||||||
|
return resolvedCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unsafe path outside downloads root: ${resolvedCandidate}`);
|
||||||
|
}
|
||||||
|
|
||||||
stripFileIllegalChars(text: string): string {
|
stripFileIllegalChars(text: string): string {
|
||||||
return text.replace(/[/\\?%*:|"<>]/g, '-');
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { TrackEntity } from '../track/track.entity';
|
|||||||
import { EnvironmentEnum } from '../environmentEnum';
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
import { TrackService } from '../track/track.service';
|
import { TrackService } from '../track/track.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { YtDlp } from 'ytdlp-nodejs';
|
|
||||||
import * as yts from 'yt-search';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
const NodeID3 = require('node-id3');
|
const NodeID3 = require('node-id3');
|
||||||
|
|
||||||
const HEADERS = {
|
const HEADERS = {
|
||||||
@@ -13,60 +12,508 @@ const HEADERS = {
|
|||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALLOWED_YOUTUBE_HOSTS = new Set([
|
||||||
|
'youtube.com',
|
||||||
|
'www.youtube.com',
|
||||||
|
'm.youtube.com',
|
||||||
|
'music.youtube.com',
|
||||||
|
'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()
|
@Injectable()
|
||||||
export class YoutubeService {
|
export class YoutubeService {
|
||||||
private readonly logger = new Logger(TrackService.name);
|
private readonly logger = new Logger(TrackService.name);
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
async findBestYoutubeMatch(
|
||||||
this.logger.debug(`Searching ${artist} - ${name} on YT`);
|
artist: string,
|
||||||
const url = (await yts(`${artist} - ${name}`)).videos[0].url;
|
name: string,
|
||||||
this.logger.debug(`Found ${artist} - ${name} on ${url}`);
|
durationMs?: number,
|
||||||
return url;
|
excludedUrls: string[] = [],
|
||||||
|
): Promise<YoutubeMatch> {
|
||||||
|
const query = `${artist} - ${name}`;
|
||||||
|
this.logger.debug(`Searching ${query} on YT`);
|
||||||
|
|
||||||
|
const videos = await this.searchYoutubeVideos(query);
|
||||||
|
const excludedVideoIds = new Set(
|
||||||
|
excludedUrls
|
||||||
|
.map((url) => this.getYoutubeVideoId(url))
|
||||||
|
.filter((id): id is string => !!id),
|
||||||
|
);
|
||||||
|
|
||||||
|
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.logger.debug(
|
||||||
|
`Selected YouTube match for "${query}": ${accepted.title} by ${accepted.author} (${accepted.score})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return accepted;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCookiesOptions(): {
|
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
|
||||||
cookiesFromBrowser?: string;
|
const searchTarget = `ytsearch15:${query}`;
|
||||||
cookies?: string;
|
const args = [
|
||||||
} {
|
'--dump-single-json',
|
||||||
const cookiesBrowser = this.configService.get<string>(
|
'--flat-playlist',
|
||||||
EnvironmentEnum.YT_COOKIES,
|
'--skip-download',
|
||||||
);
|
'--no-playlist',
|
||||||
if (cookiesBrowser) {
|
'--no-cache-dir',
|
||||||
this.logger.debug(`Using cookies from browser: ${cookiesBrowser}`);
|
'--no-cookies-from-browser',
|
||||||
return { cookiesFromBrowser: cookiesBrowser };
|
'--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 {
|
||||||
|
let parsed: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid YouTube URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('Rejected non-HTTP YouTube URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_YOUTUBE_HOSTS.has(parsed.hostname.toLowerCase())) {
|
||||||
|
throw new Error(`Rejected non-YouTube URL: ${parsed.hostname}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCookiesFile(): string | null {
|
||||||
const cookiesFile = this.configService.get<string>(
|
const cookiesFile = this.configService.get<string>(
|
||||||
EnvironmentEnum.YT_COOKIES_FILE,
|
EnvironmentEnum.YT_COOKIES_FILE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
||||||
this.logger.debug(`Using cookies file: ${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> {
|
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!track.youtubeUrl) {
|
if (!track.youtubeUrl) {
|
||||||
this.logger.error('youtubeUrl is null or undefined');
|
throw new Error('youtubeUrl is null or undefined');
|
||||||
throw Error('youtubeUrl is null or undefined');
|
|
||||||
}
|
}
|
||||||
const ytdlp = new YtDlp();
|
|
||||||
await ytdlp.downloadAudio(
|
this.assertValidYoutubeUrl(track.youtubeUrl);
|
||||||
track.youtubeUrl,
|
|
||||||
this.configService.get<'m4a'>(EnvironmentEnum.FORMAT),
|
const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
|
||||||
{
|
const quality = this.configService.get<string>('QUALITY') || '0';
|
||||||
output,
|
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
|
||||||
...this.getCookiesOptions(),
|
|
||||||
headers: HEADERS,
|
const args = [
|
||||||
jsRuntime: 'node',
|
'--no-playlist',
|
||||||
audioQuality: this.configService.get<string>('QUALITY'),
|
'--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(
|
this.logger.debug(
|
||||||
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
||||||
);
|
);
|
||||||
@@ -78,24 +525,65 @@ export class YoutubeService {
|
|||||||
title: string,
|
title: string,
|
||||||
artist: string,
|
artist: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (coverUrl) {
|
if (!coverUrl) {
|
||||||
const res = await fetch(coverUrl);
|
return;
|
||||||
const arrayBuf = await res.arrayBuffer();
|
|
||||||
const imageBuffer = Buffer.from(arrayBuf);
|
|
||||||
|
|
||||||
NodeID3.write(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
APIC: {
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
type: { id: 3, name: 'front cover' },
|
|
||||||
description: 'cover',
|
|
||||||
imageBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
folderName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
APIC: {
|
||||||
|
mime: contentType,
|
||||||
|
type: { id: 3, name: 'front cover' },
|
||||||
|
description: 'cover',
|
||||||
|
imageBuffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folderName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,42 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { TrackService } from './track.service';
|
import { TrackService } from './track.service';
|
||||||
import { TrackEntity } from './track.entity';
|
import { TrackEntity } from './track.entity';
|
||||||
|
|
||||||
@Processor('track-download-processor')
|
function getDownloadsPerMinute(): number {
|
||||||
|
const parsed = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(30, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Processor('track-download-processor', {
|
||||||
|
concurrency: 1,
|
||||||
|
})
|
||||||
export class TrackDownloadProcessor extends WorkerHost {
|
export class TrackDownloadProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(TrackDownloadProcessor.name);
|
||||||
|
|
||||||
constructor(private readonly trackService: TrackService) {
|
constructor(private readonly trackService: TrackService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<TrackEntity, void>): Promise<void> {
|
async process(job: Job<TrackEntity, void>): Promise<void> {
|
||||||
const maxPerMinute = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3);
|
if (!job.data?.id) {
|
||||||
|
this.logger.warn(`Skipping malformed download job ${job.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPerMinute = getDownloadsPerMinute();
|
||||||
const sleepMs = Math.floor(60000 / maxPerMinute);
|
const sleepMs = Math.floor(60000 / maxPerMinute);
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Processing download job ${job.id} for track ${job.data.id}; pacing ${sleepMs}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
await new Promise((res) => setTimeout(res, sleepMs));
|
await new Promise((res) => setTimeout(res, sleepMs));
|
||||||
await this.trackService.downloadFromYoutube(job.data);
|
await this.trackService.downloadFromYoutube(job.data);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,37 @@
|
|||||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import { Job } from 'bullmq';
|
import { Job } from 'bullmq';
|
||||||
import { TrackService } from './track.service';
|
import { TrackService } from './track.service';
|
||||||
import { TrackEntity } from './track.entity';
|
import { TrackEntity } from './track.entity';
|
||||||
|
|
||||||
@Processor('track-search-processor')
|
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: 1,
|
||||||
|
})
|
||||||
export class TrackSearchProcessor extends WorkerHost {
|
export class TrackSearchProcessor extends WorkerHost {
|
||||||
|
private readonly logger = new Logger(TrackSearchProcessor.name);
|
||||||
|
|
||||||
constructor(private readonly trackService: TrackService) {
|
constructor(private readonly trackService: TrackService) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<TrackEntity, void, string>): Promise<void> {
|
async process(job: Job<{ id: number }, void, string>): Promise<void> {
|
||||||
await this.trackService.findOnYoutube(job.data);
|
const delayMs = getSearchDelayMs();
|
||||||
|
|
||||||
|
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,12 +2,15 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TrackService } from './track.service';
|
import { TrackService } from './track.service';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TrackEntity } from './track.entity';
|
import { TrackEntity } from './track.entity';
|
||||||
@@ -20,33 +23,54 @@ export class TrackController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('playlist/:id')
|
@Get('playlist/:id')
|
||||||
getAllByPlaylist(@Param('id') playlistId: number): Promise<TrackEntity[]> {
|
getAllByPlaylist(
|
||||||
|
@Param('id', ParseIntPipe) playlistId: number,
|
||||||
|
): Promise<TrackEntity[]> {
|
||||||
return this.service.getAllByPlaylist(playlistId);
|
return this.service.getAllByPlaylist(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('download/:id')
|
@Get('download/:id')
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param('id') id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const track = await this.service.get(id);
|
const track = await this.service.get(id);
|
||||||
|
|
||||||
|
if (!track || !track.playlist) {
|
||||||
|
throw new NotFoundException('Track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.service.getFolderName(track, track.playlist);
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw new NotFoundException('Track file not found');
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = this.service.getTrackFileName(track);
|
const fileName = this.service.getTrackFileName(track);
|
||||||
const readStream = createReadStream(
|
|
||||||
this.service.getFolderName(track, track.playlist),
|
const readStream = createReadStream(filePath);
|
||||||
);
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}`,
|
'Content-Disposition':
|
||||||
|
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(readStream);
|
return new StreamableFile(readStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: number): Promise<void> {
|
remove(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Post('retry/:id')
|
||||||
retry(@Param('id') id: number): Promise<void> {
|
retry(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.retry(id);
|
return this.service.retry(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
rejectedYoutubeUrls?: string;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
downloadAttemptCount?: number;
|
||||||
|
|
||||||
@Column({ default: TrackStatusEnum.New })
|
@Column({ default: TrackStatusEnum.New })
|
||||||
status?: TrackStatusEnum;
|
status?: TrackStatusEnum;
|
||||||
|
|
||||||
@@ -36,6 +42,9 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
durationMs?: number;
|
||||||
|
|
||||||
@Column({ default: Date.now() })
|
@Column({ default: Date.now() })
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { UtilsService } from '../shared/utils.service';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { YoutubeService } from '../shared/youtube.service';
|
import { YoutubeService } from '../shared/youtube.service';
|
||||||
|
import { toSafeErrorMessage } from '../shared/errors/safe-error';
|
||||||
|
|
||||||
enum WsTrackOperation {
|
enum WsTrackOperation {
|
||||||
New = 'trackNew',
|
New = 'trackNew',
|
||||||
@@ -55,126 +56,284 @@ export class TrackService {
|
|||||||
this.io.emit(WsTrackOperation.Delete, { id });
|
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> {
|
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
|
||||||
const savedTrack = await this.repository.save({ ...track, playlist });
|
const savedTrack = await this.repository.save({ ...track, playlist });
|
||||||
await this.trackSearchQueue.add('', savedTrack, {
|
await this.enqueueSearch(savedTrack.id);
|
||||||
jobId: `id-${savedTrack.id}`,
|
|
||||||
});
|
|
||||||
this.io.emit(WsTrackOperation.New, {
|
this.io.emit(WsTrackOperation.New, {
|
||||||
track: savedTrack,
|
track: this.toClientTrack(savedTrack),
|
||||||
playlistId: playlist.id,
|
playlistId: playlist.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, track: TrackEntity): Promise<void> {
|
async update(id: number, track: TrackEntity): Promise<void> {
|
||||||
await this.repository.update(id, track);
|
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> {
|
async retry(id: number): Promise<void> {
|
||||||
const track = await this.get(id);
|
const track = await this.get(id);
|
||||||
await this.trackSearchQueue.add('', track, { jobId: `id-${id}` });
|
|
||||||
await this.update(id, { ...track, status: TrackStatusEnum.New });
|
if (!track) {
|
||||||
|
this.logger.warn(`Cannot retry missing track ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
async findOnYoutube(track: TrackEntity): Promise<void> {
|
||||||
if (!(await this.get(track.id))) {
|
const dbTrack = await this.get(track.id);
|
||||||
|
|
||||||
|
if (!dbTrack) {
|
||||||
|
this.logger.warn(`Skipping search for missing track ${track.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track = dbTrack;
|
||||||
|
|
||||||
await this.update(track.id, {
|
await this.update(track.id, {
|
||||||
...track,
|
...track,
|
||||||
status: TrackStatusEnum.Searching,
|
status: TrackStatusEnum.Searching,
|
||||||
});
|
});
|
||||||
let updatedTrack: TrackEntity;
|
let updatedTrack: TrackEntity;
|
||||||
try {
|
try {
|
||||||
const youtubeUrl = await this.youtubeService.findOnYoutubeOne(
|
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||||
|
|
||||||
|
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
|
||||||
track.artist,
|
track.artist,
|
||||||
track.name,
|
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) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
updatedTrack = {
|
updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
error: String(err),
|
error: toSafeErrorMessage(err),
|
||||||
status: TrackStatusEnum.Error,
|
status: TrackStatusEnum.Error,
|
||||||
};
|
};
|
||||||
|
await this.update(track.id, updatedTrack);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
await this.trackDownloadQueue.add('', updatedTrack, {
|
|
||||||
jobId: `id-${updatedTrack.id}`,
|
|
||||||
});
|
|
||||||
await this.update(track.id, updatedTrack);
|
await this.update(track.id, updatedTrack);
|
||||||
|
await this.enqueueDownload(updatedTrack.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueSearch(id: number): Promise<void> {
|
||||||
|
const track = await this.get(id);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
this.logger.warn(`Cannot enqueue search for missing track ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = `search-${id}`;
|
||||||
|
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
||||||
|
|
||||||
|
if (existingJob) {
|
||||||
|
this.logger.warn(`Removing existing search job for track ${id} before enqueue`);
|
||||||
|
await existingJob.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.trackSearchQueue.add('search-track', { id }, { jobId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enqueueDownload(id: number): Promise<void> {
|
||||||
|
const track = await this.get(id);
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
this.logger.warn(`Cannot enqueue download for missing track ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = `download-${id}`;
|
||||||
|
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
||||||
|
|
||||||
|
if (existingJob) {
|
||||||
|
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> {
|
async downloadFromYoutube(track: TrackEntity): Promise<void> {
|
||||||
if (!(await this.get(track.id))) {
|
const dbTrack = await this.get(track.id);
|
||||||
|
if (!dbTrack) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!track.name ||
|
track = dbTrack;
|
||||||
!track.artist ||
|
|
||||||
!track.playlist
|
if (!track.name || !track.artist || !track.playlist) {
|
||||||
) {
|
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`,
|
`Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Use track's own coverUrl if available, otherwise fall back to playlist coverUrl
|
|
||||||
const coverUrl = track.coverUrl || track.playlist.coverUrl;
|
const coverUrl = track.coverUrl || track.playlist.coverUrl;
|
||||||
if (!coverUrl) {
|
if (!coverUrl) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`No cover art available for track: ${track.artist} - ${track.name}`,
|
`No cover art available for track: ${track.artist} - ${track.name}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.update(track.id, {
|
await this.update(track.id, {
|
||||||
...track,
|
...track,
|
||||||
status: TrackStatusEnum.Downloading,
|
status: TrackStatusEnum.Downloading,
|
||||||
});
|
});
|
||||||
|
|
||||||
let error: string;
|
let error: string;
|
||||||
try {
|
try {
|
||||||
const folderName = this.getFolderName(track, track.playlist);
|
const folderName = this.getFolderName(track, track.playlist);
|
||||||
await this.youtubeService.downloadAndFormat(track, folderName);
|
await this.youtubeService.downloadAndFormat(track, folderName);
|
||||||
if (coverUrl) {
|
if (coverUrl) {
|
||||||
await this.youtubeService.addImage(
|
try {
|
||||||
folderName,
|
await this.youtubeService.addImage(
|
||||||
coverUrl,
|
folderName,
|
||||||
track.name,
|
coverUrl,
|
||||||
track.artist,
|
track.name,
|
||||||
);
|
track.artist,
|
||||||
|
);
|
||||||
|
} catch (imageError) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Cover art embed failed for track ${track.id}: ${toSafeErrorMessage(imageError)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
error = String(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 = {
|
const updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
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);
|
await this.update(track.id, updatedTrack);
|
||||||
}
|
}
|
||||||
|
|
||||||
getTrackFileName(track: TrackEntity): string {
|
getTrackFileName(track: TrackEntity): string {
|
||||||
const safeArtist = track.artist || 'unknown_artist';
|
const safeArtist = track.artist || 'unknown_artist';
|
||||||
const safeName = (track.name || 'unknown_track').replace('/', '');
|
const safeName = track.name || 'unknown_track';
|
||||||
const fileName = `${safeArtist} - ${safeName}`;
|
const fileName = `${safeArtist} - ${safeName}`;
|
||||||
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
|
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
|
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
|
||||||
// Individual tracks (isTrack=true) go in root downloads folder, playlists in subfolders
|
|
||||||
if (playlist?.isTrack) {
|
if (playlist?.isTrack) {
|
||||||
return resolve(
|
return this.utilsService.ensureInsideDownloadsRoot(
|
||||||
this.utilsService.getRootDownloadsPath(),
|
resolve(
|
||||||
this.getTrackFileName(track),
|
this.utilsService.getRootDownloadsPath(),
|
||||||
|
this.getTrackFileName(track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePlaylistName = playlist?.name || 'unknown_playlist';
|
const safePlaylistName = playlist?.name || 'unknown_playlist';
|
||||||
return resolve(
|
return this.utilsService.ensureInsideDownloadsRoot(
|
||||||
this.utilsService.getPlaylistFolderPath(safePlaylistName),
|
resolve(
|
||||||
this.getTrackFileName(track),
|
this.utilsService.getPlaylistFolderPath(safePlaylistName),
|
||||||
|
this.getTrackFileName(track),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,15 @@
|
|||||||
"maximumError": "4kB"
|
"maximumError": "4kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"optimization": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": {
|
||||||
|
"minify": true,
|
||||||
|
"inlineCritical": false
|
||||||
|
},
|
||||||
|
"fonts": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
+11
-12
@@ -11,15 +11,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.0.6",
|
"@angular/animations": "^19.2.22",
|
||||||
"@angular/common": "^19.0.6",
|
"@angular/common": "^19.2.22",
|
||||||
"@angular/compiler": "^19.0.6",
|
"@angular/compiler": "^19.2.22",
|
||||||
"@angular/core": "^19.0.6",
|
"@angular/core": "^19.2.22",
|
||||||
"@angular/forms": "^19.0.6",
|
"@angular/forms": "^19.2.22",
|
||||||
"@angular/platform-browser": "^19.0.6",
|
"@angular/platform-browser": "^19.2.22",
|
||||||
"@angular/platform-browser-dynamic": "^19.0.6",
|
"@angular/platform-browser-dynamic": "^19.2.22",
|
||||||
"@angular/router": "^19.0.6",
|
"@angular/router": "^19.2.22",
|
||||||
"@distube/ytdl-core": "^4.15.9",
|
|
||||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||||
"@ngneat/elf": "^2.5.1",
|
"@ngneat/elf": "^2.5.1",
|
||||||
"@ngneat/elf-cli-ng": "^1.0.0",
|
"@ngneat/elf-cli-ng": "^1.0.0",
|
||||||
@@ -36,9 +35,9 @@
|
|||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.0.7",
|
"@angular-devkit/build-angular": "^19.2.26",
|
||||||
"@angular/cli": "^19.0.7",
|
"@angular/cli": "^19.2.26",
|
||||||
"@angular/compiler-cli": "^19.0.6",
|
"@angular/compiler-cli": "^19.2.22",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"jasmine-core": "~5.1.0",
|
"jasmine-core": "~5.1.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "SpootyFe",
|
"name": "Jarri Spooty",
|
||||||
"short_name": "SpootyFe",
|
"short_name": "Spooty",
|
||||||
"description": "Self-hosted Spotify downloader",
|
"description": "Self-hosted Spotify downloader",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,40 +1,73 @@
|
|||||||
<section class="hero is-primary">
|
<section class="jarri-hero">
|
||||||
<div class="hero-body">
|
<div class="hero-left">
|
||||||
<p class="title">
|
<p class="eyebrow">Jarri subsystem · deterministic media ingestion</p>
|
||||||
|
<h1>
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
<span>Spooty</span>
|
Jarri Spooty
|
||||||
<span class="tag is-dark is-size-7">v{{version}}</span>
|
<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>
|
||||||
<p class="subtitle">Self-hosted spotify downloader</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="hero">
|
<div class="box jarri-box">
|
||||||
<div class="hero-body">
|
<div class="is-flex is-justify-content-space-between">
|
||||||
<div class="box">
|
|
||||||
<p class="subtitle">Download</p>
|
|
||||||
<div>
|
<div>
|
||||||
<div class="is-flex">
|
<p class="subtitle">Playlist History</p>
|
||||||
<input class="input"
|
<p class="has-text-grey is-size-7">One collapsed row per playlist run.</p>
|
||||||
[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>
|
|
||||||
</div>
|
</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">
|
<div class="buttons has-addons">
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
||||||
<i class="fa-solid fa-check"></i>
|
<i class="fa-solid fa-check"></i>
|
||||||
@@ -44,30 +77,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="playlists$ | async as playlists">
|
|
||||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
<ng-container *ngIf="playlists$ | async as playlists">
|
||||||
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
||||||
</ng-container>
|
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
||||||
</div>
|
</ng-container>
|
||||||
<hr>
|
</div>
|
||||||
<div class="box">
|
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<div class="box jarri-box">
|
||||||
<p class="subtitle">Songs</p>
|
<p class="subtitle">Single Songs</p>
|
||||||
<div class="buttons has-addons">
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
||||||
<i class="fa-solid fa-check"></i>
|
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
||||||
</button>
|
</ng-container>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
|||||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||||
import {VersionService} from "./services/version.service";
|
import {VersionService} from "./services/version.service";
|
||||||
import {map} from "rxjs";
|
import {map} from "rxjs";
|
||||||
|
import {HttpClient} from "@angular/common/http";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
@@ -25,14 +26,41 @@ export class AppComponent {
|
|||||||
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
||||||
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
||||||
version = this.versionService.getVersion();
|
version = this.versionService.getVersion();
|
||||||
|
spotifyConnected = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly playlistService: PlaylistService,
|
private readonly playlistService: PlaylistService,
|
||||||
private readonly versionService: VersionService,
|
private readonly versionService: VersionService,
|
||||||
|
private readonly http: HttpClient,
|
||||||
) {
|
) {
|
||||||
|
this.bootstrapAuthTokenFromUrl();
|
||||||
|
this.checkSpotifyStatus();
|
||||||
this.fetchPlaylists();
|
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 {
|
fetchPlaylists(): void {
|
||||||
this.playlistService.fetch();
|
this.playlistService.fetch();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import {ApplicationConfig, importProvidersFrom, provideZoneChangeDetection} from '@angular/core';
|
import {
|
||||||
|
ApplicationConfig,
|
||||||
|
importProvidersFrom,
|
||||||
|
provideZoneChangeDetection,
|
||||||
|
} from '@angular/core';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import {provideHttpClient} from "@angular/common/http";
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import {SocketIoModule} from "ngx-socket-io";
|
import { SocketIoModule } from 'ngx-socket-io';
|
||||||
|
import { authTokenInterceptor } from './services/auth-token.interceptor';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
provideRouter(routes),
|
provideRouter(routes),
|
||||||
provideHttpClient(),
|
provideHttpClient(withInterceptors([authTokenInterceptor])),
|
||||||
importProvidersFrom(SocketIoModule.forRoot({url: ''}))
|
importProvidersFrom(SocketIoModule.forRoot({ url: '' })),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,35 +1,65 @@
|
|||||||
<article class="panel" [ngClass]="statusClass$ | async">
|
<article class="panel playlist-card" [ngClass]="statusClass$ | async">
|
||||||
<p class="panel-heading is-flex is-justify-content-space-between">
|
<p class="panel-heading playlist-heading is-flex is-justify-content-space-between" (click)="toggleCollapse(playlist.id)">
|
||||||
<span>
|
<span class="playlist-title">
|
||||||
<i class="is-clickable fa-solid"
|
<i class="is-clickable fa-solid"
|
||||||
[ngClass]="playlist.collapsed ? 'fa-caret-up' : 'fa-caret-down'"
|
[ngClass]="playlist.collapsed ? 'fa-caret-down' : 'fa-caret-right'"></i>
|
||||||
(click)="toggleCollapse(playlist.id)"
|
|
||||||
></i>
|
<span class="history-label">{{ playlist.isTrack ? 'track' : 'playlist' }}</span>
|
||||||
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">{{playlist.error}}</span>
|
|
||||||
<ng-template #noErrorTemplate>
|
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">
|
||||||
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
{{playlist.error}}
|
||||||
</ng-template>
|
</span>
|
||||||
<a [href]="playlist.spotifyUrl"
|
|
||||||
target="_blank"
|
<ng-template #noErrorTemplate>
|
||||||
class="has-text-black" title="Link to Spotify url to be download from">
|
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
||||||
<i class="fa-brands fa-spotify"></i>
|
</ng-template>
|
||||||
</a>
|
</span>
|
||||||
</span>
|
|
||||||
<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">
|
<ng-container *ngIf="!playlist.isTrack">
|
||||||
<i class="fa-solid is-clickable hover-icon"
|
<i class="fa-solid is-clickable hover-icon"
|
||||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||||
(click)="toggleActive(playlist.id, playlist.active)"
|
(click)="toggleActive(playlist.id, playlist.active)">
|
||||||
></i>
|
</i>
|
||||||
<i> </i>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download 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>
|
<i class="fa-solid fa-repeat is-clickable hover-icon"
|
||||||
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
title="Retry failed tracks"
|
||||||
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>
|
(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>
|
</span>
|
||||||
</p>
|
</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">
|
<ng-container *ngIf="playlist.collapsed">
|
||||||
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -2,19 +2,73 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-heading {
|
.playlist-card {
|
||||||
padding: 5px 10px;
|
margin: 8px 0;
|
||||||
border-radius: 10px;
|
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 {
|
&:hover .hover-icon {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .hover-icon-reversed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.playlist-title {
|
||||||
margin: 5px 0;
|
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 {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
||||||
import {TrackListComponent} from "../track-list/track-list.component";
|
import {TrackListComponent} from "../track-list/track-list.component";
|
||||||
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
||||||
import {Observable, map} from "rxjs";
|
import {Observable, combineLatest, map} from "rxjs";
|
||||||
import {Playlist} from "../../models/playlist";
|
import {Playlist} from "../../models/playlist";
|
||||||
|
|
||||||
const STATUS2CLASS = {
|
const STATUS2CLASS = {
|
||||||
@@ -14,15 +14,15 @@ const STATUS2CLASS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-playlist-box',
|
selector: 'app-playlist-box',
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgIf,
|
NgIf,
|
||||||
TrackListComponent
|
TrackListComponent
|
||||||
],
|
],
|
||||||
templateUrl: './playlist-box.component.html',
|
templateUrl: './playlist-box.component.html',
|
||||||
styleUrl: './playlist-box.component.scss',
|
styleUrl: './playlist-box.component.scss',
|
||||||
standalone: true
|
standalone: true
|
||||||
})
|
})
|
||||||
export class PlaylistBoxComponent {
|
export class PlaylistBoxComponent {
|
||||||
@@ -31,16 +31,27 @@ export class PlaylistBoxComponent {
|
|||||||
this._playlist = val;
|
this._playlist = val;
|
||||||
this.trackCount$ = this.service.getTrackCount(val.id);
|
this.trackCount$ = this.service.getTrackCount(val.id);
|
||||||
this.trackCompletedCount$ = this.service.getCompletedTrackCount(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(
|
this.statusClass$ = this.service.getStatus$(val.id).pipe(
|
||||||
map(status => STATUS2CLASS[status])
|
map(status => STATUS2CLASS[status])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get playlist(): Playlist & PlaylistUi {
|
get playlist(): Playlist & PlaylistUi {
|
||||||
return this._playlist;
|
return this._playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
_playlist!: Playlist & PlaylistUi;
|
_playlist!: Playlist & PlaylistUi;
|
||||||
trackCount$!: Observable<number>;
|
trackCount$!: Observable<number>;
|
||||||
trackCompletedCount$!: Observable<number>;
|
trackCompletedCount$!: Observable<number>;
|
||||||
|
trackErrorCount$!: Observable<number>;
|
||||||
|
progressPercent$!: Observable<number>;
|
||||||
statusClass$!: Observable<string>;
|
statusClass$!: Observable<string>;
|
||||||
|
|
||||||
constructor(private readonly service: PlaylistService) { }
|
constructor(private readonly service: PlaylistService) { }
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { HttpInterceptorFn } from '@angular/common/http';
|
||||||
|
|
||||||
|
const TOKEN_STORAGE_KEY = 'spooty_auth_token';
|
||||||
|
|
||||||
|
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
|
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return next(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(
|
||||||
|
req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
'x-spooty-token': token,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -138,7 +138,7 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retryFailed(id: number): void {
|
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 {
|
setActive(id: number, active: boolean): void {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class TrackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retry(id: number): void {
|
retry(id: number): void {
|
||||||
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe();
|
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initWsConnection(): void {
|
private initWsConnection(): void {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>SpootyFe</title>
|
<title>Jarri Spooty</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
|||||||
Reference in New Issue
Block a user