Author SHA1 Message Date
TorMatzAndren e744e5d713 Persist rejected YouTube candidate truth 2026-06-09 22:09:29 +02:00
TorMatzAndren 3e96e5fd87 Persist selected YouTube candidate truth 2026-06-09 22:00:50 +02:00
TorMatzAndren bd5742f924 Add backend operations snapshot truth contract 2026-06-09 21:46:31 +02:00
TorMatzAndren 69d059b8a5 Add Workspace single-track intake and hardened YouTube matching 2026-06-04 22:59:09 +02:00
TorMatzAndren 2750aa5c06 Update README with Workspace v3 screenshot 2026-05-23 01:08:48 +02:00
TorMatzAndren 56534d1d33 Release Jarri Spooty Workspace v3.0.0 2026-05-23 01:01:55 +02:00
TorMatzAndren 18deaa2d90 Document deterministic runtime and Jarri Spooty branding 2026-05-16 18:23:09 +02:00
TorMatzAndren 66c71a7487 Add Jarri Spooty frontend demo screenshot 2026-05-16 18:20:26 +02:00
TorMatzAndren a41884442d Add local Docker runtime helper script 2026-05-16 17:51:55 +02:00
TorMatzAndren 01fe5bcaf8 Merge branch 'hardening/pass-006-source-determinism' 2026-05-16 17:44:10 +02:00
TorMatzAndren 8a58aa29d9 Updated .gitignore to ignore download folder 2026-05-16 16:39:04 +02:00
TorMatzAndren a71a47f76a Document Jarri Spooty hardening updates 2026-05-16 13:10:00 +02:00
TorMatzAndren d83b0e6e1e Normalize Jarri Spooty naming 2026-05-16 13:01:28 +02:00
TorMatzAndren 1f3131d99a Shape track websocket payloads 2026-05-16 12:45:02 +02:00
TorMatzAndren c3d2f39b82 Harden yt-dlp process lifecycle 2026-05-16 11:42:16 +02:00
TorMatzAndren 09bbffa6e8 Harden cover art fetching 2026-05-16 11:11:59 +02:00
TorMatzAndren 38ca4d6c8d Harden download filename sanitization 2026-05-16 11:08:26 +02:00
TorMatzAndren 0cd5294944 Remove unused downloader dependencies 2026-05-16 11:04:38 +02:00
Tor Wingalen 5d20972c2d Update frontend retry calls to POST 2026-05-15 23:55:10 +02:00
Tor Wingalen 5dc428b148 Use POST for retry state-changing endpoints 2026-05-15 23:49:54 +02:00
Tor Wingalen be11bb94d7 Replace Redis shell exec with spawn 2026-05-15 23:45:59 +02:00
Tor Wingalen f64cc97475 Replace yt-search dependency with yt-dlp search 2026-05-15 23:36:57 +02:00
Tor Wingalen 005e47bf6e Remove unused fluent-ffmpeg dependency 2026-05-15 23:17:51 +02:00
Tor Wingalen 9e83001b73 Update Angular toolchain patch versions 2026-05-15 22:11:01 +02:00
46 changed files with 4630 additions and 2512 deletions
+1 -1
View File
@@ -4,6 +4,6 @@
**/*.sqlite
# Local Netlify folder
.netlify
test-downloads/
spooty-config
+50 -1
View File
@@ -1,6 +1,6 @@
# 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.
@@ -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
@@ -64,6 +107,12 @@ This project follows a practical chronological changelog rather than autogenerat
- Added isolated Spotify credential storage
- Added safer cookie handling flow
- Improved Docker secret isolation documentation
- Removed unused downloader dependencies to reduce transitive attack surface
- Hardened download filename sanitization
- Hardened remote cover-art fetching with protocol, MIME, timeout, and size checks
- Added yt-dlp subprocess timeout and single-settlement handling
- Shaped track websocket payloads to avoid leaking internal entity fields
- Normalized visible project naming to Jarri Spooty
### 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.
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
@@ -21,6 +28,12 @@ This hardened branch focuses on:
- Persistent SQLite state across container restarts
- Deterministic Docker config persistence
- Improved operational observability
- Reduced unused dependency surface
- Hardened filename, cover-art, subprocess, and websocket boundaries
- Workspace Archive Browser MVP with scoped read-only archive listing
- Workspace Candidate Inspector MVP for selected track details
- Frontend archive destination field for planned per-run routing
- Frontend Workspace tabs for Intake, Archive, and Diagnostics layouts
---
@@ -40,6 +53,47 @@ This hardened branch focuses on:
- Automatic YouTube retry/fallback handling
- Failed YouTube candidate rejection memory
- Deterministic yt-dlp error classification
- Hardened cover-art validation and embedding
- Explicit client-facing websocket payload shaping
- Draggable/resizable Workspace UI
- Archive Browser panel for downloaded file inspection
- Candidate Inspector panel for selected track diagnostics
- Frontend-persisted archive destination input
- Per-tab workspace layout presets
- Read-only archive search, sort, and grouped file inspection
---
# 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:
```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.
@@ -114,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' \
@@ -136,8 +190,8 @@ docker run --rm -p 3000:3000 \
-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
```
jarri-spooty:local
~~~
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
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
Aggressive YouTube access can trigger:
@@ -355,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:
@@ -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
Never commit:
@@ -410,7 +269,7 @@ Never commit:
Recommended `.gitignore` additions:
```gitignore
~~~gitignore
downloads/
config/
spooty-config/
@@ -418,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" \
spootyfy-hardened:local
```
~~~
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 686 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

+24
View File
@@ -0,0 +1,24 @@
# Spooty Operations Snapshot
`GET /api/operations/snapshot` exposes backend-owned operational truth for Workspace and other clients.
The MVP reports truth Spooty already owns without a schema migration:
- track state from `TrackEntity.status`
- selected YouTube URL from `TrackEntity.youtubeUrl`
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
- raw failure evidence from `TrackEntity.error`
- download retry count from `TrackEntity.downloadAttemptCount`
- rejected YouTube candidate explanations from `TrackEntity.rejectedYoutubeCandidatesJson`
- legacy rejected YouTube candidate URLs from `TrackEntity.rejectedYoutubeUrls`
- playlist and single-song counts from `PlaylistEntity.isTrack`
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
- Spotify connection presence from the stored Spotify user token
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidate metadata is persisted when a selected candidate is rejected by manual retry or download failure. Older rows and URL-only cases fall back to rejected candidate objects containing only `url`.
Future persistence work is required for:
- full searched candidate history
- structured error class fields stored at write time instead of classified from the persisted raw error string
- operation attempt history across search and download phases
+1838 -1987
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",
"workspaces": [
"src/backend",
@@ -17,8 +17,7 @@
"clean": "rimraf dist",
"changelog": "auto-changelog -p",
"release": "release-it",
"commit": "cz",
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
"commit": "cz"
},
"devDependencies": {
"@release-it/bumper": "^7.0.1",
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",
"version": "2.4.2",
"version": "3.0.0",
"description": "",
"author": "",
"private": true,
@@ -31,18 +31,13 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.10",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/yt-search": "^2.10.3",
"bullmq": "^5.31.2",
"fluent-ffmpeg": "^2.1.3",
"isomorphic-unfetch": "^4.0.2",
"node-id3": "^0.2.9",
"reflect-metadata": "^0.2.0",
"rxjs": "7.8.0",
"spotify-url-info": "^3.2.18",
"sqlite3": "^5.1.7",
"yt-search": "^2.12.1",
"ytdlp-nodejs": "^3.4.4"
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
+5 -1
View File
@@ -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();
}
}
+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 * as fs from 'fs';
import { resolve } from 'path';
import { exec } from 'child_process';
import { spawn } from 'child_process';
import helmet from 'helmet';
import { EnvironmentEnum } from './environmentEnum';
@@ -27,7 +27,16 @@ async function bootstrap() {
try {
// Convenience mode for the single-container Docker image.
// Hardened deployments should prefer an external Redis service.
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`);
const redisProcess = spawn(
'redis-server',
['--port', String(process.env.REDIS_PORT || 6379)],
{
stdio: 'ignore',
detached: true,
},
);
redisProcess.unref();
} catch (e) {
console.log('Unable to run redis server from app');
console.log(e);
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { OperationsService } from './operations.service';
import { OperationsSnapshot } from './operations.types';
@Controller('operations')
export class OperationsController {
constructor(private readonly operationsService: OperationsService) {}
@Get('snapshot')
getSnapshot(): Promise<OperationsSnapshot> {
return this.operationsService.getSnapshot();
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SharedModule } from '../shared/shared.module';
import { TrackEntity } from '../track/track.entity';
import { OperationsController } from './operations.controller';
import { OperationsService } from './operations.service';
@Module({
imports: [
TypeOrmModule.forFeature([PlaylistEntity, TrackEntity]),
BullModule.registerQueue(
{ name: 'track-search-processor' },
{ name: 'track-download-processor' },
),
SharedModule,
],
controllers: [OperationsController],
providers: [OperationsService],
})
export class OperationsModule {}
@@ -0,0 +1,404 @@
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bullmq';
import { In, MoreThan, Repository } from 'typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SpotifyApiService } from '../shared/spotify-api.service';
import { TrackEntity, TrackStatusEnum } from '../track/track.entity';
import {
ErrorClass,
FailureTruth,
OperationsSnapshot,
RejectedCandidateTruth,
SpootyOperation,
TrackTruth,
} from './operations.types';
interface ClassifiedError {
errorClass: ErrorClass;
errorSummary: string;
errorDetail: string;
}
@Injectable()
export class OperationsService {
constructor(
@InjectRepository(TrackEntity)
private readonly trackRepository: Repository<TrackEntity>,
@InjectRepository(PlaylistEntity)
private readonly playlistRepository: Repository<PlaylistEntity>,
@InjectQueue('track-download-processor')
private readonly trackDownloadQueue: Queue,
@InjectQueue('track-search-processor')
private readonly trackSearchQueue: Queue,
private readonly spotifyApiService: SpotifyApiService,
) {}
async getSnapshot(): Promise<OperationsSnapshot> {
const [
spotifyConnected,
searchQueueCounts,
downloadQueueCounts,
activeSearchJobs,
activeDownloadJobs,
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
searchingTracks,
downloadingTracks,
recentFailures,
recentTracks,
] = await Promise.all([
this.spotifyApiService.hasUserToken(),
this.trackSearchQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackDownloadQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackSearchQueue.getJobs('active', 0, 100, true),
this.trackDownloadQueue.getJobs('active', 0, 100, true),
this.playlistRepository.countBy({ isTrack: false }),
this.playlistRepository.countBy({ isTrack: true }),
this.trackRepository.count(),
this.trackRepository.countBy({
status: In([
TrackStatusEnum.Searching,
TrackStatusEnum.Queued,
TrackStatusEnum.Downloading,
]),
}),
this.trackRepository.countBy({ status: TrackStatusEnum.Completed }),
this.trackRepository.countBy({ status: TrackStatusEnum.Error }),
this.trackRepository.countBy({ downloadAttemptCount: MoreThan(0) }),
this.trackRepository.find({
where: { status: TrackStatusEnum.Searching },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Downloading },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Error },
order: { createdAt: 'DESC' },
take: 20,
}),
this.trackRepository.find({
order: { createdAt: 'DESC' },
take: 20,
}),
]);
const activeSearchTracks = await this.mergeTracksFromActiveJobs(
searchingTracks,
activeSearchJobs,
);
const activeDownloadTracks = await this.mergeTracksFromActiveJobs(
downloadingTracks,
activeDownloadJobs,
);
const searchQueueDepth =
(searchQueueCounts.waiting || 0) + (searchQueueCounts.delayed || 0);
const downloadQueueDepth =
(downloadQueueCounts.waiting || 0) + (downloadQueueCounts.delayed || 0);
const activeSearches = activeSearchJobs.length;
const activeDownloads = activeDownloadJobs.length;
return {
generatedAt: new Date().toISOString(),
runtime: {
operation: this.getOperation({
activeSearches,
activeDownloads,
searchQueueDepth,
downloadQueueDepth,
failed,
retries,
}),
spotifyConnected,
searchQueueDepth,
downloadQueueDepth,
activeSearches,
activeDownloads,
},
counters: {
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
},
active: {
searching: activeSearchTracks.map((track) => this.toTrackTruth(track)),
downloading: activeDownloadTracks.map((track) =>
this.toTrackTruth(track),
),
},
recentFailures: recentFailures.map((track) => this.toTrackTruth(track)),
recentTracks: recentTracks.map((track) => this.toTrackTruth(track)),
};
}
private async mergeTracksFromActiveJobs(
tracks: TrackEntity[],
jobs: Job[],
): Promise<TrackEntity[]> {
const knownIds = new Set(tracks.map((track) => track.id).filter(Boolean));
const missingIds = jobs
.map((job) => Number(job.data?.id))
.filter((id) => Number.isInteger(id) && !knownIds.has(id));
if (!missingIds.length) {
return tracks;
}
const activeJobTracks = await this.trackRepository.find({
where: { id: In(missingIds) },
});
return [...tracks, ...activeJobTracks];
}
private toTrackTruth(track: TrackEntity): TrackTruth | FailureTruth {
const rejectedCandidates = this.parseRejectedCandidates(track);
const error = this.classifyError(track.error);
return {
id: track.id,
artist: track.artist,
name: track.name,
status: TrackStatusEnum[track.status] || String(track.status),
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
...(track.youtubeUrl
? {
selectedCandidate: {
url: track.youtubeUrl,
...(track.selectedYoutubeTitle
? { title: track.selectedYoutubeTitle }
: {}),
...(track.selectedYoutubeAuthor
? { author: track.selectedYoutubeAuthor }
: {}),
...(typeof track.selectedYoutubeScore === 'number'
? { score: track.selectedYoutubeScore }
: {}),
...(track.selectedYoutubeReason
? { reason: track.selectedYoutubeReason }
: {}),
},
}
: {}),
...(typeof track.downloadAttemptCount === 'number'
? { downloadAttemptCount: track.downloadAttemptCount }
: {}),
...(error || {}),
rejectedCandidateCount: rejectedCandidates.length,
rejectedCandidates,
};
}
private parseRejectedCandidates(
track: TrackEntity,
): RejectedCandidateTruth[] {
const structured = this.parseRejectedCandidateJson(track);
if (structured.length) {
return structured;
}
if (!track.rejectedYoutubeUrls) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeUrls);
return Array.isArray(parsed)
? parsed
.filter((item) => typeof item === 'string')
.map((url) => ({ url }))
: [];
} catch {
return [];
}
}
private parseRejectedCandidateJson(
track: TrackEntity,
): RejectedCandidateTruth[] {
if (!track.rejectedYoutubeCandidatesJson) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
return Array.isArray(parsed)
? parsed
.filter(
(item) =>
!!item &&
typeof item === 'object' &&
typeof item.url === 'string',
)
.map((item) => ({
url: item.url,
...(typeof item.title === 'string' ? { title: item.title } : {}),
...(typeof item.author === 'string'
? { author: item.author }
: {}),
...(typeof item.score === 'number' ? { score: item.score } : {}),
...(typeof item.reason === 'string'
? { reason: item.reason }
: {}),
...(typeof item.rejectionClass === 'string'
? { rejectionClass: item.rejectionClass }
: {}),
...(typeof item.rejectionSummary === 'string'
? { rejectionSummary: item.rejectionSummary }
: {}),
...(typeof item.rejectedAt === 'string'
? { rejectedAt: item.rejectedAt }
: {}),
}))
: [];
} catch {
return [];
}
}
private classifyError(error?: string): ClassifiedError | undefined {
if (!error) {
return undefined;
}
const detail = this.trimDetail(error);
const normalized = error.toLowerCase();
if (normalized.includes('no acceptable youtube result found')) {
return this.error(
'NO_ACCEPTABLE_CANDIDATE',
'No acceptable YouTube candidate',
detail,
);
}
if (normalized.includes('sign in to confirm your age')) {
return this.error(
'YOUTUBE_AGE_GATED',
'YouTube video is age-gated',
detail,
);
}
if (normalized.includes('private video')) {
return this.error(
'YOUTUBE_PRIVATE_VIDEO',
'YouTube video is private',
detail,
);
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return this.error(
'YOUTUBE_NO_FORMATS',
'No downloadable YouTube audio format',
detail,
);
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return this.error(
'YOUTUBE_VIDEO_UNAVAILABLE',
'YouTube video is unavailable',
detail,
);
}
if (normalized.includes('unable to download webpage')) {
return this.error(
'YOUTUBE_WEBPAGE_UNAVAILABLE',
'YouTube webpage is unavailable',
detail,
);
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime')
) {
return this.error(
'YOUTUBE_EXTRACTION_FAILURE',
'YouTube extraction failed',
detail,
);
}
if (
normalized.includes('eacces') ||
normalized.includes('enoent') ||
normalized.includes('failed to write') ||
normalized.includes('permission denied')
) {
return this.error('FILE_WRITE_FAILED', 'File write failed', detail);
}
return this.error(
'UNKNOWN_DOWNLOAD_ERROR',
'Unknown download error',
detail,
);
}
private error(
errorClass: ErrorClass,
errorSummary: string,
errorDetail: string,
): ClassifiedError {
return { errorClass, errorSummary, errorDetail };
}
private trimDetail(error: string): string {
const trimmed = error.trim();
return trimmed.length > 1200 ? `${trimmed.slice(0, 1197)}...` : trimmed;
}
private getOperation(input: {
activeSearches: number;
activeDownloads: number;
searchQueueDepth: number;
downloadQueueDepth: number;
failed: number;
retries: number;
}): SpootyOperation {
if (input.activeDownloads > 0 || input.downloadQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'downloading';
}
if (input.activeSearches > 0 || input.searchQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'searching';
}
if (input.failed > 0) {
return 'degraded';
}
return 'idle';
}
}
@@ -0,0 +1,89 @@
export type SpootyOperation =
| 'idle'
| 'searching'
| 'downloading'
| 'retrying'
| 'degraded';
export type ErrorClass =
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_WEBPAGE_UNAVAILABLE'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'NO_ACCEPTABLE_CANDIDATE'
| 'FILE_WRITE_FAILED'
| 'UNKNOWN_DOWNLOAD_ERROR';
export type RejectionClass =
| 'DOWNLOAD_FAILED'
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'UNKNOWN_DOWNLOAD_ERROR'
| 'MANUAL_RETRY';
export interface RejectedCandidateTruth {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
rejectionClass?: RejectionClass;
rejectionSummary?: string;
rejectedAt?: string;
}
export interface TrackTruth {
id: number;
artist: string;
name: string;
status: string;
spotifyUrl?: string;
youtubeUrl?: string;
selectedCandidate?: {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
};
downloadAttemptCount?: number;
errorClass?: ErrorClass;
errorSummary?: string;
errorDetail?: string;
rejectedCandidateCount: number;
rejectedCandidates: RejectedCandidateTruth[];
}
export interface FailureTruth extends TrackTruth {}
export interface OperationsSnapshot {
generatedAt: string;
runtime: {
operation: SpootyOperation;
spotifyConnected: boolean;
searchQueueDepth: number;
downloadQueueDepth: number;
activeSearches: number;
activeDownloads: number;
};
counters: {
playlists: number;
singleSongs: number;
tracks: number;
active: number;
completed: number;
failed: number;
retries: number;
};
active: {
searching: TrackTruth[];
downloading: TrackTruth[];
};
recentFailures: FailureTruth[];
recentTracks: TrackTruth[];
}
@@ -0,0 +1,11 @@
import { IsString, MaxLength } from 'class-validator';
export class SearchTrackDto {
@IsString()
@MaxLength(512)
artist: string;
@IsString()
@MaxLength(512)
title: string;
}
@@ -12,6 +12,7 @@ import { PlaylistService } from './playlist.service';
import { PlaylistEntity } from './playlist.entity';
import { CreatePlaylistDto } from './dto/create-playlist.dto';
import { UpdatePlaylistDto } from './dto/update-playlist.dto';
import { SearchTrackDto } from './dto/search-track.dto';
@Controller('playlist')
export class PlaylistController {
@@ -27,6 +28,13 @@ export class PlaylistController {
await this.service.create(playlist as PlaylistEntity);
}
@Post('search-track')
async searchTrack(
@Body() body: SearchTrackDto,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
return this.service.createFromSearch(body.artist, body.title);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
@@ -40,7 +48,7 @@ export class PlaylistController {
return this.service.remove(id);
}
@Get('retry/:id')
@Post('retry/:id')
retryFailedOfPlaylist(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
+23 -1
View File
@@ -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,
);
+1 -1
View File
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) {
throw new UnauthorizedException('Invalid or missing Spooty auth token');
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
}
return true;
@@ -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 '/';
}
}
+84 -1
View File
@@ -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 {
+16 -2
View File
@@ -22,7 +22,7 @@ export class UtilsService {
}
ensureInsideDownloadsRoot(candidatePath: string): string {
const root = this.getRootDownloadsPath();
const root = resolve(this.getRootDownloadsPath());
const resolvedCandidate = resolve(candidatePath);
const rel = relative(root, resolvedCandidate);
@@ -34,6 +34,20 @@ export class UtilsService {
}
stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled';
const sanitized = String(text || '')
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
.trim();
const fallback = sanitized || 'untitled';
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
return `_${fallback}`;
}
return fallback.slice(0, 180);
}
}
+176 -18
View File
@@ -3,7 +3,6 @@ import { TrackEntity } from '../track/track.entity';
import { EnvironmentEnum } from '../environmentEnum';
import { TrackService } from '../track/track.service';
import { ConfigService } from '@nestjs/config';
import * as yts from 'yt-search';
import * as fs from 'fs';
import { spawn } from 'child_process';
const NodeID3 = require('node-id3');
@@ -39,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 = [
@@ -63,6 +75,16 @@ interface CandidateScore extends YoutubeMatch {
rejected: boolean;
}
interface YtDlpSearchVideo {
url?: string;
webpage_url?: string;
id?: string;
title?: string;
uploader?: string;
channel?: string;
duration?: number;
}
@Injectable()
export class YoutubeService {
private readonly logger = new Logger(TrackService.name);
@@ -78,14 +100,14 @@ export class YoutubeService {
const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query);
const videos = await this.searchYoutubeVideos(query);
const excludedVideoIds = new Set(
excludedUrls
.map((url) => this.getYoutubeVideoId(url))
.filter((id): id is string => !!id),
);
const candidates = (result.videos || [])
const candidates = videos
.filter((video: any) => !!video?.url && !!video?.title)
.filter((video: any) => {
const videoId = this.getYoutubeVideoId(String(video.url));
@@ -114,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)` : ''),
);
}
@@ -130,6 +153,56 @@ export class YoutubeService {
return accepted;
}
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--extractor-args',
'youtube:player_client=android_vr',
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
searchTarget,
];
const output = await this.runYtDlpForOutput(args);
let parsed: any;
try {
parsed = JSON.parse(output);
} catch (error) {
throw new Error(`Failed to parse yt-dlp search output: ${(error as Error).message}`);
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
return entries
.filter((entry: YtDlpSearchVideo) => !!entry?.title && (!!entry?.url || !!entry?.webpage_url || !!entry?.id))
.map((entry: YtDlpSearchVideo) => ({
...entry,
url: this.normalizeYtDlpVideoUrl(entry),
author: entry.uploader || entry.channel || '',
seconds: typeof entry.duration === 'number' ? entry.duration : undefined,
}));
}
private normalizeYtDlpVideoUrl(video: YtDlpSearchVideo): string {
const rawUrl = String(video.webpage_url || video.url || '');
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return rawUrl;
}
const id = String(video.id || rawUrl || '').trim();
if (!id) {
return '';
}
return `https://www.youtube.com/watch?v=${id}`;
}
private scoreCandidate(
video: any,
artist: string,
@@ -140,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);
@@ -155,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)),
);
@@ -175,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) {
@@ -322,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')) {
@@ -343,34 +425,69 @@ export class YoutubeService {
return tempCookiesFile;
}
private runYtDlp(args: string[]): Promise<void> {
private runYtDlpForOutput(args: string[]): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn('yt-dlp', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeoutMs = 10 * 60 * 1000;
let settled = false;
const settleResolve = (value: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolvePromise(value);
};
const settleReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rejectPromise(error);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
settleReject(
new Error(`yt-dlp exceeded maximum runtime of ${timeoutMs}ms`),
);
}, timeoutMs);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
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) => {
if (code === 0) {
resolvePromise();
if (settled) {
return;
}
rejectPromise(
if (code === 0) {
settleResolve(stdout);
return;
}
settleReject(
new Error(
`yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`,
),
@@ -379,6 +496,10 @@ export class YoutubeService {
});
}
private async runYtDlp(args: string[]): Promise<void> {
await this.runYtDlpForOutput(args);
}
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
this.logger.debug(
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
@@ -398,6 +519,8 @@ export class YoutubeService {
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'-f',
'bestaudio/best',
'--extract-audio',
'--audio-format',
format,
@@ -432,12 +555,47 @@ export class YoutubeService {
return;
}
const res = await fetch(coverUrl);
let parsedCoverUrl: URL;
try {
parsedCoverUrl = new URL(coverUrl);
} catch {
throw new Error('Invalid cover art URL');
}
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
throw new Error('Rejected non-HTTP cover art URL');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let res: Response;
try {
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
throw new Error(`Failed to fetch cover art: ${res.status}`);
}
const contentType = res.headers.get('content-type')?.split(';')[0].toLowerCase() || '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
throw new Error(`Rejected unsupported cover art type: ${contentType || 'unknown'}`);
}
const maxCoverBytes = 5 * 1024 * 1024;
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${contentLength} bytes`);
}
const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
}
const imageBuffer = Buffer.from(arrayBuf);
NodeID3.write(
@@ -445,7 +603,7 @@ export class YoutubeService {
title,
artist,
APIC: {
mime: 'image/jpeg',
mime: contentType,
type: { id: 3, name: 'front cover' },
description: 'cover',
imageBuffer,
+2 -1
View File
@@ -2,6 +2,7 @@ import {
Controller,
Delete,
Get,
Post,
NotFoundException,
Param,
ParseIntPipe,
@@ -66,7 +67,7 @@ export class TrackController {
return this.service.remove(id);
}
@Get('retry/:id')
@Post('retry/:id')
retry(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
+15
View File
@@ -27,9 +27,24 @@ export class TrackEntity {
@Column({ nullable: true })
youtubeUrl?: string;
@Column({ nullable: true })
selectedYoutubeTitle?: string;
@Column({ nullable: true })
selectedYoutubeAuthor?: string;
@Column({ nullable: true })
selectedYoutubeScore?: number;
@Column({ nullable: true, type: 'text' })
selectedYoutubeReason?: string;
@Column({ nullable: true, type: 'text' })
rejectedYoutubeUrls?: string;
@Column({ nullable: true, type: 'text' })
rejectedYoutubeCandidatesJson?: string;
@Column({ default: 0 })
downloadAttemptCount?: number;
+244 -19
View File
@@ -20,6 +20,36 @@ enum WsTrackOperation {
Delete = 'trackDelete',
}
type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
rejectedYoutubeUrls?: string[];
};
type RejectionClass =
| 'DOWNLOAD_FAILED'
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'UNKNOWN_DOWNLOAD_ERROR'
| 'MANUAL_RETRY';
interface RejectedYoutubeCandidate {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
rejectionClass?: RejectionClass;
rejectionSummary?: string;
rejectedAt: string;
}
interface RejectionReason {
rejectionClass: RejectionClass;
rejectionSummary: string;
}
@WebSocketGateway()
@Injectable()
export class TrackService {
@@ -56,18 +86,35 @@ export class TrackService {
this.io.emit(WsTrackOperation.Delete, { id });
}
private toClientTrack(track: TrackEntity): ClientTrack {
return {
id: track.id,
artist: track.artist,
name: track.name,
spotifyUrl: track.spotifyUrl,
youtubeUrl: track.youtubeUrl,
rejectedYoutubeUrls: this.parseRejectedYoutubeUrls(track),
downloadAttemptCount: track.downloadAttemptCount,
status: track.status,
error: track.error,
coverUrl: track.coverUrl,
durationMs: track.durationMs,
createdAt: track.createdAt,
};
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({ ...track, playlist });
await this.enqueueSearch(savedTrack.id);
this.io.emit(WsTrackOperation.New, {
track: savedTrack,
track: this.toClientTrack(savedTrack),
playlistId: playlist.id,
});
}
async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track);
this.io.emit(WsTrackOperation.Update, track);
this.io.emit(WsTrackOperation.Update, this.toClientTrack(track));
}
private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
@@ -77,7 +124,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 [];
}
@@ -87,6 +136,161 @@ export class TrackService {
return JSON.stringify([...new Set(urls)].slice(0, 20));
}
private parseRejectedYoutubeCandidates(
track: TrackEntity,
): RejectedYoutubeCandidate[] {
if (!track.rejectedYoutubeCandidatesJson) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
return Array.isArray(parsed)
? parsed.filter(
(item): item is RejectedYoutubeCandidate =>
!!item &&
typeof item === 'object' &&
typeof item.url === 'string' &&
typeof item.rejectedAt === 'string',
)
: [];
} catch {
return [];
}
}
private stringifyRejectedYoutubeCandidates(
candidates: RejectedYoutubeCandidate[],
): string {
const byUrl = new Map<string, RejectedYoutubeCandidate>();
for (const candidate of candidates) {
if (candidate.url) {
byUrl.set(candidate.url, candidate);
}
}
return JSON.stringify([...byUrl.values()].slice(-20));
}
private buildRejectedYoutubeCandidate(
track: TrackEntity,
reason: RejectionReason,
): RejectedYoutubeCandidate | null {
if (!track.youtubeUrl) {
return null;
}
return {
url: track.youtubeUrl,
...(track.selectedYoutubeTitle
? { title: track.selectedYoutubeTitle }
: {}),
...(track.selectedYoutubeAuthor
? { author: track.selectedYoutubeAuthor }
: {}),
...(typeof track.selectedYoutubeScore === 'number'
? { score: track.selectedYoutubeScore }
: {}),
...(track.selectedYoutubeReason
? { reason: track.selectedYoutubeReason }
: {}),
...reason,
rejectedAt: new Date().toISOString(),
};
}
private rejectCurrentYoutubeCandidate(
track: TrackEntity,
reason: RejectionReason,
): {
rejectedYoutubeUrls: string;
rejectedYoutubeCandidatesJson: string;
} {
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
const rejectedCandidates = this.parseRejectedYoutubeCandidates(track);
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
if (rejectedCandidate) {
if (!rejectedUrls.includes(rejectedCandidate.url)) {
rejectedUrls.push(rejectedCandidate.url);
}
rejectedCandidates.push(rejectedCandidate);
}
return {
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
rejectedYoutubeCandidatesJson:
this.stringifyRejectedYoutubeCandidates(rejectedCandidates),
};
}
private classifyRejection(error?: string): RejectionReason {
if (!error) {
return {
rejectionClass: 'UNKNOWN_DOWNLOAD_ERROR',
rejectionSummary: 'Unknown download error',
};
}
const normalized = error.toLowerCase();
if (normalized.includes('sign in to confirm your age')) {
return {
rejectionClass: 'YOUTUBE_AGE_GATED',
rejectionSummary: 'YouTube video is age-gated',
};
}
if (normalized.includes('private video')) {
return {
rejectionClass: 'YOUTUBE_PRIVATE_VIDEO',
rejectionSummary: 'YouTube video is private',
};
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return {
rejectionClass: 'YOUTUBE_NO_FORMATS',
rejectionSummary: 'No downloadable YouTube audio format',
};
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return {
rejectionClass: 'YOUTUBE_VIDEO_UNAVAILABLE',
rejectionSummary: 'YouTube video is unavailable',
};
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime') ||
normalized.includes('unable to download webpage')
) {
return {
rejectionClass: 'YOUTUBE_EXTRACTION_FAILURE',
rejectionSummary: 'YouTube extraction failed',
};
}
return {
rejectionClass: 'DOWNLOAD_FAILED',
rejectionSummary: 'Download failed',
};
}
async retry(id: number): Promise<void> {
const track = await this.get(id);
@@ -95,16 +299,19 @@ export class TrackService {
return;
}
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
rejectedUrls.push(track.youtubeUrl);
}
const rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
rejectionClass: 'MANUAL_RETRY',
rejectionSummary: 'Rejected by manual retry',
});
await this.update(id, {
...track,
youtubeUrl: null,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
selectedYoutubeTitle: null,
selectedYoutubeAuthor: null,
selectedYoutubeScore: null,
selectedYoutubeReason: null,
...rejectedCandidateFields,
downloadAttemptCount: 0,
error: null,
status: TrackStatusEnum.New,
@@ -140,6 +347,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(
@@ -172,7 +383,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();
}
@@ -191,7 +404,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();
}
@@ -258,13 +473,17 @@ export class TrackService {
error = toSafeErrorMessage(err);
}
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
const maxAttempts = this.getMaxDownloadAttempts();
if (error && track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
rejectedUrls.push(track.youtubeUrl);
}
const rejectedCandidateFields = error
? this.rejectCurrentYoutubeCandidate(track, this.classifyRejection(error))
: {
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
this.parseRejectedYoutubeUrls(track),
),
rejectedYoutubeCandidatesJson:
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
};
if (error && nextAttemptCount < maxAttempts) {
this.logger.warn(
@@ -275,7 +494,11 @@ export class TrackService {
await this.update(track.id, {
...track,
youtubeUrl: null,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
selectedYoutubeTitle: null,
selectedYoutubeAuthor: null,
selectedYoutubeScore: null,
selectedYoutubeReason: null,
...rejectedCandidateFields,
downloadAttemptCount: nextAttemptCount,
error: null,
status: TrackStatusEnum.New,
@@ -288,7 +511,7 @@ export class TrackService {
const updatedTrack = {
...track,
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
...rejectedCandidateFields,
downloadAttemptCount: error ? nextAttemptCount : 0,
...(error ? { error } : { error: null }),
};
@@ -299,7 +522,9 @@ export class TrackService {
const safeArtist = track.artist || 'unknown_artist';
const safeName = track.name || 'unknown_track';
const fileName = `${safeArtist} - ${safeName}`;
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
const format =
this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
return `${this.utilsService.stripFileIllegalChars(fileName)}.${format}`;
}
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
+1 -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.
+12 -13
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "2.4.2",
"version": "3.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve --proxy-config proxy.conf.json",
@@ -11,15 +11,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.0.6",
"@angular/common": "^19.0.6",
"@angular/compiler": "^19.0.6",
"@angular/core": "^19.0.6",
"@angular/forms": "^19.0.6",
"@angular/platform-browser": "^19.0.6",
"@angular/platform-browser-dynamic": "^19.0.6",
"@angular/router": "^19.0.6",
"@distube/ytdl-core": "^4.15.9",
"@angular/animations": "^19.2.22",
"@angular/common": "^19.2.22",
"@angular/compiler": "^19.2.22",
"@angular/core": "^19.2.22",
"@angular/forms": "^19.2.22",
"@angular/platform-browser": "^19.2.22",
"@angular/platform-browser-dynamic": "^19.2.22",
"@angular/router": "^19.2.22",
"@fortawesome/fontawesome-free": "^6.5.2",
"@ngneat/elf": "^2.5.1",
"@ngneat/elf-cli-ng": "^1.0.0",
@@ -36,9 +35,9 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.7",
"@angular/cli": "^19.0.7",
"@angular/compiler-cli": "^19.0.6",
"@angular-devkit/build-angular": "^19.2.26",
"@angular/cli": "^19.2.26",
"@angular/compiler-cli": "^19.2.22",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "SpootyFe",
"short_name": "SpootyFe",
"name": "Jarri Spooty",
"short_name": "Spooty",
"description": "Self-hosted Spotify downloader",
"icons": [
{
+309 -81
View File
@@ -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>
+260 -65
View File
@@ -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;
}
}
+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 {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>&nbsp;
<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>&nbsp;
<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>&nbsp;
<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>&nbsp;
<i class="fa-solid fa-xmark hover-icon" title="Remove track from list" (click)="delete(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)="$event.stopPropagation(); delete(track.id)"></i>&nbsp;
<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);
}
}
+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;
spotifyUrl: string;
youtubeUrl: string;
rejectedYoutubeUrls?: string[] | string;
downloadAttemptCount?: number;
status: TrackStatusEnum;
playlistId?: number;
error?: string;
coverUrl?: string;
durationMs?: number;
createdAt?: number;
}
export enum TrackStatusEnum {
@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { ArchiveListing } from '../models/archive';
@Injectable({
providedIn: 'root'
})
export class ArchiveService {
constructor(private readonly http: HttpClient) {}
list(): Observable<ArchiveListing> {
return this.http.get<ArchiveListing>('/api/archive');
}
}
@@ -138,7 +138,7 @@ export class PlaylistService {
}
retryFailed(id: number): void {
this.http.get<void>(`${ENDPOINT}/retry/${id}`).subscribe();
this.http.post<void>(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
setActive(id: number, active: boolean): void {
+31 -5
View File
@@ -1,8 +1,8 @@
import { Injectable } from '@angular/core';
import {createStore} from "@ngneat/elf";
import {deleteEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities";
import {deleteEntities, selectAllEntities, selectManyByPredicate, upsertEntities, withEntities} from "@ngneat/elf-entities";
import {Socket} from "ngx-socket-io";
import {map, Observable, tap} from "rxjs";
import {BehaviorSubject, map, Observable, tap} from "rxjs";
import {HttpClient} from "@angular/common/http";
import {Track, TrackStatusEnum} from "../models/track";
@@ -19,11 +19,16 @@ enum WsTrackOperation {
})
export class TrackService {
private readonly selectedTrackSubject = new BehaviorSubject<Track | undefined>(undefined);
selectedTrack$ = this.selectedTrackSubject.asObservable();
private store = createStore(
{ name: STORE_NAME },
withEntities<Track>(),
);
all$ = this.store.pipe(selectAllEntities());
getAllByPlaylist(id: number, status?: TrackStatusEnum): Observable<Track[]> {
return this.store.pipe(
selectManyByPredicate((track) => track?.playlistId === id),
@@ -57,12 +62,33 @@ export class TrackService {
}
retry(id: number): void {
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe();
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
select(track: Track): void {
this.selectedTrackSubject.next(track);
}
clearSelection(): void {
this.selectedTrackSubject.next(undefined);
}
private initWsConnection(): void {
this.socket.on(WsTrackOperation.Update, (track: Track) => this.store.update(upsertEntities(track)));
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => this.store.update(deleteEntities(id)));
this.socket.on(WsTrackOperation.Update, (track: Track) => {
this.store.update(upsertEntities(track));
const selected = this.selectedTrackSubject.value;
if (selected?.id === track.id) {
this.selectedTrackSubject.next({...selected, ...track});
}
});
this.socket.on(WsTrackOperation.Delete, ({id}: {id: number}) => {
this.store.update(deleteEntities(id));
if (this.selectedTrackSubject.value?.id === id) {
this.selectedTrackSubject.next(undefined);
}
});
this.socket.on(WsTrackOperation.New, ({track, playlistId}: {track: Track, playlistId: number}) =>
this.store.update(upsertEntities([{...track, playlistId}]))
);
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>SpootyFe</title>
<title>Jarri Spooty</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
+111
View File
@@ -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);
}
}