Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd5742f924 | ||
|
|
69d059b8a5 | ||
|
|
2750aa5c06 | ||
|
|
56534d1d33 | ||
|
|
18deaa2d90 | ||
|
|
66c71a7487 | ||
|
|
a41884442d | ||
|
|
01fe5bcaf8 | ||
|
|
8a58aa29d9 | ||
|
|
a71a47f76a | ||
|
|
d83b0e6e1e | ||
|
|
1f3131d99a | ||
|
|
c3d2f39b82 | ||
|
|
09bbffa6e8 | ||
|
|
38ca4d6c8d | ||
|
|
0cd5294944 | ||
|
|
5d20972c2d | ||
|
|
5dc428b148 | ||
|
|
be11bb94d7 | ||
|
|
f64cc97475 | ||
|
|
005e47bf6e | ||
|
|
9e83001b73 | ||
|
|
5f4d7c9c4e | ||
|
|
48517a5a85 | ||
|
|
ca388a8fc8 | ||
|
|
c1fd39c377 | ||
|
|
0034b28f1a | ||
|
|
a1772c7670 | ||
|
|
64160fe21a | ||
|
|
20632c6f9f | ||
|
|
4bb61f5ebe | ||
|
|
d1b040a8b1 | ||
|
|
abc63c0cec | ||
|
|
33f077ed1e | ||
|
|
5fb84af635 | ||
|
|
f9e4cf5d07 | ||
|
|
41f5a80ffb |
@@ -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
|
||||||
@@ -4,3 +4,6 @@
|
|||||||
**/*.sqlite
|
**/*.sqlite
|
||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
|
test-downloads/
|
||||||
|
|
||||||
|
spooty-config
|
||||||
|
|||||||
+148
-108
@@ -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
|
|
||||||
|
|||||||
+22
-10
@@ -6,15 +6,27 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM node:20.20.0-alpine
|
FROM node:20.20.0-alpine
|
||||||
WORKDIR /spooty
|
WORKDIR /spooty
|
||||||
COPY --from=builder /spooty/dist .
|
|
||||||
COPY --from=builder /spooty/src ./src
|
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
|
||||||
COPY --from=builder /spooty/package.json ./package.json
|
&& addgroup -S spooty \
|
||||||
COPY --from=builder /spooty/package-lock.json ./package-lock.json
|
&& adduser -S spooty -G spooty
|
||||||
COPY --from=builder /spooty/src/backend/.env.docker ./.env
|
|
||||||
RUN npm prune --production
|
COPY --from=builder --chown=spooty:spooty /spooty/dist .
|
||||||
RUN rm -rf src package.json package-lock.json
|
COPY --from=builder --chown=spooty:spooty /spooty/src ./src
|
||||||
RUN apk add --no-cache ffmpeg
|
COPY --from=builder --chown=spooty:spooty /spooty/package.json ./package.json
|
||||||
RUN apk add --no-cache redis
|
COPY --from=builder --chown=spooty:spooty /spooty/package-lock.json ./package-lock.json
|
||||||
RUN apk add --no-cache python3 py3-pip
|
COPY --from=builder --chown=spooty:spooty /spooty/src/backend/.env.docker ./.env
|
||||||
|
|
||||||
|
RUN npm prune --production \
|
||||||
|
&& rm -rf src package.json package-lock.json \
|
||||||
|
&& mkdir -p /spooty/backend/downloads /spooty/backend/config /spooty/config \
|
||||||
|
&& chown -R spooty:spooty /spooty
|
||||||
|
|
||||||
|
USER spooty
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
|
||||||
|
|
||||||
CMD ["node", "backend/main.js"]
|
CMD ["node", "backend/main.js"]
|
||||||
@@ -1,157 +1,286 @@
|
|||||||
[](https://hub.docker.com/r/raiper34/spooty)
|
# Jarri Spooty
|
||||||
[](https://hub.docker.com/r/raiper34/spooty)
|
|
||||||

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

|
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]
|

|
||||||
> 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.0.0
|
||||||
|
|
||||||
To fully use Spooty, you need to create an application in the Spotify Developer Dashboard:
|
This hardened branch focuses on:
|
||||||
|
|
||||||
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
|
- ChronoGit-style Workspace UI panels
|
||||||
2. Sign in with your Spotify account
|
- Large playlist support (>100 tracks)
|
||||||
3. Create a new application
|
- Spotify OAuth login flow
|
||||||
4. Note your `Client ID` and `Client Secret`
|
- Better YouTube pacing and throttling resistance
|
||||||
5. Configure the redirect URI to `http://127.0.0.1:3000/api/callback` (or the corresponding URL of your instance)
|
- Improved Docker deployment
|
||||||
|
- Safer credential handling
|
||||||
|
- More resilient cover-art embedding
|
||||||
|
- Better queue stability
|
||||||
|
- Deterministic YouTube fallback handling
|
||||||
|
- Explicit yt-dlp CLI execution
|
||||||
|
- Automatic failed-candidate rejection
|
||||||
|
- Improved age-gated video handling
|
||||||
|
- Persistent SQLite state across container restarts
|
||||||
|
- Deterministic Docker config persistence
|
||||||
|
- Improved operational observability
|
||||||
|
- Reduced unused dependency surface
|
||||||
|
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
||||||
|
- Workspace Archive Browser MVP with scoped read-only archive listing
|
||||||
|
- Workspace Candidate Inspector MVP for selected track details
|
||||||
|
- Frontend archive destination field for planned per-run routing
|
||||||
|
- Frontend Workspace tabs for Intake, Archive, and Diagnostics layouts
|
||||||
|
|
||||||
These credentials will be used by Spooty to access the Spotify API.
|
---
|
||||||
|
|
||||||
### Docker
|
# Features
|
||||||
|
|
||||||
Just run docker command or use docker compose configuration.
|
- Download Spotify playlists
|
||||||
For detailed configuration, see available [environment variables](#environment-variables).
|
- Download individual Spotify tracks
|
||||||
|
- Playlist auto-subscription support
|
||||||
|
- Automatic YouTube matching
|
||||||
|
- MP3 tagging and embedded cover art
|
||||||
|
- Docker deployment
|
||||||
|
- Queue-based download system
|
||||||
|
- Spotify OAuth integration
|
||||||
|
- Large playlist pagination support
|
||||||
|
- Download pacing controls
|
||||||
|
- YouTube cookie support
|
||||||
|
- Automatic YouTube retry/fallback handling
|
||||||
|
- Failed YouTube candidate rejection memory
|
||||||
|
- Deterministic yt-dlp error classification
|
||||||
|
- Hardened cover-art validation and embedding
|
||||||
|
- Explicit client-facing websocket payload shaping
|
||||||
|
- Draggable/resizable Workspace UI
|
||||||
|
- Archive Browser panel for downloaded file inspection
|
||||||
|
- Candidate Inspector panel for selected track diagnostics
|
||||||
|
- Frontend-persisted archive destination input
|
||||||
|
- Per-tab workspace layout presets
|
||||||
|
- Read-only archive search, sort, and grouped file inspection
|
||||||
|
|
||||||
#### Docker command
|
---
|
||||||
```shell
|
|
||||||
docker run -d -p 3000:3000 \
|
|
||||||
-v /path/to/downloads:/spooty/backend/downloads \
|
|
||||||
-v /path/to/cookies.txt:/spooty/config/cookies.txt \
|
|
||||||
-e SPOTIFY_CLIENT_ID=your_client_id \
|
|
||||||
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
|
|
||||||
raiper34/spooty:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Docker compose
|
# Workspace UI
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
spooty:
|
|
||||||
image: raiper34/spooty:latest
|
|
||||||
container_name: spooty
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- /path/to/downloads:/spooty/backend/downloads
|
|
||||||
- /path/to/cookies.txt:/spooty/config/cookies.txt
|
|
||||||
environment:
|
|
||||||
- SPOTIFY_CLIENT_ID=your_client_id
|
|
||||||
- SPOTIFY_CLIENT_SECRET=your_client_secret
|
|
||||||
# Configure other environment variables if needed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build from source
|
Jarri Spooty 3.0.0 introduces a draggable/resizable Workspace shell with frontend tab presets.
|
||||||
|
|
||||||
Spooty can be also build from source files on your own.
|
Workspace tabs:
|
||||||
|
|
||||||
#### Requirements
|
- Intake
|
||||||
- Node v20.20.0 (it is recommended to use `nvm` node version manager to install proper version of node)
|
- Archive
|
||||||
- Redis in memory cache
|
- Diagnostics
|
||||||
- Ffmpeg
|
|
||||||
- Python3
|
|
||||||
|
|
||||||
#### Process
|
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.
|
||||||
- install Node v20.20.0 using `nvm install` and use that node version `nvm use`
|
|
||||||
- from project root install all dependencies using `npm install`
|
Workspace panels:
|
||||||
- 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:
|
- Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
|
||||||
```
|
- Queue Observatory: Spotify, playlist, single-song, and archive-run 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, status, error, retry attempts, rejected candidates when available, and a Back/Clear selection action
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The Archive Browser uses:
|
||||||
|
|
||||||
|
GET /api/archive
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Candidate alternatives are limited to the selected YouTube URL and rejected YouTube URLs already stored for the track. Full scoring history is not persisted yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
sudo mkdir -p /etc/tokens
|
||||||
|
|
||||||
|
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOT'
|
||||||
SPOTIFY_CLIENT_ID=your_client_id
|
SPOTIFY_CLIENT_ID=your_client_id
|
||||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||||
```
|
EOT
|
||||||
- build source files `npm run build`
|
|
||||||
- built project will be stored in `dist` folder
|
|
||||||
- start server `npm run start`
|
|
||||||
|
|
||||||
### Environment variables
|
sudo chown root:$USER /etc/tokens/spotify.env
|
||||||
|
sudo chmod 640 /etc/tokens/spotify.env
|
||||||
|
~~~
|
||||||
|
|
||||||
Some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
|
Never commit this file.
|
||||||
|
|
||||||
Name | Default | Description |
|
---
|
||||||
-------------------------|---------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
||||||
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored |
|
|
||||||
FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application |
|
|
||||||
DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
|
|
||||||
FORMAT | `mp3` | Format of downloaded files ('aac', 'flac', 'mp3', 'm4a', 'opus', 'vorbis', 'wav', 'alac') |
|
|
||||||
QUALITY | undefined | Audio quality (0-9 VBR or specific bitrate) of downloaded files |
|
|
||||||
PORT | 3000 | Port of Spooty server |
|
|
||||||
REDIS_PORT | 6379 | Port of Redis server |
|
|
||||||
REDIS_HOST | localhost | Host of Redis server |
|
|
||||||
RUN_REDIS | false | Whenever Redis server should be started from backend (recommended for Docker environment) |
|
|
||||||
SPOTIFY_CLIENT_ID | your_client_id | Client ID of your Spotify application (required) |
|
|
||||||
SPOTIFY_CLIENT_SECRET | your_client_secret | Client Secret of your Spotify application (required) |
|
|
||||||
YT_DOWNLOADS_PER_MINUTE | 3 | Set the maximum number of YouTube downloads started per minute |
|
|
||||||
YT_COOKIES | | Browser name to automatically extract YouTube cookies from (e.g. `chrome`, `firefox`). Only works when running Spooty natively (not in Docker). See [below](#yt_cookies---browser-based-cookies-non-docker). |
|
|
||||||
YT_COOKIES_FILE | `./config/cookies.txt` | Path to a Netscape-format `cookies.txt` file. Recommended for Docker deployments. See [below](#yt_cookies_file---cookies-file-recommended-for-docker). |
|
|
||||||
|
|
||||||
### YouTube cookies
|
## 3. Export YouTube Cookies (Recommended)
|
||||||
|
|
||||||
YouTube may block or throttle downloads without authentication cookies. Spooty supports two ways to provide them — use the one that fits your setup.
|
YouTube increasingly rate-limits or age-gates anonymous downloads.
|
||||||
|
|
||||||
#### `YT_COOKIES` — browser-based cookies (non-Docker)
|
Export a Netscape-format `cookies.txt` from a logged-in browser session.
|
||||||
|
|
||||||
Set `YT_COOKIES` to the name of your browser and yt-dlp will automatically read cookies directly from it.
|
Recommended storage:
|
||||||
Supported values: `chrome`, `firefox`, `edge`, `safari`, `brave`, `opera`, `chromium`.
|
|
||||||
|
|
||||||
```
|
~~~bash
|
||||||
YT_COOKIES=chrome
|
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
|
||||||
|
~~~
|
||||||
|
|
||||||
> [!NOTE]
|
---
|
||||||
> This only works when Spooty runs on the same machine as your browser (i.e. not in Docker, where no browser is present).
|
|
||||||
|
|
||||||
#### `YT_COOKIES_FILE` — cookies file (recommended for Docker)
|
# Docker Run
|
||||||
|
|
||||||
Export your YouTube cookies as a Netscape `cookies.txt` file and provide its path. This is the recommended approach for Docker deployments.
|
~~~bash
|
||||||
|
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
|
||||||
|
~~~
|
||||||
|
|
||||||
**How to get your `cookies.txt` file:**
|
Open:
|
||||||
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:**
|
http://127.0.0.1:3000/?token=change_this_token
|
||||||
|
|
||||||
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.
|
Then:
|
||||||
|
|
||||||
> [!NOTE]
|
1. Click "Connect Spotify"
|
||||||
> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set.
|
2. Login to Spotify
|
||||||
|
3. Approve access
|
||||||
|
4. Paste playlist URL
|
||||||
|
5. Download
|
||||||
|
|
||||||
# ⚖️ License
|
---
|
||||||
[MIT](https://choosealicense.com/licenses/mit/)
|
|
||||||
|
# Deterministic YouTube Fallback Handling
|
||||||
|
|
||||||
|
The hardened branch now uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions.
|
||||||
|
|
||||||
|
This provides:
|
||||||
|
|
||||||
|
- Explicit stderr visibility
|
||||||
|
- Better Docker compatibility
|
||||||
|
- Deterministic retry handling
|
||||||
|
- Automatic failed-candidate rejection
|
||||||
|
- Better age-gated video handling
|
||||||
|
- Improved operational observability
|
||||||
|
|
||||||
|
If a YouTube candidate fails:
|
||||||
|
|
||||||
|
1. The failed URL is recorded
|
||||||
|
2. The candidate is rejected
|
||||||
|
3. A new YouTube search is performed
|
||||||
|
4. The next-best valid candidate is attempted automatically
|
||||||
|
|
||||||
|
This prevents infinite retry loops against dead or restricted videos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Queue Pacing
|
||||||
|
|
||||||
|
Aggressive YouTube access can trigger:
|
||||||
|
|
||||||
|
- HTTP 302 loops
|
||||||
|
- CAPTCHA
|
||||||
|
- temporary throttling
|
||||||
|
- incomplete downloads
|
||||||
|
|
||||||
|
Recommended safe pacing:
|
||||||
|
|
||||||
|
~~~bash
|
||||||
|
-e YT_SEARCH_DELAY_MS=7000
|
||||||
|
-e YT_DOWNLOADS_PER_MINUTE=6
|
||||||
|
~~~
|
||||||
|
|
||||||
|
The hardened branch also includes additional internal pacing and retry coordination to reduce:
|
||||||
|
|
||||||
|
- repeated failed candidate loops
|
||||||
|
- aggressive retry bursts
|
||||||
|
- queue collisions
|
||||||
|
- YouTube anti-bot triggers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Notes
|
||||||
|
|
||||||
|
Never commit:
|
||||||
|
|
||||||
|
- Spotify secrets
|
||||||
|
- OAuth tokens
|
||||||
|
- cookies.txt
|
||||||
|
- downloaded music
|
||||||
|
- local databases
|
||||||
|
- spooty-config/
|
||||||
|
|
||||||
|
Recommended `.gitignore` additions:
|
||||||
|
|
||||||
|
~~~gitignore
|
||||||
|
downloads/
|
||||||
|
config/
|
||||||
|
spooty-config/
|
||||||
|
*.sqlite
|
||||||
|
cookies.txt
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
~~~
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 686 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 396 KiB |
@@ -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`
|
||||||
|
- raw failure evidence from `TrackEntity.error`
|
||||||
|
- download retry count from `TrackEntity.downloadAttemptCount`
|
||||||
|
- rejected YouTube candidates 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. Today, selected candidate title, author, score, and reason are logged during search but are not persisted. Rejected candidates are persisted only as URL strings.
|
||||||
|
|
||||||
|
Future persistence work is required for:
|
||||||
|
|
||||||
|
- selected candidate title, author, score, and scoring reason
|
||||||
|
- full searched candidate history
|
||||||
|
- structured rejected candidate metadata beyond URL
|
||||||
|
- 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
|
||||||
Generated
+1894
-1986
File diff suppressed because it is too large
Load Diff
+8
-3
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "2.4.2",
|
"version": "3.0.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "2.4.2",
|
"version": "3.0.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",
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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,331 @@
|
|||||||
|
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,
|
||||||
|
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 } : {}),
|
||||||
|
...(typeof track.downloadAttemptCount === 'number'
|
||||||
|
? { downloadAttemptCount: track.downloadAttemptCount }
|
||||||
|
: {}),
|
||||||
|
...(error || {}),
|
||||||
|
rejectedCandidateCount: rejectedCandidates.length,
|
||||||
|
rejectedCandidates,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRejectedCandidates(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 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,61 @@
|
|||||||
|
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 interface TrackTruth {
|
||||||
|
id: number;
|
||||||
|
artist: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
spotifyUrl?: string;
|
||||||
|
youtubeUrl?: string;
|
||||||
|
downloadAttemptCount?: number;
|
||||||
|
errorClass?: ErrorClass;
|
||||||
|
errorSummary?: string;
|
||||||
|
errorDetail?: string;
|
||||||
|
rejectedCandidateCount: number;
|
||||||
|
rejectedCandidates: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
|
|||||||
import { SpotifyService } from './spotify.service';
|
import { SpotifyService } from './spotify.service';
|
||||||
import { YoutubeService } from './youtube.service';
|
import { YoutubeService } from './youtube.service';
|
||||||
import { SpotifyApiService } from './spotify-api.service';
|
import { SpotifyApiService } from './spotify-api.service';
|
||||||
|
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||||
controllers: [],
|
controllers: [SpotifyAuthController],
|
||||||
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||||
})
|
})
|
||||||
export class SharedModule {}
|
export class SharedModule {}
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { resolve, dirname } from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const fetch = require('isomorphic-unfetch');
|
const fetch = require('isomorphic-unfetch');
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const { getDetails } = require('spotify-url-info')(fetch);
|
const { getDetails } = require('spotify-url-info')(fetch);
|
||||||
|
|
||||||
|
interface StoredSpotifyUserToken {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
expires_at: number;
|
||||||
|
scope?: string;
|
||||||
|
token_type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpotifyApiService {
|
export class SpotifyApiService {
|
||||||
private readonly logger = new Logger(SpotifyApiService.name);
|
private readonly logger = new Logger(SpotifyApiService.name);
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
private tokenExpiry: number = 0;
|
private tokenExpiry = 0;
|
||||||
|
|
||||||
constructor() {}
|
|
||||||
|
|
||||||
private getPlaylistId(url: string): string {
|
private getPlaylistId(url: string): string {
|
||||||
try {
|
try {
|
||||||
@@ -51,13 +60,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,6 +340,85 @@ 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}`);
|
||||||
@@ -156,7 +426,7 @@ export class SpotifyApiService {
|
|||||||
const playlistId = this.getPlaylistId(spotifyUrl);
|
const playlistId = this.getPlaylistId(spotifyUrl);
|
||||||
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
||||||
|
|
||||||
const accessToken = await this.getAccessToken();
|
const accessToken = await this.getUserAccessToken();
|
||||||
|
|
||||||
const allTracks = [];
|
const allTracks = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
@@ -168,7 +438,7 @@ export class SpotifyApiService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
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}`,
|
||||||
@@ -181,6 +451,13 @@ export class SpotifyApiService {
|
|||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Spotify API error: ${response.status} ${errorText}`,
|
`Spotify API error: ${response.status} ${errorText}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new Error(
|
||||||
|
'Spotify API refused playlist item access with 403 Forbidden. Connect Spotify through /api/spotify/login and ensure the authenticated user can access this playlist.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,22 +472,24 @@ export class SpotifyApiService {
|
|||||||
const pageTracks = data.items
|
const pageTracks = data.items
|
||||||
.map(
|
.map(
|
||||||
(item: {
|
(item: {
|
||||||
track: {
|
item: {
|
||||||
id: string;
|
id: string;
|
||||||
name: any;
|
name: any;
|
||||||
artists: any[];
|
artists: any[];
|
||||||
preview_url: any;
|
preview_url: any;
|
||||||
album: { images: any[] };
|
album: { images: any[] };
|
||||||
|
duration_ms?: number;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
if (!item.track) return null;
|
if (!item.item) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.track.id,
|
id: item.item.id,
|
||||||
name: item.track.name,
|
name: item.item.name,
|
||||||
artist: item.track.artists.map((a) => a.name).join(', '),
|
artist: item.item.artists.map((a) => a.name).join(', '),
|
||||||
previewUrl: item.track.preview_url,
|
previewUrl: item.item.preview_url,
|
||||||
coverUrl: item.track.album?.images?.[0]?.url || null,
|
coverUrl: item.item.album?.images?.[0]?.url || null,
|
||||||
|
durationMs: item.item.duration_ms,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -240,4 +519,196 @@ export class SpotifyApiService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistLibrary(
|
||||||
|
spotifyUrl: string,
|
||||||
|
): Promise<{ name: string; image: string; tracks: any[] }> {
|
||||||
|
try {
|
||||||
|
this.logger.debug(`Getting artist library for ${spotifyUrl}`);
|
||||||
|
const artistId = this.getArtistId(spotifyUrl);
|
||||||
|
const accessToken = await this.getUserAccessToken();
|
||||||
|
|
||||||
|
const artistResponse = await fetch(
|
||||||
|
`https://api.spotify.com/v1/artists/${artistId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!artistResponse.ok) {
|
||||||
|
const errorText = await artistResponse.text();
|
||||||
|
throw new Error(`Failed to fetch artist metadata: ${artistResponse.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const artist = await artistResponse.json();
|
||||||
|
const albums = await this.getAllArtistAlbums(artistId, accessToken);
|
||||||
|
const tracks = await this.getTracksForAlbums(albums, accessToken, artist.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: `Artist Library: ${artist.name}`,
|
||||||
|
image: artist.images?.[0]?.url || '',
|
||||||
|
tracks,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get artist library: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAllArtistAlbums(artistId: string, accessToken: string): Promise<any[]> {
|
||||||
|
const includeGroups = process.env.SPOTIFY_ARTIST_INCLUDE_GROUPS || 'album,single';
|
||||||
|
const albumByKey = new Map<string, any>();
|
||||||
|
const artistAlbumPageLimit = 20;
|
||||||
|
let offset = 0;
|
||||||
|
let hasMoreAlbums = true;
|
||||||
|
|
||||||
|
while (hasMoreAlbums) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Fetching artist albums from Spotify API with offset ${offset}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const albumUrl = new URL(
|
||||||
|
`https://api.spotify.com/v1/artists/${artistId}/albums`,
|
||||||
|
);
|
||||||
|
albumUrl.searchParams.set('include_groups', includeGroups);
|
||||||
|
albumUrl.searchParams.set('offset', String(offset));
|
||||||
|
|
||||||
|
this.logger.debug(`Spotify artist albums URL: ${albumUrl.toString()}`);
|
||||||
|
|
||||||
|
const response = await fetch(albumUrl.toString(), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch artist albums: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
for (const album of data.items || []) {
|
||||||
|
const key = this.normalizeLibraryKey(`${album.name}|${album.release_date}|${album.total_tracks}`);
|
||||||
|
if (!albumByKey.has(key)) {
|
||||||
|
albumByKey.set(key, album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.next) {
|
||||||
|
hasMoreAlbums = false;
|
||||||
|
} else {
|
||||||
|
offset += artistAlbumPageLimit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const albums = [...albumByKey.values()];
|
||||||
|
this.logger.debug(`Retrieved ${albums.length} deduplicated artist albums/singles`);
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTracksForAlbums(
|
||||||
|
albums: any[],
|
||||||
|
accessToken: string,
|
||||||
|
artistName: string,
|
||||||
|
): Promise<any[]> {
|
||||||
|
const trackByKey = new Map<string, any>();
|
||||||
|
const maxTracks = Number(process.env.SPOTIFY_ARTIST_LIBRARY_MAX_TRACKS || 1000);
|
||||||
|
|
||||||
|
for (const album of albums) {
|
||||||
|
let offset = 0;
|
||||||
|
let hasMoreTracks = true;
|
||||||
|
|
||||||
|
while (hasMoreTracks) {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.spotify.com/v1/albums/${album.id}/tracks?limit=50&offset=${offset}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Failed to fetch album tracks: ${response.status} ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
for (const item of data.items || []) {
|
||||||
|
if (!item?.name || !item?.artists?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullTrack = await this.getTrackById(item.id, accessToken);
|
||||||
|
const isrc = fullTrack?.external_ids?.isrc;
|
||||||
|
const durationMs = fullTrack?.duration_ms || item.duration_ms;
|
||||||
|
const artist = item.artists.map((a) => a.name).join(', ');
|
||||||
|
const key = isrc
|
||||||
|
? `isrc:${isrc}`
|
||||||
|
: this.normalizeLibraryKey(`${artist}|${item.name}|${Math.round((durationMs || 0) / 2000)}`);
|
||||||
|
|
||||||
|
if (!trackByKey.has(key)) {
|
||||||
|
trackByKey.set(key, {
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
artist,
|
||||||
|
previewUrl: item.external_urls?.spotify || null,
|
||||||
|
coverUrl: album.images?.[0]?.url || null,
|
||||||
|
durationMs,
|
||||||
|
albumName: album.name,
|
||||||
|
primaryArtist: artistName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trackByKey.size >= maxTracks) {
|
||||||
|
this.logger.warn(`Artist library max track limit reached: ${maxTracks}`);
|
||||||
|
return [...trackByKey.values()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.next) {
|
||||||
|
hasMoreTracks = false;
|
||||||
|
} else {
|
||||||
|
offset += 50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tracks = [...trackByKey.values()];
|
||||||
|
this.logger.debug(`Retrieved ${tracks.length} deduplicated artist library tracks`);
|
||||||
|
return tracks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTrackById(trackId: string, accessToken: string): Promise<any> {
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.spotify.com/v1/tracks/${trackId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLibraryKey(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/&/g, ' and ')
|
||||||
|
.replace(/[^a-z0-9]+/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export class SpotifyService {
|
|||||||
return this.spotifyApiService.isTrackUrl(url);
|
return this.spotifyApiService.isTrackUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isArtistUrl(url: string): boolean {
|
||||||
|
return this.spotifyApiService.isArtistUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
async getTrackDetail(
|
async getTrackDetail(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; artist: string; image: string }> {
|
): Promise<{ name: string; artist: string; image: string }> {
|
||||||
@@ -61,6 +65,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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
'--no-playlist',
|
||||||
|
'--no-cache-dir',
|
||||||
|
'--no-cookies-from-browser',
|
||||||
|
'-f',
|
||||||
|
'bestaudio/best',
|
||||||
|
'--extract-audio',
|
||||||
|
'--audio-format',
|
||||||
|
format,
|
||||||
|
'--audio-quality',
|
||||||
|
quality,
|
||||||
|
'--add-header',
|
||||||
|
`User-Agent:${HEADERS['User-Agent']}`,
|
||||||
|
'-o',
|
||||||
output,
|
output,
|
||||||
...this.getCookiesOptions(),
|
];
|
||||||
headers: HEADERS,
|
|
||||||
jsRuntime: 'node',
|
if (cookiesFile) {
|
||||||
audioQuality: this.configService.get<string>('QUALITY'),
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TrackService } from './track.service';
|
import { TrackService } from './track.service';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TrackEntity } from './track.entity';
|
import { TrackEntity } from './track.entity';
|
||||||
@@ -20,33 +23,54 @@ export class TrackController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('playlist/:id')
|
@Get('playlist/:id')
|
||||||
getAllByPlaylist(@Param('id') playlistId: number): Promise<TrackEntity[]> {
|
getAllByPlaylist(
|
||||||
|
@Param('id', ParseIntPipe) playlistId: number,
|
||||||
|
): Promise<TrackEntity[]> {
|
||||||
return this.service.getAllByPlaylist(playlistId);
|
return this.service.getAllByPlaylist(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('download/:id')
|
@Get('download/:id')
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param('id') id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const track = await this.service.get(id);
|
const track = await this.service.get(id);
|
||||||
|
|
||||||
|
if (!track || !track.playlist) {
|
||||||
|
throw new NotFoundException('Track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.service.getFolderName(track, track.playlist);
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw new NotFoundException('Track file not found');
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = this.service.getTrackFileName(track);
|
const fileName = this.service.getTrackFileName(track);
|
||||||
const readStream = createReadStream(
|
|
||||||
this.service.getFolderName(track, track.playlist),
|
const readStream = createReadStream(filePath);
|
||||||
);
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}`,
|
'Content-Disposition':
|
||||||
|
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(readStream);
|
return new StreamableFile(readStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: number): Promise<void> {
|
remove(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Post('retry/:id')
|
||||||
retry(@Param('id') id: number): Promise<void> {
|
retry(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.retry(id);
|
return this.service.retry(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
rejectedYoutubeUrls?: string;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
downloadAttemptCount?: number;
|
||||||
|
|
||||||
@Column({ default: TrackStatusEnum.New })
|
@Column({ default: TrackStatusEnum.New })
|
||||||
status?: TrackStatusEnum;
|
status?: TrackStatusEnum;
|
||||||
|
|
||||||
@@ -36,6 +42,9 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
durationMs?: number;
|
||||||
|
|
||||||
@Column({ default: Date.now() })
|
@Column({ default: Date.now() })
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { UtilsService } from '../shared/utils.service';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { YoutubeService } from '../shared/youtube.service';
|
import { YoutubeService } from '../shared/youtube.service';
|
||||||
|
import { toSafeErrorMessage } from '../shared/errors/safe-error';
|
||||||
|
|
||||||
enum WsTrackOperation {
|
enum WsTrackOperation {
|
||||||
New = 'trackNew',
|
New = 'trackNew',
|
||||||
@@ -19,6 +20,10 @@ enum WsTrackOperation {
|
|||||||
Delete = 'trackDelete',
|
Delete = 'trackDelete',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
|
||||||
|
rejectedYoutubeUrls?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
@WebSocketGateway()
|
@WebSocketGateway()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrackService {
|
export class TrackService {
|
||||||
@@ -55,48 +60,117 @@ 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));
|
||||||
}
|
}
|
||||||
|
|
||||||
async retry(id: number): Promise<void> {
|
async retry(id: number): Promise<void> {
|
||||||
const track = await this.get(id);
|
const track = await this.get(id);
|
||||||
await this.trackSearchQueue.add('', track, { jobId: `id-${id}` });
|
|
||||||
await this.update(id, { ...track, status: TrackStatusEnum.New });
|
if (!track) {
|
||||||
|
this.logger.warn(`Cannot retry missing track ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||||
|
|
||||||
|
if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
|
||||||
|
rejectedUrls.push(track.youtubeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.update(id, {
|
||||||
|
...track,
|
||||||
|
youtubeUrl: null,
|
||||||
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||||
|
downloadAttemptCount: 0,
|
||||||
|
error: null,
|
||||||
|
status: TrackStatusEnum.New,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.enqueueSearch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOnYoutube(track: TrackEntity): Promise<void> {
|
async findOnYoutube(track: TrackEntity): Promise<void> {
|
||||||
if (!(await this.get(track.id))) {
|
const dbTrack = await this.get(track.id);
|
||||||
|
|
||||||
|
if (!dbTrack) {
|
||||||
|
this.logger.warn(`Skipping search for missing track ${track.id}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track = dbTrack;
|
||||||
|
|
||||||
await this.update(track.id, {
|
await this.update(track.id, {
|
||||||
...track,
|
...track,
|
||||||
status: TrackStatusEnum.Searching,
|
status: TrackStatusEnum.Searching,
|
||||||
});
|
});
|
||||||
let updatedTrack: TrackEntity;
|
let updatedTrack: TrackEntity;
|
||||||
try {
|
try {
|
||||||
const youtubeUrl = await this.youtubeService.findOnYoutubeOne(
|
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||||
|
|
||||||
|
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
|
||||||
track.artist,
|
track.artist,
|
||||||
track.name,
|
track.name,
|
||||||
|
track.durationMs,
|
||||||
|
rejectedUrls,
|
||||||
|
);
|
||||||
|
updatedTrack = {
|
||||||
|
...track,
|
||||||
|
youtubeUrl: youtubeMatch.url,
|
||||||
|
status: TrackStatusEnum.Queued,
|
||||||
|
};
|
||||||
|
this.logger.debug(
|
||||||
|
`YouTube match for track ${track.id}: ${youtubeMatch.title} by ${youtubeMatch.author} score=${youtubeMatch.score} reason=${youtubeMatch.reason}`,
|
||||||
);
|
);
|
||||||
updatedTrack = { ...track, youtubeUrl, status: TrackStatusEnum.Queued };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
updatedTrack = {
|
updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
error: String(err),
|
error: toSafeErrorMessage(err),
|
||||||
status: TrackStatusEnum.Error,
|
status: TrackStatusEnum.Error,
|
||||||
};
|
};
|
||||||
await this.update(track.id, updatedTrack);
|
await this.update(track.id, updatedTrack);
|
||||||
@@ -104,9 +178,55 @@ 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 +261,57 @@ 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) {
|
||||||
|
try {
|
||||||
await this.youtubeService.addImage(
|
await this.youtubeService.addImage(
|
||||||
folderName,
|
folderName,
|
||||||
coverUrl,
|
coverUrl,
|
||||||
track.name,
|
track.name,
|
||||||
track.artist,
|
track.artist,
|
||||||
);
|
);
|
||||||
|
} catch (imageError) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Cover art embed failed for track ${track.id}: ${toSafeErrorMessage(imageError)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
error = String(err);
|
error = toSafeErrorMessage(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||||
|
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
||||||
|
const maxAttempts = this.getMaxDownloadAttempts();
|
||||||
|
|
||||||
|
if (error && track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
|
||||||
|
rejectedUrls.push(track.youtubeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && nextAttemptCount < maxAttempts) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Download failed for track ${track.id}; rejecting current YouTube candidate and retrying search ` +
|
||||||
|
`(${nextAttemptCount}/${maxAttempts})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.update(track.id, {
|
||||||
|
...track,
|
||||||
|
youtubeUrl: null,
|
||||||
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||||
|
downloadAttemptCount: nextAttemptCount,
|
||||||
|
error: null,
|
||||||
|
status: TrackStatusEnum.New,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.enqueueSearch(track.id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTrack = {
|
const updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
||||||
...(error ? { error } : {}),
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||||
|
downloadAttemptCount: error ? nextAttemptCount : 0,
|
||||||
|
...(error ? { error } : { error: null }),
|
||||||
};
|
};
|
||||||
await this.update(track.id, updatedTrack);
|
await this.update(track.id, updatedTrack);
|
||||||
}
|
}
|
||||||
@@ -165,7 +320,8 @@ 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,4 +1,4 @@
|
|||||||
# SpootyFe
|
# Jarri Spooty Frontend
|
||||||
|
|
||||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,15 @@
|
|||||||
"maximumError": "4kB"
|
"maximumError": "4kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"optimization": {
|
||||||
|
"scripts": true,
|
||||||
|
"styles": {
|
||||||
|
"minify": true,
|
||||||
|
"inlineCritical": false
|
||||||
|
},
|
||||||
|
"fonts": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
+12
-13
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.4.2",
|
"version": "3.0.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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "SpootyFe",
|
"name": "Jarri Spooty",
|
||||||
"short_name": "SpootyFe",
|
"short_name": "Spooty",
|
||||||
"description": "Self-hosted Spotify downloader",
|
"description": "Self-hosted Spotify downloader",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,73 +1,322 @@
|
|||||||
<section class="hero is-primary">
|
<main class="workspace-shell">
|
||||||
<div class="hero-body">
|
<header class="workspace-titlebar">
|
||||||
<p class="title">
|
<div class="brand-stack">
|
||||||
|
<div class="brand-line">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
<span>Spooty</span>
|
<h1>Jarri Spooty</h1>
|
||||||
<span class="tag is-dark is-size-7">v{{version}}</span>
|
<span class="tag version-tag">v{{version}}</span>
|
||||||
</p>
|
</div>
|
||||||
<p class="subtitle">Self-hosted spotify downloader</p>
|
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="truth-strip">
|
||||||
|
<span class="truth-pill" [class.connected]="spotifyConnected">
|
||||||
|
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
||||||
|
{{ spotifyConnected ? 'Spotify connected' : 'Spotify disconnected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="workspace-tabs" aria-label="Workspace tabs">
|
||||||
|
<button
|
||||||
|
*ngFor="let tab of workspaceTabs"
|
||||||
|
class="workspace-tab"
|
||||||
|
type="button"
|
||||||
|
[class.active]="activeWorkspaceTab === tab"
|
||||||
|
(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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="workspace-canvas" aria-label="Jarri Spooty workspace">
|
||||||
<div class="hero-body">
|
<article
|
||||||
<div class="box">
|
*ngFor="let panel of workspace.panels; trackBy: renderPanel"
|
||||||
<p class="subtitle">Download</p>
|
class="workspace-panel"
|
||||||
<div>
|
[ngClass]="'panel-' + panel.type"
|
||||||
<div class="is-flex">
|
[ngStyle]="panelStyle(panel)"
|
||||||
<input class="input"
|
>
|
||||||
|
<header class="panel-header" (pointerdown)="beginDrag($event, panel)">
|
||||||
|
<div class="panel-title">
|
||||||
|
<span class="status-light"></span>
|
||||||
|
<strong>{{ panel.title }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="panel-close"
|
||||||
|
type="button"
|
||||||
|
title="Close panel"
|
||||||
|
[disabled]="workspace.panels.length <= 1"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="closePanel(panel.id)"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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"
|
[class.is-danger]="url && !isValidSpotifyUrl"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="url"
|
[(ngModel)]="url"
|
||||||
placeholder="Paste playlist/song/artist url"/>
|
placeholder="Paste Spotify playlist or track URL"
|
||||||
<button class="button is-primary"
|
/>
|
||||||
|
<button
|
||||||
|
class="button is-primary queue-button"
|
||||||
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
||||||
(click)="download()"
|
(click)="download()"
|
||||||
[disabled]="!url || !isValidSpotifyUrl"
|
[disabled]="!url || !isValidSpotifyUrl"
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-download"></i> Download
|
<i class="fa-solid fa-download"></i>
|
||||||
|
Queue
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">Please enter a valid Spotify URL (e.g. https://open.spotify.com/...)</p>
|
|
||||||
</div>
|
<label class="field-label">Archive destination</label>
|
||||||
</div>
|
<input
|
||||||
<hr>
|
class="input"
|
||||||
<div class="box">
|
type="text"
|
||||||
<div class="is-flex is-justify-content-space-between">
|
[(ngModel)]="archiveDestination"
|
||||||
<p class="subtitle">Playlists</p>
|
(ngModelChange)="saveArchiveDestination()"
|
||||||
<div class="buttons has-addons">
|
placeholder="Frontend-planned archive destination"
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
/>
|
||||||
<i class="fa-solid fa-check"></i>
|
|
||||||
</button>
|
<div class="truth-note">
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
<p>Runtime download root: {{ archiveListing?.root || archiveDestination || 'loading...' }}</p>
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<p>Per-run destination routing: pending backend support</p>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ng-container *ngIf="playlists$ | async as playlists">
|
|
||||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
|
||||||
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
<hr>
|
|
||||||
<div class="box">
|
|
||||||
<div class="is-flex is-justify-content-space-between">
|
|
||||||
<p class="subtitle">Songs</p>
|
|
||||||
<div class="buttons has-addons">
|
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
|
||||||
<i class="fa-solid fa-check"></i>
|
|
||||||
</button>
|
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ng-container *ngIf="songs$ | async as songs">
|
|
||||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
|
||||||
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
|
||||||
|
Please enter a valid Spotify URL.
|
||||||
|
</p>
|
||||||
</section>
|
</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>
|
||||||
|
|
||||||
|
<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</dt>
|
||||||
|
<dd>{{ selectedTrack.error || 'none' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="candidate-options">
|
||||||
|
<strong>Other options</strong>
|
||||||
|
<p *ngIf="selectedTrack.youtubeUrl">
|
||||||
|
Selected candidate:
|
||||||
|
<a [href]="selectedTrack.youtubeUrl" target="_blank">{{ selectedTrack.youtubeUrl }}</a>
|
||||||
|
</p>
|
||||||
|
<p>Rejected candidates: {{ rejectedUrls(selectedTrack).length }}</p>
|
||||||
|
<a *ngFor="let url of rejectedUrls(selectedTrack)" [href]="url" target="_blank">{{ url }}</a>
|
||||||
|
<p *ngIf="!selectedTrack.youtubeUrl && rejectedUrls(selectedTrack).length === 0" class="empty-state">
|
||||||
|
Candidate alternatives are not persisted yet. Future scoring history will appear here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,203 @@
|
|||||||
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 {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||||
import {VersionService} from "./services/version.service";
|
import {VersionService} from "./services/version.service";
|
||||||
import {map} from "rxjs";
|
import {map} from "rxjs";
|
||||||
|
import {HttpClient} from "@angular/common/http";
|
||||||
|
import {ArchiveService} from "./services/archive.service";
|
||||||
|
import {ArchiveFile, ArchiveListing} from "./models/archive";
|
||||||
|
import {Track, TrackStatusEnum} from "./models/track";
|
||||||
|
import {TrackService} from "./services/track.service";
|
||||||
|
|
||||||
|
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',
|
||||||
@@ -13,10 +206,32 @@ import {map} from "rxjs";
|
|||||||
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 = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
private readonly spotifyUrlPattern = /^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;
|
||||||
|
};
|
||||||
|
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);
|
||||||
@@ -24,13 +239,64 @@ export class AppComponent {
|
|||||||
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(map(items => items.filter(item => !item.isTrack)));
|
||||||
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
||||||
|
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;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.stopWorkspaceInteraction();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +304,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 +316,317 @@ 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedUrls(track: Track): string[] {
|
||||||
|
if (Array.isArray(track.rejectedYoutubeUrls)) {
|
||||||
|
return track.rejectedYoutubeUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!track.rejectedYoutubeUrls) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTrack(track: Track): void {
|
||||||
|
this.trackService.select(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelectedTrack(): void {
|
||||||
|
this.trackService.clearSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
(click)="toggleCollapse(playlist.id)"
|
|
||||||
></i>
|
<span class="history-label">{{ playlist.isTrack ? 'track' : 'playlist' }}</span>
|
||||||
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">{{playlist.error}}</span>
|
|
||||||
|
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">
|
||||||
|
{{playlist.error}}
|
||||||
|
</span>
|
||||||
|
|
||||||
<ng-template #noErrorTemplate>
|
<ng-template #noErrorTemplate>
|
||||||
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="playlist-actions" (click)="$event.stopPropagation()">
|
||||||
|
<span *ngIf="!playlist.error" class="progress-text">
|
||||||
|
{{trackCompletedCount$ | async}}/{{trackCount$ | async}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span *ngIf="(trackErrorCount$ | async) as errors" class="error-count" [class.is-hidden]="errors === 0">
|
||||||
|
{{errors}} failed
|
||||||
|
</span>
|
||||||
|
|
||||||
<a [href]="playlist.spotifyUrl"
|
<a [href]="playlist.spotifyUrl"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="has-text-black" title="Link to Spotify url to be download from">
|
class="has-text-black"
|
||||||
|
title="Open Spotify URL">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<ng-container *ngIf="!playlist.isTrack">
|
<ng-container *ngIf="!playlist.isTrack">
|
||||||
<i class="fa-solid is-clickable hover-icon"
|
<i class="fa-solid is-clickable hover-icon"
|
||||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||||
(click)="toggleActive(playlist.id, playlist.active)"
|
(click)="toggleActive(playlist.id, playlist.active)">
|
||||||
></i>
|
</i>
|
||||||
<i> </i>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>
|
|
||||||
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>
|
<i class="fa-solid fa-repeat is-clickable hover-icon"
|
||||||
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
title="Retry failed tracks"
|
||||||
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>
|
(click)="retryFailed(playlist.id)">
|
||||||
|
</i>
|
||||||
|
|
||||||
|
<i class="fa-solid fa-xmark is-clickable hover-icon"
|
||||||
|
title="Remove playlist from list"
|
||||||
|
(click)="delete(playlist.id)">
|
||||||
|
</i>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="progress-shell">
|
||||||
|
<div class="progress-fill" [style.width.%]="progressPercent$ | async"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="progress-caption">
|
||||||
|
<span>{{progressPercent$ | async}}% complete</span>
|
||||||
|
<span *ngIf="playlist.active">subscribed</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ng-container *ngIf="playlist.collapsed">
|
<ng-container *ngIf="playlist.collapsed">
|
||||||
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -2,19 +2,73 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-heading {
|
.playlist-card {
|
||||||
padding: 5px 10px;
|
margin: 8px 0;
|
||||||
border-radius: 10px;
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-heading {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover .hover-icon {
|
&:hover .hover-icon {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .hover-icon-reversed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.playlist-title {
|
||||||
margin: 5px 0;
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-label {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
padding: 2px 7px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-count {
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: #ff3860;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-shell {
|
||||||
|
height: 7px;
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #1db954, #48f59b);
|
||||||
|
transition: width 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-caption {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 12px 8px;
|
||||||
|
color: #667;
|
||||||
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Component, Input} from '@angular/core';
|
|||||||
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
||||||
import {TrackListComponent} from "../track-list/track-list.component";
|
import {TrackListComponent} from "../track-list/track-list.component";
|
||||||
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
||||||
import {Observable, map} from "rxjs";
|
import {Observable, combineLatest, map} from "rxjs";
|
||||||
import {Playlist} from "../../models/playlist";
|
import {Playlist} from "../../models/playlist";
|
||||||
|
|
||||||
const STATUS2CLASS = {
|
const STATUS2CLASS = {
|
||||||
@@ -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>
|
<span>{{ track.artist }} - {{ track.name }}</span>
|
||||||
<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>
|
</a>
|
||||||
<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>
|
</a>
|
||||||
<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>
|
<i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="$event.stopPropagation(); retry(track.id)"></i>
|
||||||
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="delete(track.id)"></i>
|
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="$event.stopPropagation(); delete(track.id)"></i>
|
||||||
<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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export interface ArchiveFile {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
modifiedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArchiveListing {
|
||||||
|
root: string;
|
||||||
|
files: ArchiveFile[];
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}]))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -12,3 +12,114 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user