20 Commits
Author SHA1 Message Date
TorMatzAndren 2750aa5c06 Update README with Workspace v3 screenshot 2026-05-23 01:08:48 +02:00
TorMatzAndren 56534d1d33 Release Jarri Spooty Workspace v3.0.0 2026-05-23 01:01:55 +02:00
TorMatzAndren 18deaa2d90 Document deterministic runtime and Jarri Spooty branding 2026-05-16 18:23:09 +02:00
TorMatzAndren 66c71a7487 Add Jarri Spooty frontend demo screenshot 2026-05-16 18:20:26 +02:00
TorMatzAndren a41884442d Add local Docker runtime helper script 2026-05-16 17:51:55 +02:00
TorMatzAndren 01fe5bcaf8 Merge branch 'hardening/pass-006-source-determinism' 2026-05-16 17:44:10 +02:00
TorMatzAndren 8a58aa29d9 Updated .gitignore to ignore download folder 2026-05-16 16:39:04 +02:00
TorMatzAndren a71a47f76a Document Jarri Spooty hardening updates 2026-05-16 13:10:00 +02:00
TorMatzAndren d83b0e6e1e Normalize Jarri Spooty naming 2026-05-16 13:01:28 +02:00
TorMatzAndren 1f3131d99a Shape track websocket payloads 2026-05-16 12:45:02 +02:00
TorMatzAndren c3d2f39b82 Harden yt-dlp process lifecycle 2026-05-16 11:42:16 +02:00
TorMatzAndren 09bbffa6e8 Harden cover art fetching 2026-05-16 11:11:59 +02:00
TorMatzAndren 38ca4d6c8d Harden download filename sanitization 2026-05-16 11:08:26 +02:00
TorMatzAndren 0cd5294944 Remove unused downloader dependencies 2026-05-16 11:04:38 +02:00
Tor Wingalen 5d20972c2d Update frontend retry calls to POST 2026-05-15 23:55:10 +02:00
Tor Wingalen 5dc428b148 Use POST for retry state-changing endpoints 2026-05-15 23:49:54 +02:00
Tor Wingalen be11bb94d7 Replace Redis shell exec with spawn 2026-05-15 23:45:59 +02:00
Tor Wingalen f64cc97475 Replace yt-search dependency with yt-dlp search 2026-05-15 23:36:57 +02:00
Tor Wingalen 005e47bf6e Remove unused fluent-ffmpeg dependency 2026-05-15 23:17:51 +02:00
Tor Wingalen 9e83001b73 Update Angular toolchain patch versions 2026-05-15 22:11:01 +02:00
35 changed files with 3638 additions and 2481 deletions
+1 -1
View File
@@ -4,6 +4,6 @@
**/*.sqlite **/*.sqlite
# Local Netlify folder # Local Netlify folder
.netlify .netlify
test-downloads/ test-downloads/
spooty-config spooty-config
+50 -1
View File
@@ -1,6 +1,6 @@
# Changelog # Changelog
All notable changes to the hardened Spooty branch are documented here. All notable changes to Jarri Spooty are documented here.
This project follows a practical chronological changelog rather than autogenerated dependency noise. This project follows a practical chronological changelog rather than autogenerated dependency noise.
@@ -10,6 +10,49 @@ This project follows a practical chronological changelog rather than autogenerat
## Added ## 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 ### Spotify
- Added Spotify OAuth login flow - Added Spotify OAuth login flow
@@ -64,6 +107,12 @@ This project follows a practical chronological changelog rather than autogenerat
- Added isolated Spotify credential storage - Added isolated Spotify credential storage
- Added safer cookie handling flow - Added safer cookie handling flow
- Improved Docker secret isolation documentation - Improved Docker secret isolation documentation
- Removed unused downloader dependencies to reduce transitive attack surface
- Hardened download filename sanitization
- Hardened remote cover-art fetching with protocol, MIME, timeout, and size checks
- Added yt-dlp subprocess timeout and single-settlement handling
- Shaped track websocket payloads to avoid leaking internal entity fields
- Normalized visible project naming to Jarri Spooty
### Frontend ### Frontend
+69 -286
View File
@@ -1,12 +1,19 @@
# Spooty Hardened # 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 Workspace v3](docs/images/jarri-spooty-workspace-v3.png)
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. 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: This hardened branch focuses on:
- ChronoGit-style Workspace UI panels
- Large playlist support (>100 tracks) - Large playlist support (>100 tracks)
- Spotify OAuth login flow - Spotify OAuth login flow
- Better YouTube pacing and throttling resistance - Better YouTube pacing and throttling resistance
@@ -21,6 +28,12 @@ This hardened branch focuses on:
- Persistent SQLite state across container restarts - Persistent SQLite state across container restarts
- Deterministic Docker config persistence - Deterministic Docker config persistence
- Improved operational observability - 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
--- ---
@@ -40,6 +53,47 @@ This hardened branch focuses on:
- Automatic YouTube retry/fallback handling - Automatic YouTube retry/fallback handling
- Failed YouTube candidate rejection memory - Failed YouTube candidate rejection memory
- Deterministic yt-dlp error classification - 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.
--- ---
@@ -90,17 +144,17 @@ Copy:
Create a secure env file: Create a secure env file:
```bash ~~~bash
sudo mkdir -p /etc/tokens 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_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret SPOTIFY_CLIENT_SECRET=your_client_secret
EOF EOT
sudo chown root:$USER /etc/tokens/spotify.env sudo chown root:$USER /etc/tokens/spotify.env
sudo chmod 640 /etc/tokens/spotify.env sudo chmod 640 /etc/tokens/spotify.env
``` ~~~
Never commit this file. Never commit this file.
@@ -114,17 +168,17 @@ Export a Netscape-format `cookies.txt` from a logged-in browser session.
Recommended storage: Recommended storage:
```bash ~~~bash
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
sudo chown root:$USER /etc/tokens/youtube.cookies.txt sudo chown root:$USER /etc/tokens/youtube.cookies.txt
sudo chmod 640 /etc/tokens/youtube.cookies.txt sudo chmod 640 /etc/tokens/youtube.cookies.txt
``` ~~~
--- ---
# Docker Run # Docker Run
```bash ~~~bash
docker run --rm -p 3000:3000 \ docker run --rm -p 3000:3000 \
--env-file /etc/tokens/spotify.env \ --env-file /etc/tokens/spotify.env \
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \ -e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
@@ -136,8 +190,8 @@ docker run --rm -p 3000:3000 \
-v "$PWD/downloads:/spooty/backend/downloads" \ -v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \ -v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \ -v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
spootyfy-hardened:local jarri-spooty:local
``` ~~~
Open: Open:
@@ -153,136 +207,6 @@ Then:
--- ---
# Docker Compose
```yaml
services:
spooty:
image: spootyfy-hardened:local
container_name: 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 spootyfy-hardened: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 # Deterministic YouTube Fallback Handling
The hardened branch now uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions. The hardened branch now uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions.
@@ -307,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 # Queue Pacing
Aggressive YouTube access can trigger: Aggressive YouTube access can trigger:
@@ -355,10 +242,10 @@ Aggressive YouTube access can trigger:
Recommended safe pacing: Recommended safe pacing:
```bash ~~~bash
-e YT_SEARCH_DELAY_MS=7000 -e YT_SEARCH_DELAY_MS=7000
-e YT_DOWNLOADS_PER_MINUTE=6 -e YT_DOWNLOADS_PER_MINUTE=6
``` ~~~
The hardened branch also includes additional internal pacing and retry coordination to reduce: The hardened branch also includes additional internal pacing and retry coordination to reduce:
@@ -369,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 # Security Notes
Never commit: Never commit:
@@ -410,7 +269,7 @@ Never commit:
Recommended `.gitignore` additions: Recommended `.gitignore` additions:
```gitignore ~~~gitignore
downloads/ downloads/
config/ config/
spooty-config/ spooty-config/
@@ -418,83 +277,7 @@ spooty-config/
cookies.txt cookies.txt
.env .env
.env.local .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" \
spootyfy-hardened:local
```
--- ---
Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

+1837 -1986
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "2.4.2", "version": "3.0.0",
"name": "spooty", "name": "spooty",
"workspaces": [ "workspaces": [
"src/backend", "src/backend",
@@ -17,8 +17,7 @@
"clean": "rimraf dist", "clean": "rimraf dist",
"changelog": "auto-changelog -p", "changelog": "auto-changelog -p",
"release": "release-it", "release": "release-it",
"commit": "cz", "commit": "cz"
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
}, },
"devDependencies": { "devDependencies": {
"@release-it/bumper": "^7.0.1", "@release-it/bumper": "^7.0.1",
Executable
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE="${IMAGE:-spootyfy-hardened:local}"
CONTAINER="${CONTAINER:-jarri-spooty}"
PORT="${PORT:-3000}"
TOKEN="${SPOOTY_AUTH_TOKEN:-test-token}"
DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}"
CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}"
ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}"
YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}"
YT_COOKIES_FILE_CONTAINER="${YT_COOKIES_FILE_CONTAINER:-/spooty/config/youtube.cookies.txt}"
cd "$PROJECT"
DOCKER="docker"
if ! docker ps >/dev/null 2>&1; then
DOCKER="sudo docker"
fi
echo "== Docker before =="
$DOCKER ps --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' || true
if [[ "${1:-}" == "--build" ]]; then
echo "== Building app =="
npm run build
$DOCKER build -t "$IMAGE" .
fi
echo "== Removing named container if present =="
$DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true
echo "== Removing any container bound to host port ${PORT} =="
PORT_CONTAINERS="$($DOCKER ps --filter "publish=${PORT}" -q || true)"
if [[ -n "$PORT_CONTAINERS" ]]; then
$DOCKER rm -f $PORT_CONTAINERS
fi
mkdir -p "$DOWNLOADS" "$CONFIG_DIR"
echo "== Starting Jarri Spooty =="
$DOCKER run -d \
--name "$CONTAINER" \
-p "$PORT:3000" \
--env-file "$ENV_FILE" \
-e SPOTIFY_REDIRECT_URI="http://127.0.0.1:${PORT}/api/spotify/callback" \
-e AUTH_ENABLED=true \
-e SPOOTY_AUTH_TOKEN="$TOKEN" \
-e YT_SEARCH_DELAY_MS=7000 \
-e YT_DOWNLOADS_PER_MINUTE=6 \
-e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \
-e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \
-v "$DOWNLOADS:/spooty/backend/downloads" \
-v "$YT_COOKIES_FILE_HOST:$YT_COOKIES_FILE_CONTAINER:ro" \
-v "$CONFIG_DIR:/spooty/backend/config" \
"$IMAGE"
echo
echo "== Docker after =="
$DOCKER ps --filter "name=^/${CONTAINER}$" --format 'table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
echo
echo "Open:"
echo "http://127.0.0.1:${PORT}/?token=${TOKEN}"
echo
echo "Logs:"
echo "$DOCKER logs -f $CONTAINER"
+2 -7
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "2.4.2", "version": "3.0.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -31,18 +31,13 @@
"@nestjs/serve-static": "^4.0.2", "@nestjs/serve-static": "^4.0.2",
"@nestjs/typeorm": "^10.0.2", "@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.10", "@nestjs/websockets": "^10.3.10",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/yt-search": "^2.10.3",
"bullmq": "^5.31.2", "bullmq": "^5.31.2",
"fluent-ffmpeg": "^2.1.3",
"isomorphic-unfetch": "^4.0.2", "isomorphic-unfetch": "^4.0.2",
"node-id3": "^0.2.9", "node-id3": "^0.2.9",
"reflect-metadata": "^0.2.0", "reflect-metadata": "^0.2.0",
"rxjs": "7.8.0", "rxjs": "7.8.0",
"spotify-url-info": "^3.2.18", "spotify-url-info": "^3.2.18",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7"
"yt-search": "^2.12.1",
"ytdlp-nodejs": "^3.4.4"
}, },
"devDependencies": { "devDependencies": {
"@nestjs/cli": "^10.0.0", "@nestjs/cli": "^10.0.0",
+2
View File
@@ -14,6 +14,7 @@ import { BullModule } from '@nestjs/bullmq';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './shared/auth.guard'; import { AuthGuard } from './shared/auth.guard';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ArchiveModule } from './archive/archive.module';
@Module({ @Module({
imports: [ imports: [
@@ -79,6 +80,7 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
}), }),
TrackModule, TrackModule,
PlaylistModule, PlaylistModule,
ArchiveModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ 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();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { ArchiveController } from './archive.controller';
import { ArchiveService } from './archive.service';
@Module({
imports: [SharedModule],
controllers: [ArchiveController],
providers: [ArchiveService],
})
export class ArchiveModule {}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { promises as fs } from 'fs';
import { relative, resolve } from 'path';
import { UtilsService } from '../shared/utils.service';
export interface ArchiveFileDto {
name: string;
path: string;
sizeBytes: number;
modifiedAt: number;
}
export interface ArchiveListingDto {
root: string;
files: ArchiveFileDto[];
}
@Injectable()
export class ArchiveService {
constructor(private readonly utilsService: UtilsService) {}
async list(): Promise<ArchiveListingDto> {
const root = this.utilsService.getRootDownloadsPath();
const files = await this.listFiles(root, root);
return {
root,
files: files.sort((a, b) => b.modifiedAt - a.modifiedAt),
};
}
private async listFiles(root: string, currentPath: string): Promise<ArchiveFileDto[]> {
const entries = await fs.readdir(currentPath, { withFileTypes: true });
const files: ArchiveFileDto[] = [];
for (const entry of entries) {
const entryPath = this.utilsService.ensureInsideDownloadsRoot(
resolve(currentPath, entry.name),
);
if (entry.isDirectory()) {
files.push(...await this.listFiles(root, entryPath));
continue;
}
if (!entry.isFile()) {
continue;
}
const stat = await fs.stat(entryPath);
files.push({
name: entry.name,
path: relative(root, entryPath).split(/[\\/]+/).join('/'),
sizeBytes: stat.size,
modifiedAt: stat.mtimeMs,
});
}
return files;
}
}
+11 -2
View File
@@ -4,7 +4,7 @@ import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import * as fs from 'fs'; import * as fs from 'fs';
import { resolve } from 'path'; import { resolve } from 'path';
import { exec } from 'child_process'; import { spawn } from 'child_process';
import helmet from 'helmet'; import helmet from 'helmet';
import { EnvironmentEnum } from './environmentEnum'; import { EnvironmentEnum } from './environmentEnum';
@@ -27,7 +27,16 @@ async function bootstrap() {
try { try {
// Convenience mode for the single-container Docker image. // Convenience mode for the single-container Docker image.
// Hardened deployments should prefer an external Redis service. // Hardened deployments should prefer an external Redis service.
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`); const redisProcess = spawn(
'redis-server',
['--port', String(process.env.REDIS_PORT || 6379)],
{
stdio: 'ignore',
detached: true,
},
);
redisProcess.unref();
} catch (e) { } catch (e) {
console.log('Unable to run redis server from app'); console.log('Unable to run redis server from app');
console.log(e); console.log(e);
@@ -40,7 +40,7 @@ export class PlaylistController {
return this.service.remove(id); return this.service.remove(id);
} }
@Get('retry/:id') @Post('retry/:id')
retryFailedOfPlaylist( retryFailedOfPlaylist(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
): Promise<void> { ): Promise<void> {
+1 -1
View File
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
const providedToken = this.extractToken(request); const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) { if (providedToken !== expectedToken) {
throw new UnauthorizedException('Invalid or missing Spooty auth token'); throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
} }
return true; return true;
+16 -2
View File
@@ -22,7 +22,7 @@ export class UtilsService {
} }
ensureInsideDownloadsRoot(candidatePath: string): string { ensureInsideDownloadsRoot(candidatePath: string): string {
const root = this.getRootDownloadsPath(); const root = resolve(this.getRootDownloadsPath());
const resolvedCandidate = resolve(candidatePath); const resolvedCandidate = resolve(candidatePath);
const rel = relative(root, resolvedCandidate); const rel = relative(root, resolvedCandidate);
@@ -34,6 +34,20 @@ export class UtilsService {
} }
stripFileIllegalChars(text: string): string { stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled'; const sanitized = String(text || '')
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
.trim();
const fallback = sanitized || 'untitled';
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
return `_${fallback}`;
}
return fallback.slice(0, 180);
} }
} }
+144 -12
View File
@@ -3,7 +3,6 @@ import { TrackEntity } from '../track/track.entity';
import { EnvironmentEnum } from '../environmentEnum'; import { EnvironmentEnum } from '../environmentEnum';
import { TrackService } from '../track/track.service'; import { TrackService } from '../track/track.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as yts from 'yt-search';
import * as fs from 'fs'; import * as fs from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
const NodeID3 = require('node-id3'); const NodeID3 = require('node-id3');
@@ -63,6 +62,16 @@ interface CandidateScore extends YoutubeMatch {
rejected: boolean; rejected: boolean;
} }
interface YtDlpSearchVideo {
url?: string;
webpage_url?: string;
id?: string;
title?: string;
uploader?: string;
channel?: string;
duration?: number;
}
@Injectable() @Injectable()
export class YoutubeService { export class YoutubeService {
private readonly logger = new Logger(TrackService.name); private readonly logger = new Logger(TrackService.name);
@@ -78,14 +87,14 @@ export class YoutubeService {
const query = `${artist} - ${name}`; const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`); this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query); const videos = await this.searchYoutubeVideos(query);
const excludedVideoIds = new Set( const excludedVideoIds = new Set(
excludedUrls excludedUrls
.map((url) => this.getYoutubeVideoId(url)) .map((url) => this.getYoutubeVideoId(url))
.filter((id): id is string => !!id), .filter((id): id is string => !!id),
); );
const candidates = (result.videos || []) const candidates = videos
.filter((video: any) => !!video?.url && !!video?.title) .filter((video: any) => !!video?.url && !!video?.title)
.filter((video: any) => { .filter((video: any) => {
const videoId = this.getYoutubeVideoId(String(video.url)); const videoId = this.getYoutubeVideoId(String(video.url));
@@ -130,6 +139,55 @@ export class YoutubeService {
return accepted; return accepted;
} }
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--flat-playlist',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
searchTarget,
];
const output = await this.runYtDlpForOutput(args);
let parsed: any;
try {
parsed = JSON.parse(output);
} catch (error) {
throw new Error(`Failed to parse yt-dlp search output: ${(error as Error).message}`);
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
return entries
.filter((entry: YtDlpSearchVideo) => !!entry?.title && (!!entry?.url || !!entry?.webpage_url || !!entry?.id))
.map((entry: YtDlpSearchVideo) => ({
...entry,
url: this.normalizeYtDlpVideoUrl(entry),
author: entry.uploader || entry.channel || '',
seconds: typeof entry.duration === 'number' ? entry.duration : undefined,
}));
}
private normalizeYtDlpVideoUrl(video: YtDlpSearchVideo): string {
const rawUrl = String(video.webpage_url || video.url || '');
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return rawUrl;
}
const id = String(video.id || rawUrl || '').trim();
if (!id) {
return '';
}
return `https://www.youtube.com/watch?v=${id}`;
}
private scoreCandidate( private scoreCandidate(
video: any, video: any,
artist: string, artist: string,
@@ -343,34 +401,69 @@ export class YoutubeService {
return tempCookiesFile; return tempCookiesFile;
} }
private runYtDlp(args: string[]): Promise<void> { private runYtDlpForOutput(args: string[]): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => { return new Promise((resolvePromise, rejectPromise) => {
const child = spawn('yt-dlp', args, { const child = spawn('yt-dlp', args, {
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
const timeoutMs = 10 * 60 * 1000;
let settled = false;
const settleResolve = (value: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolvePromise(value);
};
const settleReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rejectPromise(error);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
settleReject(
new Error(`yt-dlp exceeded maximum runtime of ${timeoutMs}ms`),
);
}, timeoutMs);
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
child.stdout.on('data', (chunk) => { child.stdout?.on('data', (chunk) => {
stdout += chunk.toString(); stdout += chunk.toString();
}); });
child.stderr.on('data', (chunk) => { child.stderr?.on('data', (chunk) => {
stderr += chunk.toString(); stderr += chunk.toString();
}); });
child.on('error', (error) => { child.on('error', (error) => {
rejectPromise(new Error(`Failed to start yt-dlp: ${error.message}`)); settleReject(new Error(`Failed to start yt-dlp: ${error.message}`));
}); });
child.on('close', (code) => { child.on('close', (code) => {
if (code === 0) { if (settled) {
resolvePromise();
return; return;
} }
rejectPromise( if (code === 0) {
settleResolve(stdout);
return;
}
settleReject(
new Error( new Error(
`yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`, `yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`,
), ),
@@ -379,6 +472,10 @@ export class YoutubeService {
}); });
} }
private async runYtDlp(args: string[]): Promise<void> {
await this.runYtDlpForOutput(args);
}
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> { async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
this.logger.debug( this.logger.debug(
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`, `Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
@@ -432,12 +529,47 @@ export class YoutubeService {
return; return;
} }
const res = await fetch(coverUrl); let parsedCoverUrl: URL;
try {
parsedCoverUrl = new URL(coverUrl);
} catch {
throw new Error('Invalid cover art URL');
}
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
throw new Error('Rejected non-HTTP cover art URL');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let res: Response;
try {
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to fetch cover art: ${res.status}`); throw new Error(`Failed to fetch cover art: ${res.status}`);
} }
const contentType = res.headers.get('content-type')?.split(';')[0].toLowerCase() || '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
throw new Error(`Rejected unsupported cover art type: ${contentType || 'unknown'}`);
}
const maxCoverBytes = 5 * 1024 * 1024;
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${contentLength} bytes`);
}
const arrayBuf = await res.arrayBuffer(); const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
}
const imageBuffer = Buffer.from(arrayBuf); const imageBuffer = Buffer.from(arrayBuf);
NodeID3.write( NodeID3.write(
@@ -445,7 +577,7 @@ export class YoutubeService {
title, title,
artist, artist,
APIC: { APIC: {
mime: 'image/jpeg', mime: contentType,
type: { id: 3, name: 'front cover' }, type: { id: 3, name: 'front cover' },
description: 'cover', description: 'cover',
imageBuffer, imageBuffer,
+2 -1
View File
@@ -2,6 +2,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Post,
NotFoundException, NotFoundException,
Param, Param,
ParseIntPipe, ParseIntPipe,
@@ -66,7 +67,7 @@ export class TrackController {
return this.service.remove(id); return this.service.remove(id);
} }
@Get('retry/:id') @Post('retry/:id')
retry( retry(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
): Promise<void> { ): Promise<void> {
+23 -2
View File
@@ -20,6 +20,10 @@ enum WsTrackOperation {
Delete = 'trackDelete', Delete = 'trackDelete',
} }
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
rejectedYoutubeUrls?: string[];
};
@WebSocketGateway() @WebSocketGateway()
@Injectable() @Injectable()
export class TrackService { export class TrackService {
@@ -56,18 +60,35 @@ export class TrackService {
this.io.emit(WsTrackOperation.Delete, { id }); this.io.emit(WsTrackOperation.Delete, { id });
} }
private toClientTrack(track: TrackEntity): ClientTrack {
return {
id: track.id,
artist: track.artist,
name: track.name,
spotifyUrl: track.spotifyUrl,
youtubeUrl: track.youtubeUrl,
rejectedYoutubeUrls: this.parseRejectedYoutubeUrls(track),
downloadAttemptCount: track.downloadAttemptCount,
status: track.status,
error: track.error,
coverUrl: track.coverUrl,
durationMs: track.durationMs,
createdAt: track.createdAt,
};
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> { async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({ ...track, playlist }); const savedTrack = await this.repository.save({ ...track, playlist });
await this.enqueueSearch(savedTrack.id); await this.enqueueSearch(savedTrack.id);
this.io.emit(WsTrackOperation.New, { this.io.emit(WsTrackOperation.New, {
track: savedTrack, track: this.toClientTrack(savedTrack),
playlistId: playlist.id, playlistId: playlist.id,
}); });
} }
async update(id: number, track: TrackEntity): Promise<void> { async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track); await this.repository.update(id, track);
this.io.emit(WsTrackOperation.Update, track); this.io.emit(WsTrackOperation.Update, this.toClientTrack(track));
} }
private parseRejectedYoutubeUrls(track: TrackEntity): string[] { private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
+1 -1
View File
@@ -1,4 +1,4 @@
# SpootyFe # Jarri Spooty Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3. This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
+12 -13
View File
@@ -1,6 +1,6 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.4.2", "version": "3.0.0",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json", "start": "ng serve --proxy-config proxy.conf.json",
@@ -11,15 +11,14 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^19.0.6", "@angular/animations": "^19.2.22",
"@angular/common": "^19.0.6", "@angular/common": "^19.2.22",
"@angular/compiler": "^19.0.6", "@angular/compiler": "^19.2.22",
"@angular/core": "^19.0.6", "@angular/core": "^19.2.22",
"@angular/forms": "^19.0.6", "@angular/forms": "^19.2.22",
"@angular/platform-browser": "^19.0.6", "@angular/platform-browser": "^19.2.22",
"@angular/platform-browser-dynamic": "^19.0.6", "@angular/platform-browser-dynamic": "^19.2.22",
"@angular/router": "^19.0.6", "@angular/router": "^19.2.22",
"@distube/ytdl-core": "^4.15.9",
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@ngneat/elf": "^2.5.1", "@ngneat/elf": "^2.5.1",
"@ngneat/elf-cli-ng": "^1.0.0", "@ngneat/elf-cli-ng": "^1.0.0",
@@ -36,9 +35,9 @@
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.0.7", "@angular-devkit/build-angular": "^19.2.26",
"@angular/cli": "^19.0.7", "@angular/cli": "^19.2.26",
"@angular/compiler-cli": "^19.0.6", "@angular/compiler-cli": "^19.2.22",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0", "jasmine-core": "~5.1.0",
"karma": "~6.4.0", "karma": "~6.4.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "SpootyFe", "name": "Jarri Spooty",
"short_name": "SpootyFe", "short_name": "Spooty",
"description": "Self-hosted Spotify downloader", "description": "Self-hosted Spotify downloader",
"icons": [ "icons": [
{ {
+275 -47
View File
@@ -1,15 +1,88 @@
<section class="jarri-hero"> <main class="workspace-shell">
<div class="hero-left"> <header class="workspace-titlebar">
<p class="eyebrow">Jarri subsystem · deterministic media ingestion</p> <div class="brand-stack">
<h1> <div class="brand-line">
<i class="fa-brands fa-spotify"></i> <i class="fa-brands fa-spotify"></i>
Jarri Spooty <h1>Jarri Spooty</h1>
<span class="tag version-tag">v{{version}}</span> <span class="tag version-tag">v{{version}}</span>
</h1> </div>
<p class="hero-subtitle">Spotify metadata → YouTube candidate scoring → Local archive</p> <p>Spotify metadata → YouTube candidate scoring → Local archive</p>
</div> </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
*ngFor="let tab of workspaceTabs"
class="workspace-tab"
type="button"
[class.active]="activeWorkspaceTab === tab"
(click)="switchWorkspaceTab(tab)"
>
{{ getWorkspaceTabLabel(tab) }}
</button>
</nav>
<section class="workspace-controls">
<div class="panel-picker">
<div class="select">
<select [(ngModel)]="selectedPanelType" aria-label="Add panel type">
<option *ngFor="let panelType of panelTypes" [ngValue]="panelType">
{{ getPanelTitle(panelType) }}
</option>
</select>
</div>
<button class="button is-primary" type="button" (click)="addPanel()">
<i class="fa-solid fa-plus"></i>
Add Panel
</button>
</div>
<div class="panel-picker">
<button class="button reset-button" type="button" (click)="resetLayout()">
<i class="fa-solid fa-rotate-left"></i>
Reset Current Tab
</button>
<button class="button reset-button" type="button" (click)="resetAllLayouts()">
<i class="fa-solid fa-rotate"></i>
Reset All Tabs
</button>
</div>
</section>
<section class="workspace-canvas" aria-label="Jarri Spooty workspace">
<article
*ngFor="let panel of workspace.panels; trackBy: renderPanel"
class="workspace-panel"
[ngClass]="'panel-' + panel.type"
[ngStyle]="panelStyle(panel)"
>
<header class="panel-header" (pointerdown)="beginDrag($event, panel)">
<div class="panel-title">
<span class="status-light"></span>
<strong>{{ panel.title }}</strong>
</div>
<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 <button
class="button auth-button" class="button auth-button"
[class.connected]="spotifyConnected" [class.connected]="spotifyConnected"
@@ -18,55 +91,73 @@
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i> <i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }} {{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
</button> </button>
</div>
</section>
<section class="jarri-body"> <div class="intake-form">
<div class="intelligence-grid"> <input
<div class="intel-card"> class="input"
<span class="intel-label">Spotify</span>
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
</div>
<div class="intel-card">
<span class="intel-label">Matching</span>
<strong>Duration-aware</strong>
</div>
<div class="intel-card">
<span class="intel-label">Queue</span>
<strong>Paced</strong>
</div>
<div class="intel-card">
<span class="intel-label">Mode</span>
<strong>Archive run</strong>
</div>
</div>
<div class="box jarri-box">
<p class="subtitle">Acquire music</p>
<div class="is-flex">
<input class="input"
[class.is-danger]="url && !isValidSpotifyUrl" [class.is-danger]="url && !isValidSpotifyUrl"
type="text" type="text"
[(ngModel)]="url" [(ngModel)]="url"
placeholder="Paste Spotify playlist or track URL"/> placeholder="Paste Spotify playlist or track URL"
<button class="button is-primary" />
<button
class="button is-primary queue-button"
[class.is-loading]="(createLoading$ | async)?.isLoading" [class.is-loading]="(createLoading$ | async)?.isLoading"
(click)="download()" (click)="download()"
[disabled]="!url || !isValidSpotifyUrl" [disabled]="!url || !isValidSpotifyUrl"
> >
<i class="fa-solid fa-download"></i> Queue <i class="fa-solid fa-download"></i>
Queue
</button> </button>
</div> </div>
<label class="field-label">Archive destination</label>
<input
class="input"
type="text"
[(ngModel)]="archiveDestination"
(ngModelChange)="saveArchiveDestination()"
placeholder="Frontend-planned archive destination"
/>
<div class="truth-note">
<p>Runtime download root: {{ archiveListing?.root || archiveDestination || 'loading...' }}</p>
<p>Per-run destination routing: pending backend support</p>
</div>
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger"> <p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
Please enter a valid Spotify URL. Please enter a valid Spotify URL.
</p> </p>
</div> </section>
<div class="box jarri-box"> <section *ngSwitchCase="'queue-observatory'" class="metrics-panel">
<div class="is-flex is-justify-content-space-between"> <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> <div>
<p class="subtitle">Playlist History</p> <h2>Playlist History</h2>
<p class="has-text-grey is-size-7">One collapsed row per playlist run.</p> <p>One collapsed row per playlist run.</p>
</div> </div>
<div class="buttons has-addons"> <div class="buttons has-addons">
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()"> <button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
@@ -80,15 +171,152 @@
<ng-container *ngIf="playlists$ | async as playlists"> <ng-container *ngIf="playlists$ | async as playlists">
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box> <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> <p *ngIf="playlists?.length === 0" class="empty-state">No playlists</p>
</ng-container> </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> </div>
<div class="box jarri-box">
<p class="subtitle">Single Songs</p>
<ng-container *ngIf="songs$ | async as songs"> <ng-container *ngIf="songs$ | async as songs">
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box> <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> <p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
</ng-container> </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> </div>
</section> <button class="button is-small reset-button" type="button" [class.is-loading]="archiveLoading" (click)="refreshArchive()">
<i class="fa-solid fa-rotate"></i>
Refresh
</button>
</div>
<p *ngIf="archiveError" class="help is-danger">{{ archiveError }}</p>
<ng-container *ngIf="archiveListing as archive">
<div class="archive-summary">
<div>
<strong>{{ archiveFileCount() }}</strong>
<span>files</span>
</div>
<div>
<strong>{{ formatBytes(archiveTotalBytes()) }}</strong>
<span>total</span>
</div>
<div>
<strong>Read-only</strong>
<span>inspection</span>
</div>
</div>
<div class="archive-controls">
<input
class="input is-small"
type="search"
[(ngModel)]="archiveSearch"
placeholder="Search archive"
/>
<div class="select is-small">
<select [(ngModel)]="archiveSort" aria-label="Sort archive files">
<option *ngFor="let sortMode of archiveSortModes" [ngValue]="sortMode">
{{ sortMode }}
</option>
</select>
</div>
</div>
<p *ngIf="archiveFileCount() === 0" class="empty-state">No downloaded files found.</p>
<section *ngFor="let group of archiveGroups()" class="archive-group">
<h3>
<i class="fa-solid fa-folder"></i>
{{ group.folder }}
</h3>
<div *ngFor="let file of group.files" class="archive-file-card">
<i class="fa-solid" [ngClass]="hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'"></i>
<div>
<strong>{{ file.name }}</strong>
<span>{{ folderPath(file) }}</span>
</div>
<small>{{ formatBytes(file.sizeBytes) }}</small>
<small>{{ formatModifiedAt(file.modifiedAt) }}</small>
</div>
</section>
</ng-container>
</section>
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
<ng-container *ngIf="selectedTrack$ | async as selectedTrack; else noTrackSelected">
<div class="section-toolbar">
<div>
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
</div>
<button class="button is-small reset-button" type="button" (click)="clearSelectedTrack()">
<i class="fa-solid fa-arrow-left"></i>
Back
</button>
</div>
<dl class="inspector-list">
<dt>Spotify URL</dt>
<dd><a [href]="selectedTrack.spotifyUrl" target="_blank">{{ selectedTrack.spotifyUrl || 'none' }}</a></dd>
<dt>Status</dt>
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
<dt>Download attempts</dt>
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
<dt>Error</dt>
<dd>{{ selectedTrack.error || 'none' }}</dd>
</dl>
<div class="candidate-options">
<strong>Other options</strong>
<p *ngIf="selectedTrack.youtubeUrl">
Selected candidate:
<a [href]="selectedTrack.youtubeUrl" target="_blank">{{ selectedTrack.youtubeUrl }}</a>
</p>
<p>Rejected candidates: {{ rejectedUrls(selectedTrack).length }}</p>
<a *ngFor="let url of rejectedUrls(selectedTrack)" [href]="url" target="_blank">{{ url }}</a>
<p *ngIf="!selectedTrack.youtubeUrl && rejectedUrls(selectedTrack).length === 0" class="empty-state">
Candidate alternatives are not persisted yet. Future scoring history will appear here.
</p>
</div>
</ng-container>
<ng-template #noTrackSelected>
<div class="placeholder-panel">
<i class="fa-solid fa-magnifying-glass-chart"></i>
<p>Select a track row inside an expanded playlist to inspect Spotify, YouTube, retry, and error details.</p>
</div>
<ng-container *ngIf="allTracks$ | async as allTracks">
<button
*ngFor="let track of allTracks"
class="button is-small is-fullwidth reset-button"
type="button"
(click)="selectTrack(track)"
>
{{ track.artist }} - {{ track.name }}
</button>
</ng-container>
</ng-template>
</section>
</div>
<span
class="resize-handle"
title="Resize panel"
(pointerdown)="beginResize($event, panel)"
></span>
</article>
</section>
</main>
+260 -65
View File
@@ -1,129 +1,324 @@
:host { :host {
display: block; display: block;
min-height: 100vh; min-height: 100vh;
background: background: #0d1515;
radial-gradient(circle at top left, rgba(29, 185, 84, 0.18), transparent 30rem),
linear-gradient(135deg, #06110c 0%, #0a1210 45%, #020403 100%);
color: #eafff0; color: #eafff0;
} }
.jarri-hero { .workspace-shell {
min-height: 100vh;
overflow: auto;
}
.workspace-titlebar {
position: sticky;
top: 0;
z-index: 20;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 24px;
align-items: center; min-height: 76px;
padding: 34px 44px; padding: 14px 24px;
background: border-bottom: 1px solid rgba(126, 255, 180, 0.22);
linear-gradient(90deg, rgba(3, 12, 7, 0.95), rgba(29, 185, 84, 0.92)); background: rgba(5, 11, 10, 0.96);
border-bottom: 1px solid rgba(126, 255, 180, 0.25); box-shadow: 0 14px 34px rgba(0, 0, 0, 0.26);
} }
.eyebrow { .brand-line {
margin: 0 0 6px; display: flex;
color: #9fffc3; align-items: center;
text-transform: uppercase; gap: 10px;
letter-spacing: 0.18em; }
font-size: 0.72rem;
font-weight: 800; h1,
h2,
p {
margin: 0;
} }
h1 { h1 {
margin: 0;
color: #f3fff7; color: #f3fff7;
font-size: clamp(2.2rem, 4vw, 4.6rem); font-size: 1.35rem;
font-weight: 900; font-weight: 900;
letter-spacing: -0.06em;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45);
} }
.hero-subtitle { .brand-stack p {
margin-top: 8px; margin-top: 4px;
color: #d8ffe5; color: #bcd1c4;
font-size: 1.05rem; font-size: 0.86rem;
} }
.version-tag { .version-tag {
background: #07110c; background: #12221a;
color: #9fffc3; color: #9fffc3;
vertical-align: middle;
} }
.auth-button { .truth-pill {
border-radius: 999px; display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.28); align-items: center;
background: rgba(255, 255, 255, 0.92); gap: 8px;
color: #07110c; 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; 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; background: #9fffc3;
} }
.jarri-body { .panel-close {
padding: 34px 44px; 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; 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: 1px solid rgba(126, 255, 180, 0.18);
border-radius: 16px; border-radius: 8px;
padding: 14px 16px;
background: rgba(255, 255, 255, 0.06); 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; display: block;
color: #8ea99a; color: #9db0a6;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.13em;
font-size: 0.68rem;
font-weight: 800;
} }
.intel-card strong { .metric-card strong {
display: block; display: block;
margin-top: 4px; margin-top: 8px;
color: #f1fff5; color: #f1fff5;
font-size: 1.1rem; font-size: 1.1rem;
} }
.intel-card strong.online { .metric-card strong.online {
color: #9fffc3; color: #9fffc3;
} }
.jarri-box { .history-panel {
border-radius: 18px; display: grid;
margin-bottom: 20px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
} }
.button { .section-toolbar {
border-radius: 10px; 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) { @media (max-width: 900px) {
.jarri-hero, .workspace-titlebar,
.jarri-body { .workspace-controls {
padding: 18px; position: static;
align-items: flex-start;
flex-direction: column;
padding: 14px;
} }
.jarri-hero { .panel-picker {
display: block; width: 100%;
} }
.hero-right { .panel-picker .select,
margin-top: 14px; .panel-picker select,
.panel-picker .button,
.reset-button {
width: 100%;
} }
.intelligence-grid { .workspace-canvas {
grid-template-columns: 1fr 1fr; width: 1848px;
} }
} }
+554 -2
View File
@@ -1,4 +1,4 @@
import {Component} from '@angular/core'; import {Component, OnDestroy} from '@angular/core';
import {FormsModule} from "@angular/forms"; import {FormsModule} from "@angular/forms";
import {CommonModule, NgFor} from "@angular/common"; import {CommonModule, NgFor} from "@angular/common";
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service"; import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
@@ -6,6 +6,198 @@ import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.compo
import {VersionService} from "./services/version.service"; import {VersionService} from "./services/version.service";
import {map} from "rxjs"; import {map} from "rxjs";
import {HttpClient} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {ArchiveService} from "./services/archive.service";
import {ArchiveFile, ArchiveListing} from "./models/archive";
import {Track, TrackStatusEnum} from "./models/track";
import {TrackService} from "./services/track.service";
export type SpootyPanelType =
'source-intake'
| 'queue-observatory'
| 'playlist-history'
| 'single-songs'
| 'archive-browser'
| 'candidate-inspector';
export interface SpootyPanelInstance {
id: string;
type: SpootyPanelType;
title: string;
x: number;
y: number;
w: number;
h: number;
}
export interface SpootyWorkspaceState {
panels: SpootyPanelInstance[];
}
export type SpootyWorkspaceTab = 'intake' | 'archive' | 'diagnostics';
export type ArchiveSortMode = 'newest' | 'name' | 'size';
export interface SpootyWorkspaceTabsState {
activeTab: SpootyWorkspaceTab;
tabs: Record<SpootyWorkspaceTab, SpootyWorkspaceState>;
}
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
const GRID_SIZE = 12;
const MIN_PANEL_WIDTH = 260;
const MIN_PANEL_HEIGHT = 160;
const PANEL_TITLES: Record<SpootyPanelType, string> = {
'source-intake': 'Source Intake',
'queue-observatory': 'Queue Observatory',
'playlist-history': 'Playlist History',
'single-songs': 'Single Songs',
'archive-browser': 'Archive Browser',
'candidate-inspector': 'Candidate Inspector',
};
const WORKSPACE_TAB_LABELS: Record<SpootyWorkspaceTab, string> = {
intake: 'Intake',
archive: 'Archive',
diagnostics: 'Diagnostics',
};
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
intake: [
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
makePanel('queue-observatory', 568, 24, 520, 260, 'intake-queue-observatory'),
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
],
archive: [
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
makePanel('queue-observatory', 960, 396, 420, 260, 'archive-queue-observatory'),
],
diagnostics: [
makePanel('candidate-inspector', 24, 24, 620, 720, 'diagnostics-candidate-inspector'),
makePanel('playlist-history', 680, 24, 620, 520, 'diagnostics-playlist-history'),
makePanel('queue-observatory', 1320, 24, 420, 260, 'diagnostics-queue-observatory'),
makePanel('archive-browser', 1320, 320, 420, 420, 'diagnostics-archive-browser'),
],
};
export function makePanel(
type: SpootyPanelType,
x = 48,
y = 48,
w = 520,
h = 320,
id = `${type}-${Date.now()}`,
): SpootyPanelInstance {
return {
id,
type,
title: PANEL_TITLES[type],
x,
y,
w,
h,
};
}
export function defaultWorkspaceState(): SpootyWorkspaceState {
return defaultWorkspaceStateForTab('intake');
}
export function defaultWorkspaceStateForTab(tab: SpootyWorkspaceTab): SpootyWorkspaceState {
return {
panels: DEFAULT_TAB_PANELS[tab].map(panel => ({...panel})),
};
}
export function defaultWorkspaceTabsState(): SpootyWorkspaceTabsState {
return {
activeTab: 'intake',
tabs: {
intake: defaultWorkspaceStateForTab('intake'),
archive: defaultWorkspaceStateForTab('archive'),
diagnostics: defaultWorkspaceStateForTab('diagnostics'),
},
};
}
export function loadWorkspaceState(): SpootyWorkspaceState {
try {
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
if (!raw) {
return defaultWorkspaceState();
}
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
const panels = parsed.panels
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
.map(panel => ({
...panel,
title: PANEL_TITLES[panel.type],
x: snap(panel.x),
y: snap(panel.y),
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
}));
return panels?.length ? {panels} : defaultWorkspaceState();
} catch {
return defaultWorkspaceState();
}
}
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
try {
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
return {
activeTab: parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab] ? parsed.activeTab : 'intake',
tabs: {
intake: sanitizeWorkspaceState(parsed.tabs?.intake, defaultWorkspaceStateForTab('intake')),
archive: sanitizeWorkspaceState(parsed.tabs?.archive, defaultWorkspaceStateForTab('archive')),
diagnostics: sanitizeWorkspaceState(parsed.tabs?.diagnostics, defaultWorkspaceStateForTab('diagnostics')),
},
};
}
return {
...defaultWorkspaceTabsState(),
tabs: {
...defaultWorkspaceTabsState().tabs,
intake: loadWorkspaceState(),
},
};
} catch {
return defaultWorkspaceTabsState();
}
}
function sanitizeWorkspaceState(
state: SpootyWorkspaceState | undefined,
fallback: SpootyWorkspaceState,
): SpootyWorkspaceState {
const panels = state?.panels
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
.map(panel => ({
...panel,
title: PANEL_TITLES[panel.type],
x: snap(panel.x),
y: snap(panel.y),
w: Math.max(MIN_PANEL_WIDTH, snap(panel.w)),
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
}));
return panels?.length ? {panels} : fallback;
}
function snap(value: number): number {
return Math.round(value / GRID_SIZE) * GRID_SIZE;
}
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -14,10 +206,32 @@ import {HttpClient} from "@angular/common/http";
styleUrl: './app.component.scss', styleUrl: './app.component.scss',
standalone: true, standalone: true,
}) })
export class AppComponent { export class AppComponent implements OnDestroy {
url = '' url = ''
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/; private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
private interaction?: {
mode: 'drag' | 'resize';
panelId: string;
startPointerX: number;
startPointerY: number;
startPanelX: number;
startPanelY: number;
startPanelW: number;
startPanelH: number;
};
readonly panelTypes: SpootyPanelType[] = [
'source-intake',
'queue-observatory',
'playlist-history',
'single-songs',
'archive-browser',
'candidate-inspector',
];
readonly workspaceTabs: SpootyWorkspaceTab[] = ['intake', 'archive', 'diagnostics'];
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
selectedPanelType: SpootyPanelType = 'source-intake';
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
get isValidSpotifyUrl(): boolean { get isValidSpotifyUrl(): boolean {
return this.spotifyUrlPattern.test(this.url); return this.spotifyUrlPattern.test(this.url);
@@ -25,17 +239,41 @@ export class AppComponent {
createLoading$ = this.playlistService.createLoading$; createLoading$ = this.playlistService.createLoading$;
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack))); playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack))); songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
allTracks$ = this.trackService.all$;
selectedTrack$ = this.trackService.selectedTrack$;
version = this.versionService.getVersion(); version = this.versionService.getVersion();
spotifyConnected = false; spotifyConnected = false;
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || '';
archiveListing?: ArchiveListing;
archiveLoading = false;
archiveError = '';
archiveSearch = '';
archiveSort: ArchiveSortMode = 'newest';
trackStatuses = TrackStatusEnum;
get activeWorkspaceTab(): SpootyWorkspaceTab {
return this.workspaceTabsState.activeTab;
}
get workspace(): SpootyWorkspaceState {
return this.workspaceTabsState.tabs[this.activeWorkspaceTab];
}
constructor( constructor(
private readonly playlistService: PlaylistService, private readonly playlistService: PlaylistService,
private readonly versionService: VersionService, private readonly versionService: VersionService,
private readonly http: HttpClient, private readonly http: HttpClient,
private readonly archiveService: ArchiveService,
private readonly trackService: TrackService,
) { ) {
this.bootstrapAuthTokenFromUrl(); this.bootstrapAuthTokenFromUrl();
this.checkSpotifyStatus(); this.checkSpotifyStatus();
this.fetchPlaylists(); this.fetchPlaylists();
this.refreshArchive();
}
ngOnDestroy(): void {
this.stopWorkspaceInteraction();
} }
private bootstrapAuthTokenFromUrl(): void { private bootstrapAuthTokenFromUrl(): void {
@@ -66,6 +304,7 @@ export class AppComponent {
} }
download(): void { download(): void {
this.saveArchiveDestination();
this.url && this.playlistService.create(this.url); this.url && this.playlistService.create(this.url);
this.url = ''; this.url = '';
} }
@@ -77,4 +316,317 @@ export class AppComponent {
deleteFailed(): void { deleteFailed(): void {
this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error); this.playlistService.deleteAllByStatus(PlaylistStatusEnum.Error);
} }
saveArchiveDestination(): void {
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
}
refreshArchive(): void {
this.archiveLoading = true;
this.archiveError = '';
this.archiveService.list().subscribe({
next: (listing) => {
this.archiveListing = listing;
this.archiveDestination ||= listing.root;
this.archiveLoading = false;
},
error: () => {
this.archiveError = 'Archive listing is unavailable.';
this.archiveLoading = false;
},
});
}
formatBytes(sizeBytes: number): string {
if (sizeBytes < 1024) {
return `${sizeBytes} B`;
}
if (sizeBytes < 1024 * 1024) {
return `${(sizeBytes / 1024).toFixed(1)} KB`;
}
return `${(sizeBytes / 1024 / 1024).toFixed(1)} MB`;
}
formatModifiedAt(modifiedAt: number): string {
return new Date(modifiedAt).toLocaleString();
}
trackStatusLabel(status?: TrackStatusEnum): string {
switch (status) {
case TrackStatusEnum.New:
return 'New';
case TrackStatusEnum.Searching:
return 'Searching';
case TrackStatusEnum.Queued:
return 'Queued';
case TrackStatusEnum.Downloading:
return 'Downloading';
case TrackStatusEnum.Completed:
return 'Completed';
case TrackStatusEnum.Error:
return 'Error';
default:
return 'Unknown';
}
}
rejectedUrls(track: Track): string[] {
if (Array.isArray(track.rejectedYoutubeUrls)) {
return track.rejectedYoutubeUrls;
}
if (!track.rejectedYoutubeUrls) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeUrls);
return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
} catch {
return [];
}
}
selectTrack(track: Track): void {
this.trackService.select(track);
}
clearSelectedTrack(): void {
this.trackService.clearSelection();
}
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
return WORKSPACE_TAB_LABELS[tab];
}
switchWorkspaceTab(tab: SpootyWorkspaceTab): void {
this.workspaceTabsState = {
...this.workspaceTabsState,
activeTab: tab,
};
this.saveWorkspaceState();
}
resetLayout(): void {
this.setActiveWorkspace(defaultWorkspaceStateForTab(this.activeWorkspaceTab));
}
resetAllLayouts(): void {
this.workspaceTabsState = defaultWorkspaceTabsState();
this.saveWorkspaceState();
}
filteredArchiveFiles(): ArchiveFile[] {
const files = this.archiveListing?.files || [];
const query = this.archiveSearch.trim().toLowerCase();
const filtered = query
? files.filter(file => `${file.name} ${file.path}`.toLowerCase().includes(query))
: [...files];
return filtered.sort((a, b) => {
if (this.archiveSort === 'name') {
return a.name.localeCompare(b.name);
}
if (this.archiveSort === 'size') {
return b.sizeBytes - a.sizeBytes;
}
return b.modifiedAt - a.modifiedAt;
});
}
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
const groups = new Map<string, ArchiveFile[]>();
this.filteredArchiveFiles().forEach(file => {
const folder = this.topLevelFolder(file);
groups.set(folder, [...(groups.get(folder) || []), file]);
});
return [...groups.entries()].map(([folder, files]) => ({folder, files}));
}
archiveFileCount(): number {
return this.filteredArchiveFiles().length;
}
archiveTotalBytes(): number {
return this.filteredArchiveFiles().reduce((total, file) => total + file.sizeBytes, 0);
}
topLevelFolder(file: ArchiveFile): string {
return file.path.includes('/') ? file.path.split('/')[0] : 'Root';
}
folderPath(file: ArchiveFile): string {
const index = file.path.lastIndexOf('/');
return index === -1 ? 'Root' : file.path.slice(0, index);
}
hasFolder(file: ArchiveFile): boolean {
return file.path.includes('/');
}
getPanelTitle(type: SpootyPanelType): string {
return PANEL_TITLES[type];
}
addPanel(type: SpootyPanelType = this.selectedPanelType): void {
const offset = this.workspace.panels.length * GRID_SIZE;
this.setActiveWorkspace({
panels: [
...this.workspace.panels,
makePanel(type, 48 + offset, 72 + offset),
],
});
}
closePanel(panelId: string): void {
if (this.workspace.panels.length <= 1) {
return;
}
this.setActiveWorkspace({
panels: this.workspace.panels.filter(panel => panel.id !== panelId),
});
}
movePanel(panelId: string, x: number, y: number): void {
this.updatePanel(panelId, {
x: Math.max(0, snap(x)),
y: Math.max(0, snap(y)),
});
}
resizePanel(panelId: string, w: number, h: number): void {
this.updatePanel(panelId, {
w: Math.max(MIN_PANEL_WIDTH, snap(w)),
h: Math.max(MIN_PANEL_HEIGHT, snap(h)),
});
}
beginDrag(event: PointerEvent, panel: SpootyPanelInstance): void {
event.preventDefault();
this.bringPanelToFront(panel.id);
this.interaction = {
mode: 'drag',
panelId: panel.id,
startPointerX: event.clientX,
startPointerY: event.clientY,
startPanelX: panel.x,
startPanelY: panel.y,
startPanelW: panel.w,
startPanelH: panel.h,
};
this.startWorkspaceInteraction();
}
beginResize(event: PointerEvent, panel: SpootyPanelInstance): void {
event.preventDefault();
event.stopPropagation();
this.bringPanelToFront(panel.id);
this.interaction = {
mode: 'resize',
panelId: panel.id,
startPointerX: event.clientX,
startPointerY: event.clientY,
startPanelX: panel.x,
startPanelY: panel.y,
startPanelW: panel.w,
startPanelH: panel.h,
};
this.startWorkspaceInteraction();
}
renderPanel(index: number, panel: SpootyPanelInstance): string {
return panel.id;
}
panelStyle(panel: SpootyPanelInstance): Record<string, string> {
return {
transform: `translate(${panel.x}px, ${panel.y}px)`,
width: `${panel.w}px`,
height: `${panel.h}px`,
};
}
private readonly handlePointerMove = (event: PointerEvent): void => {
if (!this.interaction) {
return;
}
const dx = event.clientX - this.interaction.startPointerX;
const dy = event.clientY - this.interaction.startPointerY;
if (this.interaction.mode === 'drag') {
this.movePanel(
this.interaction.panelId,
this.interaction.startPanelX + dx,
this.interaction.startPanelY + dy,
);
return;
}
this.resizePanel(
this.interaction.panelId,
this.interaction.startPanelW + dx,
this.interaction.startPanelH + dy,
);
};
private readonly handlePointerUp = (): void => {
this.stopWorkspaceInteraction();
};
private startWorkspaceInteraction(): void {
document.addEventListener('pointermove', this.handlePointerMove);
document.addEventListener('pointerup', this.handlePointerUp, {once: true});
}
private stopWorkspaceInteraction(): void {
this.interaction = undefined;
document.removeEventListener('pointermove', this.handlePointerMove);
document.removeEventListener('pointerup', this.handlePointerUp);
}
private bringPanelToFront(panelId: string): void {
const panel = this.workspace.panels.find(item => item.id === panelId);
if (!panel) {
return;
}
this.setActiveWorkspace({
panels: [
...this.workspace.panels.filter(item => item.id !== panelId),
panel,
],
});
}
private updatePanel(panelId: string, changes: Partial<SpootyPanelInstance>): void {
this.setActiveWorkspace({
panels: this.workspace.panels.map(panel =>
panel.id === panelId ? {...panel, ...changes} : panel
),
});
}
private setActiveWorkspace(workspace: SpootyWorkspaceState): void {
this.workspaceTabsState = {
...this.workspaceTabsState,
tabs: {
...this.workspaceTabsState.tabs,
[this.activeWorkspaceTab]: workspace,
},
};
this.saveWorkspaceState();
}
private saveWorkspaceState(): void {
localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState));
}
} }
@@ -1,18 +1,21 @@
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async"> <a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async" (click)="select(track)">
<div> <div>
<span>{{ track.artist }} - {{ track.name }}</span>&nbsp; <span>{{ track.artist }} - {{ track.name }}</span>&nbsp;
<a [href]="track.spotifyUrl" <a [href]="track.spotifyUrl"
target="_blank" target="_blank"
(click)="$event.stopPropagation()"
class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded"> class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
<i class="fa-brands fa-spotify"></i> <i class="fa-brands fa-spotify"></i>
</a>&nbsp; </a>&nbsp;
<a [href]="track.youtubeUrl" <a [href]="track.youtubeUrl"
target="_blank" target="_blank"
(click)="$event.stopPropagation()"
class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded"> class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded">
<i class="fa-brands fa-youtube"></i> <i class="fa-brands fa-youtube"></i>
</a>&nbsp; </a>&nbsp;
<a *ngIf="track.status === trackStatuses.Completed" <a *ngIf="track.status === trackStatuses.Completed"
href="api/track/download/{{track.id}}" href="api/track/download/{{track.id}}"
(click)="$event.stopPropagation()"
class="has-text-info" class="has-text-info"
title="Download downloaded and locally saved file" title="Download downloaded and locally saved file"
download> download>
@@ -20,8 +23,8 @@
</a> </a>
</div> </div>
<div> <div>
<i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="retry(track.id)"></i>&nbsp; <i *ngIf="track.status === trackStatuses.Error" class="fa-solid fa-repeat hover-icon" title="Retry to download" (click)="$event.stopPropagation(); retry(track.id)"></i>&nbsp;
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="delete(track.id)"></i>&nbsp; <i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="$event.stopPropagation(); delete(track.id)"></i>&nbsp;
<ng-container [ngSwitch]="track.status"> <ng-container [ngSwitch]="track.status">
<span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span> <span *ngSwitchCase="trackStatuses.New" class="tag is-info">New</span>
<span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span> <span *ngSwitchCase="trackStatuses.Searching" class="tag is-warning">Searching</span>
@@ -30,4 +30,8 @@ export class TrackListComponent {
retry(id: number): void { retry(id: number): void {
this.service.retry(id); this.service.retry(id);
} }
select(track: Track): void {
this.service.select(track);
}
} }
+11
View File
@@ -0,0 +1,11 @@
export interface ArchiveFile {
name: string;
path: string;
sizeBytes: number;
modifiedAt: number;
}
export interface ArchiveListing {
root: string;
files: ArchiveFile[];
}
+4
View File
@@ -4,10 +4,14 @@ export interface Track {
name: string; name: string;
spotifyUrl: string; spotifyUrl: string;
youtubeUrl: string; youtubeUrl: string;
rejectedYoutubeUrls?: string[] | string;
downloadAttemptCount?: number;
status: TrackStatusEnum; status: TrackStatusEnum;
playlistId?: number; playlistId?: number;
error?: string; error?: string;
coverUrl?: string; coverUrl?: string;
durationMs?: number;
createdAt?: number;
} }
export enum TrackStatusEnum { export enum TrackStatusEnum {
@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ArchiveListing } from '../models/archive';
@Injectable({
providedIn: 'root'
})
export class ArchiveService {
constructor(private readonly http: HttpClient) {}
list(): Observable<ArchiveListing> {
return this.http.get<ArchiveListing>('/api/archive');
}
}
@@ -138,7 +138,7 @@ export class PlaylistService {
} }
retryFailed(id: number): void { retryFailed(id: number): void {
this.http.get<void>(`${ENDPOINT}/retry/${id}`).subscribe(); this.http.post<void>(`${ENDPOINT}/retry/${id}`, {}).subscribe();
} }
setActive(id: number, active: boolean): void { setActive(id: number, active: boolean): void {
+31 -5
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import {createStore} from "@ngneat/elf"; import {createStore} from "@ngneat/elf";
import {deleteEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities"; import {deleteEntities, selectAllEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities";
import {Socket} from "ngx-socket-io"; import {Socket} from "ngx-socket-io";
import {map, Observable, tap} from "rxjs"; import {BehaviorSubject, map, Observable, tap} from "rxjs";
import {HttpClient} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {Track, TrackStatusEnum} from "../models/track"; import {Track, TrackStatusEnum} from "../models/track";
@@ -19,11 +19,16 @@ enum WsTrackOperation {
}) })
export class TrackService { export class TrackService {
private readonly selectedTrackSubject = new BehaviorSubject<Track | undefined>(undefined);
selectedTrack$ = this.selectedTrackSubject.asObservable();
private store = createStore( private store = createStore(
{ name: STORE_NAME }, { name: STORE_NAME },
withEntities<Track>(), withEntities<Track>(),
); );
all$ = this.store.pipe(selectAllEntities());
getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> { getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> {
return this.store.pipe( return this.store.pipe(
selectManyByPredicate((track) => track?.playlistId === id), selectManyByPredicate((track) => track?.playlistId === id),
@@ -57,12 +62,33 @@ export class TrackService {
} }
retry(id: number): void { retry(id: number): void {
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe(); this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
select(track: Track): void {
this.selectedTrackSubject.next(track);
}
clearSelection(): void {
this.selectedTrackSubject.next(undefined);
} }
private initWsConnection(): void { private initWsConnection(): void {
this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track))); this.socket.on(WsTrackOperation.Update, (track: Track) => {
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id))); this.store.update(upsertEntities(track));
const selected = this.selectedTrackSubject.value;
if (selected?.id === track.id) {
this.selectedTrackSubject.next({...selected, ...track});
}
});
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => {
this.store.update(deleteEntities(id));
if (this.selectedTrackSubject.value?.id === id) {
this.selectedTrackSubject.next(undefined);
}
});
this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) => this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) =>
this.store.update(upsertEntities([{...track, playlistId}])) this.store.update(upsertEntities([{...track, playlistId}]))
); );
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>SpootyFe</title> <title>Jarri Spooty</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
+111
View File
@@ -12,3 +12,114 @@ body {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.workspace-tabs {
position: sticky;
top: 76px;
z-index: 19;
display: flex;
gap: 6px;
padding: 8px 24px 0;
background: rgba(8, 15, 15, 0.94);
border-bottom: 1px solid rgba(126, 255, 180, 0.12);
}
.workspace-tab {
min-width: 116px;
padding: 9px 14px;
border: 1px solid rgba(126, 255, 180, 0.18);
border-bottom: 0;
border-radius: 8px 8px 0 0;
background: rgba(255, 255, 255, 0.04);
color: #bcd1c4;
font-weight: 800;
cursor: pointer;
}
.workspace-tab.active {
background: #14211d;
color: #9fffc3;
}
.archive-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.archive-summary div,
.archive-file-card {
border: 1px solid rgba(126, 255, 180, 0.14);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}
.archive-summary div {
padding: 10px;
}
.archive-summary span,
.archive-file-card span,
.archive-file-card small {
display: block;
color: #9db0a6;
overflow-wrap: anywhere;
}
.archive-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.archive-group h3 {
margin: 12px 0 6px;
color: #f1fff5;
font-size: 0.9rem;
}
.archive-file-card {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto auto;
gap: 10px;
align-items: center;
margin-bottom: 6px;
padding: 8px;
}
.archive-file-card i {
color: #9fffc3;
}
.inspector-list {
margin: 0;
}
.inspector-list dt,
.candidate-options strong {
color: #f1fff5;
font-weight: 800;
}
.inspector-list dd {
margin: 0 0 8px;
}
.candidate-options {
display: grid;
gap: 6px;
color: #bcd1c4;
overflow-wrap: anywhere;
}
@media (max-width: 900px) {
.workspace-tabs {
position: static;
padding: 8px 14px 0;
overflow-x: auto;
}
.archive-file-card {
grid-template-columns: 22px minmax(0, 1fr);
}
}