Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e96e5fd87 | ||
|
|
bd5742f924 | ||
|
|
69d059b8a5 | ||
|
|
2750aa5c06 | ||
|
|
56534d1d33 | ||
|
|
18deaa2d90 | ||
|
|
66c71a7487 | ||
|
|
a41884442d | ||
|
|
01fe5bcaf8 | ||
|
|
8a58aa29d9 |
+1
-1
@@ -4,6 +4,6 @@
|
||||
**/*.sqlite
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
|
||||
test-downloads/
|
||||
|
||||
spooty-config
|
||||
|
||||
@@ -10,6 +10,49 @@ This project follows a practical chronological changelog rather than autogenerat
|
||||
|
||||
## Added
|
||||
|
||||
- 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.
|
||||
|
||||
## Changed
|
||||
|
||||
- Kept Archive Browser read-only; no arbitrary browsing, delete, move, rename, or open-file operations were added.
|
||||
|
||||
---
|
||||
|
||||
# 3.0.0
|
||||
|
||||
## Added
|
||||
|
||||
- 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.
|
||||
|
||||
## Changed
|
||||
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
# 2.4.2 Hardened Workspace Foundation
|
||||
|
||||
## Added
|
||||
|
||||
### Spotify
|
||||
|
||||
- Added Spotify OAuth login flow
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
# Jarri Spooty
|
||||
|
||||
Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
|
||||
Deterministic media ingestion workspace for Spotify metadata and YouTube archival.
|
||||
|
||||
Jarri Spooty does not download audio from Spotify itself.
|
||||

|
||||
|
||||
Spooty does not download audio from Spotify itself.
|
||||
It retrieves metadata from Spotify and locates matching audio on YouTube.
|
||||
|
||||
Deterministic Spotify metadata ingestion with duration-aware YouTube candidate scoring, paced queue execution, hardened Docker deployment, and local archival workflows.
|
||||
|
||||
Current version: 3.0.0
|
||||
|
||||
This hardened branch focuses on:
|
||||
|
||||
- ChronoGit-style Workspace UI panels
|
||||
- Large playlist support (>100 tracks)
|
||||
- Spotify OAuth login flow
|
||||
- Better YouTube pacing and throttling resistance
|
||||
@@ -23,6 +30,10 @@ This hardened branch focuses on:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -44,6 +55,45 @@ This hardened branch focuses on:
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
# Workspace UI
|
||||
|
||||
Jarri Spooty 3.0.0 introduces a draggable/resizable Workspace shell with frontend tab presets.
|
||||
|
||||
Workspace tabs:
|
||||
|
||||
- Intake
|
||||
- Archive
|
||||
- Diagnostics
|
||||
|
||||
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.
|
||||
|
||||
Workspace panels:
|
||||
|
||||
- Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
|
||||
- Queue Observatory: Spotify, playlist, single-song, and archive-run metrics
|
||||
- Playlist History: existing playlist rows and delete completed/delete failed controls
|
||||
- Single Songs: existing single-track queue rows
|
||||
- Archive Browser: read-only browser-style listing of downloaded files inside the configured downloads root, with search, sort, file count, total size, and top-level folder grouping
|
||||
- Candidate Inspector: selected track details including Spotify URL, selected YouTube candidate, status, error, retry attempts, rejected candidates when available, and a Back/Clear selection action
|
||||
|
||||
The Archive destination field is frontend-persisted planning state. Runtime downloads still use the backend `DOWNLOADS_PATH` root. Per-run destination routing is pending backend support.
|
||||
|
||||
The Archive Browser uses:
|
||||
|
||||
GET /api/archive
|
||||
|
||||
It returns the configured downloads root and files under that root only. It does not allow arbitrary filesystem browsing, deletion, moving, renaming, or path traversal.
|
||||
|
||||
Candidate alternatives are limited to the selected YouTube URL and rejected YouTube URLs already stored for the track. Full scoring history is not persisted yet.
|
||||
|
||||
---
|
||||
|
||||
@@ -94,17 +144,17 @@ Copy:
|
||||
|
||||
Create a secure env file:
|
||||
|
||||
```bash
|
||||
~~~bash
|
||||
sudo mkdir -p /etc/tokens
|
||||
|
||||
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOF'
|
||||
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOT'
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
EOF
|
||||
EOT
|
||||
|
||||
sudo chown root:$USER /etc/tokens/spotify.env
|
||||
sudo chmod 640 /etc/tokens/spotify.env
|
||||
```
|
||||
~~~
|
||||
|
||||
Never commit this file.
|
||||
|
||||
@@ -118,17 +168,17 @@ Export a Netscape-format `cookies.txt` from a logged-in browser session.
|
||||
|
||||
Recommended storage:
|
||||
|
||||
```bash
|
||||
~~~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
|
||||
~~~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' \
|
||||
@@ -141,7 +191,7 @@ docker run --rm -p 3000:3000 \
|
||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||
jarri-spooty:local
|
||||
```
|
||||
~~~
|
||||
|
||||
Open:
|
||||
|
||||
@@ -157,136 +207,6 @@ Then:
|
||||
|
||||
---
|
||||
|
||||
# Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
spooty:
|
||||
image: jarri-spooty:local
|
||||
container_name: jarri-spooty
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
env_file:
|
||||
- /etc/tokens/spotify.env
|
||||
|
||||
environment:
|
||||
SPOTIFY_REDIRECT_URI: "http://127.0.0.1:3000/api/spotify/callback"
|
||||
AUTH_ENABLED: "true"
|
||||
SPOOTY_AUTH_TOKEN: "change_this_token"
|
||||
|
||||
YT_SEARCH_DELAY_MS: "7000"
|
||||
YT_DOWNLOADS_PER_MINUTE: "6"
|
||||
|
||||
YT_COOKIES_FILE: "/spooty/config/youtube.cookies.txt"
|
||||
|
||||
volumes:
|
||||
- ./downloads:/spooty/backend/downloads
|
||||
- ./spooty-config:/spooty/backend/config
|
||||
- /etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Build From Source
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 20.20.0
|
||||
- Docker
|
||||
- ffmpeg
|
||||
- Python3
|
||||
- Redis
|
||||
- yt-dlp
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
docker build -t jarri-spooty:local .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm run start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| DB_PATH | `./config/db.sqlite` | SQLite database path |
|
||||
| DOWNLOADS_PATH | `./downloads` | Download directory |
|
||||
| FORMAT | `mp3` | Output format |
|
||||
| REDIS_HOST | `localhost` | Redis host |
|
||||
| REDIS_PORT | `6379` | Redis port |
|
||||
| SPOTIFY_CLIENT_ID | unset | Spotify client ID |
|
||||
| SPOTIFY_CLIENT_SECRET | unset | Spotify client secret |
|
||||
| SPOTIFY_REDIRECT_URI | unset | OAuth callback URI |
|
||||
| AUTH_ENABLED | `false` | Enable frontend token auth |
|
||||
| SPOOTY_AUTH_TOKEN | unset | Frontend auth token |
|
||||
| YT_SEARCH_DELAY_MS | `7000` | Delay between YouTube searches |
|
||||
| YT_DOWNLOADS_PER_MINUTE | `6` | Download pacing |
|
||||
| YT_COOKIES | unset | Browser cookie extraction |
|
||||
| YT_COOKIES_FILE | unset | Netscape cookies.txt path |
|
||||
|
||||
---
|
||||
|
||||
# YouTube Cookies
|
||||
|
||||
YouTube may throttle or block requests without cookies.
|
||||
|
||||
Two supported methods exist.
|
||||
|
||||
---
|
||||
|
||||
## Browser Cookies (Native Install Only)
|
||||
|
||||
```bash
|
||||
YT_COOKIES=firefox
|
||||
```
|
||||
|
||||
Supported:
|
||||
|
||||
- firefox
|
||||
- chrome
|
||||
- chromium
|
||||
- edge
|
||||
- brave
|
||||
- opera
|
||||
|
||||
This does NOT work reliably in Docker.
|
||||
|
||||
---
|
||||
|
||||
## Cookies File (Recommended)
|
||||
|
||||
Export a Netscape-format `cookies.txt`.
|
||||
|
||||
Mount into container:
|
||||
|
||||
```bash
|
||||
-v /path/to/cookies.txt:/spooty/config/youtube.cookies.txt:ro
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Deterministic YouTube Fallback Handling
|
||||
|
||||
The hardened branch now uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions.
|
||||
@@ -311,43 +231,6 @@ This prevents infinite retry loops against dead or restricted videos.
|
||||
|
||||
---
|
||||
|
||||
# Persistent Docker State
|
||||
|
||||
The hardened Docker flow strongly recommends persistent bind mounts for:
|
||||
|
||||
- downloads
|
||||
- SQLite database
|
||||
- runtime config
|
||||
|
||||
Recommended:
|
||||
|
||||
```bash
|
||||
-v "$PWD/downloads:/spooty/backend/downloads" \
|
||||
-v "$PWD/spooty-config:/spooty/backend/config"
|
||||
```
|
||||
|
||||
This prevents:
|
||||
|
||||
- database resets on container recreation
|
||||
- queue state loss
|
||||
- retry-state loss
|
||||
- playlist metadata loss
|
||||
|
||||
---
|
||||
|
||||
# Large Playlist Support
|
||||
|
||||
The hardened branch fixes several Spotify API limitations:
|
||||
|
||||
- Proper playlist pagination
|
||||
- >100 track playlists
|
||||
- OAuth-authenticated playlist access
|
||||
- Queue stability improvements
|
||||
|
||||
Verified working with playlists containing 300+ tracks.
|
||||
|
||||
---
|
||||
|
||||
# Queue Pacing
|
||||
|
||||
Aggressive YouTube access can trigger:
|
||||
@@ -359,10 +242,10 @@ Aggressive YouTube access can trigger:
|
||||
|
||||
Recommended safe pacing:
|
||||
|
||||
```bash
|
||||
~~~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:
|
||||
|
||||
@@ -373,34 +256,6 @@ The hardened branch also includes additional internal pacing and retry coordinat
|
||||
|
||||
---
|
||||
|
||||
# yt-dlp Notes
|
||||
|
||||
Modern YouTube behavior changes frequently.
|
||||
|
||||
The hardened branch now:
|
||||
|
||||
- Uses explicit yt-dlp CLI execution
|
||||
- Captures stderr deterministically
|
||||
- Detects age-gated failures
|
||||
- Detects missing downloadable formats
|
||||
- Handles retry/fallback selection explicitly
|
||||
|
||||
Some videos may still fail due to:
|
||||
|
||||
- regional restrictions
|
||||
- removed videos
|
||||
- YouTube anti-bot measures
|
||||
- invalid cookies
|
||||
- unavailable formats
|
||||
|
||||
Recommended:
|
||||
|
||||
- fresh cookies.txt exports
|
||||
- authenticated YouTube sessions
|
||||
- moderate pacing settings
|
||||
|
||||
---
|
||||
|
||||
# Security Notes
|
||||
|
||||
Never commit:
|
||||
@@ -414,7 +269,7 @@ Never commit:
|
||||
|
||||
Recommended `.gitignore` additions:
|
||||
|
||||
```gitignore
|
||||
~~~gitignore
|
||||
downloads/
|
||||
config/
|
||||
spooty-config/
|
||||
@@ -422,83 +277,7 @@ spooty-config/
|
||||
cookies.txt
|
||||
.env
|
||||
.env.local
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Hardened Branch Changelog
|
||||
|
||||
## Unreleased Hardened Changes
|
||||
|
||||
### Spotify
|
||||
|
||||
- Added Spotify OAuth login flow
|
||||
- Added persistent Spotify user token storage
|
||||
- Added authenticated playlist retrieval
|
||||
- Fixed large playlist pagination
|
||||
- Fixed playlist truncation at 100 tracks
|
||||
- Added playlist retrieval debugging
|
||||
- Added OAuth status endpoint
|
||||
|
||||
### YouTube
|
||||
|
||||
- Added deterministic YouTube candidate scoring
|
||||
- Added duration-aware YouTube matching
|
||||
- Added failed-candidate rejection memory
|
||||
- Added automatic retry/fallback handling
|
||||
- Added explicit YouTube candidate exclusion support
|
||||
- Added detailed YouTube candidate debug logging
|
||||
- Added explicit yt-dlp CLI execution
|
||||
- Added deterministic yt-dlp stderr capture
|
||||
- Added age-gated video detection
|
||||
- Added invalid-format detection
|
||||
- Added Docker-compatible cookie handling
|
||||
- Added temporary cookie-copy isolation
|
||||
- Added retry-aware queue coordination
|
||||
|
||||
### Backend
|
||||
|
||||
- Added safer queue pacing logic
|
||||
- Added explicit search processor throttling
|
||||
- Improved Docker runtime stability
|
||||
- Improved logging around Spotify retrieval
|
||||
- Added auth bootstrap flow support
|
||||
- Added deterministic retry-state handling
|
||||
|
||||
### Frontend
|
||||
|
||||
- Added Spotify login integration
|
||||
- Added frontend auth token bootstrap
|
||||
- Fixed playlist progress handling for large playlists
|
||||
- Improved playlist rendering stability
|
||||
|
||||
### Security
|
||||
|
||||
- Moved secrets to external env files
|
||||
- Improved Docker secret handling recommendations
|
||||
- Added security documentation
|
||||
- Prevented accidental secret inclusion in repository
|
||||
|
||||
---
|
||||
|
||||
# Development Notes
|
||||
|
||||
Recommended local test run:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 3000:3000 \
|
||||
--env-file /etc/tokens/spotify.env \
|
||||
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
||||
-e AUTH_ENABLED=true \
|
||||
-e SPOOTY_AUTH_TOKEN=test-token \
|
||||
-e YT_SEARCH_DELAY_MS=7000 \
|
||||
-e YT_DOWNLOADS_PER_MINUTE=6 \
|
||||
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt \
|
||||
-v "$PWD/downloads:/spooty/backend/downloads" \
|
||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||
jarri-spooty:local
|
||||
```
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
|
||||
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 candidates from `TrackEntity.rejectedYoutubeUrls`
|
||||
- playlist and single-song counts from `PlaylistEntity.isTrack`
|
||||
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
|
||||
- Spotify connection presence from the stored Spotify user token
|
||||
|
||||
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidates are still persisted only as URL strings.
|
||||
|
||||
Future persistence work is required for:
|
||||
|
||||
- full searched candidate history
|
||||
- structured rejected candidate metadata beyond URL
|
||||
- structured error class fields stored at write time instead of classified from the persisted raw error string
|
||||
- operation attempt history across search and download phases
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "spooty",
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spooty",
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"workspaces": [
|
||||
"src/backend",
|
||||
"src/frontend"
|
||||
@@ -25508,7 +25508,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"src/backend": {
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^10.2.3",
|
||||
@@ -25571,7 +25571,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"src/frontend": {
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.22",
|
||||
"@angular/common": "^19.2.22",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"name": "spooty",
|
||||
"workspaces": [
|
||||
"src/backend",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +50,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
limit: 60,
|
||||
limit: Number(process.env.API_THROTTLE_LIMIT || 600),
|
||||
},
|
||||
]),
|
||||
BullModule.forRootAsync({
|
||||
@@ -79,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;
|
||||
}
|
||||
}
|
||||
@@ -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,350 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Job, Queue } from 'bullmq';
|
||||
import { In, MoreThan, Repository } from 'typeorm';
|
||||
import { PlaylistEntity } from '../playlist/playlist.entity';
|
||||
import { SpotifyApiService } from '../shared/spotify-api.service';
|
||||
import { TrackEntity, TrackStatusEnum } from '../track/track.entity';
|
||||
import {
|
||||
ErrorClass,
|
||||
FailureTruth,
|
||||
OperationsSnapshot,
|
||||
SpootyOperation,
|
||||
TrackTruth,
|
||||
} from './operations.types';
|
||||
|
||||
interface ClassifiedError {
|
||||
errorClass: ErrorClass;
|
||||
errorSummary: string;
|
||||
errorDetail: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(
|
||||
@InjectRepository(TrackEntity)
|
||||
private readonly trackRepository: Repository<TrackEntity>,
|
||||
@InjectRepository(PlaylistEntity)
|
||||
private readonly playlistRepository: Repository<PlaylistEntity>,
|
||||
@InjectQueue('track-download-processor')
|
||||
private readonly trackDownloadQueue: Queue,
|
||||
@InjectQueue('track-search-processor')
|
||||
private readonly trackSearchQueue: Queue,
|
||||
private readonly spotifyApiService: SpotifyApiService,
|
||||
) {}
|
||||
|
||||
async getSnapshot(): Promise<OperationsSnapshot> {
|
||||
const [
|
||||
spotifyConnected,
|
||||
searchQueueCounts,
|
||||
downloadQueueCounts,
|
||||
activeSearchJobs,
|
||||
activeDownloadJobs,
|
||||
playlists,
|
||||
singleSongs,
|
||||
tracks,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
retries,
|
||||
searchingTracks,
|
||||
downloadingTracks,
|
||||
recentFailures,
|
||||
recentTracks,
|
||||
] = await Promise.all([
|
||||
this.spotifyApiService.hasUserToken(),
|
||||
this.trackSearchQueue.getJobCounts('waiting', 'delayed', 'active'),
|
||||
this.trackDownloadQueue.getJobCounts('waiting', 'delayed', 'active'),
|
||||
this.trackSearchQueue.getJobs('active', 0, 100, true),
|
||||
this.trackDownloadQueue.getJobs('active', 0, 100, true),
|
||||
this.playlistRepository.countBy({ isTrack: false }),
|
||||
this.playlistRepository.countBy({ isTrack: true }),
|
||||
this.trackRepository.count(),
|
||||
this.trackRepository.countBy({
|
||||
status: In([
|
||||
TrackStatusEnum.Searching,
|
||||
TrackStatusEnum.Queued,
|
||||
TrackStatusEnum.Downloading,
|
||||
]),
|
||||
}),
|
||||
this.trackRepository.countBy({ status: TrackStatusEnum.Completed }),
|
||||
this.trackRepository.countBy({ status: TrackStatusEnum.Error }),
|
||||
this.trackRepository.countBy({ downloadAttemptCount: MoreThan(0) }),
|
||||
this.trackRepository.find({
|
||||
where: { status: TrackStatusEnum.Searching },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
}),
|
||||
this.trackRepository.find({
|
||||
where: { status: TrackStatusEnum.Downloading },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
}),
|
||||
this.trackRepository.find({
|
||||
where: { status: TrackStatusEnum.Error },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
}),
|
||||
this.trackRepository.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
const activeSearchTracks = await this.mergeTracksFromActiveJobs(
|
||||
searchingTracks,
|
||||
activeSearchJobs,
|
||||
);
|
||||
const activeDownloadTracks = await this.mergeTracksFromActiveJobs(
|
||||
downloadingTracks,
|
||||
activeDownloadJobs,
|
||||
);
|
||||
|
||||
const searchQueueDepth =
|
||||
(searchQueueCounts.waiting || 0) + (searchQueueCounts.delayed || 0);
|
||||
const downloadQueueDepth =
|
||||
(downloadQueueCounts.waiting || 0) + (downloadQueueCounts.delayed || 0);
|
||||
const activeSearches = activeSearchJobs.length;
|
||||
const activeDownloads = activeDownloadJobs.length;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
runtime: {
|
||||
operation: this.getOperation({
|
||||
activeSearches,
|
||||
activeDownloads,
|
||||
searchQueueDepth,
|
||||
downloadQueueDepth,
|
||||
failed,
|
||||
retries,
|
||||
}),
|
||||
spotifyConnected,
|
||||
searchQueueDepth,
|
||||
downloadQueueDepth,
|
||||
activeSearches,
|
||||
activeDownloads,
|
||||
},
|
||||
counters: {
|
||||
playlists,
|
||||
singleSongs,
|
||||
tracks,
|
||||
active,
|
||||
completed,
|
||||
failed,
|
||||
retries,
|
||||
},
|
||||
active: {
|
||||
searching: activeSearchTracks.map((track) => this.toTrackTruth(track)),
|
||||
downloading: activeDownloadTracks.map((track) =>
|
||||
this.toTrackTruth(track),
|
||||
),
|
||||
},
|
||||
recentFailures: recentFailures.map((track) => this.toTrackTruth(track)),
|
||||
recentTracks: recentTracks.map((track) => this.toTrackTruth(track)),
|
||||
};
|
||||
}
|
||||
|
||||
private async mergeTracksFromActiveJobs(
|
||||
tracks: TrackEntity[],
|
||||
jobs: Job[],
|
||||
): Promise<TrackEntity[]> {
|
||||
const knownIds = new Set(tracks.map((track) => track.id).filter(Boolean));
|
||||
const missingIds = jobs
|
||||
.map((job) => Number(job.data?.id))
|
||||
.filter((id) => Number.isInteger(id) && !knownIds.has(id));
|
||||
|
||||
if (!missingIds.length) {
|
||||
return tracks;
|
||||
}
|
||||
|
||||
const activeJobTracks = await this.trackRepository.find({
|
||||
where: { id: In(missingIds) },
|
||||
});
|
||||
|
||||
return [...tracks, ...activeJobTracks];
|
||||
}
|
||||
|
||||
private toTrackTruth(track: TrackEntity): TrackTruth | FailureTruth {
|
||||
const rejectedCandidates = this.parseRejectedCandidates(track);
|
||||
const error = this.classifyError(track.error);
|
||||
|
||||
return {
|
||||
id: track.id,
|
||||
artist: track.artist,
|
||||
name: track.name,
|
||||
status: TrackStatusEnum[track.status] || String(track.status),
|
||||
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
|
||||
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
|
||||
...(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): string[] {
|
||||
if (!track.rejectedYoutubeUrls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private classifyError(error?: string): ClassifiedError | undefined {
|
||||
if (!error) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const detail = this.trimDetail(error);
|
||||
const normalized = error.toLowerCase();
|
||||
|
||||
if (normalized.includes('no acceptable youtube result found')) {
|
||||
return this.error(
|
||||
'NO_ACCEPTABLE_CANDIDATE',
|
||||
'No acceptable YouTube candidate',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.includes('sign in to confirm your age')) {
|
||||
return this.error(
|
||||
'YOUTUBE_AGE_GATED',
|
||||
'YouTube video is age-gated',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.includes('private video')) {
|
||||
return this.error(
|
||||
'YOUTUBE_PRIVATE_VIDEO',
|
||||
'YouTube video is private',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('only images are available') ||
|
||||
normalized.includes('requested format is not available') ||
|
||||
normalized.includes('no downloadable audio/video formats')
|
||||
) {
|
||||
return this.error(
|
||||
'YOUTUBE_NO_FORMATS',
|
||||
'No downloadable YouTube audio format',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('video unavailable') ||
|
||||
normalized.includes('this video is not available') ||
|
||||
normalized.includes('selected video unavailable')
|
||||
) {
|
||||
return this.error(
|
||||
'YOUTUBE_VIDEO_UNAVAILABLE',
|
||||
'YouTube video is unavailable',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.includes('unable to download webpage')) {
|
||||
return this.error(
|
||||
'YOUTUBE_WEBPAGE_UNAVAILABLE',
|
||||
'YouTube webpage is unavailable',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('failed to parse yt-dlp search output') ||
|
||||
normalized.includes('yt-dlp exited with code') ||
|
||||
normalized.includes('failed to start yt-dlp') ||
|
||||
normalized.includes('yt-dlp exceeded maximum runtime')
|
||||
) {
|
||||
return this.error(
|
||||
'YOUTUBE_EXTRACTION_FAILURE',
|
||||
'YouTube extraction failed',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes('eacces') ||
|
||||
normalized.includes('enoent') ||
|
||||
normalized.includes('failed to write') ||
|
||||
normalized.includes('permission denied')
|
||||
) {
|
||||
return this.error('FILE_WRITE_FAILED', 'File write failed', detail);
|
||||
}
|
||||
|
||||
return this.error(
|
||||
'UNKNOWN_DOWNLOAD_ERROR',
|
||||
'Unknown download error',
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
private error(
|
||||
errorClass: ErrorClass,
|
||||
errorSummary: string,
|
||||
errorDetail: string,
|
||||
): ClassifiedError {
|
||||
return { errorClass, errorSummary, errorDetail };
|
||||
}
|
||||
|
||||
private trimDetail(error: string): string {
|
||||
const trimmed = error.trim();
|
||||
return trimmed.length > 1200 ? `${trimmed.slice(0, 1197)}...` : trimmed;
|
||||
}
|
||||
|
||||
private getOperation(input: {
|
||||
activeSearches: number;
|
||||
activeDownloads: number;
|
||||
searchQueueDepth: number;
|
||||
downloadQueueDepth: number;
|
||||
failed: number;
|
||||
retries: number;
|
||||
}): SpootyOperation {
|
||||
if (input.activeDownloads > 0 || input.downloadQueueDepth > 0) {
|
||||
return input.retries > 0 ? 'retrying' : 'downloading';
|
||||
}
|
||||
|
||||
if (input.activeSearches > 0 || input.searchQueueDepth > 0) {
|
||||
return input.retries > 0 ? 'retrying' : 'searching';
|
||||
}
|
||||
|
||||
if (input.failed > 0) {
|
||||
return 'degraded';
|
||||
}
|
||||
|
||||
return 'idle';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
export type SpootyOperation =
|
||||
| 'idle'
|
||||
| 'searching'
|
||||
| 'downloading'
|
||||
| 'retrying'
|
||||
| 'degraded';
|
||||
|
||||
export type ErrorClass =
|
||||
| 'YOUTUBE_VIDEO_UNAVAILABLE'
|
||||
| 'YOUTUBE_AGE_GATED'
|
||||
| 'YOUTUBE_NO_FORMATS'
|
||||
| 'YOUTUBE_PRIVATE_VIDEO'
|
||||
| 'YOUTUBE_WEBPAGE_UNAVAILABLE'
|
||||
| 'YOUTUBE_EXTRACTION_FAILURE'
|
||||
| 'NO_ACCEPTABLE_CANDIDATE'
|
||||
| 'FILE_WRITE_FAILED'
|
||||
| 'UNKNOWN_DOWNLOAD_ERROR';
|
||||
|
||||
export interface TrackTruth {
|
||||
id: number;
|
||||
artist: string;
|
||||
name: string;
|
||||
status: string;
|
||||
spotifyUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
selectedCandidate?: {
|
||||
url: string;
|
||||
title?: string;
|
||||
author?: string;
|
||||
score?: number;
|
||||
reason?: string;
|
||||
};
|
||||
downloadAttemptCount?: number;
|
||||
errorClass?: ErrorClass;
|
||||
errorSummary?: string;
|
||||
errorDetail?: string;
|
||||
rejectedCandidateCount: number;
|
||||
rejectedCandidates: string[];
|
||||
}
|
||||
|
||||
export interface FailureTruth extends TrackTruth {}
|
||||
|
||||
export interface OperationsSnapshot {
|
||||
generatedAt: string;
|
||||
runtime: {
|
||||
operation: SpootyOperation;
|
||||
spotifyConnected: boolean;
|
||||
searchQueueDepth: number;
|
||||
downloadQueueDepth: number;
|
||||
activeSearches: number;
|
||||
activeDownloads: number;
|
||||
};
|
||||
counters: {
|
||||
playlists: number;
|
||||
singleSongs: number;
|
||||
tracks: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
retries: number;
|
||||
};
|
||||
active: {
|
||||
searching: TrackTruth[];
|
||||
downloading: TrackTruth[];
|
||||
};
|
||||
recentFailures: FailureTruth[];
|
||||
recentTracks: TrackTruth[];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsString, 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,
|
||||
|
||||
@@ -48,6 +48,27 @@ 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);
|
||||
@@ -63,7 +84,7 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -97,6 +118,7 @@ export class PlaylistService {
|
||||
name: trackDetail.name,
|
||||
spotifyUrl: playlist.spotifyUrl,
|
||||
coverUrl: trackDetail.image,
|
||||
durationMs: trackDetail.durationMs,
|
||||
},
|
||||
savedPlaylist,
|
||||
);
|
||||
|
||||
@@ -12,27 +12,57 @@ export class SpotifyAuthController {
|
||||
}
|
||||
|
||||
@Get('login')
|
||||
login(@Res() res: Response): void {
|
||||
res.redirect(this.spotifyApiService.getAuthorizationUrl());
|
||||
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(`/?spotify_error=${encodeURIComponent(error)}`);
|
||||
res.redirect(`${returnTo}?spotify_error=${encodeURIComponent(error)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
res.redirect('/?spotify_error=missing_code');
|
||||
res.redirect(`${returnTo}?spotify_error=missing_code`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.spotifyApiService.exchangeAuthorizationCode(code);
|
||||
res.redirect('/?spotify=connected');
|
||||
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 '/';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export class SpotifyApiService {
|
||||
return !!this.readUserToken();
|
||||
}
|
||||
|
||||
getAuthorizationUrl(): string {
|
||||
getAuthorizationUrl(state?: string): string {
|
||||
const url = new URL('https://accounts.spotify.com/authorize');
|
||||
|
||||
url.searchParams.set('response_type', 'code');
|
||||
@@ -167,6 +167,10 @@ export class SpotifyApiService {
|
||||
url.searchParams.set('redirect_uri', this.getRedirectUri());
|
||||
url.searchParams.set('show_dialog', 'true');
|
||||
|
||||
if (state) {
|
||||
url.searchParams.set('state', state);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -336,6 +340,85 @@ export class SpotifyApiService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async searchTrack(
|
||||
artist: string,
|
||||
title: string,
|
||||
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
|
||||
const accessToken = await this.getClientCredentialsAccessToken();
|
||||
const plainQuery = `${artist} ${title}`.trim();
|
||||
const structuredQuery = `track:"${title}" artist:"${artist}"`;
|
||||
|
||||
const url = new URL('https://api.spotify.com/v1/search');
|
||||
url.searchParams.set('q', structuredQuery || plainQuery);
|
||||
url.searchParams.set('type', 'track');
|
||||
url.searchParams.set('limit', '10');
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Spotify track search failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const tracks = data?.tracks?.items || [];
|
||||
|
||||
if (!tracks.length) {
|
||||
throw new Error(`No Spotify track found for ${artist} - ${title}`);
|
||||
}
|
||||
|
||||
const normalize = (value: string) =>
|
||||
(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
const wantedArtist = normalize(artist);
|
||||
const wantedTitle = normalize(title);
|
||||
|
||||
const scoreTrack = (track: any): number => {
|
||||
const trackName = normalize(track.name);
|
||||
const artistNames = (track.artists || []).map((item: any) => normalize(item.name));
|
||||
const allArtists = artistNames.join(' ');
|
||||
|
||||
let score = 0;
|
||||
if (trackName === wantedTitle) score += 70;
|
||||
else if (trackName.includes(wantedTitle) || wantedTitle.includes(trackName)) score += 40;
|
||||
|
||||
if (artistNames.some((name: string) => name === wantedArtist)) score += 50;
|
||||
else if (allArtists.includes(wantedArtist) || wantedArtist.includes(allArtists)) score += 25;
|
||||
|
||||
if (track.explicit !== undefined) score += 1;
|
||||
if (track.popularity) score += Math.min(20, Math.floor(track.popularity / 5));
|
||||
|
||||
return score;
|
||||
};
|
||||
|
||||
const ranked = [...tracks]
|
||||
.map((track: any) => ({ track, score: scoreTrack(track) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const best = ranked[0]?.track || tracks[0];
|
||||
|
||||
this.logger.debug(
|
||||
`Spotify search "${plainQuery}" selected "${best?.artists?.map((item: any) => item.name).join(', ')} - ${best?.name}" score=${ranked[0]?.score ?? 0}`,
|
||||
);
|
||||
|
||||
return {
|
||||
spotifyUrl: best.external_urls.spotify,
|
||||
name: best.name,
|
||||
artist: (best.artists || []).map((item: any) => item.name).join(', '),
|
||||
image: best.album?.images?.[0]?.url || '',
|
||||
durationMs: best.duration_ms,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> {
|
||||
try {
|
||||
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
|
||||
|
||||
@@ -72,6 +72,14 @@ export class SpotifyService {
|
||||
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 {
|
||||
|
||||
@@ -38,6 +38,19 @@ const BAD_MATCH_TERMS = [
|
||||
'reaction',
|
||||
'tutorial',
|
||||
'instrumental',
|
||||
'reaction',
|
||||
'review',
|
||||
'analysis',
|
||||
'breakdown',
|
||||
'explained',
|
||||
'secret message',
|
||||
'meaning',
|
||||
'interview',
|
||||
'podcast',
|
||||
'news',
|
||||
'documentary',
|
||||
'fifa world cup',
|
||||
'song style',
|
||||
];
|
||||
|
||||
const OFFICIALISH_TERMS = [
|
||||
@@ -123,11 +136,12 @@ export class YoutubeService {
|
||||
.join(' | '),
|
||||
);
|
||||
|
||||
const accepted = candidates.find((candidate) => !candidate.rejected);
|
||||
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}` +
|
||||
`No acceptable YouTube result found for: ${query} with minimum score ${minimumScore}` +
|
||||
(excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''),
|
||||
);
|
||||
}
|
||||
@@ -143,11 +157,12 @@ export class YoutubeService {
|
||||
const searchTarget = `ytsearch15:${query}`;
|
||||
const args = [
|
||||
'--dump-single-json',
|
||||
'--flat-playlist',
|
||||
'--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,
|
||||
@@ -198,6 +213,7 @@ export class YoutubeService {
|
||||
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);
|
||||
@@ -213,14 +229,21 @@ export class YoutubeService {
|
||||
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)),
|
||||
);
|
||||
@@ -233,8 +256,9 @@ export class YoutubeService {
|
||||
normalizedTitle.includes(this.normalize(term)),
|
||||
);
|
||||
if (badTerm) {
|
||||
score -= 25;
|
||||
reasons.push(`bad=${badTerm}:-25`);
|
||||
score -= 80;
|
||||
rejected = true;
|
||||
reasons.push(`bad=${badTerm}:-80 reject`);
|
||||
}
|
||||
|
||||
if (durationMs && seconds) {
|
||||
@@ -380,8 +404,8 @@ export class YoutubeService {
|
||||
return 'YouTube unavailable: private video';
|
||||
}
|
||||
|
||||
if (text.includes('Video unavailable')) {
|
||||
return 'YouTube unavailable';
|
||||
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')) {
|
||||
@@ -495,6 +519,8 @@ export class YoutubeService {
|
||||
'--no-playlist',
|
||||
'--no-cache-dir',
|
||||
'--no-cookies-from-browser',
|
||||
'-f',
|
||||
'bestaudio/best',
|
||||
'--extract-audio',
|
||||
'--audio-format',
|
||||
format,
|
||||
|
||||
@@ -27,6 +27,18 @@ 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;
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ enum WsTrackOperation {
|
||||
Delete = 'trackDelete',
|
||||
}
|
||||
|
||||
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
|
||||
rejectedYoutubeUrls?: string[];
|
||||
};
|
||||
|
||||
@WebSocketGateway()
|
||||
@Injectable()
|
||||
export class TrackService {
|
||||
@@ -56,13 +60,14 @@ export class TrackService {
|
||||
this.io.emit(WsTrackOperation.Delete, { id });
|
||||
}
|
||||
|
||||
private toClientTrack(track: TrackEntity): Partial<TrackEntity> {
|
||||
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,
|
||||
@@ -93,7 +98,9 @@ export class TrackService {
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item) => typeof item === 'string')
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -120,6 +127,10 @@ export class TrackService {
|
||||
await this.update(id, {
|
||||
...track,
|
||||
youtubeUrl: null,
|
||||
selectedYoutubeTitle: null,
|
||||
selectedYoutubeAuthor: null,
|
||||
selectedYoutubeScore: null,
|
||||
selectedYoutubeReason: null,
|
||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||
downloadAttemptCount: 0,
|
||||
error: null,
|
||||
@@ -156,6 +167,10 @@ export class TrackService {
|
||||
updatedTrack = {
|
||||
...track,
|
||||
youtubeUrl: youtubeMatch.url,
|
||||
selectedYoutubeTitle: youtubeMatch.title,
|
||||
selectedYoutubeAuthor: youtubeMatch.author,
|
||||
selectedYoutubeScore: youtubeMatch.score,
|
||||
selectedYoutubeReason: youtubeMatch.reason,
|
||||
status: TrackStatusEnum.Queued,
|
||||
};
|
||||
this.logger.debug(
|
||||
@@ -188,7 +203,9 @@ export class TrackService {
|
||||
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
||||
|
||||
if (existingJob) {
|
||||
this.logger.warn(`Removing existing search job for track ${id} before enqueue`);
|
||||
this.logger.warn(
|
||||
`Removing existing search job for track ${id} before enqueue`,
|
||||
);
|
||||
await existingJob.remove();
|
||||
}
|
||||
|
||||
@@ -207,7 +224,9 @@ export class TrackService {
|
||||
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
||||
|
||||
if (existingJob) {
|
||||
this.logger.warn(`Removing existing download job for track ${id} before enqueue`);
|
||||
this.logger.warn(
|
||||
`Removing existing download job for track ${id} before enqueue`,
|
||||
);
|
||||
await existingJob.remove();
|
||||
}
|
||||
|
||||
@@ -291,6 +310,10 @@ export class TrackService {
|
||||
await this.update(track.id, {
|
||||
...track,
|
||||
youtubeUrl: null,
|
||||
selectedYoutubeTitle: null,
|
||||
selectedYoutubeAuthor: null,
|
||||
selectedYoutubeScore: null,
|
||||
selectedYoutubeReason: null,
|
||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||
downloadAttemptCount: nextAttemptCount,
|
||||
error: null,
|
||||
@@ -315,7 +338,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,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.4.2",
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -1,94 +1,322 @@
|
||||
<section class="jarri-hero">
|
||||
<div class="hero-left">
|
||||
<p class="eyebrow">Jarri subsystem · deterministic media ingestion</p>
|
||||
<h1>
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
Jarri Spooty
|
||||
<span class="tag version-tag">v{{version}}</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div class="hero-right">
|
||||
<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
|
||||
class="button auth-button"
|
||||
[class.connected]="spotifyConnected"
|
||||
(click)="connectSpotify()"
|
||||
*ngFor="let tab of workspaceTabs"
|
||||
class="workspace-tab"
|
||||
type="button"
|
||||
[class.active]="activeWorkspaceTab === tab"
|
||||
(click)="switchWorkspaceTab(tab)"
|
||||
>
|
||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
||||
{{ getWorkspaceTabLabel(tab) }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
<section class="jarri-body">
|
||||
<div class="intelligence-grid">
|
||||
<div class="intel-card">
|
||||
<span class="intel-label">Spotify</span>
|
||||
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
|
||||
<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="intel-card">
|
||||
<span class="intel-label">Matching</span>
|
||||
<strong>Duration-aware</strong>
|
||||
</div>
|
||||
<div class="intel-card">
|
||||
<span class="intel-label">Queue</span>
|
||||
<strong>Paced</strong>
|
||||
</div>
|
||||
<div class="intel-card">
|
||||
<span class="intel-label">Mode</span>
|
||||
<strong>Archive run</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box jarri-box">
|
||||
<p class="subtitle">Acquire music</p>
|
||||
<div class="is-flex">
|
||||
<input class="input"
|
||||
[class.is-danger]="url && !isValidSpotifyUrl"
|
||||
type="text"
|
||||
[(ngModel)]="url"
|
||||
placeholder="Paste Spotify playlist or track URL"/>
|
||||
<button class="button is-primary"
|
||||
<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>
|
||||
|
||||
<button
|
||||
class="panel-close"
|
||||
type="button"
|
||||
title="Close panel"
|
||||
[disabled]="workspace.panels.length <= 1"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="closePanel(panel.id)"
|
||||
>
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="panel-content" [ngSwitch]="panel.type">
|
||||
<section *ngSwitchCase="'source-intake'" class="source-intake-panel">
|
||||
<button
|
||||
class="button auth-button"
|
||||
[class.connected]="spotifyConnected"
|
||||
(click)="connectSpotify()"
|
||||
>
|
||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
||||
</button>
|
||||
|
||||
<div class="intake-form">
|
||||
<input
|
||||
class="input"
|
||||
[class.is-danger]="url && !isValidSpotifyUrl"
|
||||
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>
|
||||
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
|
||||
Please enter a valid Spotify URL.
|
||||
</p>
|
||||
</div>
|
||||
>
|
||||
<i class="fa-solid fa-download"></i>
|
||||
Queue
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="box jarri-box">
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
<div>
|
||||
<p class="subtitle">Playlist History</p>
|
||||
<p class="has-text-grey is-size-7">One collapsed row per playlist run.</p>
|
||||
<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>
|
||||
|
||||
<dl class="inspector-list">
|
||||
<dt>Spotify URL</dt>
|
||||
<dd><a [href]="selectedTrack.spotifyUrl" target="_blank">{{ selectedTrack.spotifyUrl || 'none' }}</a></dd>
|
||||
<dt>Status</dt>
|
||||
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
||||
<dt>Download attempts</dt>
|
||||
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
||||
<dt>Error</dt>
|
||||
<dd>{{ selectedTrack.error || 'none' }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="candidate-options">
|
||||
<strong>Other options</strong>
|
||||
<p *ngIf="selectedTrack.youtubeUrl">
|
||||
Selected candidate:
|
||||
<a [href]="selectedTrack.youtubeUrl" target="_blank">{{ selectedTrack.youtubeUrl }}</a>
|
||||
</p>
|
||||
<p>Rejected candidates: {{ rejectedUrls(selectedTrack).length }}</p>
|
||||
<a *ngFor="let url of rejectedUrls(selectedTrack)" [href]="url" target="_blank">{{ url }}</a>
|
||||
<p *ngIf="!selectedTrack.youtubeUrl && rejectedUrls(selectedTrack).length === 0" class="empty-state">
|
||||
Candidate alternatives are not persisted yet. Future scoring history will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #noTrackSelected>
|
||||
<div class="placeholder-panel">
|
||||
<i class="fa-solid fa-magnifying-glass-chart"></i>
|
||||
<p>Select a track row inside an expanded playlist to inspect Spotify, YouTube, retry, and error details.</p>
|
||||
</div>
|
||||
<ng-container *ngIf="allTracks$ | async as allTracks">
|
||||
<button
|
||||
*ngFor="let track of allTracks"
|
||||
class="button is-small is-fullwidth reset-button"
|
||||
type="button"
|
||||
(click)="selectTrack(track)"
|
||||
>
|
||||
{{ track.artist }} - {{ track.name }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</section>
|
||||
</div>
|
||||
<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="has-text-grey has-text-centered">No playlists</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
|
||||
<div class="box jarri-box">
|
||||
<p class="subtitle">Single Songs</p>
|
||||
<ng-container *ngIf="songs$ | async as songs">
|
||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
||||
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
</section>
|
||||
<span
|
||||
class="resize-handle"
|
||||
title="Resize panel"
|
||||
(pointerdown)="beginResize($event, panel)"
|
||||
></span>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,129 +1,324 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(29, 185, 84, 0.18), transparent 30rem),
|
||||
linear-gradient(135deg, #06110c 0%, #0a1210 45%, #020403 100%);
|
||||
background: #0d1515;
|
||||
color: #eafff0;
|
||||
}
|
||||
|
||||
.jarri-hero {
|
||||
.workspace-shell {
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.workspace-titlebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
padding: 34px 44px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(3, 12, 7, 0.95), rgba(29, 185, 84, 0.92));
|
||||
border-bottom: 1px solid rgba(126, 255, 180, 0.25);
|
||||
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);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #9fffc3;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
.brand-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #f3fff7;
|
||||
font-size: clamp(2.2rem, 4vw, 4.6rem);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.06em;
|
||||
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
margin-top: 8px;
|
||||
color: #d8ffe5;
|
||||
font-size: 1.05rem;
|
||||
.brand-stack p {
|
||||
margin-top: 4px;
|
||||
color: #bcd1c4;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.version-tag {
|
||||
background: #07110c;
|
||||
background: #12221a;
|
||||
color: #9fffc3;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: #07110c;
|
||||
.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;
|
||||
}
|
||||
|
||||
.auth-button.connected {
|
||||
.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;
|
||||
}
|
||||
|
||||
.jarri-body {
|
||||
padding: 34px 44px;
|
||||
.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;
|
||||
}
|
||||
|
||||
.intelligence-grid {
|
||||
.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;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.intel-card {
|
||||
.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: 16px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.intel-label {
|
||||
.metric-card span {
|
||||
display: block;
|
||||
color: #8ea99a;
|
||||
color: #9db0a6;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.13em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.intel-card strong {
|
||||
.metric-card strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
margin-top: 8px;
|
||||
color: #f1fff5;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.intel-card strong.online {
|
||||
.metric-card strong.online {
|
||||
color: #9fffc3;
|
||||
}
|
||||
|
||||
.jarri-box {
|
||||
border-radius: 18px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
|
||||
.history-panel {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.button {
|
||||
border-radius: 10px;
|
||||
.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) {
|
||||
.jarri-hero,
|
||||
.jarri-body {
|
||||
padding: 18px;
|
||||
.workspace-titlebar,
|
||||
.workspace-controls {
|
||||
position: static;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.jarri-hero {
|
||||
display: block;
|
||||
.panel-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-right {
|
||||
margin-top: 14px;
|
||||
.panel-picker .select,
|
||||
.panel-picker select,
|
||||
.panel-picker .button,
|
||||
.reset-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.intelligence-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
.workspace-canvas {
|
||||
width: 1848px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {Component, OnDestroy} from '@angular/core';
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {CommonModule, NgFor} from "@angular/common";
|
||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||
@@ -6,6 +6,198 @@ import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.compo
|
||||
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";
|
||||
|
||||
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',
|
||||
@@ -14,10 +206,32 @@ import {HttpClient} from "@angular/common/http";
|
||||
styleUrl: './app.component.scss',
|
||||
standalone: true,
|
||||
})
|
||||
export class AppComponent {
|
||||
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;
|
||||
};
|
||||
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);
|
||||
@@ -25,17 +239,41 @@ export class AppComponent {
|
||||
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)));
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopWorkspaceInteraction();
|
||||
}
|
||||
|
||||
private bootstrapAuthTokenFromUrl(): void {
|
||||
@@ -66,6 +304,7 @@ export class AppComponent {
|
||||
}
|
||||
|
||||
download(): void {
|
||||
this.saveArchiveDestination();
|
||||
this.url && this.playlistService.create(this.url);
|
||||
this.url = '';
|
||||
}
|
||||
@@ -77,4 +316,317 @@ 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(sizeBytes: number): string {
|
||||
if (sizeBytes < 1024) {
|
||||
return `${sizeBytes} B`;
|
||||
}
|
||||
|
||||
if (sizeBytes < 1024 * 1024) {
|
||||
return `${(sizeBytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
formatModifiedAt(modifiedAt: number): string {
|
||||
return new Date(modifiedAt).toLocaleString();
|
||||
}
|
||||
|
||||
trackStatusLabel(status?: TrackStatusEnum): string {
|
||||
switch (status) {
|
||||
case TrackStatusEnum.New:
|
||||
return 'New';
|
||||
case TrackStatusEnum.Searching:
|
||||
return 'Searching';
|
||||
case TrackStatusEnum.Queued:
|
||||
return 'Queued';
|
||||
case TrackStatusEnum.Downloading:
|
||||
return 'Downloading';
|
||||
case TrackStatusEnum.Completed:
|
||||
return 'Completed';
|
||||
case TrackStatusEnum.Error:
|
||||
return 'Error';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
rejectedUrls(track: Track): string[] {
|
||||
if (Array.isArray(track.rejectedYoutubeUrls)) {
|
||||
return track.rejectedYoutubeUrls;
|
||||
}
|
||||
|
||||
if (!track.rejectedYoutubeUrls) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||
return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
selectTrack(track: Track): void {
|
||||
this.trackService.select(track);
|
||||
}
|
||||
|
||||
clearSelectedTrack(): void {
|
||||
this.trackService.clearSelection();
|
||||
}
|
||||
|
||||
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
|
||||
return WORKSPACE_TAB_LABELS[tab];
|
||||
}
|
||||
|
||||
switchWorkspaceTab(tab: SpootyWorkspaceTab): void {
|
||||
this.workspaceTabsState = {
|
||||
...this.workspaceTabsState,
|
||||
activeTab: tab,
|
||||
};
|
||||
this.saveWorkspaceState();
|
||||
}
|
||||
|
||||
resetLayout(): void {
|
||||
this.setActiveWorkspace(defaultWorkspaceStateForTab(this.activeWorkspaceTab));
|
||||
}
|
||||
|
||||
resetAllLayouts(): void {
|
||||
this.workspaceTabsState = defaultWorkspaceTabsState();
|
||||
this.saveWorkspaceState();
|
||||
}
|
||||
|
||||
filteredArchiveFiles(): ArchiveFile[] {
|
||||
const files = this.archiveListing?.files || [];
|
||||
const query = this.archiveSearch.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? files.filter(file => `${file.name} ${file.path}`.toLowerCase().includes(query))
|
||||
: [...files];
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (this.archiveSort === 'name') {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
|
||||
if (this.archiveSort === 'size') {
|
||||
return b.sizeBytes - a.sizeBytes;
|
||||
}
|
||||
|
||||
return b.modifiedAt - a.modifiedAt;
|
||||
});
|
||||
}
|
||||
|
||||
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
||||
const groups = new Map<string, ArchiveFile[]>();
|
||||
|
||||
this.filteredArchiveFiles().forEach(file => {
|
||||
const folder = this.topLevelFolder(file);
|
||||
groups.set(folder, [...(groups.get(folder) || []), file]);
|
||||
});
|
||||
|
||||
return [...groups.entries()].map(([folder, files]) => ({folder, files}));
|
||||
}
|
||||
|
||||
archiveFileCount(): number {
|
||||
return this.filteredArchiveFiles().length;
|
||||
}
|
||||
|
||||
archiveTotalBytes(): number {
|
||||
return this.filteredArchiveFiles().reduce((total, file) => total + file.sizeBytes, 0);
|
||||
}
|
||||
|
||||
topLevelFolder(file: ArchiveFile): string {
|
||||
return file.path.includes('/') ? file.path.split('/')[0] : 'Root';
|
||||
}
|
||||
|
||||
folderPath(file: ArchiveFile): string {
|
||||
const index = file.path.lastIndexOf('/');
|
||||
return index === -1 ? 'Root' : file.path.slice(0, index);
|
||||
}
|
||||
|
||||
hasFolder(file: ArchiveFile): boolean {
|
||||
return file.path.includes('/');
|
||||
}
|
||||
|
||||
getPanelTitle(type: SpootyPanelType): string {
|
||||
return PANEL_TITLES[type];
|
||||
}
|
||||
|
||||
addPanel(type: SpootyPanelType = this.selectedPanelType): void {
|
||||
const offset = this.workspace.panels.length * GRID_SIZE;
|
||||
this.setActiveWorkspace({
|
||||
panels: [
|
||||
...this.workspace.panels,
|
||||
makePanel(type, 48 + offset, 72 + offset),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
closePanel(panelId: string): void {
|
||||
if (this.workspace.panels.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setActiveWorkspace({
|
||||
panels: this.workspace.panels.filter(panel => panel.id !== panelId),
|
||||
});
|
||||
}
|
||||
|
||||
movePanel(panelId: string, x: number, y: number): void {
|
||||
this.updatePanel(panelId, {
|
||||
x: Math.max(0, snap(x)),
|
||||
y: Math.max(0, snap(y)),
|
||||
});
|
||||
}
|
||||
|
||||
resizePanel(panelId: string, w: number, h: number): void {
|
||||
this.updatePanel(panelId, {
|
||||
w: Math.max(MIN_PANEL_WIDTH, snap(w)),
|
||||
h: Math.max(MIN_PANEL_HEIGHT, snap(h)),
|
||||
});
|
||||
}
|
||||
|
||||
beginDrag(event: PointerEvent, panel: SpootyPanelInstance): void {
|
||||
event.preventDefault();
|
||||
this.bringPanelToFront(panel.id);
|
||||
this.interaction = {
|
||||
mode: 'drag',
|
||||
panelId: panel.id,
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startPanelX: panel.x,
|
||||
startPanelY: panel.y,
|
||||
startPanelW: panel.w,
|
||||
startPanelH: panel.h,
|
||||
};
|
||||
this.startWorkspaceInteraction();
|
||||
}
|
||||
|
||||
beginResize(event: PointerEvent, panel: SpootyPanelInstance): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.bringPanelToFront(panel.id);
|
||||
this.interaction = {
|
||||
mode: 'resize',
|
||||
panelId: panel.id,
|
||||
startPointerX: event.clientX,
|
||||
startPointerY: event.clientY,
|
||||
startPanelX: panel.x,
|
||||
startPanelY: panel.y,
|
||||
startPanelW: panel.w,
|
||||
startPanelH: panel.h,
|
||||
};
|
||||
this.startWorkspaceInteraction();
|
||||
}
|
||||
|
||||
renderPanel(index: number, panel: SpootyPanelInstance): string {
|
||||
return panel.id;
|
||||
}
|
||||
|
||||
panelStyle(panel: SpootyPanelInstance): Record<string, string> {
|
||||
return {
|
||||
transform: `translate(${panel.x}px, ${panel.y}px)`,
|
||||
width: `${panel.w}px`,
|
||||
height: `${panel.h}px`,
|
||||
};
|
||||
}
|
||||
|
||||
private readonly handlePointerMove = (event: PointerEvent): void => {
|
||||
if (!this.interaction) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = event.clientX - this.interaction.startPointerX;
|
||||
const dy = event.clientY - this.interaction.startPointerY;
|
||||
|
||||
if (this.interaction.mode === 'drag') {
|
||||
this.movePanel(
|
||||
this.interaction.panelId,
|
||||
this.interaction.startPanelX + dx,
|
||||
this.interaction.startPanelY + dy,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.resizePanel(
|
||||
this.interaction.panelId,
|
||||
this.interaction.startPanelW + dx,
|
||||
this.interaction.startPanelH + dy,
|
||||
);
|
||||
};
|
||||
|
||||
private readonly handlePointerUp = (): void => {
|
||||
this.stopWorkspaceInteraction();
|
||||
};
|
||||
|
||||
private startWorkspaceInteraction(): void {
|
||||
document.addEventListener('pointermove', this.handlePointerMove);
|
||||
document.addEventListener('pointerup', this.handlePointerUp, {once: true});
|
||||
}
|
||||
|
||||
private stopWorkspaceInteraction(): void {
|
||||
this.interaction = undefined;
|
||||
document.removeEventListener('pointermove', this.handlePointerMove);
|
||||
document.removeEventListener('pointerup', this.handlePointerUp);
|
||||
}
|
||||
|
||||
private bringPanelToFront(panelId: string): void {
|
||||
const panel = this.workspace.panels.find(item => item.id === panelId);
|
||||
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setActiveWorkspace({
|
||||
panels: [
|
||||
...this.workspace.panels.filter(item => item.id !== panelId),
|
||||
panel,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private updatePanel(panelId: string, changes: Partial<SpootyPanelInstance>): void {
|
||||
this.setActiveWorkspace({
|
||||
panels: this.workspace.panels.map(panel =>
|
||||
panel.id === panelId ? {...panel, ...changes} : panel
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private setActiveWorkspace(workspace: SpootyWorkspaceState): void {
|
||||
this.workspaceTabsState = {
|
||||
...this.workspaceTabsState,
|
||||
tabs: {
|
||||
...this.workspaceTabsState.tabs,
|
||||
[this.activeWorkspaceTab]: workspace,
|
||||
},
|
||||
};
|
||||
this.saveWorkspaceState();
|
||||
}
|
||||
|
||||
private saveWorkspaceState(): void {
|
||||
localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,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[];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
@@ -60,9 +65,30 @@ export class TrackService {
|
||||
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}]))
|
||||
);
|
||||
|
||||
@@ -12,3 +12,114 @@ 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;
|
||||
}
|
||||
|
||||
@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