Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422d372122 | ||
|
|
e744e5d713 | ||
|
|
3e96e5fd87 | ||
|
|
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 |
@@ -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
|
||||
# Local Netlify folder
|
||||
.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)
|
||||
- docs(readme): change redirect URI [`8162a2a`](https://github.com/Raiper34/spooty/commit/8162a2a21f762310f857dfc42cad53831769ed66)
|
||||
- Added deterministic YouTube candidate scoring
|
||||
- Added duration-aware YouTube matching
|
||||
- Added failed-candidate rejection memory
|
||||
- Added automatic retry/fallback handling
|
||||
- Added explicit YouTube candidate exclusion support
|
||||
- Added detailed YouTube candidate debug logging
|
||||
- Added explicit yt-dlp CLI execution
|
||||
- Added deterministic yt-dlp stderr capture
|
||||
- Added age-gated video detection
|
||||
- Added invalid-format detection
|
||||
- Added Docker-compatible cookie handling
|
||||
- Added temporary cookie-copy isolation
|
||||
- Added retry-aware queue coordination
|
||||
|
||||
#### [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)
|
||||
- docs(readme): remove demo image and gif from readme [`a2ac579`](https://github.com/Raiper34/spooty/commit/a2ac579854a6cbc95729d434c514fb2309f7a3bb)
|
||||
### Docker
|
||||
|
||||
#### [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)
|
||||
- fix(docker): add python3 dependency into docker image for yt download library [`46d7c66`](https://github.com/Raiper34/spooty/commit/46d7c6699bc8698896b22837e8507885677a71bd)
|
||||
- docs(readme): fix yt cookies section heading [`285578f`](https://github.com/Raiper34/spooty/commit/285578ff1ed1d7fe14d7ddd0d95a4b8079276e4e)
|
||||
- docs(readme): add docker versio badge into readme [`4be3743`](https://github.com/Raiper34/spooty/commit/4be3743f7b5a8019bd6ed0d2248f23a254a82603)
|
||||
- Added token-based frontend access mode
|
||||
- Added external secret handling recommendations
|
||||
- Added isolated Spotify credential storage
|
||||
- Added safer cookie handling flow
|
||||
- Improved Docker secret isolation documentation
|
||||
- Removed unused downloader dependencies to reduce transitive attack surface
|
||||
- Hardened download filename sanitization
|
||||
- Hardened remote cover-art fetching with protocol, MIME, timeout, and size checks
|
||||
- Added yt-dlp subprocess timeout and single-settlement handling
|
||||
- Shaped track websocket payloads to avoid leaking internal entity fields
|
||||
- Normalized visible project naming to Jarri Spooty
|
||||
|
||||
#### [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)
|
||||
- feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads [`4c05178`](https://github.com/Raiper34/spooty/commit/4c051782fd8808a808fe49fe1509ec0b0db9d05e)
|
||||
## Improved
|
||||
|
||||
#### [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)
|
||||
- fix(track): fix track missing artist and title tags [`b93993c`](https://github.com/Raiper34/spooty/commit/b93993c6cf034dccad3df08485d2126614874743)
|
||||
- style(gui): change gui primary to match spotify primary color [`5068fa8`](https://github.com/Raiper34/spooty/commit/5068fa8a4b724ef4a1060dc4d83cc80afee08bad)
|
||||
- Fixed Spotify playlist truncation at 100 tracks
|
||||
- Fixed incomplete playlist retrieval behavior
|
||||
- Fixed repeated retry loops against failed YouTube videos
|
||||
- Fixed queue deadlocks during retries
|
||||
- Fixed retry-button queue collisions
|
||||
- Fixed container database loss on restart
|
||||
- Fixed yt-dlp wrapper opacity issues
|
||||
- Fixed hidden yt-dlp stderr failures
|
||||
- Fixed multiple age-gated fallback failure paths
|
||||
- Fixed invalid YouTube candidate reuse
|
||||
- Fixed several Docker persistence edge cases
|
||||
|
||||
#### [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)
|
||||
- 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)
|
||||
## Added
|
||||
|
||||
#### [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)
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`16c57aa`](https://github.com/Raiper34/spooty/commit/16c57aaefd599f04b0a88723d55751f4ba27383a)
|
||||
# 2.0.0
|
||||
|
||||
#### [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)
|
||||
|
||||
#### [2.0.2](https://github.com/Raiper34/spooty/compare/2.0.1...2.0.2)
|
||||
|
||||
> 15 March 2025
|
||||
|
||||
- fix(ytdl): Upgrade ytdl package (automated) [`cf559eb`](https://github.com/Raiper34/spooty/commit/cf559eb41a2eb84cd29d4a732d68885f4ebcf348)
|
||||
|
||||
#### [2.0.1](https://github.com/Raiper34/spooty/compare/2.0.0...2.0.1)
|
||||
|
||||
> 23 February 2025
|
||||
|
||||
- build(ytdl): Upgrade ytdl package (automated) [`#20`](https://github.com/Raiper34/spooty/pull/20)
|
||||
- refactor(backend): queue system presented [`#5`](https://github.com/Raiper34/spooty/issues/5)
|
||||
- fix(utdl): upgrade ytdl package [`66b55b3`](https://github.com/Raiper34/spooty/commit/66b55b39432f2da71bd401fd65af6c0a207dd6c2)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`2ca165b`](https://github.com/Raiper34/spooty/commit/2ca165b59a76147439d4d00055b84102cf7a7317)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`9baf3b9`](https://github.com/Raiper34/spooty/commit/9baf3b9eb7b093029300614bc9c1a913a73fb003)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`b2fbb01`](https://github.com/Raiper34/spooty/commit/b2fbb01a7415c4027aa50dd2a1773d0b1e0dde3a)
|
||||
- docs(website): change default environment variable for REDIS_RUN var [`f539a22`](https://github.com/Raiper34/spooty/commit/f539a227575569ce5641f5dfec6fcf50598a491d)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`cbe2c9f`](https://github.com/Raiper34/spooty/commit/cbe2c9fbdc8322881b6ae3fd90586b7656d155ec)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`a55ebf6`](https://github.com/Raiper34/spooty/commit/a55ebf659974137f96a465b74a8a0c8b649b3399)
|
||||
- ci(github-actions): add automatic ytdl update into github actions [`66516e6`](https://github.com/Raiper34/spooty/commit/66516e6916af1b1e29df13d870f775f5c7ce43c2)
|
||||
|
||||
<!-- auto-changelog-above -->
|
||||
|
||||
### 2.0.0
|
||||
- refactor(backend): queue system presented
|
||||
|
||||
### 1.0.0
|
||||
- initial release
|
||||
- Initial release
|
||||
|
||||
+23
-11
@@ -6,15 +6,27 @@ RUN npm run build
|
||||
|
||||
FROM node:20.20.0-alpine
|
||||
WORKDIR /spooty
|
||||
COPY --from=builder /spooty/dist .
|
||||
COPY --from=builder /spooty/src ./src
|
||||
COPY --from=builder /spooty/package.json ./package.json
|
||||
COPY --from=builder /spooty/package-lock.json ./package-lock.json
|
||||
COPY --from=builder /spooty/src/backend/.env.docker ./.env
|
||||
RUN npm prune --production
|
||||
RUN rm -rf src package.json package-lock.json
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apk add --no-cache redis
|
||||
RUN apk add --no-cache python3 py3-pip
|
||||
|
||||
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
|
||||
&& addgroup -S spooty \
|
||||
&& adduser -S spooty -G spooty
|
||||
|
||||
COPY --from=builder --chown=spooty:spooty /spooty/dist .
|
||||
COPY --from=builder --chown=spooty:spooty /spooty/src ./src
|
||||
COPY --from=builder --chown=spooty:spooty /spooty/package.json ./package.json
|
||||
COPY --from=builder --chown=spooty:spooty /spooty/package-lock.json ./package-lock.json
|
||||
COPY --from=builder --chown=spooty:spooty /spooty/src/backend/.env.docker ./.env
|
||||
|
||||
RUN npm prune --production \
|
||||
&& rm -rf src package.json package-lock.json \
|
||||
&& mkdir -p /spooty/backend/downloads /spooty/backend/config /spooty/config \
|
||||
&& chown -R spooty:spooty /spooty
|
||||
|
||||
USER spooty
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "backend/main.js"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
CMD ["node", "backend/main.js"]
|
||||
|
||||
@@ -1,157 +1,286 @@
|
||||
[](https://hub.docker.com/r/raiper34/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)
|
||||
# Jarri Spooty
|
||||
|
||||

|
||||
# 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.
|
||||
Deterministic media ingestion workspace for Spotify metadata and YouTube archival.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Please do not use this tool for piracy! Download only music you own rights! Use this tool only on your responsibility.
|
||||

|
||||
|
||||
### Content
|
||||
- [🚀 Installation](#-installation)
|
||||
- [Spotify App Configuration](#spotify-app-configuration)
|
||||
- [Docker](#docker)
|
||||
- [Docker command](#docker-command)
|
||||
- [Docker compose](#docker-compose)
|
||||
- [Build from source](#build-from-source)
|
||||
- [Process](#requirements)
|
||||
- [Requirements](#process)
|
||||
- [Environment variables](#environment-variables)
|
||||
- [YouTube cookies](#youtube-cookies)
|
||||
- [⚖️ License](#-license)
|
||||
Spooty does not download audio from Spotify itself.
|
||||
It retrieves metadata from Spotify and locates matching audio on YouTube.
|
||||
|
||||
## 🚀 Installation
|
||||
Recommended and the easiest way how to start to use of Spooty is using docker.
|
||||
Deterministic Spotify metadata ingestion with duration-aware YouTube candidate scoring, paced queue execution, hardened Docker deployment, and local archival workflows.
|
||||
|
||||
### 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)
|
||||
2. Sign in with your Spotify account
|
||||
3. Create a new application
|
||||
4. Note your `Client ID` and `Client Secret`
|
||||
5. Configure the redirect URI to `http://127.0.0.1:3000/api/callback` (or the corresponding URL of your instance)
|
||||
- ChronoGit-style Workspace UI panels
|
||||
- Large playlist support (>100 tracks)
|
||||
- Spotify OAuth login flow
|
||||
- Better YouTube pacing and throttling resistance
|
||||
- Improved Docker deployment
|
||||
- Safer credential handling
|
||||
- More resilient cover-art embedding
|
||||
- Better queue stability
|
||||
- Deterministic YouTube fallback handling
|
||||
- Explicit yt-dlp CLI execution
|
||||
- Automatic failed-candidate rejection
|
||||
- Improved age-gated video handling
|
||||
- Persistent SQLite state across container restarts
|
||||
- Deterministic Docker config persistence
|
||||
- Improved operational observability
|
||||
- Reduced unused dependency surface
|
||||
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
||||
- 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.
|
||||
For detailed configuration, see available [environment variables](#environment-variables).
|
||||
- Download Spotify playlists
|
||||
- Download individual Spotify tracks
|
||||
- Playlist auto-subscription support
|
||||
- Automatic YouTube matching
|
||||
- MP3 tagging and embedded cover art
|
||||
- Docker deployment
|
||||
- Queue-based download system
|
||||
- Spotify OAuth integration
|
||||
- Large playlist pagination support
|
||||
- Download pacing controls
|
||||
- YouTube cookie support
|
||||
- Automatic YouTube retry/fallback handling
|
||||
- Failed YouTube candidate rejection memory
|
||||
- Deterministic yt-dlp error classification
|
||||
- Hardened cover-art validation and embedding
|
||||
- Explicit client-facing websocket payload shaping
|
||||
- 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
|
||||
```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
|
||||
```
|
||||
# Workspace UI
|
||||
|
||||
### 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
|
||||
- Node v20.20.0 (it is recommended to use `nvm` node version manager to install proper version of node)
|
||||
- Redis in memory cache
|
||||
- Ffmpeg
|
||||
- Python3
|
||||
- Intake
|
||||
- Archive
|
||||
- Diagnostics
|
||||
|
||||
#### Process
|
||||
- install Node v20.20.0 using `nvm install` and use that node version `nvm use`
|
||||
- from project root install all dependencies using `npm install`
|
||||
- copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see [environment variables](#environment-variables))
|
||||
- add your Spotify application credentials to the `.env` file:
|
||||
```
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
```
|
||||
- build source files `npm run build`
|
||||
- built project will be stored in `dist` folder
|
||||
- start server `npm run start`
|
||||
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.
|
||||
|
||||
### Environment variables
|
||||
Workspace panels:
|
||||
|
||||
Some behaviour and settings of Spooty can be configured using environment variables and `.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
|
||||
|
||||
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). |
|
||||
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.
|
||||
|
||||
### YouTube cookies
|
||||
The Archive Browser uses:
|
||||
|
||||
YouTube may block or throttle downloads without authentication cookies. Spooty supports two ways to provide them — use the one that fits your setup.
|
||||
GET /api/archive
|
||||
|
||||
#### `YT_COOKIES` — browser-based cookies (non-Docker)
|
||||
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.
|
||||
|
||||
Set `YT_COOKIES` to the name of your browser and yt-dlp will automatically read cookies directly from it.
|
||||
Supported values: `chrome`, `firefox`, `edge`, `safari`, `brave`, `opera`, `chromium`.
|
||||
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.
|
||||
|
||||
```
|
||||
YT_COOKIES=chrome
|
||||
```
|
||||
---
|
||||
|
||||
> [!NOTE]
|
||||
> This only works when Spooty runs on the same machine as your browser (i.e. not in Docker, where no browser is present).
|
||||
# Important Notice
|
||||
|
||||
#### `YT_COOKIES_FILE` — cookies file (recommended for Docker)
|
||||
Use this software responsibly.
|
||||
|
||||
Export your YouTube cookies as a Netscape `cookies.txt` file and provide its path. This is the recommended approach for Docker deployments.
|
||||
Only download music you legally own or are permitted to access.
|
||||
|
||||
**How to get your `cookies.txt` file:**
|
||||
1. Install a browser extension that exports cookies in Netscape format, e.g. [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) for Chrome or [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) for Firefox.
|
||||
2. Go to https://www.youtube.com and log in.
|
||||
3. Use the extension to export cookies for `youtube.com` and save the file as `cookies.txt`.
|
||||
The maintainers are not responsible for misuse.
|
||||
|
||||
**Docker usage:**
|
||||
---
|
||||
|
||||
Bind mount the `cookies.txt` file into the container and set `YT_COOKIES_FILE` to its path inside the container. See the [Environment variables](#environment-variables) section for details.
|
||||
# Supported URLs
|
||||
|
||||
> [!NOTE]
|
||||
> `YT_COOKIES` takes priority over `YT_COOKIES_FILE` if both are set.
|
||||
- Spotify playlists
|
||||
- Spotify tracks
|
||||
|
||||
# ⚖️ License
|
||||
[MIT](https://choosealicense.com/licenses/mit/)
|
||||
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_SECRET=your_client_secret
|
||||
EOT
|
||||
|
||||
sudo chown root:$USER /etc/tokens/spotify.env
|
||||
sudo chmod 640 /etc/tokens/spotify.env
|
||||
~~~
|
||||
|
||||
Never commit this file.
|
||||
|
||||
---
|
||||
|
||||
## 3. Export YouTube Cookies (Recommended)
|
||||
|
||||
YouTube increasingly rate-limits or age-gates anonymous downloads.
|
||||
|
||||
Export a Netscape-format `cookies.txt` from a logged-in browser session.
|
||||
|
||||
Recommended storage:
|
||||
|
||||
~~~bash
|
||||
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
|
||||
sudo chown root:$USER /etc/tokens/youtube.cookies.txt
|
||||
sudo chmod 640 /etc/tokens/youtube.cookies.txt
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Docker Run
|
||||
|
||||
~~~bash
|
||||
docker run --rm -p 3000:3000 \
|
||||
--env-file /etc/tokens/spotify.env \
|
||||
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e SPOOTY_AUTH_TOKEN=change_this_token \
|
||||
-e YT_SEARCH_DELAY_MS=7000 \
|
||||
-e YT_DOWNLOADS_PER_MINUTE=6 \
|
||||
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt \
|
||||
-v "$PWD/downloads:/spooty/backend/downloads" \
|
||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||
jarri-spooty:local
|
||||
~~~
|
||||
|
||||
Open:
|
||||
|
||||
http://127.0.0.1:3000/?token=change_this_token
|
||||
|
||||
Then:
|
||||
|
||||
1. Click "Connect Spotify"
|
||||
2. Login to Spotify
|
||||
3. Approve access
|
||||
4. Paste playlist URL
|
||||
5. Download
|
||||
|
||||
---
|
||||
|
||||
# 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`
|
||||
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
|
||||
- raw failure evidence from `TrackEntity.error`
|
||||
- download retry count from `TrackEntity.downloadAttemptCount`
|
||||
- rejected YouTube candidate explanations from `TrackEntity.rejectedYoutubeCandidatesJson`
|
||||
- legacy rejected YouTube candidate URLs from `TrackEntity.rejectedYoutubeUrls`
|
||||
- playlist and single-song counts from `PlaylistEntity.isTrack`
|
||||
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
|
||||
- Spotify connection presence from the stored Spotify user token
|
||||
|
||||
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidate metadata is persisted when a selected candidate is rejected by manual retry or download failure. Older rows and URL-only cases fall back to rejected candidate objects containing only `url`.
|
||||
|
||||
Future persistence work is required for:
|
||||
|
||||
- full searched candidate history
|
||||
- structured error class fields stored at write time instead of classified from the persisted raw error string
|
||||
- operation attempt history across search and download phases
|
||||
Generated
+1838
-1987
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"name": "spooty",
|
||||
"workspaces": [
|
||||
"src/backend",
|
||||
@@ -17,8 +17,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"changelog": "auto-changelog -p",
|
||||
"release": "release-it",
|
||||
"commit": "cz",
|
||||
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
|
||||
"commit": "cz"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@release-it/bumper": "^7.0.1",
|
||||
|
||||
@@ -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"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -31,18 +31,13 @@
|
||||
"@nestjs/serve-static": "^4.0.2",
|
||||
"@nestjs/typeorm": "^10.0.2",
|
||||
"@nestjs/websockets": "^10.3.10",
|
||||
"@types/fluent-ffmpeg": "^2.1.24",
|
||||
"@types/yt-search": "^2.10.3",
|
||||
"bullmq": "^5.31.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"isomorphic-unfetch": "^4.0.2",
|
||||
"node-id3": "^0.2.9",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "7.8.0",
|
||||
"spotify-url-info": "^3.2.18",
|
||||
"sqlite3": "^5.1.7",
|
||||
"yt-search": "^2.12.1",
|
||||
"ytdlp-nodejs": "^3.4.4"
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
|
||||
@@ -2,10 +2,13 @@ import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor() {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return 'ONLINE';
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
getHealth(): { status: string } {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { BullModule } from '@nestjs/bullmq';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
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({
|
||||
imports: [
|
||||
@@ -48,14 +50,27 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: 60,
|
||||
limit: Number(process.env.API_THROTTLE_LIMIT || 600),
|
||||
},
|
||||
]),
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
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: {
|
||||
host: configService.get<string>(EnvironmentEnum.REDIS_HOST),
|
||||
@@ -66,6 +81,8 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
}),
|
||||
TrackModule,
|
||||
PlaylistModule,
|
||||
ArchiveModule,
|
||||
OperationsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,4 +12,6 @@ export enum EnvironmentEnum {
|
||||
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
|
||||
CORS_ORIGIN = 'CORS_ORIGIN',
|
||||
NODE_ENV = 'NODE_ENV',
|
||||
SPOTIFY_REDIRECT_URI = 'SPOTIFY_REDIRECT_URI',
|
||||
SPOTIFY_TOKEN_PATH = 'SPOTIFY_TOKEN_PATH',
|
||||
}
|
||||
|
||||
+11
-2
@@ -4,7 +4,7 @@ import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||
import { AppModule } from './app.module';
|
||||
import * as fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import helmet from 'helmet';
|
||||
import { EnvironmentEnum } from './environmentEnum';
|
||||
|
||||
@@ -27,7 +27,16 @@ async function bootstrap() {
|
||||
try {
|
||||
// Convenience mode for the single-container Docker image.
|
||||
// Hardened deployments should prefer an external Redis service.
|
||||
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`);
|
||||
const redisProcess = spawn(
|
||||
'redis-server',
|
||||
['--port', String(process.env.REDIS_PORT || 6379)],
|
||||
{
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
|
||||
redisProcess.unref();
|
||||
} catch (e) {
|
||||
console.log('Unable to run redis server from app');
|
||||
console.log(e);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { OperationsService } from './operations.service';
|
||||
import { OperationsSnapshot } from './operations.types';
|
||||
|
||||
@Controller('operations')
|
||||
export class OperationsController {
|
||||
constructor(private readonly operationsService: OperationsService) {}
|
||||
|
||||
@Get('snapshot')
|
||||
getSnapshot(): Promise<OperationsSnapshot> {
|
||||
return this.operationsService.getSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { PlaylistEntity } from '../playlist/playlist.entity';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { TrackEntity } from '../track/track.entity';
|
||||
import { OperationsController } from './operations.controller';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([PlaylistEntity, TrackEntity]),
|
||||
BullModule.registerQueue(
|
||||
{ name: 'track-search-processor' },
|
||||
{ name: 'track-download-processor' },
|
||||
),
|
||||
SharedModule,
|
||||
],
|
||||
controllers: [OperationsController],
|
||||
providers: [OperationsService],
|
||||
})
|
||||
export class OperationsModule {}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { In, MoreThan, Repository } from 'typeorm';
|
||||
import { PlaylistEntity } from '../playlist/playlist.entity';
|
||||
import { SpotifyApiService } from '../shared/spotify-api.service';
|
||||
import { TrackEntity, TrackStatusEnum } from '../track/track.entity';
|
||||
import {
|
||||
ErrorClass,
|
||||
FailureTruth,
|
||||
OperationsSnapshot,
|
||||
RejectedCandidateTruth,
|
||||
SpootyOperation,
|
||||
TrackTruth,
|
||||
} from './operations.types';
|
||||
|
||||
interface ClassifiedError {
|
||||
errorClass: ErrorClass;
|
||||
errorSummary: string;
|
||||
errorDetail: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(
|
||||
@InjectRepository(TrackEntity)
|
||||
private readonly trackRepository: Repository<TrackEntity>,
|
||||
@InjectRepository(PlaylistEntity)
|
||||
private readonly playlistRepository: Repository<PlaylistEntity>,
|
||||
@InjectQueue('track-download-processor')
|
||||
private readonly trackDownloadQueue: Queue,
|
||||
@InjectQueue('track-search-processor')
|
||||
private readonly trackSearchQueue: Queue,
|
||||
private readonly spotifyApiService: SpotifyApiService,
|
||||
) {}
|
||||
|
||||
async getSnapshot(): Promise<OperationsSnapshot> {
|
||||
const [
|
||||
spotifyConnected,
|
||||
searchQueueCounts,
|
||||
downloadQueueCounts,
|
||||
activeSearchJobs,
|
||||
activeDownloadJobs,
|
||||
playlists,
|
||||
singleSongs,
|
||||
tracks,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
retries,
|
||||
searchingTracks,
|
||||
downloadingTracks,
|
||||
recentFailures,
|
||||
recentTracks,
|
||||
] = await Promise.all([
|
||||
this.spotifyApiService.hasUserToken(),
|
||||
this.trackSearchQueue.getJobCounts('waiting', 'delayed', 'active'),
|
||||
this.trackDownloadQueue.getJobCounts('waiting', 'delayed', 'active'),
|
||||
this.trackSearchQueue.getJobs('active', 0, 100, true),
|
||||
this.trackDownloadQueue.getJobs('active', 0, 100, true),
|
||||
this.playlistRepository.countBy({ isTrack: false }),
|
||||
this.playlistRepository.countBy({ isTrack: true }),
|
||||
this.trackRepository.count(),
|
||||
this.trackRepository.countBy({
|
||||
status: In([
|
||||
TrackStatusEnum.Searching,
|
||||
TrackStatusEnum.Queued,
|
||||
TrackStatusEnum.Downloading,
|
||||
]),
|
||||
}),
|
||||
this.trackRepository.countBy({ status: TrackStatusEnum.Completed }),
|
||||
this.trackRepository.countBy({ status: TrackStatusEnum.Error }),
|
||||
this.trackRepository.countBy({ downloadAttemptCount: MoreThan(0) }),
|
||||
this.trackRepository.find({
|
||||
where: { status: TrackStatusEnum.Searching },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
}),
|
||||
this.trackRepository.find({
|
||||
where: { status: TrackStatusEnum.Downloading },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
}),
|
||||
this.trackRepository.find({
|
||||
where: { status: TrackStatusEnum.Error },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
}),
|
||||
this.trackRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeSearchTracks = await this.mergeTracksFromActiveJobs(
|
||||
searchingTracks,
|
||||
activeSearchJobs,
|
||||
);
|
||||
const activeDownloadTracks = await this.mergeTracksFromActiveJobs(
|
||||
downloadingTracks,
|
||||
activeDownloadJobs,
|
||||
);
|
||||
|
||||
const searchQueueDepth =
|
||||
(searchQueueCounts.waiting || 0) + (searchQueueCounts.delayed || 0);
|
||||
const downloadQueueDepth =
|
||||
(downloadQueueCounts.waiting || 0) + (downloadQueueCounts.delayed || 0);
|
||||
const activeSearches = activeSearchJobs.length;
|
||||
const activeDownloads = activeDownloadJobs.length;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtime: {
|
||||
operation: this.getOperation({
|
||||
activeSearches,
|
||||
activeDownloads,
|
||||
searchQueueDepth,
|
||||
downloadQueueDepth,
|
||||
failed,
|
||||
retries,
|
||||
}),
|
||||
spotifyConnected,
|
||||
searchQueueDepth,
|
||||
downloadQueueDepth,
|
||||
activeSearches,
|
||||
activeDownloads,
|
||||
},
|
||||
counters: {
|
||||
playlists,
|
||||
singleSongs,
|
||||
tracks,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
retries,
|
||||
},
|
||||
active: {
|
||||
searching: activeSearchTracks.map((track) => this.toTrackTruth(track)),
|
||||
downloading: activeDownloadTracks.map((track) =>
|
||||
this.toTrackTruth(track),
|
||||
),
|
||||
},
|
||||
recentFailures: recentFailures.map((track) => this.toTrackTruth(track)),
|
||||
recentTracks: recentTracks.map((track) => this.toTrackTruth(track)),
|
||||
};
|
||||
}
|
||||
|
||||
private async mergeTracksFromActiveJobs(
|
||||
tracks: TrackEntity[],
|
||||
jobs: Job[],
|
||||
): Promise<TrackEntity[]> {
|
||||
const knownIds = new Set(tracks.map((track) => track.id).filter(Boolean));
|
||||
const missingIds = jobs
|
||||
.map((job) => Number(job.data?.id))
|
||||
.filter((id) => Number.isInteger(id) && !knownIds.has(id));
|
||||
|
||||
if (!missingIds.length) {
|
||||
return tracks;
|
||||
}
|
||||
|
||||
const activeJobTracks = await this.trackRepository.find({
|
||||
where: { id: In(missingIds) },
|
||||
});
|
||||
|
||||
return [...tracks, ...activeJobTracks];
|
||||
}
|
||||
|
||||
private toTrackTruth(track: TrackEntity): TrackTruth | FailureTruth {
|
||||
const rejectedCandidates = this.parseRejectedCandidates(track);
|
||||
const error = this.classifyError(track.error);
|
||||
|
||||
return {
|
||||
id: track.id,
|
||||
artist: track.artist,
|
||||
name: track.name,
|
||||
status: TrackStatusEnum[track.status] || String(track.status),
|
||||
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
|
||||
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
|
||||
...(track.youtubeUrl
|
||||
? {
|
||||
selectedCandidate: {
|
||||
url: track.youtubeUrl,
|
||||
...(track.selectedYoutubeTitle
|
||||
? { title: track.selectedYoutubeTitle }
|
||||
: {}),
|
||||
...(track.selectedYoutubeAuthor
|
||||
? { author: track.selectedYoutubeAuthor }
|
||||
: {}),
|
||||
...(typeof track.selectedYoutubeScore === 'number'
|
||||
? { score: track.selectedYoutubeScore }
|
||||
: {}),
|
||||
...(track.selectedYoutubeReason
|
||||
? { reason: track.selectedYoutubeReason }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(typeof track.downloadAttemptCount === 'number'
|
||||
? { downloadAttemptCount: track.downloadAttemptCount }
|
||||
: {}),
|
||||
...(error || {}),
|
||||
rejectedCandidateCount: rejectedCandidates.length,
|
||||
rejectedCandidates,
|
||||
};
|
||||
}
|
||||
|
||||
private parseRejectedCandidates(
|
||||
track: TrackEntity,
|
||||
): RejectedCandidateTruth[] {
|
||||
const structured = this.parseRejectedCandidateJson(track);
|
||||
|
||||
if (structured.length) {
|
||||
return structured;
|
||||
}
|
||||
|
||||
if (!track.rejectedYoutubeUrls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||
return Array.isArray(parsed)
|
||||
? parsed
|
||||
.filter((item) => typeof item === 'string')
|
||||
.map((url) => ({ url }))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private parseRejectedCandidateJson(
|
||||
track: TrackEntity,
|
||||
): RejectedCandidateTruth[] {
|
||||
if (!track.rejectedYoutubeCandidatesJson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
|
||||
return Array.isArray(parsed)
|
||||
? parsed
|
||||
.filter(
|
||||
(item) =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof item.url === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
url: item.url,
|
||||
...(typeof item.title === 'string' ? { title: item.title } : {}),
|
||||
...(typeof item.author === 'string'
|
||||
? { author: item.author }
|
||||
: {}),
|
||||
...(typeof item.score === 'number' ? { score: item.score } : {}),
|
||||
...(typeof item.reason === 'string'
|
||||
? { reason: item.reason }
|
||||
: {}),
|
||||
...(typeof item.rejectionClass === 'string'
|
||||
? { rejectionClass: item.rejectionClass }
|
||||
: {}),
|
||||
...(typeof item.rejectionSummary === 'string'
|
||||
? { rejectionSummary: item.rejectionSummary }
|
||||
: {}),
|
||||
...(typeof item.rejectedAt === 'string'
|
||||
? { rejectedAt: item.rejectedAt }
|
||||
: {}),
|
||||
}))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private classifyError(error?: string): ClassifiedError | undefined {
|
||||
if (!error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const detail = this.trimDetail(error);
|
||||
const normalized = error.toLowerCase();
|
||||
|
||||
if (normalized.includes('no acceptable youtube result found')) {
|
||||
return this.error(
|
||||
'NO_ACCEPTABLE_CANDIDATE',
|
||||
'No acceptable YouTube candidate',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.includes('sign in to confirm your age')) {
|
||||
return this.error(
|
||||
'YOUTUBE_AGE_GATED',
|
||||
'YouTube video is age-gated',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.includes('private video')) {
|
||||
return this.error(
|
||||
'YOUTUBE_PRIVATE_VIDEO',
|
||||
'YouTube video is private',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('only images are available') ||
|
||||
normalized.includes('requested format is not available') ||
|
||||
normalized.includes('no downloadable audio/video formats')
|
||||
) {
|
||||
return this.error(
|
||||
'YOUTUBE_NO_FORMATS',
|
||||
'No downloadable YouTube audio format',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('video unavailable') ||
|
||||
normalized.includes('this video is not available') ||
|
||||
normalized.includes('selected video unavailable')
|
||||
) {
|
||||
return this.error(
|
||||
'YOUTUBE_VIDEO_UNAVAILABLE',
|
||||
'YouTube video is unavailable',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.includes('unable to download webpage')) {
|
||||
return this.error(
|
||||
'YOUTUBE_WEBPAGE_UNAVAILABLE',
|
||||
'YouTube webpage is unavailable',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('failed to parse yt-dlp search output') ||
|
||||
normalized.includes('yt-dlp exited with code') ||
|
||||
normalized.includes('failed to start yt-dlp') ||
|
||||
normalized.includes('yt-dlp exceeded maximum runtime')
|
||||
) {
|
||||
return this.error(
|
||||
'YOUTUBE_EXTRACTION_FAILURE',
|
||||
'YouTube extraction failed',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('eacces') ||
|
||||
normalized.includes('enoent') ||
|
||||
normalized.includes('failed to write') ||
|
||||
normalized.includes('permission denied')
|
||||
) {
|
||||
return this.error('FILE_WRITE_FAILED', 'File write failed', detail);
|
||||
}
|
||||
|
||||
return this.error(
|
||||
'UNKNOWN_DOWNLOAD_ERROR',
|
||||
'Unknown download error',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
private error(
|
||||
errorClass: ErrorClass,
|
||||
errorSummary: string,
|
||||
errorDetail: string,
|
||||
): ClassifiedError {
|
||||
return { errorClass, errorSummary, errorDetail };
|
||||
}
|
||||
|
||||
private trimDetail(error: string): string {
|
||||
const trimmed = error.trim();
|
||||
return trimmed.length > 1200 ? `${trimmed.slice(0, 1197)}...` : trimmed;
|
||||
}
|
||||
|
||||
private getOperation(input: {
|
||||
activeSearches: number;
|
||||
activeDownloads: number;
|
||||
searchQueueDepth: number;
|
||||
downloadQueueDepth: number;
|
||||
failed: number;
|
||||
retries: number;
|
||||
}): SpootyOperation {
|
||||
if (input.activeDownloads > 0 || input.downloadQueueDepth > 0) {
|
||||
return input.retries > 0 ? 'retrying' : 'downloading';
|
||||
}
|
||||
|
||||
if (input.activeSearches > 0 || input.searchQueueDepth > 0) {
|
||||
return input.retries > 0 ? 'retrying' : 'searching';
|
||||
}
|
||||
|
||||
if (input.failed > 0) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
export type SpootyOperation =
|
||||
| 'idle'
|
||||
| 'searching'
|
||||
| 'downloading'
|
||||
| 'retrying'
|
||||
| 'degraded';
|
||||
|
||||
export type ErrorClass =
|
||||
| 'YOUTUBE_VIDEO_UNAVAILABLE'
|
||||
| 'YOUTUBE_AGE_GATED'
|
||||
| 'YOUTUBE_NO_FORMATS'
|
||||
| 'YOUTUBE_PRIVATE_VIDEO'
|
||||
| 'YOUTUBE_WEBPAGE_UNAVAILABLE'
|
||||
| 'YOUTUBE_EXTRACTION_FAILURE'
|
||||
| 'NO_ACCEPTABLE_CANDIDATE'
|
||||
| 'FILE_WRITE_FAILED'
|
||||
| 'UNKNOWN_DOWNLOAD_ERROR';
|
||||
|
||||
export type RejectionClass =
|
||||
| 'DOWNLOAD_FAILED'
|
||||
| 'YOUTUBE_VIDEO_UNAVAILABLE'
|
||||
| 'YOUTUBE_AGE_GATED'
|
||||
| 'YOUTUBE_NO_FORMATS'
|
||||
| 'YOUTUBE_PRIVATE_VIDEO'
|
||||
| 'YOUTUBE_EXTRACTION_FAILURE'
|
||||
| 'UNKNOWN_DOWNLOAD_ERROR'
|
||||
| 'MANUAL_RETRY';
|
||||
|
||||
export interface RejectedCandidateTruth {
|
||||
url: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
score?: number;
|
||||
reason?: string;
|
||||
rejectionClass?: RejectionClass;
|
||||
rejectionSummary?: string;
|
||||
rejectedAt?: string;
|
||||
}
|
||||
|
||||
export interface TrackTruth {
|
||||
id: number;
|
||||
artist: string;
|
||||
name: string;
|
||||
status: string;
|
||||
spotifyUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
selectedCandidate?: {
|
||||
url: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
score?: number;
|
||||
reason?: string;
|
||||
};
|
||||
downloadAttemptCount?: number;
|
||||
errorClass?: ErrorClass;
|
||||
errorSummary?: string;
|
||||
errorDetail?: string;
|
||||
rejectedCandidateCount: number;
|
||||
rejectedCandidates: RejectedCandidateTruth[];
|
||||
}
|
||||
|
||||
export interface FailureTruth extends TrackTruth {}
|
||||
|
||||
export interface OperationsSnapshot {
|
||||
generatedAt: string;
|
||||
runtime: {
|
||||
operation: SpootyOperation;
|
||||
spotifyConnected: boolean;
|
||||
searchQueueDepth: number;
|
||||
downloadQueueDepth: number;
|
||||
activeSearches: number;
|
||||
activeDownloads: number;
|
||||
};
|
||||
counters: {
|
||||
playlists: number;
|
||||
singleSongs: number;
|
||||
tracks: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
retries: number;
|
||||
};
|
||||
active: {
|
||||
searching: TrackTruth[];
|
||||
downloading: TrackTruth[];
|
||||
};
|
||||
recentFailures: FailureTruth[];
|
||||
recentTracks: TrackTruth[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SearchTrackDto {
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
artist: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
title: string;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { PlaylistService } from './playlist.service';
|
||||
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')
|
||||
export class PlaylistController {
|
||||
@@ -27,6 +28,13 @@ export class PlaylistController {
|
||||
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')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@@ -40,7 +48,7 @@ export class PlaylistController {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Get('retry/:id')
|
||||
@Post('retry/:id')
|
||||
retryFailedOfPlaylist(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -48,19 +48,43 @@ export class PlaylistService {
|
||||
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> {
|
||||
// Detect if URL is for a single track or a playlist and route accordingly
|
||||
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
|
||||
const isArtist = this.spotifyService.isArtistUrl(playlist.spotifyUrl);
|
||||
|
||||
if (isTrack) {
|
||||
await this.createSingleTrack(playlist);
|
||||
} else if (isArtist) {
|
||||
await this.createArtistLibrary(playlist);
|
||||
} else {
|
||||
await this.createPlaylist(playlist);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
trackDetail = await this.spotifyService.getTrackDetail(
|
||||
@@ -94,6 +118,7 @@ export class PlaylistService {
|
||||
name: trackDetail.name,
|
||||
spotifyUrl: playlist.spotifyUrl,
|
||||
coverUrl: trackDetail.image,
|
||||
durationMs: trackDetail.durationMs,
|
||||
},
|
||||
savedPlaylist,
|
||||
);
|
||||
@@ -105,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> {
|
||||
let detail: { tracks: any; name: any; image: any };
|
||||
let playlist2Save: PlaylistEntity;
|
||||
@@ -162,6 +288,7 @@ export class PlaylistService {
|
||||
name: track.name,
|
||||
spotifyUrl: track.previewUrl || null,
|
||||
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
||||
durationMs: track.durationMs,
|
||||
},
|
||||
savedPlaylist,
|
||||
);
|
||||
|
||||
@@ -9,6 +9,17 @@ import type { Request } from 'express';
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
if (
|
||||
request.path === '/api/health' ||
|
||||
request.path === '/api/spotify/login' ||
|
||||
request.path === '/api/spotify/callback' ||
|
||||
request.path === '/api/spotify/status'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.authEnabled()) {
|
||||
return true;
|
||||
}
|
||||
@@ -20,11 +31,10 @@ export class AuthGuard implements CanActivate {
|
||||
);
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const providedToken = this.extractToken(request);
|
||||
|
||||
if (providedToken !== expectedToken) {
|
||||
throw new UnauthorizedException('Invalid or missing Spooty auth token');
|
||||
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,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 '/';
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
|
||||
import { SpotifyService } from './spotify.service';
|
||||
import { YoutubeService } from './youtube.service';
|
||||
import { SpotifyApiService } from './spotify-api.service';
|
||||
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||
controllers: [],
|
||||
controllers: [SpotifyAuthController],
|
||||
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { resolve, dirname } from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { EnvironmentEnum } from '../environmentEnum';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fetch = require('isomorphic-unfetch');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { getDetails } = require('spotify-url-info')(fetch);
|
||||
|
||||
interface StoredSpotifyUserToken {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
scope?: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SpotifyApiService {
|
||||
private readonly logger = new Logger(SpotifyApiService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor() {}
|
||||
private tokenExpiry = 0;
|
||||
|
||||
private getPlaylistId(url: string): string {
|
||||
try {
|
||||
@@ -51,13 +60,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(
|
||||
spotifyUrl: string,
|
||||
): Promise<{ name: string; artist: string; image: string }> {
|
||||
): Promise<{ name: string; artist: string; image: string; durationMs?: number }> {
|
||||
try {
|
||||
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
||||
const trackId = this.getTrackId(spotifyUrl);
|
||||
const accessToken = await this.getAccessToken();
|
||||
const accessToken = await this.getClientCredentialsAccessToken();
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/tracks/${trackId}`,
|
||||
@@ -78,6 +281,7 @@ export class SpotifyApiService {
|
||||
name: data.name,
|
||||
artist: data.artists.map((a) => a.name).join(', '),
|
||||
image: data.album.images[0]?.url || '',
|
||||
durationMs: data.duration_ms,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get track metadata: ${error.message}`);
|
||||
@@ -102,31 +306,18 @@ export class SpotifyApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getAccessToken(): Promise<string> {
|
||||
private async getClientCredentialsAccessToken(): Promise<string> {
|
||||
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
||||
return this.accessToken;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.debug('Getting new Spotify access token');
|
||||
|
||||
const clientId = process.env.SPOTIFY_CLIENT_ID;
|
||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error(
|
||||
'Missing Spotify credentials. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env file',
|
||||
);
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
|
||||
'base64',
|
||||
);
|
||||
this.logger.debug('Getting new Spotify client credentials access token');
|
||||
|
||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Authorization: this.getBasicAuthHeader(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
@@ -141,7 +332,7 @@ export class SpotifyApiService {
|
||||
this.accessToken = data.access_token;
|
||||
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
|
||||
|
||||
this.logger.debug('Successfully obtained Spotify access token');
|
||||
this.logger.debug('Successfully obtained Spotify client credentials access token');
|
||||
return this.accessToken;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting Spotify access token: ${error.message}`);
|
||||
@@ -149,95 +340,375 @@ export class SpotifyApiService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async searchTrack(
|
||||
artist: string,
|
||||
title: string,
|
||||
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
|
||||
const accessToken = await this.getClientCredentialsAccessToken();
|
||||
const plainQuery = `${artist} ${title}`.trim();
|
||||
const structuredQuery = `track:"${title}" artist:"${artist}"`;
|
||||
|
||||
const url = new URL('https://api.spotify.com/v1/search');
|
||||
url.searchParams.set('q', structuredQuery || plainQuery);
|
||||
url.searchParams.set('type', 'track');
|
||||
url.searchParams.set('limit', '10');
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Spotify track search failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const tracks = data?.tracks?.items || [];
|
||||
|
||||
if (!tracks.length) {
|
||||
throw new Error(`No Spotify track found for ${artist} - ${title}`);
|
||||
}
|
||||
|
||||
const normalize = (value: string) =>
|
||||
(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const wantedArtist = normalize(artist);
|
||||
const wantedTitle = normalize(title);
|
||||
|
||||
const scoreTrack = (track: any): number => {
|
||||
const trackName = normalize(track.name);
|
||||
const artistNames = (track.artists || []).map((item: any) => normalize(item.name));
|
||||
const allArtists = artistNames.join(' ');
|
||||
|
||||
let score = 0;
|
||||
if (trackName === wantedTitle) score += 70;
|
||||
else if (trackName.includes(wantedTitle) || wantedTitle.includes(trackName)) score += 40;
|
||||
|
||||
if (artistNames.some((name: string) => name === wantedArtist)) score += 50;
|
||||
else if (allArtists.includes(wantedArtist) || wantedArtist.includes(allArtists)) score += 25;
|
||||
|
||||
if (track.explicit !== undefined) score += 1;
|
||||
if (track.popularity) score += Math.min(20, Math.floor(track.popularity / 5));
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
const ranked = [...tracks]
|
||||
.map((track: any) => ({ track, score: scoreTrack(track) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const best = ranked[0]?.track || tracks[0];
|
||||
|
||||
this.logger.debug(
|
||||
`Spotify search "${plainQuery}" selected "${best?.artists?.map((item: any) => item.name).join(', ')} - ${best?.name}" score=${ranked[0]?.score ?? 0}`,
|
||||
);
|
||||
|
||||
return {
|
||||
spotifyUrl: best.external_urls.spotify,
|
||||
name: best.name,
|
||||
artist: (best.artists || []).map((item: any) => item.name).join(', '),
|
||||
image: best.album?.images?.[0]?.url || '',
|
||||
durationMs: best.duration_ms,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> {
|
||||
try {
|
||||
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
|
||||
|
||||
const playlistId = this.getPlaylistId(spotifyUrl);
|
||||
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
||||
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
const accessToken = await this.getUserAccessToken();
|
||||
|
||||
const allTracks = [];
|
||||
let offset = 0;
|
||||
let hasMoreTracks = true;
|
||||
|
||||
while (hasMoreTracks) {
|
||||
this.logger.debug(
|
||||
`Fetching tracks from Spotify API with offset ${offset}`,
|
||||
);
|
||||
this.logger.debug(
|
||||
`Fetching tracks from Spotify API with offset ${offset}`,
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/playlists/${playlistId}/tracks?offset=${offset}&limit=100&fields=items(track(id,name,artists,preview_url,album(images))),next`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger.error(
|
||||
`Spotify API error: ${response.status} ${errorText}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger.error(
|
||||
`Spotify API error: ${response.status} ${errorText}`,
|
||||
if (response.status === 403) {
|
||||
throw new Error(
|
||||
'Spotify API refused playlist item access with 403 Forbidden. Connect Spotify through /api/spotify/login and ensure the authenticated user can access this playlist.',
|
||||
);
|
||||
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
this.logger.debug('No more tracks to fetch from Spotify API');
|
||||
hasMoreTracks = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageTracks = data.items
|
||||
.map(
|
||||
(item: {
|
||||
track: {
|
||||
id: string;
|
||||
name: any;
|
||||
artists: any[];
|
||||
preview_url: any;
|
||||
album: { images: any[] };
|
||||
};
|
||||
}) => {
|
||||
if (!item.track) return null;
|
||||
|
||||
return {
|
||||
id: item.track.id,
|
||||
name: item.track.name,
|
||||
artist: item.track.artists.map((a) => a.name).join(', '),
|
||||
previewUrl: item.track.preview_url,
|
||||
coverUrl: item.track.album?.images?.[0]?.url || null,
|
||||
};
|
||||
},
|
||||
)
|
||||
.filter((track) => track !== null);
|
||||
|
||||
this.logger.debug(
|
||||
`Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`,
|
||||
);
|
||||
|
||||
if (pageTracks.length > 0) {
|
||||
allTracks.push(...pageTracks);
|
||||
}
|
||||
|
||||
if (!data.next) {
|
||||
hasMoreTracks = false;
|
||||
} else {
|
||||
offset += 100;
|
||||
}
|
||||
throw new Error(`Failed to fetch tracks: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.items || data.items.length === 0) {
|
||||
this.logger.debug('No more tracks to fetch from Spotify API');
|
||||
hasMoreTracks = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const pageTracks = data.items
|
||||
.map(
|
||||
(item: {
|
||||
item: {
|
||||
id: string;
|
||||
name: any;
|
||||
artists: any[];
|
||||
preview_url: any;
|
||||
album: { images: any[] };
|
||||
duration_ms?: number;
|
||||
};
|
||||
}) => {
|
||||
if (!item.item) return null;
|
||||
|
||||
return {
|
||||
id: item.item.id,
|
||||
name: item.item.name,
|
||||
artist: item.item.artists.map((a) => a.name).join(', '),
|
||||
previewUrl: item.item.preview_url,
|
||||
coverUrl: item.item.album?.images?.[0]?.url || null,
|
||||
durationMs: item.item.duration_ms,
|
||||
};
|
||||
},
|
||||
)
|
||||
.filter((track) => track !== null);
|
||||
|
||||
this.logger.debug(
|
||||
`Total tracks retrieved from Spotify API: ${allTracks.length}`,
|
||||
`Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`,
|
||||
);
|
||||
return allTracks;
|
||||
|
||||
if (pageTracks.length > 0) {
|
||||
allTracks.push(...pageTracks);
|
||||
}
|
||||
|
||||
if (!data.next) {
|
||||
hasMoreTracks = false;
|
||||
} else {
|
||||
offset += 100;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Total tracks retrieved from Spotify API: ${allTracks.length}`,
|
||||
);
|
||||
return allTracks;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getArtistLibrary(
|
||||
spotifyUrl: string,
|
||||
): Promise<{ name: string; image: string; tracks: any[] }> {
|
||||
try {
|
||||
this.logger.debug(`Getting artist library for ${spotifyUrl}`);
|
||||
const artistId = this.getArtistId(spotifyUrl);
|
||||
const accessToken = await this.getUserAccessToken();
|
||||
|
||||
const artistResponse = await fetch(
|
||||
`https://api.spotify.com/v1/artists/${artistId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!artistResponse.ok) {
|
||||
const errorText = await artistResponse.text();
|
||||
throw new Error(`Failed to fetch artist metadata: ${artistResponse.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const artist = await artistResponse.json();
|
||||
const albums = await this.getAllArtistAlbums(artistId, accessToken);
|
||||
const tracks = await this.getTracksForAlbums(albums, accessToken, artist.name);
|
||||
|
||||
return {
|
||||
name: `Artist Library: ${artist.name}`,
|
||||
image: artist.images?.[0]?.url || '',
|
||||
tracks,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get artist library: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async getAllArtistAlbums(artistId: string, accessToken: string): Promise<any[]> {
|
||||
const includeGroups = process.env.SPOTIFY_ARTIST_INCLUDE_GROUPS || 'album,single';
|
||||
const albumByKey = new Map<string, any>();
|
||||
const artistAlbumPageLimit = 20;
|
||||
let offset = 0;
|
||||
let hasMoreAlbums = true;
|
||||
|
||||
while (hasMoreAlbums) {
|
||||
this.logger.debug(
|
||||
`Fetching artist albums from Spotify API with offset ${offset}`,
|
||||
);
|
||||
|
||||
const albumUrl = new URL(
|
||||
`https://api.spotify.com/v1/artists/${artistId}/albums`,
|
||||
);
|
||||
albumUrl.searchParams.set('include_groups', includeGroups);
|
||||
albumUrl.searchParams.set('offset', String(offset));
|
||||
|
||||
this.logger.debug(`Spotify artist albums URL: ${albumUrl.toString()}`);
|
||||
|
||||
const response = await fetch(albumUrl.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to fetch artist albums: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
for (const album of data.items || []) {
|
||||
const key = this.normalizeLibraryKey(`${album.name}|${album.release_date}|${album.total_tracks}`);
|
||||
if (!albumByKey.has(key)) {
|
||||
albumByKey.set(key, album);
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.next) {
|
||||
hasMoreAlbums = false;
|
||||
} else {
|
||||
offset += artistAlbumPageLimit;
|
||||
}
|
||||
}
|
||||
|
||||
const albums = [...albumByKey.values()];
|
||||
this.logger.debug(`Retrieved ${albums.length} deduplicated artist albums/singles`);
|
||||
return albums;
|
||||
}
|
||||
|
||||
private async getTracksForAlbums(
|
||||
albums: any[],
|
||||
accessToken: string,
|
||||
artistName: string,
|
||||
): Promise<any[]> {
|
||||
const trackByKey = new Map<string, any>();
|
||||
const maxTracks = Number(process.env.SPOTIFY_ARTIST_LIBRARY_MAX_TRACKS || 1000);
|
||||
|
||||
for (const album of albums) {
|
||||
let offset = 0;
|
||||
let hasMoreTracks = true;
|
||||
|
||||
while (hasMoreTracks) {
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/albums/${album.id}/tracks?limit=50&offset=${offset}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to fetch album tracks: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
for (const item of data.items || []) {
|
||||
if (!item?.name || !item?.artists?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fullTrack = await this.getTrackById(item.id, accessToken);
|
||||
const isrc = fullTrack?.external_ids?.isrc;
|
||||
const durationMs = fullTrack?.duration_ms || item.duration_ms;
|
||||
const artist = item.artists.map((a) => a.name).join(', ');
|
||||
const key = isrc
|
||||
? `isrc:${isrc}`
|
||||
: this.normalizeLibraryKey(`${artist}|${item.name}|${Math.round((durationMs || 0) / 2000)}`);
|
||||
|
||||
if (!trackByKey.has(key)) {
|
||||
trackByKey.set(key, {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
artist,
|
||||
previewUrl: item.external_urls?.spotify || null,
|
||||
coverUrl: album.images?.[0]?.url || null,
|
||||
durationMs,
|
||||
albumName: album.name,
|
||||
primaryArtist: artistName,
|
||||
});
|
||||
}
|
||||
|
||||
if (trackByKey.size >= maxTracks) {
|
||||
this.logger.warn(`Artist library max track limit reached: ${maxTracks}`);
|
||||
return [...trackByKey.values()];
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.next) {
|
||||
hasMoreTracks = false;
|
||||
} else {
|
||||
offset += 50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const tracks = [...trackByKey.values()];
|
||||
this.logger.debug(`Retrieved ${tracks.length} deduplicated artist library tracks`);
|
||||
return tracks;
|
||||
}
|
||||
|
||||
private async getTrackById(trackId: string, accessToken: string): Promise<any> {
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/tracks/${trackId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private normalizeLibraryKey(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ export class SpotifyService {
|
||||
return this.spotifyApiService.isTrackUrl(url);
|
||||
}
|
||||
|
||||
isArtistUrl(url: string): boolean {
|
||||
return this.spotifyApiService.isArtistUrl(url);
|
||||
}
|
||||
|
||||
async getTrackDetail(
|
||||
spotifyUrl: string,
|
||||
): Promise<{ name: string; artist: string; image: string }> {
|
||||
@@ -61,6 +65,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[]> {
|
||||
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
||||
try {
|
||||
|
||||
@@ -22,11 +22,11 @@ export class UtilsService {
|
||||
}
|
||||
|
||||
ensureInsideDownloadsRoot(candidatePath: string): string {
|
||||
const root = this.getRootDownloadsPath();
|
||||
const root = resolve(this.getRootDownloadsPath());
|
||||
const resolvedCandidate = resolve(candidatePath);
|
||||
const rel = relative(root, resolvedCandidate);
|
||||
|
||||
if (rel === '' || (!rel.startsWith('..') && !resolve(rel).startsWith('/'))) {
|
||||
if (rel === '' || (!rel.startsWith('..') && !relative('', rel).startsWith('..'))) {
|
||||
return resolvedCandidate;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,20 @@ export class UtilsService {
|
||||
}
|
||||
|
||||
stripFileIllegalChars(text: string): string {
|
||||
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled';
|
||||
const sanitized = String(text || '')
|
||||
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[. ]+$/g, '')
|
||||
.trim();
|
||||
|
||||
const fallback = sanitized || 'untitled';
|
||||
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
|
||||
|
||||
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
|
||||
return `_${fallback}`;
|
||||
}
|
||||
|
||||
return fallback.slice(0, 180);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@ import { TrackEntity } from '../track/track.entity';
|
||||
import { EnvironmentEnum } from '../environmentEnum';
|
||||
import { TrackService } from '../track/track.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { YtDlp } from 'ytdlp-nodejs';
|
||||
import * as yts from 'yt-search';
|
||||
import * as fs from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
const NodeID3 = require('node-id3');
|
||||
|
||||
const HEADERS = {
|
||||
@@ -21,26 +20,338 @@ const ALLOWED_YOUTUBE_HOSTS = new Set([
|
||||
'youtu.be',
|
||||
]);
|
||||
|
||||
const BAD_MATCH_TERMS = [
|
||||
'live',
|
||||
'ao vivo',
|
||||
'acústico',
|
||||
'acustico',
|
||||
'acoustic',
|
||||
'concert',
|
||||
'festival',
|
||||
'session',
|
||||
'cover',
|
||||
'karaoke',
|
||||
'remix',
|
||||
'sped up',
|
||||
'slowed',
|
||||
'nightcore',
|
||||
'reaction',
|
||||
'tutorial',
|
||||
'instrumental',
|
||||
'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()
|
||||
export class YoutubeService {
|
||||
private readonly logger = new Logger(TrackService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
||||
async findBestYoutubeMatch(
|
||||
artist: string,
|
||||
name: string,
|
||||
durationMs?: number,
|
||||
excludedUrls: string[] = [],
|
||||
): Promise<YoutubeMatch> {
|
||||
const query = `${artist} - ${name}`;
|
||||
this.logger.debug(`Searching ${query} on YT`);
|
||||
|
||||
const result = await yts(query);
|
||||
const firstVideo = result.videos?.[0];
|
||||
const videos = await this.searchYoutubeVideos(query);
|
||||
const excludedVideoIds = new Set(
|
||||
excludedUrls
|
||||
.map((url) => this.getYoutubeVideoId(url))
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
if (!firstVideo?.url) {
|
||||
throw new Error(`No YouTube result found for: ${query}`);
|
||||
const candidates = videos
|
||||
.filter((video: any) => !!video?.url && !!video?.title)
|
||||
.filter((video: any) => {
|
||||
const videoId = this.getYoutubeVideoId(String(video.url));
|
||||
return !videoId || !excludedVideoIds.has(videoId);
|
||||
})
|
||||
.slice(0, 15)
|
||||
.map((video: any) => this.scoreCandidate(video, artist, name, durationMs))
|
||||
.filter((match: CandidateScore) => {
|
||||
try {
|
||||
this.assertValidYoutubeUrl(match.url);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
this.logger.debug(
|
||||
`YouTube candidates for "${query}": ` +
|
||||
candidates
|
||||
.slice(0, 5)
|
||||
.map(
|
||||
(c) =>
|
||||
`[${c.score}${c.rejected ? ' rejected' : ''}] ${c.title} by ${c.author} (${c.reason})`,
|
||||
)
|
||||
.join(' | '),
|
||||
);
|
||||
|
||||
const 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(`Found ${query} on ${firstVideo.url}`);
|
||||
return firstVideo.url;
|
||||
this.logger.debug(
|
||||
`Selected YouTube match for "${query}": ${accepted.title} by ${accepted.author} (${accepted.score})`,
|
||||
);
|
||||
|
||||
return accepted;
|
||||
}
|
||||
|
||||
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
|
||||
const searchTarget = `ytsearch15:${query}`;
|
||||
const args = [
|
||||
'--dump-single-json',
|
||||
'--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 {
|
||||
@@ -61,25 +372,132 @@ export class YoutubeService {
|
||||
}
|
||||
}
|
||||
|
||||
private getCookiesOptions(): {
|
||||
cookiesFromBrowser?: string;
|
||||
cookies?: string;
|
||||
} {
|
||||
const cookiesBrowser = this.configService.get<string>(
|
||||
EnvironmentEnum.YT_COOKIES,
|
||||
);
|
||||
if (cookiesBrowser) {
|
||||
this.logger.debug(`Using cookies from browser: ${cookiesBrowser}`);
|
||||
return { cookiesFromBrowser: cookiesBrowser };
|
||||
}
|
||||
private getCookiesFile(): string | null {
|
||||
const cookiesFile = this.configService.get<string>(
|
||||
EnvironmentEnum.YT_COOKIES_FILE,
|
||||
);
|
||||
|
||||
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
||||
this.logger.debug(`Using cookies file: ${cookiesFile}`);
|
||||
return { cookies: cookiesFile };
|
||||
return cookiesFile;
|
||||
}
|
||||
return {};
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private classifyYtDlpError(output: string): string {
|
||||
const text = output || '';
|
||||
|
||||
if (text.includes('Sign in to confirm your age')) {
|
||||
return 'YouTube age-gated: cookies required or invalid';
|
||||
}
|
||||
|
||||
if (text.includes('Only images are available')) {
|
||||
return 'YouTube unavailable: no downloadable audio/video formats';
|
||||
}
|
||||
|
||||
if (text.includes('Requested format is not available')) {
|
||||
return 'YouTube unavailable: requested audio format is not available';
|
||||
}
|
||||
|
||||
if (text.includes('Private video')) {
|
||||
return 'YouTube unavailable: private video';
|
||||
}
|
||||
|
||||
if (text.includes('Video unavailable') || 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> {
|
||||
@@ -93,18 +511,34 @@ export class YoutubeService {
|
||||
|
||||
this.assertValidYoutubeUrl(track.youtubeUrl);
|
||||
|
||||
const ytdlp = new YtDlp();
|
||||
await ytdlp.downloadAudio(
|
||||
track.youtubeUrl,
|
||||
this.configService.get<'m4a'>(EnvironmentEnum.FORMAT),
|
||||
{
|
||||
output,
|
||||
...this.getCookiesOptions(),
|
||||
headers: HEADERS,
|
||||
jsRuntime: 'node',
|
||||
audioQuality: this.configService.get<string>('QUALITY'),
|
||||
},
|
||||
);
|
||||
const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
|
||||
const quality = this.configService.get<string>('QUALITY') || '0';
|
||||
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
|
||||
|
||||
const args = [
|
||||
'--no-playlist',
|
||||
'--no-cache-dir',
|
||||
'--no-cookies-from-browser',
|
||||
'-f',
|
||||
'bestaudio/best',
|
||||
'--extract-audio',
|
||||
'--audio-format',
|
||||
format,
|
||||
'--audio-quality',
|
||||
quality,
|
||||
'--add-header',
|
||||
`User-Agent:${HEADERS['User-Agent']}`,
|
||||
'-o',
|
||||
output,
|
||||
];
|
||||
|
||||
if (cookiesFile) {
|
||||
args.push('--cookies', cookiesFile);
|
||||
}
|
||||
|
||||
args.push(track.youtubeUrl);
|
||||
|
||||
await this.runYtDlp(args);
|
||||
|
||||
this.logger.debug(
|
||||
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
||||
@@ -121,12 +555,47 @@ export class YoutubeService {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(coverUrl);
|
||||
let parsedCoverUrl: URL;
|
||||
try {
|
||||
parsedCoverUrl = new URL(coverUrl);
|
||||
} catch {
|
||||
throw new Error('Invalid cover art URL');
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
|
||||
throw new Error('Rejected non-HTTP cover art URL');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15_000);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch cover art: ${res.status}`);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type')?.split(';')[0].toLowerCase() || '';
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
|
||||
throw new Error(`Rejected unsupported cover art type: ${contentType || 'unknown'}`);
|
||||
}
|
||||
|
||||
const maxCoverBytes = 5 * 1024 * 1024;
|
||||
const contentLength = Number(res.headers.get('content-length') || 0);
|
||||
if (contentLength > maxCoverBytes) {
|
||||
throw new Error(`Rejected oversized cover art: ${contentLength} bytes`);
|
||||
}
|
||||
|
||||
const arrayBuf = await res.arrayBuffer();
|
||||
if (arrayBuf.byteLength > maxCoverBytes) {
|
||||
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(arrayBuf);
|
||||
|
||||
NodeID3.write(
|
||||
@@ -134,7 +603,7 @@ export class YoutubeService {
|
||||
title,
|
||||
artist,
|
||||
APIC: {
|
||||
mime: 'image/jpeg',
|
||||
mime: contentType,
|
||||
type: { id: 3, name: 'front cover' },
|
||||
description: 'cover',
|
||||
imageBuffer,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { TrackService } from './track.service';
|
||||
import { TrackEntity } from './track.entity';
|
||||
@@ -13,15 +14,29 @@ function getDownloadsPerMinute(): number {
|
||||
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 {
|
||||
private readonly logger = new Logger(TrackDownloadProcessor.name);
|
||||
|
||||
constructor(private readonly trackService: TrackService) {
|
||||
super();
|
||||
}
|
||||
|
||||
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 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 this.trackService.downloadFromYoutube(job.data);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Job } from 'bullmq';
|
||||
import { TrackService } from './track.service';
|
||||
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 {
|
||||
private readonly logger = new Logger(TrackSearchProcessor.name);
|
||||
|
||||
constructor(private readonly trackService: TrackService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<TrackEntity, void, string>): Promise<void> {
|
||||
await this.trackService.findOnYoutube(job.data);
|
||||
async process(job: Job<{ id: number }, void, string>): Promise<void> {
|
||||
const delayMs = getSearchDelayMs();
|
||||
|
||||
this.logger.debug(
|
||||
`Processing search job ${job.id} for track ${job.data.id}; pacing ${delayMs}ms`,
|
||||
);
|
||||
|
||||
await new Promise((res) => setTimeout(res, delayMs));
|
||||
await this.trackService.findOnYoutube({ id: job.data.id } as TrackEntity);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Post,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
@@ -66,7 +67,7 @@ export class TrackController {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Get('retry/:id')
|
||||
@Post('retry/:id')
|
||||
retry(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -27,6 +27,27 @@ export class TrackEntity {
|
||||
@Column({ nullable: true })
|
||||
youtubeUrl?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
selectedYoutubeTitle?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
selectedYoutubeAuthor?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
selectedYoutubeScore?: number;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
selectedYoutubeReason?: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
rejectedYoutubeUrls?: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
rejectedYoutubeCandidatesJson?: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
downloadAttemptCount?: number;
|
||||
|
||||
@Column({ default: TrackStatusEnum.New })
|
||||
status?: TrackStatusEnum;
|
||||
|
||||
@@ -36,6 +57,9 @@ export class TrackEntity {
|
||||
@Column({ nullable: true })
|
||||
coverUrl?: string; // Track-specific album art (overrides playlist coverUrl)
|
||||
|
||||
@Column({ nullable: true })
|
||||
durationMs?: number;
|
||||
|
||||
@Column({ default: Date.now() })
|
||||
createdAt?: number;
|
||||
|
||||
|
||||
@@ -20,6 +20,36 @@ enum WsTrackOperation {
|
||||
Delete = 'trackDelete',
|
||||
}
|
||||
|
||||
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
|
||||
rejectedYoutubeUrls?: string[];
|
||||
};
|
||||
|
||||
type RejectionClass =
|
||||
| 'DOWNLOAD_FAILED'
|
||||
| 'YOUTUBE_VIDEO_UNAVAILABLE'
|
||||
| 'YOUTUBE_AGE_GATED'
|
||||
| 'YOUTUBE_NO_FORMATS'
|
||||
| 'YOUTUBE_PRIVATE_VIDEO'
|
||||
| 'YOUTUBE_EXTRACTION_FAILURE'
|
||||
| 'UNKNOWN_DOWNLOAD_ERROR'
|
||||
| 'MANUAL_RETRY';
|
||||
|
||||
interface RejectedYoutubeCandidate {
|
||||
url: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
score?: number;
|
||||
reason?: string;
|
||||
rejectionClass?: RejectionClass;
|
||||
rejectionSummary?: string;
|
||||
rejectedAt: string;
|
||||
}
|
||||
|
||||
interface RejectionReason {
|
||||
rejectionClass: RejectionClass;
|
||||
rejectionSummary: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway()
|
||||
@Injectable()
|
||||
export class TrackService {
|
||||
@@ -56,52 +86,276 @@ export class TrackService {
|
||||
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> {
|
||||
const savedTrack = await this.repository.save({ ...track, playlist });
|
||||
await this.trackSearchQueue.add('', savedTrack, {
|
||||
jobId: `id-${savedTrack.id}`,
|
||||
});
|
||||
await this.enqueueSearch(savedTrack.id);
|
||||
this.io.emit(WsTrackOperation.New, {
|
||||
track: savedTrack,
|
||||
track: this.toClientTrack(savedTrack),
|
||||
playlistId: playlist.id,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, track: TrackEntity): Promise<void> {
|
||||
await this.repository.update(id, track);
|
||||
this.io.emit(WsTrackOperation.Update, track);
|
||||
this.io.emit(WsTrackOperation.Update, this.toClientTrack(track));
|
||||
}
|
||||
|
||||
private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
|
||||
if (!track.rejectedYoutubeUrls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private stringifyRejectedYoutubeUrls(urls: string[]): string {
|
||||
return JSON.stringify([...new Set(urls)].slice(0, 20));
|
||||
}
|
||||
|
||||
private parseRejectedYoutubeCandidates(
|
||||
track: TrackEntity,
|
||||
): RejectedYoutubeCandidate[] {
|
||||
if (!track.rejectedYoutubeCandidatesJson) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter(
|
||||
(item): item is RejectedYoutubeCandidate =>
|
||||
!!item &&
|
||||
typeof item === 'object' &&
|
||||
typeof item.url === 'string' &&
|
||||
typeof item.rejectedAt === 'string',
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private stringifyRejectedYoutubeCandidates(
|
||||
candidates: RejectedYoutubeCandidate[],
|
||||
): string {
|
||||
const byUrl = new Map<string, RejectedYoutubeCandidate>();
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.url) {
|
||||
byUrl.set(candidate.url, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify([...byUrl.values()].slice(-20));
|
||||
}
|
||||
|
||||
private buildRejectedYoutubeCandidate(
|
||||
track: TrackEntity,
|
||||
reason: RejectionReason,
|
||||
): RejectedYoutubeCandidate | null {
|
||||
if (!track.youtubeUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url: track.youtubeUrl,
|
||||
...(track.selectedYoutubeTitle
|
||||
? { title: track.selectedYoutubeTitle }
|
||||
: {}),
|
||||
...(track.selectedYoutubeAuthor
|
||||
? { author: track.selectedYoutubeAuthor }
|
||||
: {}),
|
||||
...(typeof track.selectedYoutubeScore === 'number'
|
||||
? { score: track.selectedYoutubeScore }
|
||||
: {}),
|
||||
...(track.selectedYoutubeReason
|
||||
? { reason: track.selectedYoutubeReason }
|
||||
: {}),
|
||||
...reason,
|
||||
rejectedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private rejectCurrentYoutubeCandidate(
|
||||
track: TrackEntity,
|
||||
reason: RejectionReason,
|
||||
): {
|
||||
rejectedYoutubeUrls: string;
|
||||
rejectedYoutubeCandidatesJson: string;
|
||||
} {
|
||||
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||
const rejectedCandidates = this.parseRejectedYoutubeCandidates(track);
|
||||
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
|
||||
|
||||
if (rejectedCandidate) {
|
||||
if (!rejectedUrls.includes(rejectedCandidate.url)) {
|
||||
rejectedUrls.push(rejectedCandidate.url);
|
||||
}
|
||||
|
||||
rejectedCandidates.push(rejectedCandidate);
|
||||
}
|
||||
|
||||
return {
|
||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||
rejectedYoutubeCandidatesJson:
|
||||
this.stringifyRejectedYoutubeCandidates(rejectedCandidates),
|
||||
};
|
||||
}
|
||||
|
||||
private classifyRejection(error?: string): RejectionReason {
|
||||
if (!error) {
|
||||
return {
|
||||
rejectionClass: 'UNKNOWN_DOWNLOAD_ERROR',
|
||||
rejectionSummary: 'Unknown download error',
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = error.toLowerCase();
|
||||
|
||||
if (normalized.includes('sign in to confirm your age')) {
|
||||
return {
|
||||
rejectionClass: 'YOUTUBE_AGE_GATED',
|
||||
rejectionSummary: 'YouTube video is age-gated',
|
||||
};
|
||||
}
|
||||
|
||||
if (normalized.includes('private video')) {
|
||||
return {
|
||||
rejectionClass: 'YOUTUBE_PRIVATE_VIDEO',
|
||||
rejectionSummary: 'YouTube video is private',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('only images are available') ||
|
||||
normalized.includes('requested format is not available') ||
|
||||
normalized.includes('no downloadable audio/video formats')
|
||||
) {
|
||||
return {
|
||||
rejectionClass: 'YOUTUBE_NO_FORMATS',
|
||||
rejectionSummary: 'No downloadable YouTube audio format',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('video unavailable') ||
|
||||
normalized.includes('this video is not available') ||
|
||||
normalized.includes('selected video unavailable')
|
||||
) {
|
||||
return {
|
||||
rejectionClass: 'YOUTUBE_VIDEO_UNAVAILABLE',
|
||||
rejectionSummary: 'YouTube video is unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('failed to parse yt-dlp search output') ||
|
||||
normalized.includes('yt-dlp exited with code') ||
|
||||
normalized.includes('failed to start yt-dlp') ||
|
||||
normalized.includes('yt-dlp exceeded maximum runtime') ||
|
||||
normalized.includes('unable to download webpage')
|
||||
) {
|
||||
return {
|
||||
rejectionClass: 'YOUTUBE_EXTRACTION_FAILURE',
|
||||
rejectionSummary: 'YouTube extraction failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rejectionClass: 'DOWNLOAD_FAILED',
|
||||
rejectionSummary: 'Download failed',
|
||||
};
|
||||
}
|
||||
|
||||
async retry(id: number): Promise<void> {
|
||||
const track = await this.get(id);
|
||||
const existingJob = await this.trackSearchQueue.getJob(`id-${id}`);
|
||||
|
||||
if (existingJob) {
|
||||
this.logger.warn(`Search job already exists for track ${id}`);
|
||||
if (!track) {
|
||||
this.logger.warn(`Cannot retry missing track ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.trackSearchQueue.add('', track, {
|
||||
jobId: `id-${id}`,
|
||||
const rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
|
||||
rejectionClass: 'MANUAL_RETRY',
|
||||
rejectionSummary: 'Rejected by manual retry',
|
||||
});
|
||||
await this.update(id, { ...track, status: TrackStatusEnum.New });
|
||||
|
||||
await this.update(id, {
|
||||
...track,
|
||||
youtubeUrl: null,
|
||||
selectedYoutubeTitle: null,
|
||||
selectedYoutubeAuthor: null,
|
||||
selectedYoutubeScore: null,
|
||||
selectedYoutubeReason: null,
|
||||
...rejectedCandidateFields,
|
||||
downloadAttemptCount: 0,
|
||||
error: null,
|
||||
status: TrackStatusEnum.New,
|
||||
});
|
||||
|
||||
await this.enqueueSearch(id);
|
||||
}
|
||||
|
||||
async findOnYoutube(track: TrackEntity): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
track = dbTrack;
|
||||
|
||||
await this.update(track.id, {
|
||||
...track,
|
||||
status: TrackStatusEnum.Searching,
|
||||
});
|
||||
let updatedTrack: TrackEntity;
|
||||
try {
|
||||
const youtubeUrl = await this.youtubeService.findOnYoutubeOne(
|
||||
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||
|
||||
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
|
||||
track.artist,
|
||||
track.name,
|
||||
track.durationMs,
|
||||
rejectedUrls,
|
||||
);
|
||||
updatedTrack = {
|
||||
...track,
|
||||
youtubeUrl: youtubeMatch.url,
|
||||
selectedYoutubeTitle: youtubeMatch.title,
|
||||
selectedYoutubeAuthor: youtubeMatch.author,
|
||||
selectedYoutubeScore: youtubeMatch.score,
|
||||
selectedYoutubeReason: youtubeMatch.reason,
|
||||
status: TrackStatusEnum.Queued,
|
||||
};
|
||||
this.logger.debug(
|
||||
`YouTube match for track ${track.id}: ${youtubeMatch.title} by ${youtubeMatch.author} score=${youtubeMatch.score} reason=${youtubeMatch.reason}`,
|
||||
);
|
||||
updatedTrack = { ...track, youtubeUrl, status: TrackStatusEnum.Queued };
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
updatedTrack = {
|
||||
@@ -114,9 +368,59 @@ export class TrackService {
|
||||
}
|
||||
|
||||
await this.update(track.id, updatedTrack);
|
||||
await this.trackDownloadQueue.add('', updatedTrack, {
|
||||
jobId: `id-${updatedTrack.id}`,
|
||||
});
|
||||
await this.enqueueDownload(updatedTrack.id);
|
||||
}
|
||||
|
||||
private async enqueueSearch(id: number): Promise<void> {
|
||||
const track = await this.get(id);
|
||||
|
||||
if (!track) {
|
||||
this.logger.warn(`Cannot enqueue search for missing track ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = `search-${id}`;
|
||||
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
||||
|
||||
if (existingJob) {
|
||||
this.logger.warn(
|
||||
`Removing existing search job for track ${id} before enqueue`,
|
||||
);
|
||||
await existingJob.remove();
|
||||
}
|
||||
|
||||
await this.trackSearchQueue.add('search-track', { id }, { jobId });
|
||||
}
|
||||
|
||||
private async enqueueDownload(id: number): Promise<void> {
|
||||
const track = await this.get(id);
|
||||
|
||||
if (!track) {
|
||||
this.logger.warn(`Cannot enqueue download for missing track ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobId = `download-${id}`;
|
||||
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
||||
|
||||
if (existingJob) {
|
||||
this.logger.warn(
|
||||
`Removing existing download job for track ${id} before enqueue`,
|
||||
);
|
||||
await existingJob.remove();
|
||||
}
|
||||
|
||||
await this.trackDownloadQueue.add('download-track', { id }, { jobId });
|
||||
}
|
||||
|
||||
private getMaxDownloadAttempts(): number {
|
||||
const parsed = Number(process.env.YT_DOWNLOAD_FALLBACK_ATTEMPTS || 3);
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.min(10, Math.floor(parsed)));
|
||||
}
|
||||
|
||||
async downloadFromYoutube(track: TrackEntity): Promise<void> {
|
||||
@@ -151,22 +455,65 @@ export class TrackService {
|
||||
const folderName = this.getFolderName(track, track.playlist);
|
||||
await this.youtubeService.downloadAndFormat(track, folderName);
|
||||
if (coverUrl) {
|
||||
await this.youtubeService.addImage(
|
||||
folderName,
|
||||
coverUrl,
|
||||
track.name,
|
||||
track.artist,
|
||||
);
|
||||
try {
|
||||
await this.youtubeService.addImage(
|
||||
folderName,
|
||||
coverUrl,
|
||||
track.name,
|
||||
track.artist,
|
||||
);
|
||||
} catch (imageError) {
|
||||
this.logger.warn(
|
||||
`Cover art embed failed for track ${track.id}: ${toSafeErrorMessage(imageError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
error = toSafeErrorMessage(err);
|
||||
}
|
||||
|
||||
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
||||
const maxAttempts = this.getMaxDownloadAttempts();
|
||||
const rejectedCandidateFields = error
|
||||
? this.rejectCurrentYoutubeCandidate(track, this.classifyRejection(error))
|
||||
: {
|
||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
|
||||
this.parseRejectedYoutubeUrls(track),
|
||||
),
|
||||
rejectedYoutubeCandidatesJson:
|
||||
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
|
||||
};
|
||||
|
||||
if (error && nextAttemptCount < maxAttempts) {
|
||||
this.logger.warn(
|
||||
`Download failed for track ${track.id}; rejecting current YouTube candidate and retrying search ` +
|
||||
`(${nextAttemptCount}/${maxAttempts})`,
|
||||
);
|
||||
|
||||
await this.update(track.id, {
|
||||
...track,
|
||||
youtubeUrl: null,
|
||||
selectedYoutubeTitle: null,
|
||||
selectedYoutubeAuthor: null,
|
||||
selectedYoutubeScore: null,
|
||||
selectedYoutubeReason: null,
|
||||
...rejectedCandidateFields,
|
||||
downloadAttemptCount: nextAttemptCount,
|
||||
error: null,
|
||||
status: TrackStatusEnum.New,
|
||||
});
|
||||
|
||||
await this.enqueueSearch(track.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedTrack = {
|
||||
...track,
|
||||
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
||||
...(error ? { error } : {}),
|
||||
...rejectedCandidateFields,
|
||||
downloadAttemptCount: error ? nextAttemptCount : 0,
|
||||
...(error ? { error } : { error: null }),
|
||||
};
|
||||
await this.update(track.id, updatedTrack);
|
||||
}
|
||||
@@ -175,7 +522,9 @@ export class TrackService {
|
||||
const safeArtist = track.artist || 'unknown_artist';
|
||||
const safeName = track.name || 'unknown_track';
|
||||
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -50,7 +50,15 @@
|
||||
"maximumError": "4kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"optimization": {
|
||||
"scripts": true,
|
||||
"styles": {
|
||||
"minify": true,
|
||||
"inlineCritical": false
|
||||
},
|
||||
"fonts": true
|
||||
}
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
||||
+12
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
@@ -11,15 +11,14 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.0.6",
|
||||
"@angular/common": "^19.0.6",
|
||||
"@angular/compiler": "^19.0.6",
|
||||
"@angular/core": "^19.0.6",
|
||||
"@angular/forms": "^19.0.6",
|
||||
"@angular/platform-browser": "^19.0.6",
|
||||
"@angular/platform-browser-dynamic": "^19.0.6",
|
||||
"@angular/router": "^19.0.6",
|
||||
"@distube/ytdl-core": "^4.15.9",
|
||||
"@angular/animations": "^19.2.22",
|
||||
"@angular/common": "^19.2.22",
|
||||
"@angular/compiler": "^19.2.22",
|
||||
"@angular/core": "^19.2.22",
|
||||
"@angular/forms": "^19.2.22",
|
||||
"@angular/platform-browser": "^19.2.22",
|
||||
"@angular/platform-browser-dynamic": "^19.2.22",
|
||||
"@angular/router": "^19.2.22",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@ngneat/elf": "^2.5.1",
|
||||
"@ngneat/elf-cli-ng": "^1.0.0",
|
||||
@@ -36,9 +35,9 @@
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.0.7",
|
||||
"@angular/cli": "^19.0.7",
|
||||
"@angular/compiler-cli": "^19.0.6",
|
||||
"@angular-devkit/build-angular": "^19.2.26",
|
||||
"@angular/cli": "^19.2.26",
|
||||
"@angular/compiler-cli": "^19.2.22",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "SpootyFe",
|
||||
"short_name": "SpootyFe",
|
||||
"name": "Jarri Spooty",
|
||||
"short_name": "Spooty",
|
||||
"description": "Self-hosted Spotify downloader",
|
||||
"icons": [
|
||||
{
|
||||
|
||||
@@ -1,73 +1,484 @@
|
||||
<section class="hero is-primary">
|
||||
<div class="hero-body">
|
||||
<p class="title">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
<span>Spooty</span>
|
||||
<span class="tag is-dark is-size-7">v{{version}}</span>
|
||||
</p>
|
||||
<p class="subtitle">Self-hosted spotify downloader</p>
|
||||
</div>
|
||||
</section>
|
||||
<main class="workspace-shell">
|
||||
<header class="workspace-titlebar">
|
||||
<div class="brand-stack">
|
||||
<div class="brand-line">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
<h1>Jarri Spooty</h1>
|
||||
<span class="tag version-tag">v{{ version }}</span>
|
||||
</div>
|
||||
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||
</div>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-body">
|
||||
<div class="box">
|
||||
<p class="subtitle">Download</p>
|
||||
<div>
|
||||
<div class="is-flex">
|
||||
<input class="input"
|
||||
[class.is-danger]="url && !isValidSpotifyUrl"
|
||||
type="text"
|
||||
[(ngModel)]="url"
|
||||
placeholder="Paste playlist/song/artist url"/>
|
||||
<button class="button is-primary"
|
||||
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
||||
(click)="download()"
|
||||
[disabled]="!url || !isValidSpotifyUrl"
|
||||
>
|
||||
<i class="fa-solid fa-download"></i> Download
|
||||
</button>
|
||||
<div 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>
|
||||
</section>
|
||||
|
||||
<section class="workspace-canvas" aria-label="Jarri Spooty workspace">
|
||||
<article
|
||||
*ngFor="let panel of workspace.panels; trackBy: renderPanel"
|
||||
class="workspace-panel"
|
||||
[ngClass]="'panel-' + panel.type"
|
||||
[ngStyle]="panelStyle(panel)"
|
||||
>
|
||||
<header class="panel-header" (pointerdown)="beginDrag($event, panel)">
|
||||
<div class="panel-title">
|
||||
<span class="status-light"></span>
|
||||
<strong>{{ panel.title }}</strong>
|
||||
</div>
|
||||
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">Please enter a valid Spotify URL (e.g. https://open.spotify.com/...)</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="box">
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<p class="subtitle">Playlists</p>
|
||||
<div class="buttons has-addons">
|
||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</section>
|
||||
</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"
|
||||
type="text"
|
||||
[(ngModel)]="url"
|
||||
placeholder="Paste Spotify playlist or track URL"
|
||||
/>
|
||||
<button
|
||||
class="button is-primary queue-button"
|
||||
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
||||
(click)="download()"
|
||||
[disabled]="!url || !isValidSpotifyUrl"
|
||||
>
|
||||
<i class="fa-solid fa-download"></i>
|
||||
Queue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="field-label">Archive destination</label>
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
[(ngModel)]="archiveDestination"
|
||||
(ngModelChange)="saveArchiveDestination()"
|
||||
placeholder="Frontend-planned archive destination"
|
||||
/>
|
||||
|
||||
<div class="truth-note">
|
||||
<p>
|
||||
Runtime download root:
|
||||
{{ archiveListing?.root || archiveDestination || "loading..." }}
|
||||
</p>
|
||||
<p>Per-run destination routing: pending backend support</p>
|
||||
</div>
|
||||
|
||||
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
|
||||
Please enter a valid Spotify URL.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'queue-observatory'" class="metrics-panel">
|
||||
<ng-container *ngIf="playlists$ | async as playlists">
|
||||
<ng-container *ngIf="songs$ | async as songs">
|
||||
<div class="metric-card">
|
||||
<span>Spotify</span>
|
||||
<strong [class.online]="spotifyConnected">{{
|
||||
spotifyConnected ? "Connected" : "Disconnected"
|
||||
}}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>Playlists</span>
|
||||
<strong>{{ playlists.length }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>Single Songs</span>
|
||||
<strong>{{ songs.length }}</strong>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>Mode</span>
|
||||
<strong>Archive run</strong>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'playlist-history'" class="history-panel">
|
||||
<div class="section-toolbar">
|
||||
<div>
|
||||
<h2>Playlist History</h2>
|
||||
<p>One collapsed row per playlist run.</p>
|
||||
</div>
|
||||
<div class="buttons has-addons">
|
||||
<button
|
||||
class="button is-outlined is-success"
|
||||
title="Remove completed from list"
|
||||
(click)="deleteCompleted()"
|
||||
>
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
<button
|
||||
class="button is-outlined is-danger"
|
||||
title="Remove failed from list"
|
||||
(click)="deleteFailed()"
|
||||
>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="playlists$ | async as playlists">
|
||||
<app-playlist-box
|
||||
*ngFor="let playlist of playlists"
|
||||
[playlist]="playlist"
|
||||
></app-playlist-box>
|
||||
<p *ngIf="playlists?.length === 0" class="empty-state">
|
||||
No playlists
|
||||
</p>
|
||||
</ng-container>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'single-songs'" class="history-panel">
|
||||
<div class="section-toolbar">
|
||||
<div>
|
||||
<h2>Single Songs</h2>
|
||||
<p>Direct Spotify track queue entries.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="songs$ | async as songs">
|
||||
<app-playlist-box
|
||||
*ngFor="let playlist of songs"
|
||||
[playlist]="playlist"
|
||||
></app-playlist-box>
|
||||
<p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
|
||||
</ng-container>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'archive-browser'" class="history-panel">
|
||||
<div class="section-toolbar">
|
||||
<div>
|
||||
<h2>Archive Browser</h2>
|
||||
<p>Root: {{ archiveListing?.root || "loading..." }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button is-small reset-button"
|
||||
type="button"
|
||||
[class.is-loading]="archiveLoading"
|
||||
(click)="refreshArchive()"
|
||||
>
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p *ngIf="archiveError" class="help is-danger">{{ archiveError }}</p>
|
||||
<ng-container *ngIf="archiveListing as archive">
|
||||
<div class="archive-summary">
|
||||
<div>
|
||||
<strong>{{ archiveFileCount() }}</strong>
|
||||
<span>files</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ formatBytes(archiveTotalBytes()) }}</strong>
|
||||
<span>total</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Read-only</strong>
|
||||
<span>inspection</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive-controls">
|
||||
<input
|
||||
class="input is-small"
|
||||
type="search"
|
||||
[(ngModel)]="archiveSearch"
|
||||
placeholder="Search archive"
|
||||
/>
|
||||
<div class="select is-small">
|
||||
<select
|
||||
[(ngModel)]="archiveSort"
|
||||
aria-label="Sort archive files"
|
||||
>
|
||||
<option
|
||||
*ngFor="let sortMode of archiveSortModes"
|
||||
[ngValue]="sortMode"
|
||||
>
|
||||
{{ sortMode }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p *ngIf="archiveFileCount() === 0" class="empty-state">
|
||||
No downloaded files found.
|
||||
</p>
|
||||
|
||||
<section
|
||||
*ngFor="let group of archiveGroups()"
|
||||
class="archive-group"
|
||||
>
|
||||
<h3>
|
||||
<i class="fa-solid fa-folder"></i>
|
||||
{{ group.folder }}
|
||||
</h3>
|
||||
|
||||
<div *ngFor="let file of group.files" class="archive-file-card">
|
||||
<i
|
||||
class="fa-solid"
|
||||
[ngClass]="
|
||||
hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'
|
||||
"
|
||||
></i>
|
||||
<div>
|
||||
<strong>{{ file.name }}</strong>
|
||||
<span>{{ folderPath(file) }}</span>
|
||||
</div>
|
||||
<small>{{ formatBytes(file.sizeBytes) }}</small>
|
||||
<small>{{ formatModifiedAt(file.modifiedAt) }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
|
||||
<ng-container
|
||||
*ngIf="
|
||||
selectedTrack$ | async as selectedTrack;
|
||||
else noTrackSelected
|
||||
"
|
||||
>
|
||||
<div class="section-toolbar">
|
||||
<div>
|
||||
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
|
||||
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button is-small reset-button"
|
||||
type="button"
|
||||
(click)="clearSelectedTrack()"
|
||||
>
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-container
|
||||
*ngIf="
|
||||
selectedTrackTruth(selectedTrack) as trackTruth;
|
||||
else noTrackTruth
|
||||
"
|
||||
>
|
||||
<dl class="inspector-list">
|
||||
<dt>Spotify URL</dt>
|
||||
<dd>
|
||||
<a [href]="selectedTrack.spotifyUrl" target="_blank">{{
|
||||
selectedTrack.spotifyUrl || "none"
|
||||
}}</a>
|
||||
</dd>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
||||
<dt>Download attempts</dt>
|
||||
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
||||
<dt>Error class</dt>
|
||||
<dd>{{ errorClass(trackTruth) }}</dd>
|
||||
<dt>Error summary</dt>
|
||||
<dd>{{ errorSummary(trackTruth) }}</dd>
|
||||
<dt>Error detail</dt>
|
||||
<dd>
|
||||
<details
|
||||
*ngIf="errorDetail(trackTruth); else noErrorDetail"
|
||||
class="truth-detail"
|
||||
>
|
||||
<summary>Show backend evidence</summary>
|
||||
<pre>{{ errorDetail(trackTruth) }}</pre>
|
||||
</details>
|
||||
<ng-template #noErrorDetail>none</ng-template>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<div class="candidate-options">
|
||||
<strong>Selected candidate</strong>
|
||||
<ng-container
|
||||
*ngIf="
|
||||
selectedCandidate(trackTruth) as candidate;
|
||||
else noSelectedCandidate
|
||||
"
|
||||
>
|
||||
<dl class="candidate-card">
|
||||
<dt>URL</dt>
|
||||
<dd>
|
||||
<a [href]="candidate.url" target="_blank">{{
|
||||
candidate.url
|
||||
}}</a>
|
||||
</dd>
|
||||
<dt>Title</dt>
|
||||
<dd>{{ candidate.title || "unknown" }}</dd>
|
||||
<dt>Author</dt>
|
||||
<dd>{{ candidate.author || "unknown" }}</dd>
|
||||
<dt>Score</dt>
|
||||
<dd>{{ candidate.score ?? "unknown" }}</dd>
|
||||
<dt>Reason</dt>
|
||||
<dd>{{ candidate.reason || "unknown" }}</dd>
|
||||
</dl>
|
||||
</ng-container>
|
||||
<ng-template #noSelectedCandidate>
|
||||
<p class="empty-state compact">No selected candidate.</p>
|
||||
</ng-template>
|
||||
|
||||
<strong
|
||||
>Rejected candidates:
|
||||
{{ rejectedCandidateCount(trackTruth) }}</strong
|
||||
>
|
||||
<div
|
||||
*ngFor="let candidate of rejectedCandidates(trackTruth)"
|
||||
class="candidate-card rejected-candidate-card"
|
||||
>
|
||||
<dl>
|
||||
<dt>URL</dt>
|
||||
<dd>
|
||||
<a [href]="candidate.url" target="_blank">{{
|
||||
candidate.url
|
||||
}}</a>
|
||||
</dd>
|
||||
<dt>Title</dt>
|
||||
<dd>{{ candidate.title || "unknown" }}</dd>
|
||||
<dt>Author</dt>
|
||||
<dd>{{ candidate.author || "unknown" }}</dd>
|
||||
<dt>Score</dt>
|
||||
<dd>{{ candidate.score ?? "unknown" }}</dd>
|
||||
<dt>Reason</dt>
|
||||
<dd>{{ candidate.reason || "unknown" }}</dd>
|
||||
<dt>Rejection class</dt>
|
||||
<dd>{{ candidate.rejectionClass || "unknown" }}</dd>
|
||||
<dt>Rejection summary</dt>
|
||||
<dd>{{ candidate.rejectionSummary || "unknown" }}</dd>
|
||||
<dt>Rejected at</dt>
|
||||
<dd>{{ candidate.rejectedAt || "unknown" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<p
|
||||
*ngIf="rejectedCandidateCount(trackTruth) === 0"
|
||||
class="empty-state compact"
|
||||
>
|
||||
No rejected candidates.
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noTrackTruth>
|
||||
<div class="placeholder-panel">
|
||||
<i class="fa-solid fa-database"></i>
|
||||
<p>
|
||||
{{
|
||||
operationsSnapshotError ||
|
||||
"Waiting for backend operations truth."
|
||||
}}
|
||||
</p>
|
||||
<button
|
||||
class="button is-small reset-button"
|
||||
type="button"
|
||||
(click)="refreshOperationsSnapshot()"
|
||||
>
|
||||
Refresh truth
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noTrackSelected>
|
||||
<div class="placeholder-panel">
|
||||
<i class="fa-solid fa-magnifying-glass-chart"></i>
|
||||
<p>
|
||||
Select a track row inside an expanded playlist to inspect
|
||||
Spotify, YouTube, retry, and error details.
|
||||
</p>
|
||||
</div>
|
||||
<ng-container *ngIf="allTracks$ | async as allTracks">
|
||||
<button
|
||||
*ngFor="let track of allTracks"
|
||||
class="button is-small is-fullwidth reset-button"
|
||||
type="button"
|
||||
(click)="selectTrack(track)"
|
||||
>
|
||||
{{ track.artist }} - {{ track.name }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="resize-handle"
|
||||
title="Resize panel"
|
||||
(pointerdown)="beginResize($event, panel)"
|
||||
></span>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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,36 +1,387 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {CommonModule, NgFor} from "@angular/common";
|
||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||
import {VersionService} from "./services/version.service";
|
||||
import {map} from "rxjs";
|
||||
import { Component, OnDestroy } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CommonModule, NgFor } from '@angular/common';
|
||||
import {
|
||||
PlaylistService,
|
||||
PlaylistStatusEnum,
|
||||
} from './services/playlist.service';
|
||||
import { PlaylistBoxComponent } from './components/playlist-box/playlist-box.component';
|
||||
import { VersionService } from './services/version.service';
|
||||
import { map } from 'rxjs';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { ArchiveService } from './services/archive.service';
|
||||
import { ArchiveFile, ArchiveListing } from './models/archive';
|
||||
import { Track, TrackStatusEnum } from './models/track';
|
||||
import { TrackService } from './services/track.service';
|
||||
import {
|
||||
OperationsSnapshot,
|
||||
RejectedCandidateTruth,
|
||||
SelectedCandidateTruth,
|
||||
TrackTruth,
|
||||
} from './models/operations-snapshot';
|
||||
|
||||
export type SpootyPanelType =
|
||||
| 'source-intake'
|
||||
| 'queue-observatory'
|
||||
| 'playlist-history'
|
||||
| 'single-songs'
|
||||
| 'archive-browser'
|
||||
| 'candidate-inspector';
|
||||
|
||||
export interface SpootyPanelInstance {
|
||||
id: string;
|
||||
type: SpootyPanelType;
|
||||
title: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface SpootyWorkspaceState {
|
||||
panels: SpootyPanelInstance[];
|
||||
}
|
||||
|
||||
export type SpootyWorkspaceTab = 'intake' | 'archive' | 'diagnostics';
|
||||
export type ArchiveSortMode = 'newest' | 'name' | 'size';
|
||||
|
||||
export interface SpootyWorkspaceTabsState {
|
||||
activeTab: SpootyWorkspaceTab;
|
||||
tabs: Record<SpootyWorkspaceTab, SpootyWorkspaceState>;
|
||||
}
|
||||
|
||||
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
|
||||
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
|
||||
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
|
||||
const GRID_SIZE = 12;
|
||||
const MIN_PANEL_WIDTH = 260;
|
||||
const MIN_PANEL_HEIGHT = 160;
|
||||
|
||||
const PANEL_TITLES: Record<SpootyPanelType, string> = {
|
||||
'source-intake': 'Source Intake',
|
||||
'queue-observatory': 'Queue Observatory',
|
||||
'playlist-history': 'Playlist History',
|
||||
'single-songs': 'Single Songs',
|
||||
'archive-browser': 'Archive Browser',
|
||||
'candidate-inspector': 'Candidate Inspector',
|
||||
};
|
||||
|
||||
const WORKSPACE_TAB_LABELS: Record<SpootyWorkspaceTab, string> = {
|
||||
intake: 'Intake',
|
||||
archive: 'Archive',
|
||||
diagnostics: 'Diagnostics',
|
||||
};
|
||||
|
||||
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
|
||||
intake: [
|
||||
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
|
||||
makePanel(
|
||||
'queue-observatory',
|
||||
568,
|
||||
24,
|
||||
520,
|
||||
260,
|
||||
'intake-queue-observatory',
|
||||
),
|
||||
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
|
||||
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
|
||||
],
|
||||
archive: [
|
||||
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
|
||||
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
|
||||
makePanel(
|
||||
'queue-observatory',
|
||||
960,
|
||||
396,
|
||||
420,
|
||||
260,
|
||||
'archive-queue-observatory',
|
||||
),
|
||||
],
|
||||
diagnostics: [
|
||||
makePanel(
|
||||
'candidate-inspector',
|
||||
24,
|
||||
24,
|
||||
620,
|
||||
720,
|
||||
'diagnostics-candidate-inspector',
|
||||
),
|
||||
makePanel(
|
||||
'playlist-history',
|
||||
680,
|
||||
24,
|
||||
620,
|
||||
520,
|
||||
'diagnostics-playlist-history',
|
||||
),
|
||||
makePanel(
|
||||
'queue-observatory',
|
||||
1320,
|
||||
24,
|
||||
420,
|
||||
260,
|
||||
'diagnostics-queue-observatory',
|
||||
),
|
||||
makePanel(
|
||||
'archive-browser',
|
||||
1320,
|
||||
320,
|
||||
420,
|
||||
420,
|
||||
'diagnostics-archive-browser',
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export function makePanel(
|
||||
type: SpootyPanelType,
|
||||
x = 48,
|
||||
y = 48,
|
||||
w = 520,
|
||||
h = 320,
|
||||
id = `${type}-${Date.now()}`,
|
||||
): SpootyPanelInstance {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
title: PANEL_TITLES[type],
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWorkspaceState(): SpootyWorkspaceState {
|
||||
return defaultWorkspaceStateForTab('intake');
|
||||
}
|
||||
|
||||
export function defaultWorkspaceStateForTab(
|
||||
tab: SpootyWorkspaceTab,
|
||||
): SpootyWorkspaceState {
|
||||
return {
|
||||
panels: DEFAULT_TAB_PANELS[tab].map((panel) => ({ ...panel })),
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
||||
return {
|
||||
activeTab: 'intake',
|
||||
tabs: {
|
||||
intake: defaultWorkspaceStateForTab('intake'),
|
||||
archive: defaultWorkspaceStateForTab('archive'),
|
||||
diagnostics: defaultWorkspaceStateForTab('diagnostics'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function loadWorkspaceState(): SpootyWorkspaceState {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return defaultWorkspaceState();
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
|
||||
const panels = parsed.panels
|
||||
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||
.map((panel) => ({
|
||||
...panel,
|
||||
title: PANEL_TITLES[panel.type],
|
||||
x: snap(panel.x),
|
||||
y: snap(panel.y),
|
||||
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
|
||||
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||
}));
|
||||
|
||||
return panels?.length ? { panels } : defaultWorkspaceState();
|
||||
} catch {
|
||||
return defaultWorkspaceState();
|
||||
}
|
||||
}
|
||||
|
||||
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY);
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
||||
return {
|
||||
activeTab:
|
||||
parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab]
|
||||
? parsed.activeTab
|
||||
: 'intake',
|
||||
tabs: {
|
||||
intake: sanitizeWorkspaceState(
|
||||
parsed.tabs?.intake,
|
||||
defaultWorkspaceStateForTab('intake'),
|
||||
),
|
||||
archive: sanitizeWorkspaceState(
|
||||
parsed.tabs?.archive,
|
||||
defaultWorkspaceStateForTab('archive'),
|
||||
),
|
||||
diagnostics: sanitizeWorkspaceState(
|
||||
parsed.tabs?.diagnostics,
|
||||
defaultWorkspaceStateForTab('diagnostics'),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultWorkspaceTabsState(),
|
||||
tabs: {
|
||||
...defaultWorkspaceTabsState().tabs,
|
||||
intake: loadWorkspaceState(),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return defaultWorkspaceTabsState();
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeWorkspaceState(
|
||||
state: SpootyWorkspaceState | undefined,
|
||||
fallback: SpootyWorkspaceState,
|
||||
): SpootyWorkspaceState {
|
||||
const panels = state?.panels
|
||||
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||
.map((panel) => ({
|
||||
...panel,
|
||||
title: PANEL_TITLES[panel.type],
|
||||
x: snap(panel.x),
|
||||
y: snap(panel.y),
|
||||
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
|
||||
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||
}));
|
||||
|
||||
return panels?.length ? { panels } : fallback;
|
||||
}
|
||||
|
||||
function snap(value: number): number {
|
||||
return Math.round(value / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
standalone: true,
|
||||
selector: 'app-root',
|
||||
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
standalone: true,
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
url = ''
|
||||
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
||||
export class AppComponent implements OnDestroy {
|
||||
url = '';
|
||||
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;
|
||||
};
|
||||
private readonly operationsRefreshMs = 5000;
|
||||
private operationsRefreshTimer?: number;
|
||||
readonly panelTypes: SpootyPanelType[] = [
|
||||
'source-intake',
|
||||
'queue-observatory',
|
||||
'playlist-history',
|
||||
'single-songs',
|
||||
'archive-browser',
|
||||
'candidate-inspector',
|
||||
];
|
||||
readonly workspaceTabs: SpootyWorkspaceTab[] = [
|
||||
'intake',
|
||||
'archive',
|
||||
'diagnostics',
|
||||
];
|
||||
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
|
||||
selectedPanelType: SpootyPanelType = 'source-intake';
|
||||
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
|
||||
|
||||
get isValidSpotifyUrl(): boolean {
|
||||
return this.spotifyUrlPattern.test(this.url);
|
||||
}
|
||||
createLoading$ = this.playlistService.createLoading$;
|
||||
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
||||
songs$ = 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)),
|
||||
);
|
||||
allTracks$ = this.trackService.all$;
|
||||
selectedTrack$ = this.trackService.selectedTrack$;
|
||||
version = this.versionService.getVersion();
|
||||
spotifyConnected = false;
|
||||
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || '';
|
||||
archiveListing?: ArchiveListing;
|
||||
archiveLoading = false;
|
||||
archiveError = '';
|
||||
archiveSearch = '';
|
||||
archiveSort: ArchiveSortMode = 'newest';
|
||||
trackStatuses = TrackStatusEnum;
|
||||
operationsSnapshot?: OperationsSnapshot;
|
||||
operationsSnapshotError = '';
|
||||
|
||||
get activeWorkspaceTab(): SpootyWorkspaceTab {
|
||||
return this.workspaceTabsState.activeTab;
|
||||
}
|
||||
|
||||
get workspace(): SpootyWorkspaceState {
|
||||
return this.workspaceTabsState.tabs[this.activeWorkspaceTab];
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly playlistService: PlaylistService,
|
||||
private readonly versionService: VersionService,
|
||||
private readonly http: HttpClient,
|
||||
private readonly archiveService: ArchiveService,
|
||||
private readonly trackService: TrackService,
|
||||
) {
|
||||
this.bootstrapAuthTokenFromUrl();
|
||||
this.checkSpotifyStatus();
|
||||
this.fetchPlaylists();
|
||||
this.refreshArchive();
|
||||
this.refreshOperationsSnapshot();
|
||||
this.operationsRefreshTimer = window.setInterval(
|
||||
() => this.refreshOperationsSnapshot(),
|
||||
this.operationsRefreshMs,
|
||||
);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopWorkspaceInteraction();
|
||||
if (this.operationsRefreshTimer) {
|
||||
window.clearInterval(this.operationsRefreshTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private bootstrapAuthTokenFromUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('spooty_auth_token', token);
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
|
||||
checkSpotifyStatus(): void {
|
||||
this.http
|
||||
.get<{ connected: boolean }>('/api/spotify/status')
|
||||
.subscribe((status) => (this.spotifyConnected = status.connected));
|
||||
}
|
||||
|
||||
connectSpotify(): void {
|
||||
window.location.href = '/api/spotify/login';
|
||||
}
|
||||
|
||||
fetchPlaylists(): void {
|
||||
@@ -38,6 +389,7 @@ export class AppComponent {
|
||||
}
|
||||
|
||||
download(): void {
|
||||
this.saveArchiveDestination();
|
||||
this.url && this.playlistService.create(this.url);
|
||||
this.url = '';
|
||||
}
|
||||
@@ -49,4 +401,369 @@ export class AppComponent {
|
||||
deleteFailed(): void {
|
||||
this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error);
|
||||
}
|
||||
|
||||
saveArchiveDestination(): void {
|
||||
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
|
||||
}
|
||||
|
||||
refreshArchive(): void {
|
||||
this.archiveLoading = true;
|
||||
this.archiveError = '';
|
||||
this.archiveService.list().subscribe({
|
||||
next: (listing) => {
|
||||
this.archiveListing = listing;
|
||||
this.archiveDestination ||= listing.root;
|
||||
this.archiveLoading = false;
|
||||
},
|
||||
error: () => {
|
||||
this.archiveError = 'Archive listing is unavailable.';
|
||||
this.archiveLoading = false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
refreshOperationsSnapshot(): void {
|
||||
this.http.get<OperationsSnapshot>('/api/operations/snapshot').subscribe({
|
||||
next: (snapshot) => {
|
||||
this.operationsSnapshot = snapshot;
|
||||
this.operationsSnapshotError = '';
|
||||
},
|
||||
error: () => {
|
||||
this.operationsSnapshotError = 'Operations snapshot is unavailable.';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(sizeBytes: number): string {
|
||||
if (sizeBytes < 1024) {
|
||||
return `${sizeBytes} B`;
|
||||
}
|
||||
|
||||
if (sizeBytes < 1024 * 1024) {
|
||||
return `${(sizeBytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
formatModifiedAt(modifiedAt: number): string {
|
||||
return new Date(modifiedAt).toLocaleString();
|
||||
}
|
||||
|
||||
trackStatusLabel(status?: TrackStatusEnum): string {
|
||||
switch (status) {
|
||||
case TrackStatusEnum.New:
|
||||
return 'New';
|
||||
case TrackStatusEnum.Searching:
|
||||
return 'Searching';
|
||||
case TrackStatusEnum.Queued:
|
||||
return 'Queued';
|
||||
case TrackStatusEnum.Downloading:
|
||||
return 'Downloading';
|
||||
case TrackStatusEnum.Completed:
|
||||
return 'Completed';
|
||||
case TrackStatusEnum.Error:
|
||||
return 'Error';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
selectTrack(track: Track): void {
|
||||
this.trackService.select(track);
|
||||
this.refreshOperationsSnapshot();
|
||||
}
|
||||
|
||||
clearSelectedTrack(): void {
|
||||
this.trackService.clearSelection();
|
||||
}
|
||||
|
||||
selectedTrackTruth(track: Track): TrackTruth | undefined {
|
||||
return this.findTrackTruth(track.id);
|
||||
}
|
||||
|
||||
selectedCandidate(truth?: TrackTruth): SelectedCandidateTruth | undefined {
|
||||
return truth?.selectedCandidate;
|
||||
}
|
||||
|
||||
rejectedCandidates(truth?: TrackTruth): RejectedCandidateTruth[] {
|
||||
return truth?.rejectedCandidates || [];
|
||||
}
|
||||
|
||||
rejectedCandidateCount(truth?: TrackTruth): number {
|
||||
return truth?.rejectedCandidateCount || 0;
|
||||
}
|
||||
|
||||
errorClass(truth?: TrackTruth): string {
|
||||
return truth?.errorClass || 'none';
|
||||
}
|
||||
|
||||
errorSummary(truth?: TrackTruth): string {
|
||||
return truth?.errorSummary || 'none';
|
||||
}
|
||||
|
||||
errorDetail(truth?: TrackTruth): string {
|
||||
return truth?.errorDetail || '';
|
||||
}
|
||||
|
||||
private findTrackTruth(id: number): TrackTruth | undefined {
|
||||
if (!this.operationsSnapshot) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
...this.operationsSnapshot.active.searching,
|
||||
...this.operationsSnapshot.active.downloading,
|
||||
...this.operationsSnapshot.recentFailures,
|
||||
...this.operationsSnapshot.recentTracks,
|
||||
].find((track) => track.id === id);
|
||||
}
|
||||
|
||||
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
|
||||
return WORKSPACE_TAB_LABELS[tab];
|
||||
}
|
||||
|
||||
switchWorkspaceTab(tab: SpootyWorkspaceTab): void {
|
||||
this.workspaceTabsState = {
|
||||
...this.workspaceTabsState,
|
||||
activeTab: tab,
|
||||
};
|
||||
this.saveWorkspaceState();
|
||||
}
|
||||
|
||||
resetLayout(): void {
|
||||
this.setActiveWorkspace(
|
||||
defaultWorkspaceStateForTab(this.activeWorkspaceTab),
|
||||
);
|
||||
}
|
||||
|
||||
resetAllLayouts(): void {
|
||||
this.workspaceTabsState = defaultWorkspaceTabsState();
|
||||
this.saveWorkspaceState();
|
||||
}
|
||||
|
||||
filteredArchiveFiles(): ArchiveFile[] {
|
||||
const files = this.archiveListing?.files || [];
|
||||
const query = this.archiveSearch.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? files.filter((file) =>
|
||||
`${file.name} ${file.path}`.toLowerCase().includes(query),
|
||||
)
|
||||
: [...files];
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (this.archiveSort === 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
if (this.archiveSort === 'size') {
|
||||
return b.sizeBytes - a.sizeBytes;
|
||||
}
|
||||
|
||||
return b.modifiedAt - a.modifiedAt;
|
||||
});
|
||||
}
|
||||
|
||||
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
||||
const groups = new Map<string, ArchiveFile[]>();
|
||||
|
||||
this.filteredArchiveFiles().forEach((file) => {
|
||||
const folder = this.topLevelFolder(file);
|
||||
groups.set(folder, [...(groups.get(folder) || []), file]);
|
||||
});
|
||||
|
||||
return [...groups.entries()].map(([folder, files]) => ({ folder, files }));
|
||||
}
|
||||
|
||||
archiveFileCount(): number {
|
||||
return this.filteredArchiveFiles().length;
|
||||
}
|
||||
|
||||
archiveTotalBytes(): number {
|
||||
return this.filteredArchiveFiles().reduce(
|
||||
(total, file) => total + file.sizeBytes,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
topLevelFolder(file: ArchiveFile): string {
|
||||
return file.path.includes('/') ? file.path.split('/')[0] : 'Root';
|
||||
}
|
||||
|
||||
folderPath(file: ArchiveFile): string {
|
||||
const index = file.path.lastIndexOf('/');
|
||||
return index === -1 ? 'Root' : file.path.slice(0, index);
|
||||
}
|
||||
|
||||
hasFolder(file: ArchiveFile): boolean {
|
||||
return file.path.includes('/');
|
||||
}
|
||||
|
||||
getPanelTitle(type: SpootyPanelType): string {
|
||||
return PANEL_TITLES[type];
|
||||
}
|
||||
|
||||
addPanel(type: SpootyPanelType = this.selectedPanelType): void {
|
||||
const offset = this.workspace.panels.length * GRID_SIZE;
|
||||
this.setActiveWorkspace({
|
||||
panels: [
|
||||
...this.workspace.panels,
|
||||
makePanel(type, 48 + offset, 72 + offset),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
closePanel(panelId: string): void {
|
||||
if (this.workspace.panels.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setActiveWorkspace({
|
||||
panels: this.workspace.panels.filter((panel) => panel.id !== panelId),
|
||||
});
|
||||
}
|
||||
|
||||
movePanel(panelId: string, x: number, y: number): void {
|
||||
this.updatePanel(panelId, {
|
||||
x: Math.max(0, snap(x)),
|
||||
y: Math.max(0, snap(y)),
|
||||
});
|
||||
}
|
||||
|
||||
resizePanel(panelId: string, w: number, h: number): void {
|
||||
this.updatePanel(panelId, {
|
||||
w: Math.max(MIN_PANEL_WIDTH, snap(w)),
|
||||
h: Math.max(MIN_PANEL_HEIGHT, snap(h)),
|
||||
});
|
||||
}
|
||||
|
||||
beginDrag(event: PointerEvent, panel: SpootyPanelInstance): void {
|
||||
event.preventDefault();
|
||||
this.bringPanelToFront(panel.id);
|
||||
this.interaction = {
|
||||
mode: 'drag',
|
||||
panelId: panel.id,
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startPanelX: panel.x,
|
||||
startPanelY: panel.y,
|
||||
startPanelW: panel.w,
|
||||
startPanelH: panel.h,
|
||||
};
|
||||
this.startWorkspaceInteraction();
|
||||
}
|
||||
|
||||
beginResize(event: PointerEvent, panel: SpootyPanelInstance): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.bringPanelToFront(panel.id);
|
||||
this.interaction = {
|
||||
mode: 'resize',
|
||||
panelId: panel.id,
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startPanelX: panel.x,
|
||||
startPanelY: panel.y,
|
||||
startPanelW: panel.w,
|
||||
startPanelH: panel.h,
|
||||
};
|
||||
this.startWorkspaceInteraction();
|
||||
}
|
||||
|
||||
renderPanel(index: number, panel: SpootyPanelInstance): string {
|
||||
return panel.id;
|
||||
}
|
||||
|
||||
panelStyle(panel: SpootyPanelInstance): Record<string, string> {
|
||||
return {
|
||||
transform: `translate(${panel.x}px, ${panel.y}px)`,
|
||||
width: `${panel.w}px`,
|
||||
height: `${panel.h}px`,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly handlePointerMove = (event: PointerEvent): void => {
|
||||
if (!this.interaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = event.clientX - this.interaction.startPointerX;
|
||||
const dy = event.clientY - this.interaction.startPointerY;
|
||||
|
||||
if (this.interaction.mode === 'drag') {
|
||||
this.movePanel(
|
||||
this.interaction.panelId,
|
||||
this.interaction.startPanelX + dx,
|
||||
this.interaction.startPanelY + dy,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizePanel(
|
||||
this.interaction.panelId,
|
||||
this.interaction.startPanelW + dx,
|
||||
this.interaction.startPanelH + dy,
|
||||
);
|
||||
};
|
||||
|
||||
private readonly handlePointerUp = (): void => {
|
||||
this.stopWorkspaceInteraction();
|
||||
};
|
||||
|
||||
private startWorkspaceInteraction(): void {
|
||||
document.addEventListener('pointermove', this.handlePointerMove);
|
||||
document.addEventListener('pointerup', this.handlePointerUp, {
|
||||
once: true,
|
||||
});
|
||||
}
|
||||
|
||||
private stopWorkspaceInteraction(): void {
|
||||
this.interaction = undefined;
|
||||
document.removeEventListener('pointermove', this.handlePointerMove);
|
||||
document.removeEventListener('pointerup', this.handlePointerUp);
|
||||
}
|
||||
|
||||
private bringPanelToFront(panelId: string): void {
|
||||
const panel = this.workspace.panels.find((item) => item.id === panelId);
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setActiveWorkspace({
|
||||
panels: [
|
||||
...this.workspace.panels.filter((item) => item.id !== panelId),
|
||||
panel,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private updatePanel(
|
||||
panelId: string,
|
||||
changes: Partial<SpootyPanelInstance>,
|
||||
): void {
|
||||
this.setActiveWorkspace({
|
||||
panels: this.workspace.panels.map((panel) =>
|
||||
panel.id === panelId ? { ...panel, ...changes } : panel,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private setActiveWorkspace(workspace: SpootyWorkspaceState): void {
|
||||
this.workspaceTabsState = {
|
||||
...this.workspaceTabsState,
|
||||
tabs: {
|
||||
...this.workspaceTabsState.tabs,
|
||||
[this.activeWorkspaceTab]: workspace,
|
||||
},
|
||||
};
|
||||
this.saveWorkspaceState();
|
||||
}
|
||||
|
||||
private saveWorkspaceState(): void {
|
||||
localStorage.setItem(
|
||||
WORKSPACE_TABS_STORAGE_KEY,
|
||||
JSON.stringify(this.workspaceTabsState),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,65 @@
|
||||
<article class="panel" [ngClass]="statusClass$ | async">
|
||||
<p class="panel-heading is-flex is-justify-content-space-between">
|
||||
<span>
|
||||
<i class="is-clickable fa-solid"
|
||||
[ngClass]="playlist.collapsed ? 'fa-caret-up' : 'fa-caret-down'"
|
||||
(click)="toggleCollapse(playlist.id)"
|
||||
></i>
|
||||
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">{{playlist.error}}</span>
|
||||
<ng-template #noErrorTemplate>
|
||||
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
||||
</ng-template>
|
||||
<a [href]="playlist.spotifyUrl"
|
||||
target="_blank"
|
||||
class="has-text-black" title="Link to Spotify url to be download from">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
</a>
|
||||
</span>
|
||||
<span>
|
||||
<article class="panel playlist-card" [ngClass]="statusClass$ | async">
|
||||
<p class="panel-heading playlist-heading is-flex is-justify-content-space-between" (click)="toggleCollapse(playlist.id)">
|
||||
<span class="playlist-title">
|
||||
<i class="is-clickable fa-solid"
|
||||
[ngClass]="playlist.collapsed ? 'fa-caret-down' : 'fa-caret-right'"></i>
|
||||
|
||||
<span class="history-label">{{ playlist.isTrack ? 'track' : 'playlist' }}</span>
|
||||
|
||||
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">
|
||||
{{playlist.error}}
|
||||
</span>
|
||||
|
||||
<ng-template #noErrorTemplate>
|
||||
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
||||
</ng-template>
|
||||
</span>
|
||||
|
||||
<span class="playlist-actions" (click)="$event.stopPropagation()">
|
||||
<span *ngIf="!playlist.error" class="progress-text">
|
||||
{{trackCompletedCount$ | async}}/{{trackCount$ | async}}
|
||||
</span>
|
||||
|
||||
<span *ngIf="(trackErrorCount$ | async) as errors" class="error-count" [class.is-hidden]="errors === 0">
|
||||
{{errors}} failed
|
||||
</span>
|
||||
|
||||
<a [href]="playlist.spotifyUrl"
|
||||
target="_blank"
|
||||
class="has-text-black"
|
||||
title="Open Spotify URL">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
</a>
|
||||
|
||||
<ng-container *ngIf="!playlist.isTrack">
|
||||
<i class="fa-solid is-clickable hover-icon"
|
||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||
(click)="toggleActive(playlist.id, playlist.active)"
|
||||
></i>
|
||||
<i> </i>
|
||||
(click)="toggleActive(playlist.id, playlist.active)">
|
||||
</i>
|
||||
</ng-container>
|
||||
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>
|
||||
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>
|
||||
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
||||
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>
|
||||
|
||||
<i class="fa-solid fa-repeat is-clickable hover-icon"
|
||||
title="Retry failed tracks"
|
||||
(click)="retryFailed(playlist.id)">
|
||||
</i>
|
||||
|
||||
<i class="fa-solid fa-xmark is-clickable hover-icon"
|
||||
title="Remove playlist from list"
|
||||
(click)="delete(playlist.id)">
|
||||
</i>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="progress-shell">
|
||||
<div class="progress-fill" [style.width.%]="progressPercent$ | async"></div>
|
||||
</div>
|
||||
|
||||
<p class="progress-caption">
|
||||
<span>{{progressPercent$ | async}}% complete</span>
|
||||
<span *ngIf="playlist.active">subscribed</span>
|
||||
</p>
|
||||
|
||||
<ng-container *ngIf="playlist.collapsed">
|
||||
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
||||
</ng-container>
|
||||
|
||||
@@ -2,19 +2,73 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
padding: 5px 10px;
|
||||
border-radius: 10px;
|
||||
.playlist-card {
|
||||
margin: 8px 0;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.playlist-heading {
|
||||
padding: 8px 12px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .hover-icon {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&:hover .hover-icon-reversed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin: 5px 0;
|
||||
.playlist-title {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.history-label {
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
padding: 2px 7px;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.playlist-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.error-count {
|
||||
border-radius: 999px;
|
||||
padding: 2px 7px;
|
||||
background: #ff3860;
|
||||
color: white;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.progress-shell {
|
||||
height: 7px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1db954, #48f59b);
|
||||
transition: width 180ms ease-out;
|
||||
}
|
||||
|
||||
.progress-caption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 0;
|
||||
padding: 5px 12px 8px;
|
||||
color: #667;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Component, Input} from '@angular/core';
|
||||
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
||||
import {TrackListComponent} from "../track-list/track-list.component";
|
||||
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
||||
import {Observable, map} from "rxjs";
|
||||
import {Observable, combineLatest, map} from "rxjs";
|
||||
import {Playlist} from "../../models/playlist";
|
||||
|
||||
const STATUS2CLASS = {
|
||||
@@ -14,15 +14,15 @@ const STATUS2CLASS = {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-box',
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
NgIf,
|
||||
TrackListComponent
|
||||
],
|
||||
templateUrl: './playlist-box.component.html',
|
||||
styleUrl: './playlist-box.component.scss',
|
||||
selector: 'app-playlist-box',
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
NgIf,
|
||||
TrackListComponent
|
||||
],
|
||||
templateUrl: './playlist-box.component.html',
|
||||
styleUrl: './playlist-box.component.scss',
|
||||
standalone: true
|
||||
})
|
||||
export class PlaylistBoxComponent {
|
||||
@@ -31,16 +31,27 @@ export class PlaylistBoxComponent {
|
||||
this._playlist = val;
|
||||
this.trackCount$ = this.service.getTrackCount(val.id);
|
||||
this.trackCompletedCount$ = this.service.getCompletedTrackCount(val.id);
|
||||
this.trackErrorCount$ = this.service.getErrorTrackCount(val.id);
|
||||
this.progressPercent$ = combineLatest([
|
||||
this.trackCompletedCount$,
|
||||
this.trackCount$,
|
||||
]).pipe(
|
||||
map(([completed, total]) => total ? Math.round((completed / total) * 100) : 0)
|
||||
);
|
||||
this.statusClass$ = this.service.getStatus$(val.id).pipe(
|
||||
map(status => STATUS2CLASS[status])
|
||||
);
|
||||
}
|
||||
|
||||
get playlist(): Playlist & PlaylistUi {
|
||||
return this._playlist;
|
||||
}
|
||||
|
||||
_playlist!: Playlist & PlaylistUi;
|
||||
trackCount$!: Observable<number>;
|
||||
trackCompletedCount$!: Observable<number>;
|
||||
trackErrorCount$!: Observable<number>;
|
||||
progressPercent$!: Observable<number>;
|
||||
statusClass$!: Observable<string>;
|
||||
|
||||
constructor(private readonly service: PlaylistService) { }
|
||||
|
||||
@@ -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>
|
||||
<span>{{ track.artist }} - {{ track.name }}</span>
|
||||
<a [href]="track.spotifyUrl"
|
||||
target="_blank"
|
||||
(click)="$event.stopPropagation()"
|
||||
class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
</a>
|
||||
<a [href]="track.youtubeUrl"
|
||||
target="_blank"
|
||||
(click)="$event.stopPropagation()"
|
||||
class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded">
|
||||
<i class="fa-brands fa-youtube"></i>
|
||||
</a>
|
||||
<a *ngIf="track.status === trackStatuses.Completed"
|
||||
href="api/track/download/{{track.id}}"
|
||||
(click)="$event.stopPropagation()"
|
||||
class="has-text-info"
|
||||
title="Download downloaded and locally saved file"
|
||||
download>
|
||||
@@ -20,8 +23,8 @@
|
||||
</a>
|
||||
</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 class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="delete(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)="$event.stopPropagation(); delete(track.id)"></i>
|
||||
<ng-container [ngSwitch]="track.status">
|
||||
<span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span>
|
||||
<span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span>
|
||||
|
||||
@@ -30,4 +30,8 @@ export class TrackListComponent {
|
||||
retry(id: number): void {
|
||||
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[];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface SelectedCandidateTruth {
|
||||
url: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
score?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RejectedCandidateTruth {
|
||||
url: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
score?: number;
|
||||
reason?: string;
|
||||
rejectionClass?: string;
|
||||
rejectionSummary?: string;
|
||||
rejectedAt?: string;
|
||||
}
|
||||
|
||||
export interface TrackTruth {
|
||||
id: number;
|
||||
artist: string;
|
||||
name: string;
|
||||
status: string;
|
||||
spotifyUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
selectedCandidate?: SelectedCandidateTruth;
|
||||
downloadAttemptCount?: number;
|
||||
errorClass?: string;
|
||||
errorSummary?: string;
|
||||
errorDetail?: string;
|
||||
rejectedCandidateCount: number;
|
||||
rejectedCandidates: RejectedCandidateTruth[];
|
||||
}
|
||||
|
||||
export interface OperationsSnapshot {
|
||||
generatedAt: string;
|
||||
runtime: {
|
||||
operation: string;
|
||||
spotifyConnected: boolean;
|
||||
searchQueueDepth: number;
|
||||
downloadQueueDepth: number;
|
||||
activeSearches: number;
|
||||
activeDownloads: number;
|
||||
};
|
||||
counters: {
|
||||
playlists: number;
|
||||
singleSongs: number;
|
||||
tracks: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
retries: number;
|
||||
};
|
||||
active: {
|
||||
searching: TrackTruth[];
|
||||
downloading: TrackTruth[];
|
||||
};
|
||||
recentFailures: TrackTruth[];
|
||||
recentTracks: TrackTruth[];
|
||||
}
|
||||
@@ -4,10 +4,14 @@ export interface Track {
|
||||
name: string;
|
||||
spotifyUrl: string;
|
||||
youtubeUrl: string;
|
||||
rejectedYoutubeUrls?: string[] | string;
|
||||
downloadAttemptCount?: number;
|
||||
status: TrackStatusEnum;
|
||||
playlistId?: number;
|
||||
error?: string;
|
||||
coverUrl?: string;
|
||||
durationMs?: number;
|
||||
createdAt?: number;
|
||||
}
|
||||
|
||||
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 {
|
||||
this.http.get<void>(`${ENDPOINT}/retry/${id}`).subscribe();
|
||||
this.http.post<void>(`${ENDPOINT}/retry/${id}`, {}).subscribe();
|
||||
}
|
||||
|
||||
setActive(id: number, active: boolean): void {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
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 {map, Observable, tap} from "rxjs";
|
||||
import {BehaviorSubject, map, Observable, tap} from "rxjs";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {Track, TrackStatusEnum} from "../models/track";
|
||||
|
||||
@@ -19,11 +19,16 @@ enum WsTrackOperation {
|
||||
})
|
||||
export class TrackService {
|
||||
|
||||
private readonly selectedTrackSubject = new BehaviorSubject<Track | undefined>(undefined);
|
||||
selectedTrack$ = this.selectedTrackSubject.asObservable();
|
||||
|
||||
private store = createStore(
|
||||
{ name: STORE_NAME },
|
||||
withEntities<Track>(),
|
||||
);
|
||||
|
||||
all$ = this.store.pipe(selectAllEntities());
|
||||
|
||||
getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> {
|
||||
return this.store.pipe(
|
||||
selectManyByPredicate((track) => track?.playlistId === id),
|
||||
@@ -57,12 +62,33 @@ export class TrackService {
|
||||
}
|
||||
|
||||
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 {
|
||||
this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track)));
|
||||
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id)));
|
||||
this.socket.on(WsTrackOperation.Update, (track: Track) => {
|
||||
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.store.update(upsertEntities([{...track, playlistId}]))
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>SpootyFe</title>
|
||||
<title>Jarri Spooty</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||
|
||||
@@ -12,3 +12,170 @@ body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
z-index: 19;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 24px 0;
|
||||
background: rgba(8, 15, 15, 0.94);
|
||||
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
|
||||
}
|
||||
|
||||
.workspace-tab {
|
||||
min-width: 116px;
|
||||
padding: 9px 14px;
|
||||
border: 1px solid rgba(126, 255, 180, 0.18);
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #bcd1c4;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-tab.active {
|
||||
background: #14211d;
|
||||
color: #9fffc3;
|
||||
}
|
||||
|
||||
.archive-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.archive-summary div,
|
||||
.archive-file-card {
|
||||
border: 1px solid rgba(126, 255, 180, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.archive-summary div {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.archive-summary span,
|
||||
.archive-file-card span,
|
||||
.archive-file-card small {
|
||||
display: block;
|
||||
color: #9db0a6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.archive-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.archive-group h3 {
|
||||
margin: 12px 0 6px;
|
||||
color: #f1fff5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.archive-file-card {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) auto auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.archive-file-card i {
|
||||
color: #9fffc3;
|
||||
}
|
||||
|
||||
.inspector-list {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.inspector-list dt,
|
||||
.candidate-options strong {
|
||||
color: #f1fff5;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.inspector-list dd {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.candidate-options {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #bcd1c4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(126, 255, 180, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.candidate-card,
|
||||
.candidate-card dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.candidate-card dt {
|
||||
color: #f1fff5;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.candidate-card dd {
|
||||
margin: 0 0 8px;
|
||||
color: #bcd1c4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.candidate-card dd:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rejected-candidate-card {
|
||||
border-color: rgba(255, 214, 102, 0.18);
|
||||
}
|
||||
|
||||
.truth-detail summary {
|
||||
color: #9fffc3;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.truth-detail pre {
|
||||
margin-top: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(126, 255, 180, 0.14);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
color: #dceee4;
|
||||
font-size: 0.78rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.workspace-tabs {
|
||||
position: static;
|
||||
padding: 8px 14px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.archive-file-card {
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user