Author SHA1 Message Date
TorMatzAndren 8b79371d36 Update README for 3.1.0 release 2026-06-10 05:43:16 +02:00
TorMatzAndren dca82a1c54 Bump version to 3.1.0 2026-06-09 23:07:27 +02:00
TorMatzAndren 422d372122 Expose candidate truth in Spooty workspace inspector 2026-06-09 22:57:15 +02:00
TorMatzAndren e744e5d713 Persist rejected YouTube candidate truth 2026-06-09 22:09:29 +02:00
TorMatzAndren 3e96e5fd87 Persist selected YouTube candidate truth 2026-06-09 22:00:50 +02:00
TorMatzAndren bd5742f924 Add backend operations snapshot truth contract 2026-06-09 21:46:31 +02:00
TorMatzAndren 69d059b8a5 Add Workspace single-track intake and hardened YouTube matching 2026-06-04 22:59:09 +02:00
TorMatzAndren 2750aa5c06 Update README with Workspace v3 screenshot 2026-05-23 01:08:48 +02:00
TorMatzAndren 56534d1d33 Release Jarri Spooty Workspace v3.0.0 2026-05-23 01:01:55 +02:00
TorMatzAndren 18deaa2d90 Document deterministic runtime and Jarri Spooty branding 2026-05-16 18:23:09 +02:00
TorMatzAndren 66c71a7487 Add Jarri Spooty frontend demo screenshot 2026-05-16 18:20:26 +02:00
TorMatzAndren a41884442d Add local Docker runtime helper script 2026-05-16 17:51:55 +02:00
TorMatzAndren 01fe5bcaf8 Merge branch 'hardening/pass-006-source-determinism' 2026-05-16 17:44:10 +02:00
TorMatzAndren 8a58aa29d9 Updated .gitignore to ignore download folder 2026-05-16 16:39:04 +02:00
TorMatzAndren a71a47f76a Document Jarri Spooty hardening updates 2026-05-16 13:10:00 +02:00
TorMatzAndren d83b0e6e1e Normalize Jarri Spooty naming 2026-05-16 13:01:28 +02:00
TorMatzAndren 1f3131d99a Shape track websocket payloads 2026-05-16 12:45:02 +02:00
TorMatzAndren c3d2f39b82 Harden yt-dlp process lifecycle 2026-05-16 11:42:16 +02:00
TorMatzAndren 09bbffa6e8 Harden cover art fetching 2026-05-16 11:11:59 +02:00
TorMatzAndren 38ca4d6c8d Harden download filename sanitization 2026-05-16 11:08:26 +02:00
TorMatzAndren 0cd5294944 Remove unused downloader dependencies 2026-05-16 11:04:38 +02:00
Tor Wingalen 5d20972c2d Update frontend retry calls to POST 2026-05-15 23:55:10 +02:00
Tor Wingalen 5dc428b148 Use POST for retry state-changing endpoints 2026-05-15 23:49:54 +02:00
Tor Wingalen be11bb94d7 Replace Redis shell exec with spawn 2026-05-15 23:45:59 +02:00
Tor Wingalen f64cc97475 Replace yt-search dependency with yt-dlp search 2026-05-15 23:36:57 +02:00
Tor Wingalen 005e47bf6e Remove unused fluent-ffmpeg dependency 2026-05-15 23:17:51 +02:00
Tor Wingalen 9e83001b73 Update Angular toolchain patch versions 2026-05-15 22:11:01 +02:00
Tor Wingalen 5f4d7c9c4e Document deterministic yt-dlp hardening and Docker persistence 2026-05-15 21:07:01 +02:00
Tor Wingalen 48517a5a85 Use direct yt-dlp CLI with deterministic fallback handling 2026-05-15 20:47:25 +02:00
Tor Wingalen ca388a8fc8 Improve deterministic YouTube matching and playlist handling 2026-05-14 21:52:58 +02:00
Tor Wingalen c1fd39c377 Add Spotify OAuth and large playlist support 2026-05-14 19:40:27 +02:00
Tor Wingalen 0034b28f1a Improve large playlist stability and Spotify item endpoint 2026-05-14 19:07:53 +02:00
Tor Wingalen a1772c7670 Make cover art non-fatal and disable critical CSS inlining 2026-05-14 18:37:15 +02:00
Tor Wingalen 64160fe21a Ignore local test downloads 2026-05-14 18:20:58 +02:00
Tor Wingalen 20632c6f9f Add deterministic YouTube matching and fix download path guard 2026-05-14 18:18:37 +02:00
Tor Wingalen 4bb61f5ebe Add deterministic YouTube source scoring 2026-05-14 18:00:53 +02:00
Tor Matz Andrén d1b040a8b1 Allow frontend auth token bootstrap from URL 2026-05-14 17:36:44 +02:00
Tor Matz Andrén abc63c0cec Add public health endpoint for Docker healthcheck 2026-05-14 17:17:51 +02:00
Tor Matz Andrén 33f077ed1e Reduce Docker build context and harden runtime image 2026-05-14 17:07:22 +02:00
Tor Matz Andrén 5fb84af635 Harden queue execution and job lifecycle 2026-05-14 16:45:16 +02:00
Tor Matz Andrén f9e4cf5d07 Add HTTP security headers and CORS defaults 2026-05-14 16:38:42 +02:00
Tor Matz Andrén 41f5a80ffb Harden API validation throttling and error handling 2026-05-14 16:25:52 +02:00
64 changed files with 7125 additions and 2579 deletions
+26
View File
@@ -0,0 +1,26 @@
node_modules
dist
.git
.github
coverage
.vscode
*.log
downloads
config
.env
.env.*
README.md
assets
# Local runtime/download artifacts
test-downloads
downloads
config
*.sqlite
*.sqlite3
*.db
*.mp3
*.m4a
*.webm
*.part
spooty-config
+3
View File
@@ -4,3 +4,6 @@
**/*.sqlite **/*.sqlite
# Local Netlify folder # Local Netlify folder
.netlify .netlify
test-downloads/
spooty-config
+148 -108
View File
@@ -1,171 +1,211 @@
### Changelog # Changelog
All notable changes to this project will be documented in this file. Dates are displayed in UTC. All notable changes to Jarri Spooty are documented here.
#### [2.4.2](https://github.com/Raiper34/spooty/compare/2.4.1...2.4.2) This project follows a practical chronological changelog rather than autogenerated dependency noise.
- fix(spotify): use data.next for playlist pagination instead of filtered track count [`#52`](https://github.com/Raiper34/spooty/issues/52) [`#35`](https://github.com/Raiper34/spooty/issues/35) ---
#### [2.4.1](https://github.com/Raiper34/spooty/compare/2.4.0...2.4.1) # Unreleased
> 7 February 2026 ## Added
- fix(docker): fix docker node version [`a14f5ea`](https://github.com/Raiper34/spooty/commit/a14f5eab6805acdf4755dbd8f37a289a0d675c12) - Added Workspace tabs for Intake, Archive, and Diagnostics.
- Added per-tab frontend layout persistence with migration from the old single workspace layout into Intake.
- Added reset current tab and reset all tabs controls.
- Added Candidate Inspector Back/Clear selection action.
- Added Candidate Inspector selected candidate and honest alternative-state messaging.
- Added Archive Browser file-browser view with file icons, root summary, file count, total size, search, sort, and top-level folder grouping.
#### [2.4.0](https://github.com/Raiper34/spooty/compare/2.3.4...2.4.0) ## Changed
> 7 February 2026 - Kept Archive Browser read-only; no arbitrary browsing, delete, move, rename, or open-file operations were added.
- feat(files): add possibility to set the audio quality of downloaded files [`#39`](https://github.com/Raiper34/spooty/issues/39) ---
- fix(downloading): fix downloading songs from browser with special characters [`#41`](https://github.com/Raiper34/spooty/issues/41)
- fix(downloadin): fix ejs runtime problem [`#38`](https://github.com/Raiper34/spooty/issues/38)
#### [2.3.4](https://github.com/Raiper34/spooty/compare/2.3.3...2.3.4) # 3.0.0
> 4 February 2026 ## Added
- fix(ytdl): Upgrade ytdl package (automated) [`ef32347`](https://github.com/Raiper34/spooty/commit/ef32347903d7caa2cc72c9c54341195b2ac0bb96) - Added ChronoGit-style draggable/resizable Workspace UI panels.
- Added Workspace localStorage persistence and reset layout control.
- Added Source Intake Archive destination field persisted in frontend localStorage.
- Added truthful Source Intake runtime messaging for `DOWNLOADS_PATH` and pending per-run destination routing.
- Added Archive Browser MVP with a refreshable file list.
- Added scoped read-only `GET /api/archive` listing for files inside the configured downloads root.
- Added Candidate Inspector MVP for selected track details.
- Added selected-track behavior in track rows.
- Added rejected YouTube candidate display when rejected URLs are available.
#### [2.3.3](https://github.com/Raiper34/spooty/compare/2.3.2...2.3.3) ## Changed
> 30 January 2026 - Bumped visible, root, backend, frontend, and lockfile package versions to 3.0.0.
- Kept existing playlist, track, retry, delete, Spotify OAuth, queue, download, and yt-dlp behavior intact.
## Pending
- Per-run archive destination routing is still pending backend support.
- Candidate scoring history is not exposed yet; the inspector shows current stored track state and rejected URLs.
- 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 Hardened Workspace Foundation
> 26 January 2026 ## Added
- fix(ytdl): Upgrade ytdl package (automated) [`531730b`](https://github.com/Raiper34/spooty/commit/531730bc57f9ab1339277ad995f37611a9f55aa8) ### Spotify
#### [2.3.1](https://github.com/Raiper34/spooty/compare/2.3.0...2.3.1) - 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
> 23 January 2026 ### YouTube
- fix(ytdl): Upgrade ytdl package (automated) [`08b83bf`](https://github.com/Raiper34/spooty/commit/08b83bf9de70999bddb47cd1d64d63d1ab550d88) - Added deterministic YouTube candidate scoring
- docs(readme): change redirect URI [`8162a2a`](https://github.com/Raiper34/spooty/commit/8162a2a21f762310f857dfc42cad53831769ed66) - 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
#### [2.3.0](https://github.com/Raiper34/spooty/compare/2.2.1...2.3.0) ### Queue Hardening
> 29 December 2025 - 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
- feat(track): allow download of individual track and fix album art [`#29`](https://github.com/Raiper34/spooty/issues/29) ### Docker
- 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 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
> 15 November 2025 ### Security
- fix(downloading): migrate from @distube/ytdl-core to ytdlp-nodejs yt downloading library [`5795f7c`](https://github.com/Raiper34/spooty/commit/5795f7cc178ab4d8a8d3d8a7e94f33743ff6e9e0) - Added token-based frontend access mode
- fix(docker): add python3 dependency into docker image for yt download library [`46d7c66`](https://github.com/Raiper34/spooty/commit/46d7c6699bc8698896b22837e8507885677a71bd) - Added external secret handling recommendations
- docs(readme): fix yt cookies section heading [`285578f`](https://github.com/Raiper34/spooty/commit/285578ff1ed1d7fe14d7ddd0d95a4b8079276e4e) - Added isolated Spotify credential storage
- docs(readme): add docker versio badge into readme [`4be3743`](https://github.com/Raiper34/spooty/commit/4be3743f7b5a8019bd6ed0d2248f23a254a82603) - 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
#### [2.2.0](https://github.com/Raiper34/spooty/compare/2.1.1...2.2.0) ### Frontend
> 17 October 2025 - Added Spotify login integration
- Added auth token bootstrap from URL
- Improved playlist rendering behavior
- Improved large playlist progress tracking
- feat(spotify): integrate Spotify API for playlist metadata and track retrieval [`de55e42`](https://github.com/Raiper34/spooty/commit/de55e42fcd246d86e8d3156d8e0ab4898b5755d6) ## Improved
- 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) - 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
> 13 August 2025 ## Fixed
- feat(gui): add version to gui header [`#25`](https://github.com/Raiper34/spooty/issues/25) - Fixed Spotify playlist truncation at 100 tracks
- fix(track): fix track missing artist and title tags [`b93993c`](https://github.com/Raiper34/spooty/commit/b93993c6cf034dccad3df08485d2126614874743) - Fixed incomplete playlist retrieval behavior
- style(gui): change gui primary to match spotify primary color [`5068fa8`](https://github.com/Raiper34/spooty/commit/5068fa8a4b724ef4a1060dc4d83cc80afee08bad) - 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
#### [2.1.0](https://github.com/Raiper34/spooty/compare/2.0.11...2.1.0) ---
> 12 August 2025 # 2.4.2 (Original Spooty Endpoint)
- feat(track): add track cover art into downloaded file [`#24`](https://github.com/Raiper34/spooty/issues/24) ## Fixed
#### [2.0.11](https://github.com/Raiper34/spooty/compare/2.0.10...2.0.11) - Fixed Spotify playlist pagination using `data.next`
- Fixed incomplete playlist retrieval behavior
> 14 June 2025 ---
- fix(ytdl): Upgrade ytdl package (automated) [`fe79302`](https://github.com/Raiper34/spooty/commit/fe79302c2a747093df39405e42c06c08957acc3c) # 2.4.1
#### [2.0.10](https://github.com/Raiper34/spooty/compare/2.0.9...2.0.10) ## Fixed
> 4 June 2025 - Fixed Docker Node.js version compatibility
- fix(ytdl): Upgrade ytdl package (automated) [`f74a4d2`](https://github.com/Raiper34/spooty/commit/f74a4d2ed9da50e29f4e44b0027b2891c7473ec6) ---
#### [2.0.9](https://github.com/Raiper34/spooty/compare/2.0.8...2.0.9) # 2.4.0
> 8 May 2025 ## Added
- fix(ytdl): Upgrade ytdl package (automated) [`cc6a201`](https://github.com/Raiper34/spooty/commit/cc6a20196d474152eecaeb9862edfb3505ed3106) - Added configurable audio quality selection
#### [2.0.8](https://github.com/Raiper34/spooty/compare/2.0.7...2.0.8) ## Fixed
> 27 April 2025 - Fixed special character download issues
- Fixed EJS runtime issue
- 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) # 2.3.0
> 5 April 2025 ## Added
- fix(ytdl): Upgrade ytdl package (automated) [`9653db5`](https://github.com/Raiper34/spooty/commit/9653db54ea48e93526f2124b11b8012d987b7bcc) - Added individual track downloads
- Added cover art support
#### [2.0.6](https://github.com/Raiper34/spooty/compare/2.0.5...2.0.6) ---
> 1 April 2025 # 2.2.0
- ci(netlify): remove netlify cli from deps [`e4cde85`](https://github.com/Raiper34/spooty/commit/e4cde8585ba0dfc422cec89360612947ebe92ff1) ## Added
- 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) - Added Spotify API integration
- Added YouTube cookie support
- Added download timeout handling
> 20 March 2025 ---
- ci(docker): fix docker buildx in release-it for github actions [`db65823`](https://github.com/Raiper34/spooty/commit/db65823ce0794280cb373a4381e24ad704a4db56) # 2.0.0
- 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) ## Added
> 15 March 2025 - Introduced queue-based backend architecture
- 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) # 1.0.0
> 15 March 2025 ## Added
- fix(ytdl): Upgrade ytdl package (automated) [`844d026`](https://github.com/Raiper34/spooty/commit/844d02668512cb8446f27f5629412fb5331b1298) - Initial release
#### [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
+23 -11
View File
@@ -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
CMD ["node", "backend/main.js"]
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"]
+301 -125
View File
@@ -1,157 +1,333 @@
[![npm version](https://img.shields.io/docker/pulls/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty) # Jarri Spooty
[![npm version](https://img.shields.io/docker/image-size/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty)
![Docker Image Version](https://img.shields.io/docker/v/raiper34/spooty)
[![npm version](https://img.shields.io/docker/stars/raiper34/spooty)](https://hub.docker.com/r/raiper34/spooty)
[![GitHub License](https://img.shields.io/github/license/raiper34/spooty)](https://github.com/Raiper34/spooty)
[![GitHub Repo stars](https://img.shields.io/github/stars/raiper34/spooty)](https://github.com/Raiper34/spooty)
![spooty logo](assets/logo.svg) Deterministic media ingestion workspace for Spotify metadata and YouTube archival.
# 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 Workspace v3](docs/images/jarri-spooty-workspace-v3.png)
> Please do not use this tool for piracy! Download only music you own rights! Use this tool only on your responsibility.
### Content Spooty does not download audio from Spotify itself.
- [🚀 Installation](#-installation) It retrieves metadata from Spotify and locates matching audio on YouTube.
- [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 Deterministic Spotify metadata ingestion with duration-aware YouTube candidate scoring, paced queue execution, hardened Docker deployment, and local archival workflows.
Recommended and the easiest way how to start to use of Spooty is using docker.
### Spotify App Configuration Current version: 3.1.0
To fully use Spooty, you need to create an application in the Spotify Developer Dashboard: ## What's New in 3.1.0
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) Jarri Spooty 3.1.0 introduces the first generation of operational truth contracts.
2. Sign in with your Spotify account
3. Create a new application
4. Note your `Client ID` and `Client Secret`
5. Configure the redirect URI to `http://127.0.0.1:3000/api/callback` (or the corresponding URL of your instance)
These credentials will be used by Spooty to access the Spotify API. New capabilities include:
### Docker - Operations Snapshot API
- Candidate selection provenance
- Selected YouTube candidate persistence
- Rejected YouTube candidate persistence
- Candidate scoring visibility
- Candidate selection reasoning
- Improved Workspace diagnostics
- Backend-owned operational truth for future Workspace integrations
Just run docker command or use docker compose configuration. Spooty now exposes why a candidate was selected rather than treating YouTube matching as a black box.
For detailed configuration, see available [environment variables](#environment-variables).
#### Docker command This hardened branch focuses on:
```shell
docker run -d -p 3000:3000 \
-v /path/to/downloads:/spooty/backend/downloads \
-v /path/to/cookies.txt:/spooty/config/cookies.txt \
-e SPOTIFY_CLIENT_ID=your_client_id \
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
raiper34/spooty:latest
```
#### Docker compose - ChronoGit-style Workspace UI panels
```yaml - Large playlist support (>100 tracks)
services: - Spotify OAuth login flow
spooty: - Better YouTube pacing and throttling resistance
image: raiper34/spooty:latest - Improved Docker deployment
container_name: spooty - Safer credential handling
restart: unless-stopped - More resilient cover-art embedding
ports: - Better queue stability
- "3000:3000" - Deterministic YouTube fallback handling
volumes: - Explicit yt-dlp CLI execution
- /path/to/downloads:/spooty/backend/downloads - Automatic failed-candidate rejection
- /path/to/cookies.txt:/spooty/config/cookies.txt - Improved age-gated video handling
environment: - Persistent SQLite state across container restarts
- SPOTIFY_CLIENT_ID=your_client_id - Deterministic Docker config persistence
- SPOTIFY_CLIENT_SECRET=your_client_secret - Improved operational observability
# Configure other environment variables if needed - Reduced unused dependency surface
``` - Hardened filename, cover-art, subprocess, and websocket boundaries
- Workspace Archive Browser MVP with scoped read-only archive listing
- Workspace Candidate Inspector with candidate provenance
- Operations Snapshot API
- Candidate scoring visibility
- Candidate rejection persistence
- Frontend archive destination field for planned per-run routing
- Frontend Workspace tabs for Intake, Archive, and Diagnostics layouts
### Build from source ---
Spooty can be also build from source files on your own. # Features
#### Requirements - Download Spotify playlists
- Node v20.20.0 (it is recommended to use `nvm` node version manager to install proper version of node) - Download individual Spotify tracks
- Redis in memory cache - Playlist auto-subscription support
- Ffmpeg - Automatic YouTube matching
- Python3 - 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
- Candidate scoring visibility
- Candidate selection reasoning
- Candidate provenance persistence
- Hardened cover-art validation and embedding
- Explicit client-facing websocket payload shaping
- Draggable/resizable Workspace UI
- Archive Browser panel for downloaded file inspection
- Candidate Inspector panel for selected track diagnostics
- Operations Snapshot diagnostics API
- Frontend-persisted archive destination input
- Per-tab workspace layout presets
- Read-only archive search, sort, and grouped file inspection
#### 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 # Workspace UI
Some behaviour and settings of Spooty can be configured using environment variables and `.env` file. Jarri Spooty 3.1.0 introduces a draggable/resizable Workspace shell with frontend tab presets.
Name | Default | Description | Workspace tabs:
-------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
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 - Intake
- Archive
- Diagnostics
YouTube may block or throttle downloads without authentication cookies. Spooty supports two ways to provide them — use the one that fits your setup. Each tab has its own persisted panel layout in browser localStorage. The old single workspace layout is migrated into the Intake tab when possible. The current tab can be reset independently, and all tabs can be reset together.
#### `YT_COOKIES` — browser-based cookies (non-Docker) Workspace panels:
Set `YT_COOKIES` to the name of your browser and yt-dlp will automatically read cookies directly from it. - Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
Supported values: `chrome`, `firefox`, `edge`, `safari`, `brave`, `opera`, `chromium`. - Queue Observatory: Spotify, playlist, single-song, archive, and runtime metrics
- Playlist History: existing playlist rows and delete completed/delete failed controls
- Single Songs: existing single-track queue rows
- Archive Browser: read-only browser-style listing of downloaded files inside the configured downloads root, with search, sort, file count, total size, and top-level folder grouping
- Candidate Inspector: selected track details including Spotify URL, selected YouTube candidate, candidate score, candidate reasoning, status, error classification, error summary, retry attempts, rejected candidates, and download diagnostics
``` The Archive destination field is frontend-persisted planning state. Runtime downloads still use the backend `DOWNLOADS_PATH` root. Per-run destination routing is pending backend support.
YT_COOKIES=chrome
```
> [!NOTE] The Archive Browser uses:
> 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) GET /api/archive
Export your YouTube cookies as a Netscape `cookies.txt` file and provide its path. This is the recommended approach for Docker deployments. It returns the configured downloads root and files under that root only. It does not allow arbitrary filesystem browsing, deletion, moving, renaming, or path traversal.
**How to get your `cookies.txt` file:** Candidate Inspector displays persisted candidate provenance including:
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:** - Selected candidate
- Candidate score
- Candidate reasoning
- Rejected candidates
- Download attempts
- Error classification
- Error summaries
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. Full candidate-pool history is not yet retained.
> [!NOTE] ---
> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set.
# ⚖️ License # Operations Snapshot API
[MIT](https://choosealicense.com/licenses/mit/)
Spooty exposes deterministic operational truth through:
GET /api/operations/snapshot
This endpoint provides:
- Runtime operation state
- Queue depths
- Active searches
- Active downloads
- Playlist counts
- Track counts
- Failure summaries
- Candidate-selection metadata
The endpoint is intended for Workspace integrations and diagnostics surfaces.
Workspace consumes this truth rather than deriving operational state independently.
---
# Important Notice
Use this software responsibly.
Only download music you legally own or are permitted to access.
The maintainers are not responsible for misuse.
---
# Supported URLs
- Spotify playlists
- Spotify tracks
Example:
https://open.spotify.com/playlist/...
https://open.spotify.com/track/...
---
# Quick Start Recommended
## 1. Create Spotify Developer App
Go to:
https://developer.spotify.com/dashboard
Create an application.
Add this Redirect URI:
http://127.0.0.1:3000/api/spotify/callback
Copy:
- Client ID
- Client Secret
---
## 2. Store Credentials Outside Repository
Create a secure env file:
sudo mkdir -p /etc/tokens
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOT'
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
EOT
sudo chown root:$USER /etc/tokens/spotify.env
sudo chmod 640 /etc/tokens/spotify.env
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:
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
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
---
# Deterministic YouTube Fallback Handling
The hardened branch 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.
---
# Queue Pacing
Aggressive YouTube access can trigger:
- HTTP 302 loops
- CAPTCHA
- temporary throttling
- incomplete downloads
Recommended safe pacing:
-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
---
# Security Notes
Never commit:
- Spotify secrets
- OAuth tokens
- cookies.txt
- downloaded music
- local databases
- spooty-config/
Recommended `.gitignore` additions:
downloads/
config/
spooty-config/
*.sqlite
cookies.txt
.env
.env.local
---
# License
MIT
Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

+24
View File
@@ -0,0 +1,24 @@
# Spooty Operations Snapshot
`GET /api/operations/snapshot` exposes backend-owned operational truth for Workspace and other clients.
The MVP reports truth Spooty already owns without a schema migration:
- track state from `TrackEntity.status`
- selected YouTube URL from `TrackEntity.youtubeUrl`
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
- raw failure evidence from `TrackEntity.error`
- download retry count from `TrackEntity.downloadAttemptCount`
- rejected YouTube candidate explanations from `TrackEntity.rejectedYoutubeCandidatesJson`
- legacy rejected YouTube candidate URLs from `TrackEntity.rejectedYoutubeUrls`
- playlist and single-song counts from `PlaylistEntity.isTrack`
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
- Spotify connection presence from the stored Spotify user token
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidate metadata is persisted when a selected candidate is rejected by manual retry or download failure. Older rows and URL-only cases fall back to rejected candidate objects containing only `url`.
Future persistence work is required for:
- full searched candidate history
- structured error class fields stored at write time instead of classified from the persisted raw error string
- operation attempt history across search and download phases
+1895 -1987
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.4.2", "version": "3.1.0",
"name": "spooty", "name": "spooty",
"workspaces": [ "workspaces": [
"src/backend", "src/backend",
@@ -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"
} }
} }
Executable
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="${IMAGE:-spootyfy-hardened:local}"
CONTAINER="${CONTAINER:-jarri-spooty}"
PORT="${PORT:-3000}"
TOKEN="${SPOOTY_AUTH_TOKEN:-test-token}"
DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}"
CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}"
ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}"
YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}"
YT_COOKIES_FILE_CONTAINER="${YT_COOKIES_FILE_CONTAINER:-/spooty/config/youtube.cookies.txt}"
cd "$PROJECT"
DOCKER="docker"
if ! docker ps >/dev/null 2>&1; then
DOCKER="sudo docker"
fi
echo "== Docker before =="
$DOCKER ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' || true
if [[ "${1:-}" == "--build" ]]; then
echo "== Building app =="
npm run build
$DOCKER build -t "$IMAGE" .
fi
echo "== Removing named container if present =="
$DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true
echo "== Removing any container bound to host port ${PORT} =="
PORT_CONTAINERS="$($DOCKER ps --filter "publish=${PORT}" -q || true)"
if [[ -n "$PORT_CONTAINERS" ]]; then
$DOCKER rm -f $PORT_CONTAINERS
fi
mkdir -p "$DOWNLOADS" "$CONFIG_DIR"
echo "== Starting Jarri Spooty =="
$DOCKER run -d \
--name "$CONTAINER" \
-p "$PORT:3000" \
--env-file "$ENV_FILE" \
-e SPOTIFY_REDIRECT_URI="http://127.0.0.1:${PORT}/api/spotify/callback" \
-e AUTH_ENABLED=true \
-e SPOOTY_AUTH_TOKEN="$TOKEN" \
-e YT_SEARCH_DELAY_MS=7000 \
-e YT_DOWNLOADS_PER_MINUTE=6 \
-e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \
-e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \
-v "$DOWNLOADS:/spooty/backend/downloads" \
-v "$YT_COOKIES_FILE_HOST:$YT_COOKIES_FILE_CONTAINER:ro" \
-v "$CONFIG_DIR:/spooty/backend/config" \
"$IMAGE"
echo
echo "== Docker after =="
$DOCKER ps --filter "name=^/${CONTAINER}$" --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
echo
echo "Open:"
echo "http://127.0.0.1:${PORT}/?token=${TOKEN}"
echo
echo "Logs:"
echo "$DOCKER logs -f $CONTAINER"
+6
View File
@@ -19,3 +19,9 @@ YT_DOWNLOADS_PER_MINUTE=3
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token. # Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
AUTH_ENABLED=false AUTH_ENABLED=false
SPOOTY_AUTH_TOKEN= SPOOTY_AUTH_TOKEN=
# Optional frontend origin restriction
CORS_ORIGIN=http://localhost:4200
NODE_ENV=development
+6
View File
@@ -17,3 +17,9 @@ YT_DOWNLOADS_PER_MINUTE=3
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token. # Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
AUTH_ENABLED=false AUTH_ENABLED=false
SPOOTY_AUTH_TOKEN= SPOOTY_AUTH_TOKEN=
# Optional frontend origin restriction
CORS_ORIGIN=http://localhost:4200
NODE_ENV=production
+2 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "2.4.2", "version": "3.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -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",
+5 -2
View File
@@ -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' };
}
} }
+29 -1
View File
@@ -13,6 +13,9 @@ import { EnvironmentEnum } from './environmentEnum';
import { BullModule } from '@nestjs/bullmq'; import { BullModule } from '@nestjs/bullmq';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './shared/auth.guard'; import { AuthGuard } from './shared/auth.guard';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ArchiveModule } from './archive/archive.module';
import { OperationsModule } from './operations/operations.module';
@Module({ @Module({
imports: [ imports: [
@@ -44,11 +47,30 @@ import { AuthGuard } from './shared/auth.guard';
], ],
inject: [ConfigService], inject: [ConfigService],
}), }),
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: Number(process.env.API_THROTTLE_LIMIT || 600),
},
]),
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,8 @@ import { AuthGuard } from './shared/auth.guard';
}), }),
TrackModule, TrackModule,
PlaylistModule, PlaylistModule,
ArchiveModule,
OperationsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
@@ -66,6 +90,10 @@ import { AuthGuard } from './shared/auth.guard';
provide: APP_GUARD, provide: APP_GUARD,
useClass: AuthGuard, useClass: AuthGuard,
}, },
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
], ],
}) })
export class AppModule {} export class AppModule {}
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { ArchiveListingDto, ArchiveService } from './archive.service';
@Controller('archive')
export class ArchiveController {
constructor(private readonly archiveService: ArchiveService) {}
@Get()
list(): Promise<ArchiveListingDto> {
return this.archiveService.list();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { ArchiveController } from './archive.controller';
import { ArchiveService } from './archive.service';
@Module({
imports: [SharedModule],
controllers: [ArchiveController],
providers: [ArchiveService],
})
export class ArchiveModule {}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { promises as fs } from 'fs';
import { relative, resolve } from 'path';
import { UtilsService } from '../shared/utils.service';
export interface ArchiveFileDto {
name: string;
path: string;
sizeBytes: number;
modifiedAt: number;
}
export interface ArchiveListingDto {
root: string;
files: ArchiveFileDto[];
}
@Injectable()
export class ArchiveService {
constructor(private readonly utilsService: UtilsService) {}
async list(): Promise<ArchiveListingDto> {
const root = this.utilsService.getRootDownloadsPath();
const files = await this.listFiles(root, root);
return {
root,
files: files.sort((a, b) => b.modifiedAt - a.modifiedAt),
};
}
private async listFiles(root: string, currentPath: string): Promise<ArchiveFileDto[]> {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
const files: ArchiveFileDto[] = [];
for (const entry of entries) {
const entryPath = this.utilsService.ensureInsideDownloadsRoot(
resolve(currentPath, entry.name),
);
if (entry.isDirectory()) {
files.push(...await this.listFiles(root, entryPath));
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(entryPath);
files.push({
name: entry.name,
path: relative(root, entryPath).split(/[\\/]+/).join('/'),
sizeBytes: stat.size,
modifiedAt: stat.mtimeMs,
});
}
return files;
}
}
+4
View File
@@ -10,4 +10,8 @@ export enum EnvironmentEnum {
YT_COOKIES_FILE = 'YT_COOKIES_FILE', YT_COOKIES_FILE = 'YT_COOKIES_FILE',
AUTH_ENABLED = 'AUTH_ENABLED', AUTH_ENABLED = 'AUTH_ENABLED',
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN', 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',
} }
+49 -3
View File
@@ -1,8 +1,11 @@
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 { function envFlag(name: string): boolean {
@@ -24,14 +27,57 @@ async function bootstrap() {
try { try {
// Convenience mode for the single-container Docker image. // Convenience mode for the single-container Docker image.
// Hardened deployments should prefer an external Redis service. // Hardened deployments should prefer an external Redis service.
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`); const redisProcess = spawn(
'redis-server',
['--port', String(process.env.REDIS_PORT || 6379)],
{
stdio: 'ignore',
detached: true,
},
);
redisProcess.unref();
} catch (e) { } catch (e) {
console.log('Unable to run redis server from app'); console.log('Unable to run redis server from app');
console.log(e); console.log(e);
} }
} }
const app = await NestFactory.create(AppModule); 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);
} }
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { OperationsService } from './operations.service';
import { OperationsSnapshot } from './operations.types';
@Controller('operations')
export class OperationsController {
constructor(private readonly operationsService: OperationsService) {}
@Get('snapshot')
getSnapshot(): Promise<OperationsSnapshot> {
return this.operationsService.getSnapshot();
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SharedModule } from '../shared/shared.module';
import { TrackEntity } from '../track/track.entity';
import { OperationsController } from './operations.controller';
import { OperationsService } from './operations.service';
@Module({
imports: [
TypeOrmModule.forFeature([PlaylistEntity, TrackEntity]),
BullModule.registerQueue(
{ name: 'track-search-processor' },
{ name: 'track-download-processor' },
),
SharedModule,
],
controllers: [OperationsController],
providers: [OperationsService],
})
export class OperationsModule {}
@@ -0,0 +1,404 @@
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bullmq';
import { In, MoreThan, Repository } from 'typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SpotifyApiService } from '../shared/spotify-api.service';
import { TrackEntity, TrackStatusEnum } from '../track/track.entity';
import {
ErrorClass,
FailureTruth,
OperationsSnapshot,
RejectedCandidateTruth,
SpootyOperation,
TrackTruth,
} from './operations.types';
interface ClassifiedError {
errorClass: ErrorClass;
errorSummary: string;
errorDetail: string;
}
@Injectable()
export class OperationsService {
constructor(
@InjectRepository(TrackEntity)
private readonly trackRepository: Repository<TrackEntity>,
@InjectRepository(PlaylistEntity)
private readonly playlistRepository: Repository<PlaylistEntity>,
@InjectQueue('track-download-processor')
private readonly trackDownloadQueue: Queue,
@InjectQueue('track-search-processor')
private readonly trackSearchQueue: Queue,
private readonly spotifyApiService: SpotifyApiService,
) {}
async getSnapshot(): Promise<OperationsSnapshot> {
const [
spotifyConnected,
searchQueueCounts,
downloadQueueCounts,
activeSearchJobs,
activeDownloadJobs,
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
searchingTracks,
downloadingTracks,
recentFailures,
recentTracks,
] = await Promise.all([
this.spotifyApiService.hasUserToken(),
this.trackSearchQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackDownloadQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackSearchQueue.getJobs('active', 0, 100, true),
this.trackDownloadQueue.getJobs('active', 0, 100, true),
this.playlistRepository.countBy({ isTrack: false }),
this.playlistRepository.countBy({ isTrack: true }),
this.trackRepository.count(),
this.trackRepository.countBy({
status: In([
TrackStatusEnum.Searching,
TrackStatusEnum.Queued,
TrackStatusEnum.Downloading,
]),
}),
this.trackRepository.countBy({ status: TrackStatusEnum.Completed }),
this.trackRepository.countBy({ status: TrackStatusEnum.Error }),
this.trackRepository.countBy({ downloadAttemptCount: MoreThan(0) }),
this.trackRepository.find({
where: { status: TrackStatusEnum.Searching },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Downloading },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Error },
order: { createdAt: 'DESC' },
take: 20,
}),
this.trackRepository.find({
order: { createdAt: 'DESC' },
take: 20,
}),
]);
const activeSearchTracks = await this.mergeTracksFromActiveJobs(
searchingTracks,
activeSearchJobs,
);
const activeDownloadTracks = await this.mergeTracksFromActiveJobs(
downloadingTracks,
activeDownloadJobs,
);
const searchQueueDepth =
(searchQueueCounts.waiting || 0) + (searchQueueCounts.delayed || 0);
const downloadQueueDepth =
(downloadQueueCounts.waiting || 0) + (downloadQueueCounts.delayed || 0);
const activeSearches = activeSearchJobs.length;
const activeDownloads = activeDownloadJobs.length;
return {
generatedAt: new Date().toISOString(),
runtime: {
operation: this.getOperation({
activeSearches,
activeDownloads,
searchQueueDepth,
downloadQueueDepth,
failed,
retries,
}),
spotifyConnected,
searchQueueDepth,
downloadQueueDepth,
activeSearches,
activeDownloads,
},
counters: {
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
},
active: {
searching: activeSearchTracks.map((track) => this.toTrackTruth(track)),
downloading: activeDownloadTracks.map((track) =>
this.toTrackTruth(track),
),
},
recentFailures: recentFailures.map((track) => this.toTrackTruth(track)),
recentTracks: recentTracks.map((track) => this.toTrackTruth(track)),
};
}
private async mergeTracksFromActiveJobs(
tracks: TrackEntity[],
jobs: Job[],
): Promise<TrackEntity[]> {
const knownIds = new Set(tracks.map((track) => track.id).filter(Boolean));
const missingIds = jobs
.map((job) => Number(job.data?.id))
.filter((id) => Number.isInteger(id) && !knownIds.has(id));
if (!missingIds.length) {
return tracks;
}
const activeJobTracks = await this.trackRepository.find({
where: { id: In(missingIds) },
});
return [...tracks, ...activeJobTracks];
}
private toTrackTruth(track: TrackEntity): TrackTruth | FailureTruth {
const rejectedCandidates = this.parseRejectedCandidates(track);
const error = this.classifyError(track.error);
return {
id: track.id,
artist: track.artist,
name: track.name,
status: TrackStatusEnum[track.status] || String(track.status),
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
...(track.youtubeUrl
? {
selectedCandidate: {
url: track.youtubeUrl,
...(track.selectedYoutubeTitle
? { title: track.selectedYoutubeTitle }
: {}),
...(track.selectedYoutubeAuthor
? { author: track.selectedYoutubeAuthor }
: {}),
...(typeof track.selectedYoutubeScore === 'number'
? { score: track.selectedYoutubeScore }
: {}),
...(track.selectedYoutubeReason
? { reason: track.selectedYoutubeReason }
: {}),
},
}
: {}),
...(typeof track.downloadAttemptCount === 'number'
? { downloadAttemptCount: track.downloadAttemptCount }
: {}),
...(error || {}),
rejectedCandidateCount: rejectedCandidates.length,
rejectedCandidates,
};
}
private parseRejectedCandidates(
track: TrackEntity,
): RejectedCandidateTruth[] {
const structured = this.parseRejectedCandidateJson(track);
if (structured.length) {
return structured;
}
if (!track.rejectedYoutubeUrls) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeUrls);
return Array.isArray(parsed)
? parsed
.filter((item) => typeof item === 'string')
.map((url) => ({ url }))
: [];
} catch {
return [];
}
}
private parseRejectedCandidateJson(
track: TrackEntity,
): RejectedCandidateTruth[] {
if (!track.rejectedYoutubeCandidatesJson) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
return Array.isArray(parsed)
? parsed
.filter(
(item) =>
!!item &&
typeof item === 'object' &&
typeof item.url === 'string',
)
.map((item) => ({
url: item.url,
...(typeof item.title === 'string' ? { title: item.title } : {}),
...(typeof item.author === 'string'
? { author: item.author }
: {}),
...(typeof item.score === 'number' ? { score: item.score } : {}),
...(typeof item.reason === 'string'
? { reason: item.reason }
: {}),
...(typeof item.rejectionClass === 'string'
? { rejectionClass: item.rejectionClass }
: {}),
...(typeof item.rejectionSummary === 'string'
? { rejectionSummary: item.rejectionSummary }
: {}),
...(typeof item.rejectedAt === 'string'
? { rejectedAt: item.rejectedAt }
: {}),
}))
: [];
} catch {
return [];
}
}
private classifyError(error?: string): ClassifiedError | undefined {
if (!error) {
return undefined;
}
const detail = this.trimDetail(error);
const normalized = error.toLowerCase();
if (normalized.includes('no acceptable youtube result found')) {
return this.error(
'NO_ACCEPTABLE_CANDIDATE',
'No acceptable YouTube candidate',
detail,
);
}
if (normalized.includes('sign in to confirm your age')) {
return this.error(
'YOUTUBE_AGE_GATED',
'YouTube video is age-gated',
detail,
);
}
if (normalized.includes('private video')) {
return this.error(
'YOUTUBE_PRIVATE_VIDEO',
'YouTube video is private',
detail,
);
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return this.error(
'YOUTUBE_NO_FORMATS',
'No downloadable YouTube audio format',
detail,
);
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return this.error(
'YOUTUBE_VIDEO_UNAVAILABLE',
'YouTube video is unavailable',
detail,
);
}
if (normalized.includes('unable to download webpage')) {
return this.error(
'YOUTUBE_WEBPAGE_UNAVAILABLE',
'YouTube webpage is unavailable',
detail,
);
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime')
) {
return this.error(
'YOUTUBE_EXTRACTION_FAILURE',
'YouTube extraction failed',
detail,
);
}
if (
normalized.includes('eacces') ||
normalized.includes('enoent') ||
normalized.includes('failed to write') ||
normalized.includes('permission denied')
) {
return this.error('FILE_WRITE_FAILED', 'File write failed', detail);
}
return this.error(
'UNKNOWN_DOWNLOAD_ERROR',
'Unknown download error',
detail,
);
}
private error(
errorClass: ErrorClass,
errorSummary: string,
errorDetail: string,
): ClassifiedError {
return { errorClass, errorSummary, errorDetail };
}
private trimDetail(error: string): string {
const trimmed = error.trim();
return trimmed.length > 1200 ? `${trimmed.slice(0, 1197)}...` : trimmed;
}
private getOperation(input: {
activeSearches: number;
activeDownloads: number;
searchQueueDepth: number;
downloadQueueDepth: number;
failed: number;
retries: number;
}): SpootyOperation {
if (input.activeDownloads > 0 || input.downloadQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'downloading';
}
if (input.activeSearches > 0 || input.searchQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'searching';
}
if (input.failed > 0) {
return 'degraded';
}
return 'idle';
}
}
@@ -0,0 +1,89 @@
export type SpootyOperation =
| 'idle'
| 'searching'
| 'downloading'
| 'retrying'
| 'degraded';
export type ErrorClass =
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_WEBPAGE_UNAVAILABLE'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'NO_ACCEPTABLE_CANDIDATE'
| 'FILE_WRITE_FAILED'
| 'UNKNOWN_DOWNLOAD_ERROR';
export type RejectionClass =
| 'DOWNLOAD_FAILED'
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'UNKNOWN_DOWNLOAD_ERROR'
| 'MANUAL_RETRY';
export interface RejectedCandidateTruth {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
rejectionClass?: RejectionClass;
rejectionSummary?: string;
rejectedAt?: string;
}
export interface TrackTruth {
id: number;
artist: string;
name: string;
status: string;
spotifyUrl?: string;
youtubeUrl?: string;
selectedCandidate?: {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
};
downloadAttemptCount?: number;
errorClass?: ErrorClass;
errorSummary?: string;
errorDetail?: string;
rejectedCandidateCount: number;
rejectedCandidates: RejectedCandidateTruth[];
}
export interface FailureTruth extends TrackTruth {}
export interface OperationsSnapshot {
generatedAt: string;
runtime: {
operation: SpootyOperation;
spotifyConnected: boolean;
searchQueueDepth: number;
downloadQueueDepth: number;
activeSearches: number;
activeDownloads: number;
};
counters: {
playlists: number;
singleSongs: number;
tracks: number;
active: number;
completed: number;
failed: number;
retries: number;
};
active: {
searching: TrackTruth[];
downloading: TrackTruth[];
};
recentFailures: FailureTruth[];
recentTracks: TrackTruth[];
}
@@ -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,11 @@
import { IsString, MaxLength } from 'class-validator';
export class SearchTrackDto {
@IsString()
@MaxLength(512)
artist: string;
@IsString()
@MaxLength(512)
title: string;
}
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdatePlaylistDto {
@IsOptional()
@IsBoolean()
active?: boolean;
}
@@ -4,11 +4,15 @@ 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';
import { SearchTrackDto } from './dto/search-track.dto';
@Controller('playlist') @Controller('playlist')
export class PlaylistController { export class PlaylistController {
@@ -20,25 +24,34 @@ 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);
}
@Post('search-track')
async searchTrack(
@Body() body: SearchTrackDto,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
return this.service.createFromSearch(body.artist, body.title);
} }
@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);
} }
} }
+142 -4
View File
@@ -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',
@@ -47,19 +48,43 @@ export class PlaylistService {
this.io.emit(WsPlaylistOperation.Delete, { id }); this.io.emit(WsPlaylistOperation.Delete, { id });
} }
async createFromSearch(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
const track = await this.spotifyService.searchTrack(artist, title);
await this.create({
spotifyUrl: track.spotifyUrl,
name: track.name,
active: false,
isTrack: true,
} as PlaylistEntity);
return {
spotifyUrl: track.spotifyUrl,
name: track.name,
artist: track.artist,
};
}
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);
} }
} }
private async createSingleTrack(playlist: PlaylistEntity): Promise<void> { private async createSingleTrack(playlist: PlaylistEntity): Promise<void> {
let trackDetail: { name: string; artist: string; image: string }; let trackDetail: { name: string; artist: string; image: string; durationMs?: number };
let playlist2Save: PlaylistEntity; let playlist2Save: PlaylistEntity;
try { try {
trackDetail = await this.spotifyService.getTrackDetail( trackDetail = await this.spotifyService.getTrackDetail(
@@ -77,7 +102,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);
@@ -89,6 +118,7 @@ export class PlaylistService {
name: trackDetail.name, name: trackDetail.name,
spotifyUrl: playlist.spotifyUrl, spotifyUrl: playlist.spotifyUrl,
coverUrl: trackDetail.image, coverUrl: trackDetail.image,
durationMs: trackDetail.durationMs,
}, },
savedPlaylist, savedPlaylist,
); );
@@ -100,6 +130,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 +248,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 +288,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,
); );
@@ -224,7 +359,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 = {
+12 -2
View File
@@ -9,6 +9,17 @@ import type { Request } from 'express';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { 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()) { if (!this.authEnabled()) {
return true; return true;
} }
@@ -20,11 +31,10 @@ export class AuthGuard implements CanActivate {
); );
} }
const request = context.switchToHttp().getRequest<Request>();
const providedToken = this.extractToken(request); const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) { if (providedToken !== expectedToken) {
throw new UnauthorizedException('Invalid or missing Spooty auth token'); throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
} }
return true; return true;
@@ -0,0 +1,68 @@
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(
@Query('returnTo') returnTo: string | undefined,
@Res() res: Response,
): void {
const state =
returnTo && /^https?:\/\/localhost:\d+\/?$/.test(returnTo)
? Buffer.from(JSON.stringify({ returnTo }), 'utf8').toString('base64url')
: undefined;
res.redirect(this.spotifyApiService.getAuthorizationUrl(state));
}
@Get('callback')
async callback(
@Query('code') code: string | undefined,
@Query('error') error: string | undefined,
@Query('state') state: string | undefined,
@Res() res: Response,
): Promise<void> {
const returnTo = this.parseReturnTo(state);
if (error) {
res.redirect(`${returnTo}?spotify_error=${encodeURIComponent(error)}`);
return;
}
if (!code) {
res.redirect(`${returnTo}?spotify_error=missing_code`);
return;
}
await this.spotifyApiService.exchangeAuthorizationCode(code);
res.redirect(`${returnTo}?spotify=connected`);
}
private parseReturnTo(state: string | undefined): string {
if (!state) {
return '/';
}
try {
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8'));
const returnTo = parsed?.returnTo;
if (typeof returnTo === 'string' && /^https?:\/\/localhost:\d+\/?$/.test(returnTo)) {
return returnTo;
}
} catch {
return '/';
}
return '/';
}
}
@@ -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,
});
}
}
+2 -1
View File
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
import { SpotifyService } from './spotify.service'; import { SpotifyService } from './spotify.service';
import { YoutubeService } from './youtube.service'; import { YoutubeService } from './youtube.service';
import { SpotifyApiService } from './spotify-api.service'; import { SpotifyApiService } from './spotify-api.service';
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService], providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
controllers: [], controllers: [SpotifyAuthController],
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService], exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
}) })
export class SharedModule {} export class SharedModule {}
+556 -85
View File
@@ -1,16 +1,25 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { resolve, dirname } from 'path';
import * as fs from 'fs';
import { EnvironmentEnum } from '../environmentEnum';
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const fetch = require('isomorphic-unfetch'); const fetch = require('isomorphic-unfetch');
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const { getDetails } = require('spotify-url-info')(fetch); const { getDetails } = require('spotify-url-info')(fetch);
interface StoredSpotifyUserToken {
access_token: string;
refresh_token: string;
expires_at: number;
scope?: string;
token_type?: string;
}
@Injectable() @Injectable()
export class SpotifyApiService { export class SpotifyApiService {
private readonly logger = new Logger(SpotifyApiService.name); private readonly logger = new Logger(SpotifyApiService.name);
private accessToken: string | null = null; private accessToken: string | null = null;
private tokenExpiry: number = 0; private tokenExpiry = 0;
constructor() {}
private getPlaylistId(url: string): string { private getPlaylistId(url: string): string {
try { try {
@@ -51,13 +60,207 @@ 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(state?: string): 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');
if (state) {
url.searchParams.set('state', state);
}
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 +281,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 +306,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 +332,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}`);
@@ -149,95 +340,375 @@ export class SpotifyApiService {
} }
} }
async searchTrack(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
const accessToken = await this.getClientCredentialsAccessToken();
const plainQuery = `${artist} ${title}`.trim();
const structuredQuery = `track:"${title}" artist:"${artist}"`;
const url = new URL('https://api.spotify.com/v1/search');
url.searchParams.set('q', structuredQuery || plainQuery);
url.searchParams.set('type', 'track');
url.searchParams.set('limit', '10');
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Spotify track search failed: ${response.status} ${errorText}`);
}
const data = await response.json();
const tracks = data?.tracks?.items || [];
if (!tracks.length) {
throw new Error(`No Spotify track found for ${artist} - ${title}`);
}
const normalize = (value: string) =>
(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const wantedArtist = normalize(artist);
const wantedTitle = normalize(title);
const scoreTrack = (track: any): number => {
const trackName = normalize(track.name);
const artistNames = (track.artists || []).map((item: any) => normalize(item.name));
const allArtists = artistNames.join(' ');
let score = 0;
if (trackName === wantedTitle) score += 70;
else if (trackName.includes(wantedTitle) || wantedTitle.includes(trackName)) score += 40;
if (artistNames.some((name: string) => name === wantedArtist)) score += 50;
else if (allArtists.includes(wantedArtist) || wantedArtist.includes(allArtists)) score += 25;
if (track.explicit !== undefined) score += 1;
if (track.popularity) score += Math.min(20, Math.floor(track.popularity / 5));
return score;
};
const ranked = [...tracks]
.map((track: any) => ({ track, score: scoreTrack(track) }))
.sort((a, b) => b.score - a.score);
const best = ranked[0]?.track || tracks[0];
this.logger.debug(
`Spotify search "${plainQuery}" selected "${best?.artists?.map((item: any) => item.name).join(', ')} - ${best?.name}" score=${ranked[0]?.score ?? 0}`,
);
return {
spotifyUrl: best.external_urls.spotify,
name: best.name,
artist: (best.artists || []).map((item: any) => item.name).join(', '),
image: best.album?.images?.[0]?.url || '',
durationMs: best.duration_ms,
};
}
async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> { async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> {
try { try {
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`); this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
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();
}
} }
+19
View File
@@ -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,21 @@ 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 searchTrack(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
return this.spotifyApiService.searchTrack(artist, title);
}
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 {
+17 -3
View File
@@ -22,11 +22,11 @@ export class UtilsService {
} }
ensureInsideDownloadsRoot(candidatePath: string): string { ensureInsideDownloadsRoot(candidatePath: string): string {
const root = this.getRootDownloadsPath(); const root = resolve(this.getRootDownloadsPath());
const resolvedCandidate = resolve(candidatePath); const resolvedCandidate = resolve(candidatePath);
const rel = relative(root, resolvedCandidate); const rel = relative(root, resolvedCandidate);
if (rel === '' || (!rel.startsWith('..') && !resolve(rel).startsWith('/'))) { if (rel === '' || (!rel.startsWith('..') && !relative('', rel).startsWith('..'))) {
return resolvedCandidate; return resolvedCandidate;
} }
@@ -34,6 +34,20 @@ export class UtilsService {
} }
stripFileIllegalChars(text: string): string { stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled'; const sanitized = String(text || '')
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
.trim();
const fallback = sanitized || 'untitled';
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
return `_${fallback}`;
}
return fallback.slice(0, 180);
} }
} }
+506 -37
View File
@@ -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 = {
@@ -21,26 +20,338 @@ const ALLOWED_YOUTUBE_HOSTS = new Set([
'youtu.be', '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',
'reaction',
'review',
'analysis',
'breakdown',
'explained',
'secret message',
'meaning',
'interview',
'podcast',
'news',
'documentary',
'fifa world cup',
'song style',
];
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(
artist: string,
name: string,
durationMs?: number,
excludedUrls: string[] = [],
): Promise<YoutubeMatch> {
const query = `${artist} - ${name}`; const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`); this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query); const videos = await this.searchYoutubeVideos(query);
const firstVideo = result.videos?.[0]; const excludedVideoIds = new Set(
excludedUrls
.map((url) => this.getYoutubeVideoId(url))
.filter((id): id is string => !!id),
);
if (!firstVideo?.url) { const candidates = videos
throw new Error(`No YouTube result found for: ${query}`); .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 minimumScore = Number(process.env.YT_MIN_CANDIDATE_SCORE || 60);
const accepted = candidates.find((candidate) => !candidate.rejected && candidate.score >= minimumScore);
if (!accepted) {
throw new Error(
`No acceptable YouTube result found for: ${query} with minimum score ${minimumScore}` +
(excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''),
);
} }
this.assertValidYoutubeUrl(firstVideo.url); this.logger.debug(
this.logger.debug(`Found ${query} on ${firstVideo.url}`); `Selected YouTube match for "${query}": ${accepted.title} by ${accepted.author} (${accepted.score})`,
return firstVideo.url; );
return accepted;
}
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--extractor-args',
'youtube:player_client=android_vr',
'--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);
this.logger.debug(`YT candidate raw duration title="${title}" duration=${video.duration} seconds=${video.seconds} parsed=${seconds}`);
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');
}
let hasArtistSignal = false;
for (const artistPart of this.artistParts(normalizedArtist)) {
if (artistPart.length >= 3 && (normalizedTitle.includes(artistPart) || normalizedAuthor.includes(artistPart))) {
score += 20;
reasons.push(`artist=${artistPart}:20`);
hasArtistSignal = true;
break;
}
}
if (!hasArtistSignal) {
score -= 35;
reasons.push('artist=missing:-35');
}
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 -= 80;
rejected = true;
reasons.push(`bad=${badTerm}:-80 reject`);
}
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 { assertValidYoutubeUrl(url: string): void {
@@ -61,25 +372,132 @@ export class YoutubeService {
} }
} }
private getCookiesOptions(): { private getCookiesFile(): string | null {
cookiesFromBrowser?: string;
cookies?: string;
} {
const cookiesBrowser = this.configService.get<string>(
EnvironmentEnum.YT_COOKIES,
);
if (cookiesBrowser) {
this.logger.debug(`Using cookies from browser: ${cookiesBrowser}`);
return { cookiesFromBrowser: cookiesBrowser };
}
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') || text.includes('This video is not available')) {
return 'YouTube unavailable: selected video 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> {
@@ -93,18 +511,34 @@ export class YoutubeService {
this.assertValidYoutubeUrl(track.youtubeUrl); this.assertValidYoutubeUrl(track.youtubeUrl);
const ytdlp = new YtDlp(); const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
await ytdlp.downloadAudio( const quality = this.configService.get<string>('QUALITY') || '0';
track.youtubeUrl, const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
this.configService.get<'m4a'>(EnvironmentEnum.FORMAT),
{ const args = [
output, '--no-playlist',
...this.getCookiesOptions(), '--no-cache-dir',
headers: HEADERS, '--no-cookies-from-browser',
jsRuntime: 'node', '-f',
audioQuality: this.configService.get<string>('QUALITY'), 'bestaudio/best',
}, '--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}`,
@@ -121,12 +555,47 @@ export class YoutubeService {
return; return;
} }
const res = await fetch(coverUrl); let parsedCoverUrl: URL;
try {
parsedCoverUrl = new URL(coverUrl);
} catch {
throw new Error('Invalid cover art URL');
}
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
throw new Error('Rejected non-HTTP cover art URL');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let res: Response;
try {
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch cover art: ${res.status}`); 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(); const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
}
const imageBuffer = Buffer.from(arrayBuf); const imageBuffer = Buffer.from(arrayBuf);
NodeID3.write( NodeID3.write(
@@ -134,7 +603,7 @@ export class YoutubeService {
title, title,
artist, artist,
APIC: { APIC: {
mime: 'image/jpeg', mime: contentType,
type: { id: 3, name: 'front cover' }, type: { id: 3, name: 'front cover' },
description: 'cover', description: 'cover',
imageBuffer, imageBuffer,
@@ -1,4 +1,5 @@
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';
@@ -13,15 +14,29 @@ function getDownloadsPerMinute(): number {
return Math.max(1, Math.min(30, Math.floor(parsed))); return Math.max(1, Math.min(30, Math.floor(parsed)));
} }
@Processor('track-download-processor') @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> {
if (!job.data?.id) {
this.logger.warn(`Skipping malformed download job ${job.id}`);
return;
}
const maxPerMinute = getDownloadsPerMinute(); 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);
} }
} }
+34 -10
View File
@@ -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);
} }
} }
+24
View File
@@ -27,6 +27,27 @@ export class TrackEntity {
@Column({ nullable: true }) @Column({ nullable: true })
youtubeUrl?: string; youtubeUrl?: string;
@Column({ nullable: true })
selectedYoutubeTitle?: string;
@Column({ nullable: true })
selectedYoutubeAuthor?: string;
@Column({ nullable: true })
selectedYoutubeScore?: number;
@Column({ nullable: true, type: 'text' })
selectedYoutubeReason?: string;
@Column({ nullable: true, type: 'text' })
rejectedYoutubeUrls?: string;
@Column({ nullable: true, type: 'text' })
rejectedYoutubeCandidatesJson?: string;
@Column({ default: 0 })
downloadAttemptCount?: number;
@Column({ default: TrackStatusEnum.New }) @Column({ default: TrackStatusEnum.New })
status?: TrackStatusEnum; status?: TrackStatusEnum;
@@ -36,6 +57,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;
+382 -23
View File
@@ -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',
@@ -19,6 +20,36 @@ enum WsTrackOperation {
Delete = 'trackDelete', Delete = 'trackDelete',
} }
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
rejectedYoutubeUrls?: string[];
};
type RejectionClass =
| 'DOWNLOAD_FAILED'
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'UNKNOWN_DOWNLOAD_ERROR'
| 'MANUAL_RETRY';
interface RejectedYoutubeCandidate {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
rejectionClass?: RejectionClass;
rejectionSummary?: string;
rejectedAt: string;
}
interface RejectionReason {
rejectionClass: RejectionClass;
rejectionSummary: string;
}
@WebSocketGateway() @WebSocketGateway()
@Injectable() @Injectable()
export class TrackService { export class TrackService {
@@ -55,48 +86,281 @@ export class TrackService {
this.io.emit(WsTrackOperation.Delete, { id }); this.io.emit(WsTrackOperation.Delete, { id });
} }
private toClientTrack(track: TrackEntity): ClientTrack {
return {
id: track.id,
artist: track.artist,
name: track.name,
spotifyUrl: track.spotifyUrl,
youtubeUrl: track.youtubeUrl,
rejectedYoutubeUrls: this.parseRejectedYoutubeUrls(track),
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));
}
private parseRejectedYoutubeCandidates(
track: TrackEntity,
): RejectedYoutubeCandidate[] {
if (!track.rejectedYoutubeCandidatesJson) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
return Array.isArray(parsed)
? parsed.filter(
(item): item is RejectedYoutubeCandidate =>
!!item &&
typeof item === 'object' &&
typeof item.url === 'string' &&
typeof item.rejectedAt === 'string',
)
: [];
} catch {
return [];
}
}
private stringifyRejectedYoutubeCandidates(
candidates: RejectedYoutubeCandidate[],
): string {
const byUrl = new Map<string, RejectedYoutubeCandidate>();
for (const candidate of candidates) {
if (candidate.url) {
byUrl.set(candidate.url, candidate);
}
}
return JSON.stringify([...byUrl.values()].slice(-20));
}
private buildRejectedYoutubeCandidate(
track: TrackEntity,
reason: RejectionReason,
): RejectedYoutubeCandidate | null {
if (!track.youtubeUrl) {
return null;
}
return {
url: track.youtubeUrl,
...(track.selectedYoutubeTitle
? { title: track.selectedYoutubeTitle }
: {}),
...(track.selectedYoutubeAuthor
? { author: track.selectedYoutubeAuthor }
: {}),
...(typeof track.selectedYoutubeScore === 'number'
? { score: track.selectedYoutubeScore }
: {}),
...(track.selectedYoutubeReason
? { reason: track.selectedYoutubeReason }
: {}),
...reason,
rejectedAt: new Date().toISOString(),
};
}
private rejectCurrentYoutubeCandidate(
track: TrackEntity,
reason: RejectionReason,
): {
rejectedYoutubeUrls: string;
rejectedYoutubeCandidatesJson: string;
} {
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
const rejectedCandidates = this.parseRejectedYoutubeCandidates(track);
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
if (rejectedCandidate) {
if (!rejectedUrls.includes(rejectedCandidate.url)) {
rejectedUrls.push(rejectedCandidate.url);
}
rejectedCandidates.push(rejectedCandidate);
}
return {
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
rejectedYoutubeCandidatesJson:
this.stringifyRejectedYoutubeCandidates(rejectedCandidates),
};
}
private classifyRejection(error?: string): RejectionReason {
if (!error) {
return {
rejectionClass: 'UNKNOWN_DOWNLOAD_ERROR',
rejectionSummary: 'Unknown download error',
};
}
const normalized = error.toLowerCase();
if (normalized.includes('sign in to confirm your age')) {
return {
rejectionClass: 'YOUTUBE_AGE_GATED',
rejectionSummary: 'YouTube video is age-gated',
};
}
if (normalized.includes('private video')) {
return {
rejectionClass: 'YOUTUBE_PRIVATE_VIDEO',
rejectionSummary: 'YouTube video is private',
};
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return {
rejectionClass: 'YOUTUBE_NO_FORMATS',
rejectionSummary: 'No downloadable YouTube audio format',
};
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return {
rejectionClass: 'YOUTUBE_VIDEO_UNAVAILABLE',
rejectionSummary: 'YouTube video is unavailable',
};
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime') ||
normalized.includes('unable to download webpage')
) {
return {
rejectionClass: 'YOUTUBE_EXTRACTION_FAILURE',
rejectionSummary: 'YouTube extraction failed',
};
}
return {
rejectionClass: 'DOWNLOAD_FAILED',
rejectionSummary: 'Download failed',
};
} }
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 rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
rejectionClass: 'MANUAL_RETRY',
rejectionSummary: 'Rejected by manual retry',
});
await this.update(id, {
...track,
youtubeUrl: null,
selectedYoutubeTitle: null,
selectedYoutubeAuthor: null,
selectedYoutubeScore: null,
selectedYoutubeReason: null,
...rejectedCandidateFields,
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,
selectedYoutubeTitle: youtubeMatch.title,
selectedYoutubeAuthor: youtubeMatch.author,
selectedYoutubeScore: youtubeMatch.score,
selectedYoutubeReason: youtubeMatch.reason,
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); await this.update(track.id, updatedTrack);
@@ -104,9 +368,59 @@ export class TrackService {
} }
await this.update(track.id, updatedTrack); await this.update(track.id, updatedTrack);
await this.trackDownloadQueue.add('', updatedTrack, { await this.enqueueDownload(updatedTrack.id);
jobId: `id-${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> {
@@ -141,22 +455,65 @@ export class TrackService {
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 nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
const maxAttempts = this.getMaxDownloadAttempts();
const rejectedCandidateFields = error
? this.rejectCurrentYoutubeCandidate(track, this.classifyRejection(error))
: {
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
this.parseRejectedYoutubeUrls(track),
),
rejectedYoutubeCandidatesJson:
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
};
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,
selectedYoutubeTitle: null,
selectedYoutubeAuthor: null,
selectedYoutubeScore: null,
selectedYoutubeReason: null,
...rejectedCandidateFields,
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 } : {}), ...rejectedCandidateFields,
downloadAttemptCount: error ? nextAttemptCount : 0,
...(error ? { error } : { error: null }),
}; };
await this.update(track.id, updatedTrack); await this.update(track.id, updatedTrack);
} }
@@ -165,7 +522,9 @@ export class TrackService {
const safeArtist = track.artist || 'unknown_artist'; const safeArtist = track.artist || 'unknown_artist';
const safeName = track.name || 'unknown_track'; 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)}`; const format =
this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
return `${this.utilsService.stripFileIllegalChars(fileName)}.${format}`;
} }
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
+1 -1
View File
@@ -1,4 +1,4 @@
# SpootyFe # Jarri Spooty Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3. This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
+9 -1
View File
@@ -50,7 +50,15 @@
"maximumError": "4kB" "maximumError": "4kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all",
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false
},
"fonts": true
}
}, },
"development": { "development": {
"optimization": false, "optimization": false,
+12 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.4.2", "version": "3.1.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -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",
+2 -2
View File
@@ -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": [
{ {
+478 -67
View File
@@ -1,73 +1,484 @@
<section class="hero is-primary"> <main class="workspace-shell">
<div class="hero-body"> <header class="workspace-titlebar">
<p class="title"> <div class="brand-stack">
<i class="fa-brands fa-spotify"></i> <div class="brand-line">
<span>Spooty</span> <i class="fa-brands fa-spotify"></i>
<span class="tag is-dark is-size-7">v{{version}}</span> <h1>Jarri Spooty</h1>
</p> <span class="tag version-tag">v{{ version }}</span>
<p class="subtitle">Self-hosted spotify downloader</p> </div>
</div> <p>Spotify metadata → YouTube candidate scoring → Local archive</p>
</section> </div>
<section class="hero"> <div class="truth-strip">
<div class="hero-body"> <span class="truth-pill" [class.connected]="spotifyConnected">
<div class="box"> <i
<p class="subtitle">Download</p> class="fa-solid"
<div> [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"
<div class="is-flex"> ></i>
<input class="input" {{ spotifyConnected ? "Spotify connected" : "Spotify disconnected" }}
[class.is-danger]="url && !isValidSpotifyUrl" </span>
type="text" </div>
[(ngModel)]="url" </header>
placeholder="Paste playlist/song/artist url"/>
<button class="button is-primary" <nav class="workspace-tabs" aria-label="Workspace tabs">
[class.is-loading]="(createLoading$ | async)?.isLoading" <button
(click)="download()" *ngFor="let tab of workspaceTabs"
[disabled]="!url || !isValidSpotifyUrl" class="workspace-tab"
> type="button"
<i class="fa-solid fa-download"></i> Download [class.active]="activeWorkspaceTab === tab"
</button> (click)="switchWorkspaceTab(tab)"
>
{{ getWorkspaceTabLabel(tab) }}
</button>
</nav>
<section class="workspace-controls">
<div class="panel-picker">
<div class="select">
<select [(ngModel)]="selectedPanelType" aria-label="Add panel type">
<option *ngFor="let panelType of panelTypes" [ngValue]="panelType">
{{ getPanelTitle(panelType) }}
</option>
</select>
</div>
<button class="button is-primary" type="button" (click)="addPanel()">
<i class="fa-solid fa-plus"></i>
Add Panel
</button>
</div>
<div class="panel-picker">
<button class="button reset-button" type="button" (click)="resetLayout()">
<i class="fa-solid fa-rotate-left"></i>
Reset Current Tab
</button>
<button
class="button reset-button"
type="button"
(click)="resetAllLayouts()"
>
<i class="fa-solid fa-rotate"></i>
Reset All Tabs
</button>
</div>
</section>
<section class="workspace-canvas" aria-label="Jarri Spooty workspace">
<article
*ngFor="let panel of workspace.panels; trackBy: renderPanel"
class="workspace-panel"
[ngClass]="'panel-' + panel.type"
[ngStyle]="panelStyle(panel)"
>
<header class="panel-header" (pointerdown)="beginDrag($event, panel)">
<div class="panel-title">
<span class="status-light"></span>
<strong>{{ panel.title }}</strong>
</div> </div>
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">Please enter a valid Spotify URL (e.g. https://open.spotify.com/...)</p>
</div> <button
</div> class="panel-close"
<hr> type="button"
<div class="box"> title="Close panel"
<div class="is-flex is-justify-content-space-between"> [disabled]="workspace.panels.length <= 1"
<p class="subtitle">Playlists</p> (pointerdown)="$event.stopPropagation()"
<div class="buttons has-addons"> (click)="closePanel(panel.id)"
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()"> >
<i class="fa-solid fa-check"></i>
</button>
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button> </button>
</div> </header>
</div>
<ng-container *ngIf="playlists$ | async as playlists">
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
</ng-container>
</div>
<hr>
<div class="box">
<div class="is-flex is-justify-content-space-between">
<p class="subtitle">Songs</p>
<div class="buttons has-addons">
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
<i class="fa-solid fa-check"></i>
</button>
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<ng-container *ngIf="songs$ | async as songs">
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
</ng-container>
</div>
</div>
</section>
<div class="panel-content" [ngSwitch]="panel.type">
<section *ngSwitchCase="'source-intake'" class="source-intake-panel">
<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 class="intake-form">
<input
class="input"
[class.is-danger]="url && !isValidSpotifyUrl"
type="text"
[(ngModel)]="url"
placeholder="Paste Spotify playlist or track URL"
/>
<button
class="button is-primary queue-button"
[class.is-loading]="(createLoading$ | async)?.isLoading"
(click)="download()"
[disabled]="!url || !isValidSpotifyUrl"
>
<i class="fa-solid fa-download"></i>
Queue
</button>
</div>
<label class="field-label">Archive destination</label>
<input
class="input"
type="text"
[(ngModel)]="archiveDestination"
(ngModelChange)="saveArchiveDestination()"
placeholder="Frontend-planned archive destination"
/>
<div class="truth-note">
<p>
Runtime download root:
{{ archiveListing?.root || archiveDestination || "loading..." }}
</p>
<p>Per-run destination routing: pending backend support</p>
</div>
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
Please enter a valid Spotify URL.
</p>
</section>
<section *ngSwitchCase="'queue-observatory'" class="metrics-panel">
<ng-container *ngIf="playlists$ | async as playlists">
<ng-container *ngIf="songs$ | async as songs">
<div class="metric-card">
<span>Spotify</span>
<strong [class.online]="spotifyConnected">{{
spotifyConnected ? "Connected" : "Disconnected"
}}</strong>
</div>
<div class="metric-card">
<span>Playlists</span>
<strong>{{ playlists.length }}</strong>
</div>
<div class="metric-card">
<span>Single Songs</span>
<strong>{{ songs.length }}</strong>
</div>
<div class="metric-card">
<span>Mode</span>
<strong>Archive run</strong>
</div>
</ng-container>
</ng-container>
</section>
<section *ngSwitchCase="'playlist-history'" class="history-panel">
<div class="section-toolbar">
<div>
<h2>Playlist History</h2>
<p>One collapsed row per playlist run.</p>
</div>
<div class="buttons has-addons">
<button
class="button is-outlined is-success"
title="Remove completed from list"
(click)="deleteCompleted()"
>
<i class="fa-solid fa-check"></i>
</button>
<button
class="button is-outlined is-danger"
title="Remove failed from list"
(click)="deleteFailed()"
>
<i class="fa-solid fa-xmark"></i>
</button>
</div>
</div>
<ng-container *ngIf="playlists$ | async as playlists">
<app-playlist-box
*ngFor="let playlist of playlists"
[playlist]="playlist"
></app-playlist-box>
<p *ngIf="playlists?.length === 0" class="empty-state">
No playlists
</p>
</ng-container>
</section>
<section *ngSwitchCase="'single-songs'" class="history-panel">
<div class="section-toolbar">
<div>
<h2>Single Songs</h2>
<p>Direct Spotify track queue entries.</p>
</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="empty-state">No songs</p>
</ng-container>
</section>
<section *ngSwitchCase="'archive-browser'" class="history-panel">
<div class="section-toolbar">
<div>
<h2>Archive Browser</h2>
<p>Root: {{ archiveListing?.root || "loading..." }}</p>
</div>
<button
class="button is-small reset-button"
type="button"
[class.is-loading]="archiveLoading"
(click)="refreshArchive()"
>
<i class="fa-solid fa-rotate"></i>
Refresh
</button>
</div>
<p *ngIf="archiveError" class="help is-danger">{{ archiveError }}</p>
<ng-container *ngIf="archiveListing as archive">
<div class="archive-summary">
<div>
<strong>{{ archiveFileCount() }}</strong>
<span>files</span>
</div>
<div>
<strong>{{ formatBytes(archiveTotalBytes()) }}</strong>
<span>total</span>
</div>
<div>
<strong>Read-only</strong>
<span>inspection</span>
</div>
</div>
<div class="archive-controls">
<input
class="input is-small"
type="search"
[(ngModel)]="archiveSearch"
placeholder="Search archive"
/>
<div class="select is-small">
<select
[(ngModel)]="archiveSort"
aria-label="Sort archive files"
>
<option
*ngFor="let sortMode of archiveSortModes"
[ngValue]="sortMode"
>
{{ sortMode }}
</option>
</select>
</div>
</div>
<p *ngIf="archiveFileCount() === 0" class="empty-state">
No downloaded files found.
</p>
<section
*ngFor="let group of archiveGroups()"
class="archive-group"
>
<h3>
<i class="fa-solid fa-folder"></i>
{{ group.folder }}
</h3>
<div *ngFor="let file of group.files" class="archive-file-card">
<i
class="fa-solid"
[ngClass]="
hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'
"
></i>
<div>
<strong>{{ file.name }}</strong>
<span>{{ folderPath(file) }}</span>
</div>
<small>{{ formatBytes(file.sizeBytes) }}</small>
<small>{{ formatModifiedAt(file.modifiedAt) }}</small>
</div>
</section>
</ng-container>
</section>
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
<ng-container
*ngIf="
selectedTrack$ | async as selectedTrack;
else noTrackSelected
"
>
<div class="section-toolbar">
<div>
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
</div>
<button
class="button is-small reset-button"
type="button"
(click)="clearSelectedTrack()"
>
<i class="fa-solid fa-arrow-left"></i>
Back
</button>
</div>
<ng-container
*ngIf="
selectedTrackTruth(selectedTrack) as trackTruth;
else noTrackTruth
"
>
<dl class="inspector-list">
<dt>Spotify URL</dt>
<dd>
<a [href]="selectedTrack.spotifyUrl" target="_blank">{{
selectedTrack.spotifyUrl || "none"
}}</a>
</dd>
<dt>Status</dt>
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
<dt>Download attempts</dt>
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
<dt>Error class</dt>
<dd>{{ errorClass(trackTruth) }}</dd>
<dt>Error summary</dt>
<dd>{{ errorSummary(trackTruth) }}</dd>
<dt>Error detail</dt>
<dd>
<details
*ngIf="errorDetail(trackTruth); else noErrorDetail"
class="truth-detail"
>
<summary>Show backend evidence</summary>
<pre>{{ errorDetail(trackTruth) }}</pre>
</details>
<ng-template #noErrorDetail>none</ng-template>
</dd>
</dl>
<div class="candidate-options">
<strong>Selected candidate</strong>
<ng-container
*ngIf="
selectedCandidate(trackTruth) as candidate;
else noSelectedCandidate
"
>
<dl class="candidate-card">
<dt>URL</dt>
<dd>
<a [href]="candidate.url" target="_blank">{{
candidate.url
}}</a>
</dd>
<dt>Title</dt>
<dd>{{ candidate.title || "unknown" }}</dd>
<dt>Author</dt>
<dd>{{ candidate.author || "unknown" }}</dd>
<dt>Score</dt>
<dd>{{ candidate.score ?? "unknown" }}</dd>
<dt>Reason</dt>
<dd>{{ candidate.reason || "unknown" }}</dd>
</dl>
</ng-container>
<ng-template #noSelectedCandidate>
<p class="empty-state compact">No selected candidate.</p>
</ng-template>
<strong
>Rejected candidates:
{{ rejectedCandidateCount(trackTruth) }}</strong
>
<div
*ngFor="let candidate of rejectedCandidates(trackTruth)"
class="candidate-card rejected-candidate-card"
>
<dl>
<dt>URL</dt>
<dd>
<a [href]="candidate.url" target="_blank">{{
candidate.url
}}</a>
</dd>
<dt>Title</dt>
<dd>{{ candidate.title || "unknown" }}</dd>
<dt>Author</dt>
<dd>{{ candidate.author || "unknown" }}</dd>
<dt>Score</dt>
<dd>{{ candidate.score ?? "unknown" }}</dd>
<dt>Reason</dt>
<dd>{{ candidate.reason || "unknown" }}</dd>
<dt>Rejection class</dt>
<dd>{{ candidate.rejectionClass || "unknown" }}</dd>
<dt>Rejection summary</dt>
<dd>{{ candidate.rejectionSummary || "unknown" }}</dd>
<dt>Rejected at</dt>
<dd>{{ candidate.rejectedAt || "unknown" }}</dd>
</dl>
</div>
<p
*ngIf="rejectedCandidateCount(trackTruth) === 0"
class="empty-state compact"
>
No rejected candidates.
</p>
</div>
</ng-container>
<ng-template #noTrackTruth>
<div class="placeholder-panel">
<i class="fa-solid fa-database"></i>
<p>
{{
operationsSnapshotError ||
"Waiting for backend operations truth."
}}
</p>
<button
class="button is-small reset-button"
type="button"
(click)="refreshOperationsSnapshot()"
>
Refresh truth
</button>
</div>
</ng-template>
</ng-container>
<ng-template #noTrackSelected>
<div class="placeholder-panel">
<i class="fa-solid fa-magnifying-glass-chart"></i>
<p>
Select a track row inside an expanded playlist to inspect
Spotify, YouTube, retry, and error details.
</p>
</div>
<ng-container *ngIf="allTracks$ | async as allTracks">
<button
*ngFor="let track of allTracks"
class="button is-small is-fullwidth reset-button"
type="button"
(click)="selectTrack(track)"
>
{{ track.artist }} - {{ track.name }}
</button>
</ng-container>
</ng-template>
</section>
</div>
<span
class="resize-handle"
title="Resize panel"
(pointerdown)="beginResize($event, panel)"
></span>
</article>
</section>
</main>
+324
View File
@@ -0,0 +1,324 @@
:host {
display: block;
min-height: 100vh;
background: #0d1515;
color: #eafff0;
}
.workspace-shell {
min-height: 100vh;
overflow: auto;
}
.workspace-titlebar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
justify-content: space-between;
gap: 24px;
min-height: 76px;
padding: 14px 24px;
border-bottom: 1px solid rgba(126, 255, 180, 0.22);
background: rgba(5, 11, 10, 0.96);
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.26);
}
.brand-line {
display: flex;
align-items: center;
gap: 10px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
color: #f3fff7;
font-size: 1.35rem;
font-weight: 900;
}
.brand-stack p {
margin-top: 4px;
color: #bcd1c4;
font-size: 0.86rem;
}
.version-tag {
background: #12221a;
color: #9fffc3;
}
.truth-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 0 12px;
border: 1px solid rgba(255, 214, 102, 0.35);
border-radius: 8px;
background: rgba(255, 214, 102, 0.12);
color: #ffe3a3;
font-weight: 800;
}
.truth-pill.connected {
border-color: rgba(159, 255, 195, 0.45);
background: rgba(159, 255, 195, 0.14);
color: #9fffc3;
}
.workspace-controls {
position: sticky;
top: 121px;
z-index: 18;
display: flex;
justify-content: space-between;
padding: 10px 24px;
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
background: rgba(8, 15, 15, 0.9);
}
.panel-picker {
display: flex;
gap: 10px;
}
.button {
border-radius: 8px;
font-weight: 800;
}
.reset-button {
background: #17211f;
border-color: rgba(188, 209, 196, 0.28);
color: #dceee4;
}
.workspace-canvas {
position: relative;
width: 1848px;
min-width: 100%;
height: 900px;
}
.workspace-panel {
position: absolute;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(126, 255, 180, 0.2);
background: rgba(13, 22, 21, 0.96);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.32);
}
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
min-height: 42px;
padding: 0 10px 0 12px;
border-bottom: 1px solid rgba(126, 255, 180, 0.16);
background: #14211d;
cursor: grab;
user-select: none;
touch-action: none;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
}
.panel-title strong {
overflow: hidden;
color: #effff4;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-light {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9fffc3;
}
.panel-close {
display: inline-flex;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid rgba(188, 209, 196, 0.24);
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: #dceee4;
}
.panel-content {
flex: 1;
overflow: auto;
padding: 14px;
}
.resize-handle {
position: absolute;
right: 0;
bottom: 0;
width: 20px;
height: 20px;
cursor: nwse-resize;
touch-action: none;
}
.resize-handle::after {
position: absolute;
right: 5px;
bottom: 5px;
width: 9px;
height: 9px;
border-right: 2px solid rgba(159, 255, 195, 0.7);
border-bottom: 2px solid rgba(159, 255, 195, 0.7);
content: "";
}
.source-intake-panel {
display: grid;
}
.auth-button {
width: max-content;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.92);
color: #07110c;
}
.intake-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.queue-button {
min-width: 112px;
}
.metrics-panel {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.metric-card {
padding: 14px;
border: 1px solid rgba(126, 255, 180, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
}
.metric-card span {
display: block;
color: #9db0a6;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.metric-card strong {
display: block;
margin-top: 8px;
color: #f1fff5;
font-size: 1.1rem;
}
.metric-card strong.online {
color: #9fffc3;
}
.history-panel {
display: grid;
}
.section-toolbar {
display: flex;
justify-content: space-between;
gap: 16px;
}
.section-toolbar h2 {
color: #f1fff5;
font-size: 1rem;
font-weight: 900;
}
.section-toolbar p,
.empty-state {
color: #9db0a6;
font-size: 0.84rem;
}
.empty-state {
padding: 24px;
text-align: center;
}
.placeholder-panel {
display: grid;
align-content: center;
justify-items: center;
height: 100%;
color: #bcd1c4;
text-align: center;
}
.placeholder-panel i {
color: #9fffc3;
font-size: 2rem;
}
.placeholder-panel p {
max-width: 34ch;
}
.truth-note,
.archive-row,
.inspector-list,
.rejected-list {
color: #bcd1c4;
overflow-wrap: anywhere;
}
.archive-row {
padding: 8px 0;
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
}
@media (max-width: 900px) {
.workspace-titlebar,
.workspace-controls {
position: static;
align-items: flex-start;
flex-direction: column;
padding: 14px;
}
.panel-picker {
width: 100%;
}
.panel-picker .select,
.panel-picker select,
.panel-picker .button,
.reset-button {
width: 100%;
}
.workspace-canvas {
width: 1848px;
}
}
+735 -18
View File
@@ -1,36 +1,387 @@
import {Component} from '@angular/core'; import { Component, OnDestroy } from '@angular/core';
import {FormsModule} from "@angular/forms"; import { FormsModule } from '@angular/forms';
import {CommonModule, NgFor} from "@angular/common"; import { CommonModule, NgFor } from '@angular/common';
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service"; import {
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component"; PlaylistService,
import {VersionService} from "./services/version.service"; PlaylistStatusEnum,
import {map} from "rxjs"; } from './services/playlist.service';
import { PlaylistBoxComponent } from './components/playlist-box/playlist-box.component';
import { VersionService } from './services/version.service';
import { map } from 'rxjs';
import { HttpClient } from '@angular/common/http';
import { ArchiveService } from './services/archive.service';
import { ArchiveFile, ArchiveListing } from './models/archive';
import { Track, TrackStatusEnum } from './models/track';
import { TrackService } from './services/track.service';
import {
OperationsSnapshot,
RejectedCandidateTruth,
SelectedCandidateTruth,
TrackTruth,
} from './models/operations-snapshot';
export type SpootyPanelType =
| 'source-intake'
| 'queue-observatory'
| 'playlist-history'
| 'single-songs'
| 'archive-browser'
| 'candidate-inspector';
export interface SpootyPanelInstance {
id: string;
type: SpootyPanelType;
title: string;
x: number;
y: number;
w: number;
h: number;
}
export interface SpootyWorkspaceState {
panels: SpootyPanelInstance[];
}
export type SpootyWorkspaceTab = 'intake' | 'archive' | 'diagnostics';
export type ArchiveSortMode = 'newest' | 'name' | 'size';
export interface SpootyWorkspaceTabsState {
activeTab: SpootyWorkspaceTab;
tabs: Record<SpootyWorkspaceTab, SpootyWorkspaceState>;
}
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
const GRID_SIZE = 12;
const MIN_PANEL_WIDTH = 260;
const MIN_PANEL_HEIGHT = 160;
const PANEL_TITLES: Record<SpootyPanelType, string> = {
'source-intake': 'Source Intake',
'queue-observatory': 'Queue Observatory',
'playlist-history': 'Playlist History',
'single-songs': 'Single Songs',
'archive-browser': 'Archive Browser',
'candidate-inspector': 'Candidate Inspector',
};
const WORKSPACE_TAB_LABELS: Record<SpootyWorkspaceTab, string> = {
intake: 'Intake',
archive: 'Archive',
diagnostics: 'Diagnostics',
};
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
intake: [
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
makePanel(
'queue-observatory',
568,
24,
520,
260,
'intake-queue-observatory',
),
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
],
archive: [
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
makePanel(
'queue-observatory',
960,
396,
420,
260,
'archive-queue-observatory',
),
],
diagnostics: [
makePanel(
'candidate-inspector',
24,
24,
620,
720,
'diagnostics-candidate-inspector',
),
makePanel(
'playlist-history',
680,
24,
620,
520,
'diagnostics-playlist-history',
),
makePanel(
'queue-observatory',
1320,
24,
420,
260,
'diagnostics-queue-observatory',
),
makePanel(
'archive-browser',
1320,
320,
420,
420,
'diagnostics-archive-browser',
),
],
};
export function makePanel(
type: SpootyPanelType,
x = 48,
y = 48,
w = 520,
h = 320,
id = `${type}-${Date.now()}`,
): SpootyPanelInstance {
return {
id,
type,
title: PANEL_TITLES[type],
x,
y,
w,
h,
};
}
export function defaultWorkspaceState(): SpootyWorkspaceState {
return defaultWorkspaceStateForTab('intake');
}
export function defaultWorkspaceStateForTab(
tab: SpootyWorkspaceTab,
): SpootyWorkspaceState {
return {
panels: DEFAULT_TAB_PANELS[tab].map((panel) => ({ ...panel })),
};
}
export function defaultWorkspaceTabsState(): SpootyWorkspaceTabsState {
return {
activeTab: 'intake',
tabs: {
intake: defaultWorkspaceStateForTab('intake'),
archive: defaultWorkspaceStateForTab('archive'),
diagnostics: defaultWorkspaceStateForTab('diagnostics'),
},
};
}
export function loadWorkspaceState(): SpootyWorkspaceState {
try {
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
if (!raw) {
return defaultWorkspaceState();
}
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
const panels = parsed.panels
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
.map((panel) => ({
...panel,
title: PANEL_TITLES[panel.type],
x: snap(panel.x),
y: snap(panel.y),
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
}));
return panels?.length ? { panels } : defaultWorkspaceState();
} catch {
return defaultWorkspaceState();
}
}
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
try {
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
return {
activeTab:
parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab]
? parsed.activeTab
: 'intake',
tabs: {
intake: sanitizeWorkspaceState(
parsed.tabs?.intake,
defaultWorkspaceStateForTab('intake'),
),
archive: sanitizeWorkspaceState(
parsed.tabs?.archive,
defaultWorkspaceStateForTab('archive'),
),
diagnostics: sanitizeWorkspaceState(
parsed.tabs?.diagnostics,
defaultWorkspaceStateForTab('diagnostics'),
),
},
};
}
return {
...defaultWorkspaceTabsState(),
tabs: {
...defaultWorkspaceTabsState().tabs,
intake: loadWorkspaceState(),
},
};
} catch {
return defaultWorkspaceTabsState();
}
}
function sanitizeWorkspaceState(
state: SpootyWorkspaceState | undefined,
fallback: SpootyWorkspaceState,
): SpootyWorkspaceState {
const panels = state?.panels
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
.map((panel) => ({
...panel,
title: PANEL_TITLES[panel.type],
x: snap(panel.x),
y: snap(panel.y),
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
}));
return panels?.length ? { panels } : fallback;
}
function snap(value: number): number {
return Math.round(value / GRID_SIZE) * GRID_SIZE;
}
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent], imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
templateUrl: './app.component.html', templateUrl: './app.component.html',
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
standalone: true, standalone: true,
}) })
export class AppComponent { export class AppComponent implements OnDestroy {
url = '';
url = '' private readonly spotifyUrlPattern =
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/; /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
private interaction?: {
mode: 'drag' | 'resize';
panelId: string;
startPointerX: number;
startPointerY: number;
startPanelX: number;
startPanelY: number;
startPanelW: number;
startPanelH: number;
};
private readonly operationsRefreshMs = 5000;
private operationsRefreshTimer?: number;
readonly panelTypes: SpootyPanelType[] = [
'source-intake',
'queue-observatory',
'playlist-history',
'single-songs',
'archive-browser',
'candidate-inspector',
];
readonly workspaceTabs: SpootyWorkspaceTab[] = [
'intake',
'archive',
'diagnostics',
];
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
selectedPanelType: SpootyPanelType = 'source-intake';
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
get isValidSpotifyUrl(): boolean { get isValidSpotifyUrl(): boolean {
return this.spotifyUrlPattern.test(this.url); return this.spotifyUrlPattern.test(this.url);
} }
createLoading$ = this.playlistService.createLoading$; createLoading$ = this.playlistService.createLoading$;
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack))); playlists$ = this.playlistService.all$.pipe(
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack))); map((items) => items.filter((item) => !item.isTrack)),
);
songs$ = this.playlistService.all$.pipe(
map((items) => items.filter((item) => item.isTrack)),
);
allTracks$ = this.trackService.all$;
selectedTrack$ = this.trackService.selectedTrack$;
version = this.versionService.getVersion(); version = this.versionService.getVersion();
spotifyConnected = false;
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || '';
archiveListing?: ArchiveListing;
archiveLoading = false;
archiveError = '';
archiveSearch = '';
archiveSort: ArchiveSortMode = 'newest';
trackStatuses = TrackStatusEnum;
operationsSnapshot?: OperationsSnapshot;
operationsSnapshotError = '';
get activeWorkspaceTab(): SpootyWorkspaceTab {
return this.workspaceTabsState.activeTab;
}
get workspace(): SpootyWorkspaceState {
return this.workspaceTabsState.tabs[this.activeWorkspaceTab];
}
constructor( constructor(
private readonly playlistService: PlaylistService, private readonly playlistService: PlaylistService,
private readonly versionService: VersionService, private readonly versionService: VersionService,
private readonly http: HttpClient,
private readonly archiveService: ArchiveService,
private readonly trackService: TrackService,
) { ) {
this.bootstrapAuthTokenFromUrl();
this.checkSpotifyStatus();
this.fetchPlaylists(); this.fetchPlaylists();
this.refreshArchive();
this.refreshOperationsSnapshot();
this.operationsRefreshTimer = window.setInterval(
() => this.refreshOperationsSnapshot(),
this.operationsRefreshMs,
);
}
ngOnDestroy(): void {
this.stopWorkspaceInteraction();
if (this.operationsRefreshTimer) {
window.clearInterval(this.operationsRefreshTimer);
}
}
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 {
@@ -38,6 +389,7 @@ export class AppComponent {
} }
download(): void { download(): void {
this.saveArchiveDestination();
this.url && this.playlistService.create(this.url); this.url && this.playlistService.create(this.url);
this.url = ''; this.url = '';
} }
@@ -49,4 +401,369 @@ export class AppComponent {
deleteFailed(): void { deleteFailed(): void {
this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error); this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error);
} }
saveArchiveDestination(): void {
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
}
refreshArchive(): void {
this.archiveLoading = true;
this.archiveError = '';
this.archiveService.list().subscribe({
next: (listing) => {
this.archiveListing = listing;
this.archiveDestination ||= listing.root;
this.archiveLoading = false;
},
error: () => {
this.archiveError = 'Archive listing is unavailable.';
this.archiveLoading = false;
},
});
}
refreshOperationsSnapshot(): void {
this.http.get<OperationsSnapshot>('/api/operations/snapshot').subscribe({
next: (snapshot) => {
this.operationsSnapshot = snapshot;
this.operationsSnapshotError = '';
},
error: () => {
this.operationsSnapshotError = 'Operations snapshot is unavailable.';
},
});
}
formatBytes(sizeBytes: number): string {
if (sizeBytes < 1024) {
return `${sizeBytes} B`;
}
if (sizeBytes < 1024 * 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`;
}
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`;
}
formatModifiedAt(modifiedAt: number): string {
return new Date(modifiedAt).toLocaleString();
}
trackStatusLabel(status?: TrackStatusEnum): string {
switch (status) {
case TrackStatusEnum.New:
return 'New';
case TrackStatusEnum.Searching:
return 'Searching';
case TrackStatusEnum.Queued:
return 'Queued';
case TrackStatusEnum.Downloading:
return 'Downloading';
case TrackStatusEnum.Completed:
return 'Completed';
case TrackStatusEnum.Error:
return 'Error';
default:
return 'Unknown';
}
}
selectTrack(track: Track): void {
this.trackService.select(track);
this.refreshOperationsSnapshot();
}
clearSelectedTrack(): void {
this.trackService.clearSelection();
}
selectedTrackTruth(track: Track): TrackTruth | undefined {
return this.findTrackTruth(track.id);
}
selectedCandidate(truth?: TrackTruth): SelectedCandidateTruth | undefined {
return truth?.selectedCandidate;
}
rejectedCandidates(truth?: TrackTruth): RejectedCandidateTruth[] {
return truth?.rejectedCandidates || [];
}
rejectedCandidateCount(truth?: TrackTruth): number {
return truth?.rejectedCandidateCount || 0;
}
errorClass(truth?: TrackTruth): string {
return truth?.errorClass || 'none';
}
errorSummary(truth?: TrackTruth): string {
return truth?.errorSummary || 'none';
}
errorDetail(truth?: TrackTruth): string {
return truth?.errorDetail || '';
}
private findTrackTruth(id: number): TrackTruth | undefined {
if (!this.operationsSnapshot) {
return undefined;
}
return [
...this.operationsSnapshot.active.searching,
...this.operationsSnapshot.active.downloading,
...this.operationsSnapshot.recentFailures,
...this.operationsSnapshot.recentTracks,
].find((track) => track.id === id);
}
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
return WORKSPACE_TAB_LABELS[tab];
}
switchWorkspaceTab(tab: SpootyWorkspaceTab): void {
this.workspaceTabsState = {
...this.workspaceTabsState,
activeTab: tab,
};
this.saveWorkspaceState();
}
resetLayout(): void {
this.setActiveWorkspace(
defaultWorkspaceStateForTab(this.activeWorkspaceTab),
);
}
resetAllLayouts(): void {
this.workspaceTabsState = defaultWorkspaceTabsState();
this.saveWorkspaceState();
}
filteredArchiveFiles(): ArchiveFile[] {
const files = this.archiveListing?.files || [];
const query = this.archiveSearch.trim().toLowerCase();
const filtered = query
? files.filter((file) =>
`${file.name} ${file.path}`.toLowerCase().includes(query),
)
: [...files];
return filtered.sort((a, b) => {
if (this.archiveSort === 'name') {
return a.name.localeCompare(b.name);
}
if (this.archiveSort === 'size') {
return b.sizeBytes - a.sizeBytes;
}
return b.modifiedAt - a.modifiedAt;
});
}
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
const groups = new Map<string, ArchiveFile[]>();
this.filteredArchiveFiles().forEach((file) => {
const folder = this.topLevelFolder(file);
groups.set(folder, [...(groups.get(folder) || []), file]);
});
return [...groups.entries()].map(([folder, files]) => ({ folder, files }));
}
archiveFileCount(): number {
return this.filteredArchiveFiles().length;
}
archiveTotalBytes(): number {
return this.filteredArchiveFiles().reduce(
(total, file) => total + file.sizeBytes,
0,
);
}
topLevelFolder(file: ArchiveFile): string {
return file.path.includes('/') ? file.path.split('/')[0] : 'Root';
}
folderPath(file: ArchiveFile): string {
const index = file.path.lastIndexOf('/');
return index === -1 ? 'Root' : file.path.slice(0, index);
}
hasFolder(file: ArchiveFile): boolean {
return file.path.includes('/');
}
getPanelTitle(type: SpootyPanelType): string {
return PANEL_TITLES[type];
}
addPanel(type: SpootyPanelType = this.selectedPanelType): void {
const offset = this.workspace.panels.length * GRID_SIZE;
this.setActiveWorkspace({
panels: [
...this.workspace.panels,
makePanel(type, 48 + offset, 72 + offset),
],
});
}
closePanel(panelId: string): void {
if (this.workspace.panels.length <= 1) {
return;
}
this.setActiveWorkspace({
panels: this.workspace.panels.filter((panel) => panel.id !== panelId),
});
}
movePanel(panelId: string, x: number, y: number): void {
this.updatePanel(panelId, {
x: Math.max(0, snap(x)),
y: Math.max(0, snap(y)),
});
}
resizePanel(panelId: string, w: number, h: number): void {
this.updatePanel(panelId, {
w: Math.max(MIN_PANEL_WIDTH, snap(w)),
h: Math.max(MIN_PANEL_HEIGHT, snap(h)),
});
}
beginDrag(event: PointerEvent, panel: SpootyPanelInstance): void {
event.preventDefault();
this.bringPanelToFront(panel.id);
this.interaction = {
mode: 'drag',
panelId: panel.id,
startPointerX: event.clientX,
startPointerY: event.clientY,
startPanelX: panel.x,
startPanelY: panel.y,
startPanelW: panel.w,
startPanelH: panel.h,
};
this.startWorkspaceInteraction();
}
beginResize(event: PointerEvent, panel: SpootyPanelInstance): void {
event.preventDefault();
event.stopPropagation();
this.bringPanelToFront(panel.id);
this.interaction = {
mode: 'resize',
panelId: panel.id,
startPointerX: event.clientX,
startPointerY: event.clientY,
startPanelX: panel.x,
startPanelY: panel.y,
startPanelW: panel.w,
startPanelH: panel.h,
};
this.startWorkspaceInteraction();
}
renderPanel(index: number, panel: SpootyPanelInstance): string {
return panel.id;
}
panelStyle(panel: SpootyPanelInstance): Record<string, string> {
return {
transform: `translate(${panel.x}px, ${panel.y}px)`,
width: `${panel.w}px`,
height: `${panel.h}px`,
};
}
private readonly handlePointerMove = (event: PointerEvent): void => {
if (!this.interaction) {
return;
}
const dx = event.clientX - this.interaction.startPointerX;
const dy = event.clientY - this.interaction.startPointerY;
if (this.interaction.mode === 'drag') {
this.movePanel(
this.interaction.panelId,
this.interaction.startPanelX + dx,
this.interaction.startPanelY + dy,
);
return;
}
this.resizePanel(
this.interaction.panelId,
this.interaction.startPanelW + dx,
this.interaction.startPanelH + dy,
);
};
private readonly handlePointerUp = (): void => {
this.stopWorkspaceInteraction();
};
private startWorkspaceInteraction(): void {
document.addEventListener('pointermove', this.handlePointerMove);
document.addEventListener('pointerup', this.handlePointerUp, {
once: true,
});
}
private stopWorkspaceInteraction(): void {
this.interaction = undefined;
document.removeEventListener('pointermove', this.handlePointerMove);
document.removeEventListener('pointerup', this.handlePointerUp);
}
private bringPanelToFront(panelId: string): void {
const panel = this.workspace.panels.find((item) => item.id === panelId);
if (!panel) {
return;
}
this.setActiveWorkspace({
panels: [
...this.workspace.panels.filter((item) => item.id !== panelId),
panel,
],
});
}
private updatePanel(
panelId: string,
changes: Partial<SpootyPanelInstance>,
): void {
this.setActiveWorkspace({
panels: this.workspace.panels.map((panel) =>
panel.id === panelId ? { ...panel, ...changes } : panel,
),
});
}
private setActiveWorkspace(workspace: SpootyWorkspaceState): void {
this.workspaceTabsState = {
...this.workspaceTabsState,
tabs: {
...this.workspaceTabsState.tabs,
[this.activeWorkspaceTab]: workspace,
},
};
this.saveWorkspaceState();
}
private saveWorkspaceState(): void {
localStorage.setItem(
WORKSPACE_TABS_STORAGE_KEY,
JSON.stringify(this.workspaceTabsState),
);
}
} }
@@ -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>&nbsp;
(click)="toggleCollapse(playlist.id)"
></i>&nbsp; <span class="history-label">{{ playlist.isTrack ? 'track' : 'playlist' }}</span>&nbsp;
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">{{playlist.error}}</span>
<ng-template #noErrorTemplate> <span *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>&nbsp; </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>&nbsp;</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>&nbsp;
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>&nbsp; <i class="fa-solid fa-repeat is-clickable hover-icon"
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>&nbsp; title="Retry failed tracks"
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>&nbsp; (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) { }
@@ -1,18 +1,21 @@
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async"> <a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async" (click)="select(track)">
<div> <div>
<span>{{ track.artist }} - {{ track.name }}</span>&nbsp; <span>{{ track.artist }} - {{ track.name }}</span>&nbsp;
<a [href]="track.spotifyUrl" <a [href]="track.spotifyUrl"
target="_blank" target="_blank"
(click)="$event.stopPropagation()"
class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded"> class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
<i class="fa-brands fa-spotify"></i> <i class="fa-brands fa-spotify"></i>
</a>&nbsp; </a>&nbsp;
<a [href]="track.youtubeUrl" <a [href]="track.youtubeUrl"
target="_blank" target="_blank"
(click)="$event.stopPropagation()"
class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded"> class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded">
<i class="fa-brands fa-youtube"></i> <i class="fa-brands fa-youtube"></i>
</a>&nbsp; </a>&nbsp;
<a *ngIf="track.status === trackStatuses.Completed" <a *ngIf="track.status === trackStatuses.Completed"
href="api/track/download/{{track.id}}" href="api/track/download/{{track.id}}"
(click)="$event.stopPropagation()"
class="has-text-info" class="has-text-info"
title="Download downloaded and locally saved file" title="Download downloaded and locally saved file"
download> download>
@@ -20,8 +23,8 @@
</a> </a>
</div> </div>
<div> <div>
<i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="retry(track.id)"></i>&nbsp; <i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="$event.stopPropagation(); retry(track.id)"></i>&nbsp;
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="delete(track.id)"></i>&nbsp; <i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="$event.stopPropagation(); delete(track.id)"></i>&nbsp;
<ng-container [ngSwitch]="track.status"> <ng-container [ngSwitch]="track.status">
<span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span> <span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span>
<span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span> <span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span>
@@ -30,4 +30,8 @@ export class TrackListComponent {
retry(id: number): void { retry(id: number): void {
this.service.retry(id); this.service.retry(id);
} }
select(track: Track): void {
this.service.select(track);
}
} }
+11
View File
@@ -0,0 +1,11 @@
export interface ArchiveFile {
name: string;
path: string;
sizeBytes: number;
modifiedAt: number;
}
export interface ArchiveListing {
root: string;
files: ArchiveFile[];
}
@@ -0,0 +1,61 @@
export interface SelectedCandidateTruth {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
}
export interface RejectedCandidateTruth {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
rejectionClass?: string;
rejectionSummary?: string;
rejectedAt?: string;
}
export interface TrackTruth {
id: number;
artist: string;
name: string;
status: string;
spotifyUrl?: string;
youtubeUrl?: string;
selectedCandidate?: SelectedCandidateTruth;
downloadAttemptCount?: number;
errorClass?: string;
errorSummary?: string;
errorDetail?: string;
rejectedCandidateCount: number;
rejectedCandidates: RejectedCandidateTruth[];
}
export interface OperationsSnapshot {
generatedAt: string;
runtime: {
operation: string;
spotifyConnected: boolean;
searchQueueDepth: number;
downloadQueueDepth: number;
activeSearches: number;
activeDownloads: number;
};
counters: {
playlists: number;
singleSongs: number;
tracks: number;
active: number;
completed: number;
failed: number;
retries: number;
};
active: {
searching: TrackTruth[];
downloading: TrackTruth[];
};
recentFailures: TrackTruth[];
recentTracks: TrackTruth[];
}
+4
View File
@@ -4,10 +4,14 @@ export interface Track {
name: string; name: string;
spotifyUrl: string; spotifyUrl: string;
youtubeUrl: string; youtubeUrl: string;
rejectedYoutubeUrls?: string[] | string;
downloadAttemptCount?: number;
status: TrackStatusEnum; status: TrackStatusEnum;
playlistId?: number; playlistId?: number;
error?: string; error?: string;
coverUrl?: string; coverUrl?: string;
durationMs?: number;
createdAt?: number;
} }
export enum TrackStatusEnum { export enum TrackStatusEnum {
@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ArchiveListing } from '../models/archive';
@Injectable({
providedIn: 'root'
})
export class ArchiveService {
constructor(private readonly http: HttpClient) {}
list(): Observable<ArchiveListing> {
return this.http.get<ArchiveListing>('/api/archive');
}
}
@@ -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 {
+31 -5
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import {createStore} from "@ngneat/elf"; import {createStore} from "@ngneat/elf";
import {deleteEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities"; import {deleteEntities, selectAllEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities";
import {Socket} from "ngx-socket-io"; import {Socket} from "ngx-socket-io";
import {map, Observable, tap} from "rxjs"; import {BehaviorSubject, map, Observable, tap} from "rxjs";
import {HttpClient} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {Track, TrackStatusEnum} from "../models/track"; import {Track, TrackStatusEnum} from "../models/track";
@@ -19,11 +19,16 @@ enum WsTrackOperation {
}) })
export class TrackService { export class TrackService {
private readonly selectedTrackSubject = new BehaviorSubject<Track | undefined>(undefined);
selectedTrack$ = this.selectedTrackSubject.asObservable();
private store = createStore( private store = createStore(
{ name: STORE_NAME }, { name: STORE_NAME },
withEntities<Track>(), withEntities<Track>(),
); );
all$ = this.store.pipe(selectAllEntities());
getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> { getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> {
return this.store.pipe( return this.store.pipe(
selectManyByPredicate((track) => track?.playlistId === id), selectManyByPredicate((track) => track?.playlistId === id),
@@ -57,12 +62,33 @@ 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();
}
select(track: Track): void {
this.selectedTrackSubject.next(track);
}
clearSelection(): void {
this.selectedTrackSubject.next(undefined);
} }
private initWsConnection(): void { private initWsConnection(): void {
this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track))); this.socket.on(WsTrackOperation.Update, (track: Track) => {
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id))); this.store.update(upsertEntities(track));
const selected = this.selectedTrackSubject.value;
if (selected?.id === track.id) {
this.selectedTrackSubject.next({...selected, ...track});
}
});
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => {
this.store.update(deleteEntities(id));
if (this.selectedTrackSubject.value?.id === id) {
this.selectedTrackSubject.next(undefined);
}
});
this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) => this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) =>
this.store.update(upsertEntities([{...track, playlistId}])) this.store.update(upsertEntities([{...track, playlistId}]))
); );
+1 -1
View File
@@ -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">
+167
View File
@@ -12,3 +12,170 @@ body {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.workspace-tabs {
position: sticky;
top: 76px;
z-index: 19;
display: flex;
gap: 6px;
padding: 8px 24px 0;
background: rgba(8, 15, 15, 0.94);
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
}
.workspace-tab {
min-width: 116px;
padding: 9px 14px;
border: 1px solid rgba(126, 255, 180, 0.18);
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: rgba(255, 255, 255, 0.04);
color: #bcd1c4;
font-weight: 800;
cursor: pointer;
}
.workspace-tab.active {
background: #14211d;
color: #9fffc3;
}
.archive-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.archive-summary div,
.archive-file-card {
border: 1px solid rgba(126, 255, 180, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.archive-summary div {
padding: 10px;
}
.archive-summary span,
.archive-file-card span,
.archive-file-card small {
display: block;
color: #9db0a6;
overflow-wrap: anywhere;
}
.archive-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.archive-group h3 {
margin: 12px 0 6px;
color: #f1fff5;
font-size: 0.9rem;
}
.archive-file-card {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
margin-bottom: 6px;
padding: 8px;
}
.archive-file-card i {
color: #9fffc3;
}
.inspector-list {
margin: 0;
}
.inspector-list dt,
.candidate-options strong {
color: #f1fff5;
font-weight: 800;
}
.inspector-list dd {
margin: 0 0 8px;
}
.candidate-options {
display: grid;
gap: 6px;
color: #bcd1c4;
overflow-wrap: anywhere;
}
.candidate-card {
display: block;
padding: 10px;
border: 1px solid rgba(126, 255, 180, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.candidate-card,
.candidate-card dl {
margin: 0;
}
.candidate-card dt {
color: #f1fff5;
font-size: 0.76rem;
font-weight: 900;
text-transform: uppercase;
}
.candidate-card dd {
margin: 0 0 8px;
color: #bcd1c4;
overflow-wrap: anywhere;
}
.candidate-card dd:last-child {
margin-bottom: 0;
}
.rejected-candidate-card {
border-color: rgba(255, 214, 102, 0.18);
}
.truth-detail summary {
color: #9fffc3;
cursor: pointer;
font-weight: 800;
}
.truth-detail pre {
margin-top: 8px;
padding: 10px;
border: 1px solid rgba(126, 255, 180, 0.14);
border-radius: 8px;
background: rgba(0, 0, 0, 0.22);
color: #dceee4;
font-size: 0.78rem;
overflow: auto;
white-space: pre-wrap;
}
.empty-state.compact {
padding: 10px;
}
@media (max-width: 900px) {
.workspace-tabs {
position: static;
padding: 8px 14px 0;
overflow-x: auto;
}
.archive-file-card {
grid-template-columns: 22px minmax(0, 1fr);
}
}