Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd060cbc9 | ||
|
|
1c332edde8 | ||
|
|
b76a7d5586 | ||
|
|
609ed65f4f | ||
|
|
b52510df67 | ||
|
|
e5d09fe02b | ||
|
|
1fd3eff5b3 | ||
|
|
09c420ab46 | ||
|
|
bf09a151a8 | ||
|
|
f467836046 | ||
|
|
75129db86c | ||
|
|
8b79371d36 | ||
|
|
dca82a1c54 | ||
|
|
422d372122 | ||
|
|
e744e5d713 |
+4
-1
@@ -7,7 +7,10 @@ RUN npm run build
|
|||||||
FROM node:20.20.0-alpine
|
FROM node:20.20.0-alpine
|
||||||
WORKDIR /spooty
|
WORKDIR /spooty
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
|
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl \
|
||||||
|
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
|
||||||
|
&& chmod +x /usr/local/bin/yt-dlp \
|
||||||
|
&& yt-dlp --version \
|
||||||
&& addgroup -S spooty \
|
&& addgroup -S spooty \
|
||||||
&& adduser -S spooty -G spooty
|
&& adduser -S spooty -G spooty
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,24 @@ 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.
|
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
|
Current version: 3.1.0
|
||||||
|
|
||||||
|
## What's New in 3.1.0
|
||||||
|
|
||||||
|
Jarri Spooty 3.1.0 introduces the first generation of operational truth contracts.
|
||||||
|
|
||||||
|
New capabilities include:
|
||||||
|
|
||||||
|
- Operations Snapshot API
|
||||||
|
- Candidate selection provenance
|
||||||
|
- Selected YouTube candidate persistence
|
||||||
|
- Rejected YouTube candidate persistence
|
||||||
|
- Candidate scoring visibility
|
||||||
|
- Candidate selection reasoning
|
||||||
|
- Improved Workspace diagnostics
|
||||||
|
- Backend-owned operational truth for future Workspace integrations
|
||||||
|
|
||||||
|
Spooty now exposes why a candidate was selected rather than treating YouTube matching as a black box.
|
||||||
|
|
||||||
This hardened branch focuses on:
|
This hardened branch focuses on:
|
||||||
|
|
||||||
@@ -31,7 +48,10 @@ This hardened branch focuses on:
|
|||||||
- Reduced unused dependency surface
|
- Reduced unused dependency surface
|
||||||
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
||||||
- Workspace Archive Browser MVP with scoped read-only archive listing
|
- Workspace Archive Browser MVP with scoped read-only archive listing
|
||||||
- Workspace Candidate Inspector MVP for selected track details
|
- Workspace Candidate Inspector with candidate provenance
|
||||||
|
- Operations Snapshot API
|
||||||
|
- Candidate scoring visibility
|
||||||
|
- Candidate rejection persistence
|
||||||
- Frontend archive destination field for planned per-run routing
|
- Frontend archive destination field for planned per-run routing
|
||||||
- Frontend Workspace tabs for Intake, Archive, and Diagnostics layouts
|
- Frontend Workspace tabs for Intake, Archive, and Diagnostics layouts
|
||||||
|
|
||||||
@@ -53,11 +73,15 @@ This hardened branch focuses on:
|
|||||||
- Automatic YouTube retry/fallback handling
|
- Automatic YouTube retry/fallback handling
|
||||||
- Failed YouTube candidate rejection memory
|
- Failed YouTube candidate rejection memory
|
||||||
- Deterministic yt-dlp error classification
|
- Deterministic yt-dlp error classification
|
||||||
|
- Candidate scoring visibility
|
||||||
|
- Candidate selection reasoning
|
||||||
|
- Candidate provenance persistence
|
||||||
- Hardened cover-art validation and embedding
|
- Hardened cover-art validation and embedding
|
||||||
- Explicit client-facing websocket payload shaping
|
- Explicit client-facing websocket payload shaping
|
||||||
- Draggable/resizable Workspace UI
|
- Draggable/resizable Workspace UI
|
||||||
- Archive Browser panel for downloaded file inspection
|
- Archive Browser panel for downloaded file inspection
|
||||||
- Candidate Inspector panel for selected track diagnostics
|
- Candidate Inspector panel for selected track diagnostics
|
||||||
|
- Operations Snapshot diagnostics API
|
||||||
- Frontend-persisted archive destination input
|
- Frontend-persisted archive destination input
|
||||||
- Per-tab workspace layout presets
|
- Per-tab workspace layout presets
|
||||||
- Read-only archive search, sort, and grouped file inspection
|
- Read-only archive search, sort, and grouped file inspection
|
||||||
@@ -66,7 +90,7 @@ This hardened branch focuses on:
|
|||||||
|
|
||||||
# Workspace UI
|
# Workspace UI
|
||||||
|
|
||||||
Jarri Spooty 3.0.0 introduces a draggable/resizable Workspace shell with frontend tab presets.
|
Jarri Spooty 3.1.0 introduces a draggable/resizable Workspace shell with frontend tab presets.
|
||||||
|
|
||||||
Workspace tabs:
|
Workspace tabs:
|
||||||
|
|
||||||
@@ -79,11 +103,11 @@ Each tab has its own persisted panel layout in browser localStorage. The old sin
|
|||||||
Workspace panels:
|
Workspace panels:
|
||||||
|
|
||||||
- Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
|
- Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
|
||||||
- Queue Observatory: Spotify, playlist, single-song, and archive-run metrics
|
- Queue Observatory: Spotify, playlist, single-song, archive, and runtime metrics
|
||||||
- Playlist History: existing playlist rows and delete completed/delete failed controls
|
- Playlist History: existing playlist rows and delete completed/delete failed controls
|
||||||
- Single Songs: existing single-track queue rows
|
- 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
|
- 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
|
- Candidate Inspector: selected track details including Spotify URL, selected YouTube candidate, candidate score, candidate reasoning, status, error classification, error summary, retry attempts, rejected candidates, and download diagnostics
|
||||||
|
|
||||||
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 destination field is frontend-persisted planning state. Runtime downloads still use the backend `DOWNLOADS_PATH` root. Per-run destination routing is pending backend support.
|
||||||
|
|
||||||
@@ -93,7 +117,40 @@ The Archive Browser uses:
|
|||||||
|
|
||||||
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.
|
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.
|
Candidate Inspector displays persisted candidate provenance including:
|
||||||
|
|
||||||
|
- Selected candidate
|
||||||
|
- Candidate score
|
||||||
|
- Candidate reasoning
|
||||||
|
- Rejected candidates
|
||||||
|
- Download attempts
|
||||||
|
- Error classification
|
||||||
|
- Error summaries
|
||||||
|
|
||||||
|
Full candidate-pool history is not yet retained.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Operations Snapshot API
|
||||||
|
|
||||||
|
Spooty exposes deterministic operational truth through:
|
||||||
|
|
||||||
|
GET /api/operations/snapshot
|
||||||
|
|
||||||
|
This endpoint provides:
|
||||||
|
|
||||||
|
- Runtime operation state
|
||||||
|
- Queue depths
|
||||||
|
- Active searches
|
||||||
|
- Active downloads
|
||||||
|
- Playlist counts
|
||||||
|
- Track counts
|
||||||
|
- Failure summaries
|
||||||
|
- Candidate-selection metadata
|
||||||
|
|
||||||
|
The endpoint is intended for Workspace integrations and diagnostics surfaces.
|
||||||
|
|
||||||
|
Workspace consumes this truth rather than deriving operational state independently.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -119,7 +176,7 @@ Example:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Quick Start (Recommended)
|
# Quick Start Recommended
|
||||||
|
|
||||||
## 1. Create Spotify Developer App
|
## 1. Create Spotify Developer App
|
||||||
|
|
||||||
@@ -144,23 +201,21 @@ Copy:
|
|||||||
|
|
||||||
Create a secure env file:
|
Create a secure env file:
|
||||||
|
|
||||||
~~~bash
|
sudo mkdir -p /etc/tokens
|
||||||
sudo mkdir -p /etc/tokens
|
|
||||||
|
|
||||||
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOT'
|
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOT'
|
||||||
SPOTIFY_CLIENT_ID=your_client_id
|
SPOTIFY_CLIENT_ID=your_client_id
|
||||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||||
EOT
|
EOT
|
||||||
|
|
||||||
sudo chown root:$USER /etc/tokens/spotify.env
|
sudo chown root:$USER /etc/tokens/spotify.env
|
||||||
sudo chmod 640 /etc/tokens/spotify.env
|
sudo chmod 640 /etc/tokens/spotify.env
|
||||||
~~~
|
|
||||||
|
|
||||||
Never commit this file.
|
Never commit this file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Export YouTube Cookies (Recommended)
|
## 3. Export YouTube Cookies Recommended
|
||||||
|
|
||||||
YouTube increasingly rate-limits or age-gates anonymous downloads.
|
YouTube increasingly rate-limits or age-gates anonymous downloads.
|
||||||
|
|
||||||
@@ -168,30 +223,26 @@ Export a Netscape-format `cookies.txt` from a logged-in browser session.
|
|||||||
|
|
||||||
Recommended storage:
|
Recommended storage:
|
||||||
|
|
||||||
~~~bash
|
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
|
||||||
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
|
sudo chown root:$USER /etc/tokens/youtube.cookies.txt
|
||||||
sudo chown root:$USER /etc/tokens/youtube.cookies.txt
|
sudo chmod 640 /etc/tokens/youtube.cookies.txt
|
||||||
sudo chmod 640 /etc/tokens/youtube.cookies.txt
|
|
||||||
~~~
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Docker Run
|
# Docker Run
|
||||||
|
|
||||||
~~~bash
|
docker run --rm -p 3000:3000 \
|
||||||
docker run --rm -p 3000:3000 \
|
--env-file /etc/tokens/spotify.env \
|
||||||
--env-file /etc/tokens/spotify.env \
|
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
||||||
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
-e AUTH_ENABLED=true \
|
||||||
-e AUTH_ENABLED=true \
|
-e SPOOTY_AUTH_TOKEN=change_this_token \
|
||||||
-e SPOOTY_AUTH_TOKEN=change_this_token \
|
-e YT_SEARCH_DELAY_MS=7000 \
|
||||||
-e YT_SEARCH_DELAY_MS=7000 \
|
-e YT_DOWNLOADS_PER_MINUTE=6 \
|
||||||
-e YT_DOWNLOADS_PER_MINUTE=6 \
|
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt \
|
||||||
-e YT_COOKIES_FILE=/spooty/config/youtube.cookies.txt \
|
-v "$PWD/downloads:/spooty/backend/downloads" \
|
||||||
-v "$PWD/downloads:/spooty/backend/downloads" \
|
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
jarri-spooty:local
|
||||||
jarri-spooty:local
|
|
||||||
~~~
|
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
|
|
||||||
@@ -209,7 +260,7 @@ Then:
|
|||||||
|
|
||||||
# Deterministic YouTube Fallback Handling
|
# Deterministic YouTube Fallback Handling
|
||||||
|
|
||||||
The hardened branch now uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions.
|
The hardened branch uses direct `yt-dlp` CLI execution rather than relying entirely on wrapper abstractions.
|
||||||
|
|
||||||
This provides:
|
This provides:
|
||||||
|
|
||||||
@@ -242,10 +293,8 @@ Aggressive YouTube access can trigger:
|
|||||||
|
|
||||||
Recommended safe pacing:
|
Recommended safe pacing:
|
||||||
|
|
||||||
~~~bash
|
-e YT_SEARCH_DELAY_MS=7000
|
||||||
-e YT_SEARCH_DELAY_MS=7000
|
-e YT_DOWNLOADS_PER_MINUTE=6
|
||||||
-e YT_DOWNLOADS_PER_MINUTE=6
|
|
||||||
~~~
|
|
||||||
|
|
||||||
The hardened branch also includes additional internal pacing and retry coordination to reduce:
|
The hardened branch also includes additional internal pacing and retry coordination to reduce:
|
||||||
|
|
||||||
@@ -269,15 +318,13 @@ Never commit:
|
|||||||
|
|
||||||
Recommended `.gitignore` additions:
|
Recommended `.gitignore` additions:
|
||||||
|
|
||||||
~~~gitignore
|
downloads/
|
||||||
downloads/
|
config/
|
||||||
config/
|
spooty-config/
|
||||||
spooty-config/
|
*.sqlite
|
||||||
*.sqlite
|
cookies.txt
|
||||||
cookies.txt
|
.env
|
||||||
.env
|
.env.local
|
||||||
.env.local
|
|
||||||
~~~
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ The MVP reports truth Spooty already owns without a schema migration:
|
|||||||
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
|
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
|
||||||
- raw failure evidence from `TrackEntity.error`
|
- raw failure evidence from `TrackEntity.error`
|
||||||
- download retry count from `TrackEntity.downloadAttemptCount`
|
- download retry count from `TrackEntity.downloadAttemptCount`
|
||||||
- rejected YouTube candidates from `TrackEntity.rejectedYoutubeUrls`
|
- rejected YouTube candidate explanations from `TrackEntity.rejectedYoutubeCandidatesJson`
|
||||||
|
- legacy rejected YouTube candidate URLs from `TrackEntity.rejectedYoutubeUrls`
|
||||||
- playlist and single-song counts from `PlaylistEntity.isTrack`
|
- playlist and single-song counts from `PlaylistEntity.isTrack`
|
||||||
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
|
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
|
||||||
- Spotify connection presence from the stored Spotify user token
|
- Spotify connection presence from the stored Spotify user token
|
||||||
|
|
||||||
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidates are still persisted only as URL strings.
|
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:
|
Future persistence work is required for:
|
||||||
|
|
||||||
- full searched candidate history
|
- full searched candidate history
|
||||||
- structured rejected candidate metadata beyond URL
|
|
||||||
- structured error class fields stored at write time instead of classified from the persisted raw error string
|
- 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
|
- operation attempt history across search and download phases
|
||||||
|
|||||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "spooty",
|
"name": "spooty",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "spooty",
|
"name": "spooty",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/backend",
|
"src/backend",
|
||||||
"src/frontend"
|
"src/frontend"
|
||||||
@@ -25508,7 +25508,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"src/backend": {
|
"src/backend": {
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^10.2.3",
|
"@nestjs/bullmq": "^10.2.3",
|
||||||
@@ -25571,7 +25571,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"src/frontend": {
|
"src/frontend": {
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.2.22",
|
"@angular/animations": "^19.2.22",
|
||||||
"@angular/common": "^19.2.22",
|
"@angular/common": "^19.2.22",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"name": "spooty",
|
"name": "spooty",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"src/backend",
|
"src/backend",
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ TOKEN="${SPOOTY_AUTH_TOKEN:-test-token}"
|
|||||||
DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}"
|
DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}"
|
||||||
CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}"
|
CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}"
|
||||||
ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}"
|
ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}"
|
||||||
|
|
||||||
YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}"
|
YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}"
|
||||||
|
RUNTIME_SECRETS_DIR="${RUNTIME_SECRETS_DIR:-$PROJECT/spooty-runtime-secrets}"
|
||||||
|
YT_COOKIES_FILE_STAGED="${YT_COOKIES_FILE_STAGED:-$RUNTIME_SECRETS_DIR/youtube.cookies.txt}"
|
||||||
YT_COOKIES_FILE_CONTAINER="${YT_COOKIES_FILE_CONTAINER:-/spooty/config/youtube.cookies.txt}"
|
YT_COOKIES_FILE_CONTAINER="${YT_COOKIES_FILE_CONTAINER:-/spooty/config/youtube.cookies.txt}"
|
||||||
|
|
||||||
cd "$PROJECT"
|
cd "$PROJECT"
|
||||||
@@ -28,6 +31,18 @@ if [[ "${1:-}" == "--build" ]]; then
|
|||||||
$DOCKER build -t "$IMAGE" .
|
$DOCKER build -t "$IMAGE" .
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "== Preparing runtime directories =="
|
||||||
|
mkdir -p "$DOWNLOADS" "$CONFIG_DIR" "$RUNTIME_SECRETS_DIR"
|
||||||
|
|
||||||
|
if [[ -f "$YT_COOKIES_FILE_HOST" ]]; then
|
||||||
|
echo "== Staging YouTube cookies =="
|
||||||
|
cp "$YT_COOKIES_FILE_HOST" "$YT_COOKIES_FILE_STAGED"
|
||||||
|
chmod 0666 "$YT_COOKIES_FILE_STAGED"
|
||||||
|
else
|
||||||
|
echo "ERROR: YouTube cookies file not found: $YT_COOKIES_FILE_HOST" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "== Removing named container if present =="
|
echo "== Removing named container if present =="
|
||||||
$DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
$DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
@@ -37,8 +52,6 @@ if [[ -n "$PORT_CONTAINERS" ]]; then
|
|||||||
$DOCKER rm -f $PORT_CONTAINERS
|
$DOCKER rm -f $PORT_CONTAINERS
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$DOWNLOADS" "$CONFIG_DIR"
|
|
||||||
|
|
||||||
echo "== Starting Jarri Spooty =="
|
echo "== Starting Jarri Spooty =="
|
||||||
$DOCKER run -d \
|
$DOCKER run -d \
|
||||||
--name "$CONTAINER" \
|
--name "$CONTAINER" \
|
||||||
@@ -52,7 +65,7 @@ $DOCKER run -d \
|
|||||||
-e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \
|
-e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \
|
||||||
-e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \
|
-e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \
|
||||||
-v "$DOWNLOADS:/spooty/backend/downloads" \
|
-v "$DOWNLOADS:/spooty/backend/downloads" \
|
||||||
-v "$YT_COOKIES_FILE_HOST:$YT_COOKIES_FILE_CONTAINER:ro" \
|
-v "$YT_COOKIES_FILE_STAGED:$YT_COOKIES_FILE_CONTAINER" \
|
||||||
-v "$CONFIG_DIR:/spooty/backend/config" \
|
-v "$CONFIG_DIR:/spooty/backend/config" \
|
||||||
"$IMAGE"
|
"$IMAGE"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ import { AuthGuard } from './shared/auth.guard';
|
|||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ArchiveModule } from './archive/archive.module';
|
import { ArchiveModule } from './archive/archive.module';
|
||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
function resolveFrontendRoot(fePath: string | undefined): string {
|
||||||
|
const configuredRoot = resolve(__dirname, fePath || '../frontend/browser');
|
||||||
|
const candidateRoots = [
|
||||||
|
configuredRoot,
|
||||||
|
resolve(configuredRoot, 'browser'),
|
||||||
|
resolve(configuredRoot, '..'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidateRoot of candidateRoots) {
|
||||||
|
if (fs.existsSync(resolve(candidateRoot, 'index.html'))) {
|
||||||
|
return candidateRoot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredRoot;
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -38,8 +56,7 @@ import { OperationsModule } from './operations/operations.module';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => [
|
useFactory: async (configService: ConfigService) => [
|
||||||
{
|
{
|
||||||
rootPath: resolve(
|
rootPath: resolveFrontendRoot(
|
||||||
__dirname,
|
|
||||||
configService.get<string>(EnvironmentEnum.FE_PATH),
|
configService.get<string>(EnvironmentEnum.FE_PATH),
|
||||||
),
|
),
|
||||||
exclude: ['/api/(.*)'],
|
exclude: ['/api/(.*)'],
|
||||||
|
|||||||
+14
-3
@@ -3,7 +3,7 @@ import { ValidationPipe } from '@nestjs/common';
|
|||||||
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
@@ -17,11 +17,17 @@ async function bootstrap() {
|
|||||||
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderName = resolve(
|
const downloadsPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
||||||
);
|
);
|
||||||
fs.mkdirSync(folderName, { recursive: true });
|
fs.mkdirSync(downloadsPath, { recursive: true });
|
||||||
|
|
||||||
|
const dbPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
process.env[EnvironmentEnum.DB_PATH] || './config/db.sqlite',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
if (envFlag(EnvironmentEnum.REDIS_RUN)) {
|
if (envFlag(EnvironmentEnum.REDIS_RUN)) {
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +66,11 @@ async function bootstrap() {
|
|||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
crossOriginResourcePolicy: false,
|
crossOriginResourcePolicy: false,
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
upgradeInsecureRequests: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ErrorClass,
|
ErrorClass,
|
||||||
FailureTruth,
|
FailureTruth,
|
||||||
OperationsSnapshot,
|
OperationsSnapshot,
|
||||||
|
RejectedCandidateTruth,
|
||||||
SpootyOperation,
|
SpootyOperation,
|
||||||
TrackTruth,
|
TrackTruth,
|
||||||
} from './operations.types';
|
} from './operations.types';
|
||||||
@@ -204,7 +205,15 @@ export class OperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseRejectedCandidates(track: TrackEntity): string[] {
|
private parseRejectedCandidates(
|
||||||
|
track: TrackEntity,
|
||||||
|
): RejectedCandidateTruth[] {
|
||||||
|
const structured = this.parseRejectedCandidateJson(track);
|
||||||
|
|
||||||
|
if (structured.length) {
|
||||||
|
return structured;
|
||||||
|
}
|
||||||
|
|
||||||
if (!track.rejectedYoutubeUrls) {
|
if (!track.rejectedYoutubeUrls) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -212,7 +221,52 @@ export class OperationsService {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||||
return Array.isArray(parsed)
|
return Array.isArray(parsed)
|
||||||
? parsed.filter((item) => typeof item === 'string')
|
? 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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ export type ErrorClass =
|
|||||||
| 'FILE_WRITE_FAILED'
|
| 'FILE_WRITE_FAILED'
|
||||||
| 'UNKNOWN_DOWNLOAD_ERROR';
|
| '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 {
|
export interface TrackTruth {
|
||||||
id: number;
|
id: number;
|
||||||
artist: string;
|
artist: string;
|
||||||
@@ -35,7 +56,7 @@ export interface TrackTruth {
|
|||||||
errorSummary?: string;
|
errorSummary?: string;
|
||||||
errorDetail?: string;
|
errorDetail?: string;
|
||||||
rejectedCandidateCount: number;
|
rejectedCandidateCount: number;
|
||||||
rejectedCandidates: string[];
|
rejectedCandidates: RejectedCandidateTruth[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FailureTruth extends TrackTruth {}
|
export interface FailureTruth extends TrackTruth {}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ export class YoutubeService {
|
|||||||
|
|
||||||
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
|
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
|
||||||
const searchTarget = `ytsearch15:${query}`;
|
const searchTarget = `ytsearch15:${query}`;
|
||||||
|
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
|
||||||
const args = [
|
const args = [
|
||||||
'--dump-single-json',
|
'--dump-single-json',
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
@@ -168,6 +169,10 @@ export class YoutubeService {
|
|||||||
searchTarget,
|
searchTarget,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (cookiesFile) {
|
||||||
|
args.splice(args.length - 1, 0, '--cookies', cookiesFile);
|
||||||
|
}
|
||||||
|
|
||||||
const output = await this.runYtDlpForOutput(args);
|
const output = await this.runYtDlpForOutput(args);
|
||||||
let parsed: any;
|
let parsed: any;
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true, type: 'text' })
|
@Column({ nullable: true, type: 'text' })
|
||||||
rejectedYoutubeUrls?: string;
|
rejectedYoutubeUrls?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
rejectedYoutubeCandidatesJson?: string;
|
||||||
|
|
||||||
@Column({ default: 0 })
|
@Column({ default: 0 })
|
||||||
downloadAttemptCount?: number;
|
downloadAttemptCount?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,32 @@ type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
|
|||||||
rejectedYoutubeUrls?: string[];
|
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()
|
@WebSocketGateway()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrackService {
|
export class TrackService {
|
||||||
@@ -110,6 +136,175 @@ export class TrackService {
|
|||||||
return JSON.stringify([...new Set(urls)].slice(0, 20));
|
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 isPermanentYoutubeCandidateRejection(
|
||||||
|
reason: RejectionReason,
|
||||||
|
): boolean {
|
||||||
|
return [
|
||||||
|
'YOUTUBE_VIDEO_UNAVAILABLE',
|
||||||
|
'YOUTUBE_AGE_GATED',
|
||||||
|
'YOUTUBE_NO_FORMATS',
|
||||||
|
'YOUTUBE_PRIVATE_VIDEO',
|
||||||
|
].includes(reason.rejectionClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordCurrentYoutubeCandidateFailure(
|
||||||
|
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 (
|
||||||
|
this.isPermanentYoutubeCandidateRejection(reason) &&
|
||||||
|
!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> {
|
async retry(id: number): Promise<void> {
|
||||||
const track = await this.get(id);
|
const track = await this.get(id);
|
||||||
|
|
||||||
@@ -118,12 +313,6 @@ export class TrackService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
|
||||||
|
|
||||||
if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
|
|
||||||
rejectedUrls.push(track.youtubeUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.update(id, {
|
await this.update(id, {
|
||||||
...track,
|
...track,
|
||||||
youtubeUrl: null,
|
youtubeUrl: null,
|
||||||
@@ -131,7 +320,9 @@ export class TrackService {
|
|||||||
selectedYoutubeAuthor: null,
|
selectedYoutubeAuthor: null,
|
||||||
selectedYoutubeScore: null,
|
selectedYoutubeScore: null,
|
||||||
selectedYoutubeReason: null,
|
selectedYoutubeReason: null,
|
||||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
rejectedYoutubeUrls: track.rejectedYoutubeUrls || JSON.stringify([]),
|
||||||
|
rejectedYoutubeCandidatesJson:
|
||||||
|
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
|
||||||
downloadAttemptCount: 0,
|
downloadAttemptCount: 0,
|
||||||
error: null,
|
error: null,
|
||||||
status: TrackStatusEnum.New,
|
status: TrackStatusEnum.New,
|
||||||
@@ -293,13 +484,17 @@ export class TrackService {
|
|||||||
error = toSafeErrorMessage(err);
|
error = toSafeErrorMessage(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
|
||||||
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
||||||
const maxAttempts = this.getMaxDownloadAttempts();
|
const maxAttempts = this.getMaxDownloadAttempts();
|
||||||
|
const rejectedCandidateFields = error
|
||||||
if (error && track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
|
? this.recordCurrentYoutubeCandidateFailure(track, this.classifyRejection(error))
|
||||||
rejectedUrls.push(track.youtubeUrl);
|
: {
|
||||||
}
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
|
||||||
|
this.parseRejectedYoutubeUrls(track),
|
||||||
|
),
|
||||||
|
rejectedYoutubeCandidatesJson:
|
||||||
|
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
|
||||||
|
};
|
||||||
|
|
||||||
if (error && nextAttemptCount < maxAttempts) {
|
if (error && nextAttemptCount < maxAttempts) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -314,7 +509,7 @@ export class TrackService {
|
|||||||
selectedYoutubeAuthor: null,
|
selectedYoutubeAuthor: null,
|
||||||
selectedYoutubeScore: null,
|
selectedYoutubeScore: null,
|
||||||
selectedYoutubeReason: null,
|
selectedYoutubeReason: null,
|
||||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
...rejectedCandidateFields,
|
||||||
downloadAttemptCount: nextAttemptCount,
|
downloadAttemptCount: nextAttemptCount,
|
||||||
error: null,
|
error: null,
|
||||||
status: TrackStatusEnum.New,
|
status: TrackStatusEnum.New,
|
||||||
@@ -327,7 +522,7 @@ export class TrackService {
|
|||||||
const updatedTrack = {
|
const updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
||||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
...rejectedCandidateFields,
|
||||||
downloadAttemptCount: error ? nextAttemptCount : 0,
|
downloadAttemptCount: error ? nextAttemptCount : 0,
|
||||||
...(error ? { error } : { error: null }),
|
...(error ? { error } : { error: null }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "3.0.0",
|
"version": "3.1.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
<div class="brand-line">
|
<div class="brand-line">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
<h1>Jarri Spooty</h1>
|
<h1>Jarri Spooty</h1>
|
||||||
<span class="tag version-tag">v{{version}}</span>
|
<span class="tag version-tag">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="truth-strip">
|
<div class="truth-strip">
|
||||||
<span class="truth-pill" [class.connected]="spotifyConnected">
|
<span class="truth-pill" [class.connected]="spotifyConnected">
|
||||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
<i
|
||||||
{{ spotifyConnected ? 'Spotify connected' : 'Spotify disconnected' }}
|
class="fa-solid"
|
||||||
|
[ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"
|
||||||
|
></i>
|
||||||
|
{{ spotifyConnected ? "Spotify connected" : "Spotify disconnected" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -29,6 +32,13 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<section *ngIf="!authTokenPresent" class="workspace-controls">
|
||||||
|
<div class="truth-pill">
|
||||||
|
Auth token required. Open once with `?token=your_token_here` and it will
|
||||||
|
be stored in your browser.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="workspace-controls">
|
<section class="workspace-controls">
|
||||||
<div class="panel-picker">
|
<div class="panel-picker">
|
||||||
<div class="select">
|
<div class="select">
|
||||||
@@ -49,7 +59,11 @@
|
|||||||
<i class="fa-solid fa-rotate-left"></i>
|
<i class="fa-solid fa-rotate-left"></i>
|
||||||
Reset Current Tab
|
Reset Current Tab
|
||||||
</button>
|
</button>
|
||||||
<button class="button reset-button" type="button" (click)="resetAllLayouts()">
|
<button
|
||||||
|
class="button reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="resetAllLayouts()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-rotate"></i>
|
<i class="fa-solid fa-rotate"></i>
|
||||||
Reset All Tabs
|
Reset All Tabs
|
||||||
</button>
|
</button>
|
||||||
@@ -88,8 +102,11 @@
|
|||||||
[class.connected]="spotifyConnected"
|
[class.connected]="spotifyConnected"
|
||||||
(click)="connectSpotify()"
|
(click)="connectSpotify()"
|
||||||
>
|
>
|
||||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
<i
|
||||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
class="fa-solid"
|
||||||
|
[ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"
|
||||||
|
></i>
|
||||||
|
{{ spotifyConnected ? "Spotify connected" : "Connect Spotify" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="intake-form">
|
<div class="intake-form">
|
||||||
@@ -121,7 +138,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="truth-note">
|
<div class="truth-note">
|
||||||
<p>Runtime download root: {{ archiveListing?.root || archiveDestination || 'loading...' }}</p>
|
<p>
|
||||||
|
Runtime download root:
|
||||||
|
{{ archiveListing?.root || archiveDestination || "loading..." }}
|
||||||
|
</p>
|
||||||
<p>Per-run destination routing: pending backend support</p>
|
<p>Per-run destination routing: pending backend support</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +155,9 @@
|
|||||||
<ng-container *ngIf="songs$ | async as songs">
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>Spotify</span>
|
<span>Spotify</span>
|
||||||
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
|
<strong [class.online]="spotifyConnected">{{
|
||||||
|
spotifyConnected ? "Connected" : "Disconnected"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>Playlists</span>
|
<span>Playlists</span>
|
||||||
@@ -160,18 +182,31 @@
|
|||||||
<p>One collapsed row per playlist run.</p>
|
<p>One collapsed row per playlist run.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons has-addons">
|
<div class="buttons has-addons">
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
<button
|
||||||
|
class="button is-outlined is-success"
|
||||||
|
title="Remove completed from list"
|
||||||
|
(click)="deleteCompleted()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-check"></i>
|
<i class="fa-solid fa-check"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
<button
|
||||||
|
class="button is-outlined is-danger"
|
||||||
|
title="Remove failed from list"
|
||||||
|
(click)="deleteFailed()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="playlists$ | async as playlists">
|
<ng-container *ngIf="playlists$ | async as playlists">
|
||||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
<app-playlist-box
|
||||||
<p *ngIf="playlists?.length === 0" class="empty-state">No playlists</p>
|
*ngFor="let playlist of playlists"
|
||||||
|
[playlist]="playlist"
|
||||||
|
></app-playlist-box>
|
||||||
|
<p *ngIf="playlists?.length === 0" class="empty-state">
|
||||||
|
No playlists
|
||||||
|
</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -184,7 +219,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="songs$ | async as songs">
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
<app-playlist-box
|
||||||
|
*ngFor="let playlist of songs"
|
||||||
|
[playlist]="playlist"
|
||||||
|
></app-playlist-box>
|
||||||
<p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
|
<p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</section>
|
</section>
|
||||||
@@ -193,9 +231,14 @@
|
|||||||
<div class="section-toolbar">
|
<div class="section-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2>Archive Browser</h2>
|
<h2>Archive Browser</h2>
|
||||||
<p>Root: {{ archiveListing?.root || 'loading...' }}</p>
|
<p>Root: {{ archiveListing?.root || "loading..." }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="button is-small reset-button" type="button" [class.is-loading]="archiveLoading" (click)="refreshArchive()">
|
<button
|
||||||
|
class="button is-small reset-button"
|
||||||
|
type="button"
|
||||||
|
[class.is-loading]="archiveLoading"
|
||||||
|
(click)="refreshArchive()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-rotate"></i>
|
<i class="fa-solid fa-rotate"></i>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -226,24 +269,40 @@
|
|||||||
placeholder="Search archive"
|
placeholder="Search archive"
|
||||||
/>
|
/>
|
||||||
<div class="select is-small">
|
<div class="select is-small">
|
||||||
<select [(ngModel)]="archiveSort" aria-label="Sort archive files">
|
<select
|
||||||
<option *ngFor="let sortMode of archiveSortModes" [ngValue]="sortMode">
|
[(ngModel)]="archiveSort"
|
||||||
|
aria-label="Sort archive files"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
*ngFor="let sortMode of archiveSortModes"
|
||||||
|
[ngValue]="sortMode"
|
||||||
|
>
|
||||||
{{ sortMode }}
|
{{ sortMode }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p *ngIf="archiveFileCount() === 0" class="empty-state">No downloaded files found.</p>
|
<p *ngIf="archiveFileCount() === 0" class="empty-state">
|
||||||
|
No downloaded files found.
|
||||||
|
</p>
|
||||||
|
|
||||||
<section *ngFor="let group of archiveGroups()" class="archive-group">
|
<section
|
||||||
|
*ngFor="let group of archiveGroups()"
|
||||||
|
class="archive-group"
|
||||||
|
>
|
||||||
<h3>
|
<h3>
|
||||||
<i class="fa-solid fa-folder"></i>
|
<i class="fa-solid fa-folder"></i>
|
||||||
{{ group.folder }}
|
{{ group.folder }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div *ngFor="let file of group.files" class="archive-file-card">
|
<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>
|
<i
|
||||||
|
class="fa-solid"
|
||||||
|
[ngClass]="
|
||||||
|
hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ file.name }}</strong>
|
<strong>{{ file.name }}</strong>
|
||||||
<span>{{ folderPath(file) }}</span>
|
<span>{{ folderPath(file) }}</span>
|
||||||
@@ -256,47 +315,157 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
|
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
|
||||||
<ng-container *ngIf="selectedTrack$ | async as selectedTrack; else noTrackSelected">
|
<ng-container
|
||||||
|
*ngIf="
|
||||||
|
selectedTrack$ | async as selectedTrack;
|
||||||
|
else noTrackSelected
|
||||||
|
"
|
||||||
|
>
|
||||||
<div class="section-toolbar">
|
<div class="section-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
|
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
|
||||||
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
|
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="button is-small reset-button" type="button" (click)="clearSelectedTrack()">
|
<button
|
||||||
|
class="button is-small reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="clearSelectedTrack()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-arrow-left"></i>
|
<i class="fa-solid fa-arrow-left"></i>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="inspector-list">
|
<ng-container
|
||||||
<dt>Spotify URL</dt>
|
*ngIf="
|
||||||
<dd><a [href]="selectedTrack.spotifyUrl" target="_blank">{{ selectedTrack.spotifyUrl || 'none' }}</a></dd>
|
selectedTrackTruth(selectedTrack) as trackTruth;
|
||||||
<dt>Status</dt>
|
else noTrackTruth
|
||||||
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
"
|
||||||
<dt>Download attempts</dt>
|
>
|
||||||
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
<dl class="inspector-list">
|
||||||
<dt>Error</dt>
|
<dt>Spotify URL</dt>
|
||||||
<dd>{{ selectedTrack.error || 'none' }}</dd>
|
<dd>
|
||||||
</dl>
|
<a [href]="selectedTrack.spotifyUrl" target="_blank">{{
|
||||||
|
selectedTrack.spotifyUrl || "none"
|
||||||
|
}}</a>
|
||||||
|
</dd>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
||||||
|
<dt>Download attempts</dt>
|
||||||
|
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
||||||
|
<dt>Error class</dt>
|
||||||
|
<dd>{{ errorClass(trackTruth) }}</dd>
|
||||||
|
<dt>Error summary</dt>
|
||||||
|
<dd>{{ errorSummary(trackTruth) }}</dd>
|
||||||
|
<dt>Error detail</dt>
|
||||||
|
<dd>
|
||||||
|
<details
|
||||||
|
*ngIf="errorDetail(trackTruth); else noErrorDetail"
|
||||||
|
class="truth-detail"
|
||||||
|
>
|
||||||
|
<summary>Show backend evidence</summary>
|
||||||
|
<pre>{{ errorDetail(trackTruth) }}</pre>
|
||||||
|
</details>
|
||||||
|
<ng-template #noErrorDetail>none</ng-template>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div class="candidate-options">
|
<div class="candidate-options">
|
||||||
<strong>Other options</strong>
|
<strong>Selected candidate</strong>
|
||||||
<p *ngIf="selectedTrack.youtubeUrl">
|
<ng-container
|
||||||
Selected candidate:
|
*ngIf="
|
||||||
<a [href]="selectedTrack.youtubeUrl" target="_blank">{{ selectedTrack.youtubeUrl }}</a>
|
selectedCandidate(trackTruth) as candidate;
|
||||||
</p>
|
else noSelectedCandidate
|
||||||
<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">
|
<dl class="candidate-card">
|
||||||
Candidate alternatives are not persisted yet. Future scoring history will appear here.
|
<dt>URL</dt>
|
||||||
</p>
|
<dd>
|
||||||
</div>
|
<a [href]="candidate.url" target="_blank">{{
|
||||||
|
candidate.url
|
||||||
|
}}</a>
|
||||||
|
</dd>
|
||||||
|
<dt>Title</dt>
|
||||||
|
<dd>{{ candidate.title || "unknown" }}</dd>
|
||||||
|
<dt>Author</dt>
|
||||||
|
<dd>{{ candidate.author || "unknown" }}</dd>
|
||||||
|
<dt>Score</dt>
|
||||||
|
<dd>{{ candidate.score ?? "unknown" }}</dd>
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ candidate.reason || "unknown" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noSelectedCandidate>
|
||||||
|
<p class="empty-state compact">No selected candidate.</p>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<strong
|
||||||
|
>Rejected candidates:
|
||||||
|
{{ rejectedCandidateCount(trackTruth) }}</strong
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
*ngFor="let candidate of rejectedCandidates(trackTruth)"
|
||||||
|
class="candidate-card rejected-candidate-card"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
<dt>URL</dt>
|
||||||
|
<dd>
|
||||||
|
<a [href]="candidate.url" target="_blank">{{
|
||||||
|
candidate.url
|
||||||
|
}}</a>
|
||||||
|
</dd>
|
||||||
|
<dt>Title</dt>
|
||||||
|
<dd>{{ candidate.title || "unknown" }}</dd>
|
||||||
|
<dt>Author</dt>
|
||||||
|
<dd>{{ candidate.author || "unknown" }}</dd>
|
||||||
|
<dt>Score</dt>
|
||||||
|
<dd>{{ candidate.score ?? "unknown" }}</dd>
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ candidate.reason || "unknown" }}</dd>
|
||||||
|
<dt>Rejection class</dt>
|
||||||
|
<dd>{{ candidate.rejectionClass || "unknown" }}</dd>
|
||||||
|
<dt>Rejection summary</dt>
|
||||||
|
<dd>{{ candidate.rejectionSummary || "unknown" }}</dd>
|
||||||
|
<dt>Rejected at</dt>
|
||||||
|
<dd>{{ candidate.rejectedAt || "unknown" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
*ngIf="rejectedCandidateCount(trackTruth) === 0"
|
||||||
|
class="empty-state compact"
|
||||||
|
>
|
||||||
|
No rejected candidates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #noTrackTruth>
|
||||||
|
<div class="placeholder-panel">
|
||||||
|
<i class="fa-solid fa-database"></i>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
operationsSnapshotError ||
|
||||||
|
"Waiting for backend operations truth."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="button is-small reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="refreshOperationsSnapshot()"
|
||||||
|
>
|
||||||
|
Refresh truth
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #noTrackSelected>
|
<ng-template #noTrackSelected>
|
||||||
<div class="placeholder-panel">
|
<div class="placeholder-panel">
|
||||||
<i class="fa-solid fa-magnifying-glass-chart"></i>
|
<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>
|
<p>
|
||||||
|
Select a track row inside an expanded playlist to inspect
|
||||||
|
Spotify, YouTube, retry, and error details.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="allTracks$ | async as allTracks">
|
<ng-container *ngIf="allTracks$ | async as allTracks">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import {Component, OnDestroy} from '@angular/core';
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
import {FormsModule} from "@angular/forms";
|
import { FormsModule } from '@angular/forms';
|
||||||
import {CommonModule, NgFor} from "@angular/common";
|
import { CommonModule, NgFor } from '@angular/common';
|
||||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
import {
|
||||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
PlaylistService,
|
||||||
import {VersionService} from "./services/version.service";
|
PlaylistStatusEnum,
|
||||||
import {map} from "rxjs";
|
} from './services/playlist.service';
|
||||||
import {HttpClient} from "@angular/common/http";
|
import { PlaylistBoxComponent } from './components/playlist-box/playlist-box.component';
|
||||||
import {ArchiveService} from "./services/archive.service";
|
import { VersionService } from './services/version.service';
|
||||||
import {ArchiveFile, ArchiveListing} from "./models/archive";
|
import { map } from 'rxjs';
|
||||||
import {Track, TrackStatusEnum} from "./models/track";
|
import { HttpClient } from '@angular/common/http';
|
||||||
import {TrackService} from "./services/track.service";
|
import { ArchiveService } from './services/archive.service';
|
||||||
|
import { ArchiveFile, ArchiveListing } from './models/archive';
|
||||||
|
import { Track, TrackStatusEnum } from './models/track';
|
||||||
|
import { TrackService } from './services/track.service';
|
||||||
|
import {
|
||||||
|
OperationsSnapshot,
|
||||||
|
RejectedCandidateTruth,
|
||||||
|
SelectedCandidateTruth,
|
||||||
|
TrackTruth,
|
||||||
|
} from './models/operations-snapshot';
|
||||||
|
|
||||||
export type SpootyPanelType =
|
export type SpootyPanelType =
|
||||||
'source-intake'
|
| 'source-intake'
|
||||||
| 'queue-observatory'
|
| 'queue-observatory'
|
||||||
| 'playlist-history'
|
| 'playlist-history'
|
||||||
| 'single-songs'
|
| 'single-songs'
|
||||||
@@ -44,6 +53,7 @@ export interface SpootyWorkspaceTabsState {
|
|||||||
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
|
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
|
||||||
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
|
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
|
||||||
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
|
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
|
||||||
|
const AUTH_TOKEN_STORAGE_KEY = 'spooty_auth_token';
|
||||||
const GRID_SIZE = 12;
|
const GRID_SIZE = 12;
|
||||||
const MIN_PANEL_WIDTH = 260;
|
const MIN_PANEL_WIDTH = 260;
|
||||||
const MIN_PANEL_HEIGHT = 160;
|
const MIN_PANEL_HEIGHT = 160;
|
||||||
@@ -66,20 +76,62 @@ const WORKSPACE_TAB_LABELS: Record<SpootyWorkspaceTab, string> = {
|
|||||||
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
|
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
|
||||||
intake: [
|
intake: [
|
||||||
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
|
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
|
||||||
makePanel('queue-observatory', 568, 24, 520, 260, 'intake-queue-observatory'),
|
makePanel(
|
||||||
|
'queue-observatory',
|
||||||
|
568,
|
||||||
|
24,
|
||||||
|
520,
|
||||||
|
260,
|
||||||
|
'intake-queue-observatory',
|
||||||
|
),
|
||||||
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
|
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
|
||||||
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
|
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
|
||||||
],
|
],
|
||||||
archive: [
|
archive: [
|
||||||
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
|
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
|
||||||
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
|
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
|
||||||
makePanel('queue-observatory', 960, 396, 420, 260, 'archive-queue-observatory'),
|
makePanel(
|
||||||
|
'queue-observatory',
|
||||||
|
960,
|
||||||
|
396,
|
||||||
|
420,
|
||||||
|
260,
|
||||||
|
'archive-queue-observatory',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
diagnostics: [
|
diagnostics: [
|
||||||
makePanel('candidate-inspector', 24, 24, 620, 720, 'diagnostics-candidate-inspector'),
|
makePanel(
|
||||||
makePanel('playlist-history', 680, 24, 620, 520, 'diagnostics-playlist-history'),
|
'candidate-inspector',
|
||||||
makePanel('queue-observatory', 1320, 24, 420, 260, 'diagnostics-queue-observatory'),
|
24,
|
||||||
makePanel('archive-browser', 1320, 320, 420, 420, 'diagnostics-archive-browser'),
|
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',
|
||||||
|
),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,9 +158,11 @@ export function defaultWorkspaceState(): SpootyWorkspaceState {
|
|||||||
return defaultWorkspaceStateForTab('intake');
|
return defaultWorkspaceStateForTab('intake');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultWorkspaceStateForTab(tab: SpootyWorkspaceTab): SpootyWorkspaceState {
|
export function defaultWorkspaceStateForTab(
|
||||||
|
tab: SpootyWorkspaceTab,
|
||||||
|
): SpootyWorkspaceState {
|
||||||
return {
|
return {
|
||||||
panels: DEFAULT_TAB_PANELS[tab].map(panel => ({...panel})),
|
panels: DEFAULT_TAB_PANELS[tab].map((panel) => ({ ...panel })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,9 +177,26 @@ export function defaultWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStorageItem(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to read localStorage key "${key}"`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStorageItem(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to write localStorage key "${key}"`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function loadWorkspaceState(): SpootyWorkspaceState {
|
export function loadWorkspaceState(): SpootyWorkspaceState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
|
const raw = getStorageItem(WORKSPACE_STORAGE_KEY);
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
return defaultWorkspaceState();
|
return defaultWorkspaceState();
|
||||||
@@ -133,8 +204,8 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
|
|||||||
|
|
||||||
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
|
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
|
||||||
const panels = parsed.panels
|
const panels = parsed.panels
|
||||||
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||||
.map(panel => ({
|
.map((panel) => ({
|
||||||
...panel,
|
...panel,
|
||||||
title: PANEL_TITLES[panel.type],
|
title: PANEL_TITLES[panel.type],
|
||||||
x: snap(panel.x),
|
x: snap(panel.x),
|
||||||
@@ -143,7 +214,7 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
|
|||||||
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return panels?.length ? {panels} : defaultWorkspaceState();
|
return panels?.length ? { panels } : defaultWorkspaceState();
|
||||||
} catch {
|
} catch {
|
||||||
return defaultWorkspaceState();
|
return defaultWorkspaceState();
|
||||||
}
|
}
|
||||||
@@ -151,16 +222,28 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
|
|||||||
|
|
||||||
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY);
|
const raw = getStorageItem(WORKSPACE_TABS_STORAGE_KEY);
|
||||||
|
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
||||||
return {
|
return {
|
||||||
activeTab: parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab] ? parsed.activeTab : 'intake',
|
activeTab:
|
||||||
|
parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab]
|
||||||
|
? parsed.activeTab
|
||||||
|
: 'intake',
|
||||||
tabs: {
|
tabs: {
|
||||||
intake: sanitizeWorkspaceState(parsed.tabs?.intake, defaultWorkspaceStateForTab('intake')),
|
intake: sanitizeWorkspaceState(
|
||||||
archive: sanitizeWorkspaceState(parsed.tabs?.archive, defaultWorkspaceStateForTab('archive')),
|
parsed.tabs?.intake,
|
||||||
diagnostics: sanitizeWorkspaceState(parsed.tabs?.diagnostics, defaultWorkspaceStateForTab('diagnostics')),
|
defaultWorkspaceStateForTab('intake'),
|
||||||
|
),
|
||||||
|
archive: sanitizeWorkspaceState(
|
||||||
|
parsed.tabs?.archive,
|
||||||
|
defaultWorkspaceStateForTab('archive'),
|
||||||
|
),
|
||||||
|
diagnostics: sanitizeWorkspaceState(
|
||||||
|
parsed.tabs?.diagnostics,
|
||||||
|
defaultWorkspaceStateForTab('diagnostics'),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -182,8 +265,8 @@ function sanitizeWorkspaceState(
|
|||||||
fallback: SpootyWorkspaceState,
|
fallback: SpootyWorkspaceState,
|
||||||
): SpootyWorkspaceState {
|
): SpootyWorkspaceState {
|
||||||
const panels = state?.panels
|
const panels = state?.panels
|
||||||
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||||
.map(panel => ({
|
.map((panel) => ({
|
||||||
...panel,
|
...panel,
|
||||||
title: PANEL_TITLES[panel.type],
|
title: PANEL_TITLES[panel.type],
|
||||||
x: snap(panel.x),
|
x: snap(panel.x),
|
||||||
@@ -192,7 +275,7 @@ function sanitizeWorkspaceState(
|
|||||||
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return panels?.length ? {panels} : fallback;
|
return panels?.length ? { panels } : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snap(value: number): number {
|
function snap(value: number): number {
|
||||||
@@ -200,16 +283,16 @@ function snap(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnDestroy {
|
export class AppComponent implements OnDestroy {
|
||||||
|
url = '';
|
||||||
url = ''
|
private readonly spotifyUrlPattern =
|
||||||
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
/^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
||||||
private interaction?: {
|
private interaction?: {
|
||||||
mode: 'drag' | 'resize';
|
mode: 'drag' | 'resize';
|
||||||
panelId: string;
|
panelId: string;
|
||||||
@@ -220,6 +303,8 @@ export class AppComponent implements OnDestroy {
|
|||||||
startPanelW: number;
|
startPanelW: number;
|
||||||
startPanelH: number;
|
startPanelH: number;
|
||||||
};
|
};
|
||||||
|
private readonly operationsRefreshMs = 5000;
|
||||||
|
private operationsRefreshTimer?: number;
|
||||||
readonly panelTypes: SpootyPanelType[] = [
|
readonly panelTypes: SpootyPanelType[] = [
|
||||||
'source-intake',
|
'source-intake',
|
||||||
'queue-observatory',
|
'queue-observatory',
|
||||||
@@ -228,7 +313,11 @@ export class AppComponent implements OnDestroy {
|
|||||||
'archive-browser',
|
'archive-browser',
|
||||||
'candidate-inspector',
|
'candidate-inspector',
|
||||||
];
|
];
|
||||||
readonly workspaceTabs: SpootyWorkspaceTab[] = ['intake', 'archive', 'diagnostics'];
|
readonly workspaceTabs: SpootyWorkspaceTab[] = [
|
||||||
|
'intake',
|
||||||
|
'archive',
|
||||||
|
'diagnostics',
|
||||||
|
];
|
||||||
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
|
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
|
||||||
selectedPanelType: SpootyPanelType = 'source-intake';
|
selectedPanelType: SpootyPanelType = 'source-intake';
|
||||||
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
|
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
|
||||||
@@ -237,19 +326,26 @@ export class AppComponent implements OnDestroy {
|
|||||||
return this.spotifyUrlPattern.test(this.url);
|
return this.spotifyUrlPattern.test(this.url);
|
||||||
}
|
}
|
||||||
createLoading$ = this.playlistService.createLoading$;
|
createLoading$ = this.playlistService.createLoading$;
|
||||||
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
playlists$ = this.playlistService.all$.pipe(
|
||||||
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
map((items) => items.filter((item) => !item.isTrack)),
|
||||||
|
);
|
||||||
|
songs$ = this.playlistService.all$.pipe(
|
||||||
|
map((items) => items.filter((item) => item.isTrack)),
|
||||||
|
);
|
||||||
allTracks$ = this.trackService.all$;
|
allTracks$ = this.trackService.all$;
|
||||||
selectedTrack$ = this.trackService.selectedTrack$;
|
selectedTrack$ = this.trackService.selectedTrack$;
|
||||||
version = this.versionService.getVersion();
|
version = this.versionService.getVersion();
|
||||||
spotifyConnected = false;
|
spotifyConnected = false;
|
||||||
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || '';
|
authTokenPresent = Boolean(getStorageItem(AUTH_TOKEN_STORAGE_KEY));
|
||||||
|
archiveDestination = getStorageItem(ARCHIVE_DESTINATION_KEY) || '';
|
||||||
archiveListing?: ArchiveListing;
|
archiveListing?: ArchiveListing;
|
||||||
archiveLoading = false;
|
archiveLoading = false;
|
||||||
archiveError = '';
|
archiveError = '';
|
||||||
archiveSearch = '';
|
archiveSearch = '';
|
||||||
archiveSort: ArchiveSortMode = 'newest';
|
archiveSort: ArchiveSortMode = 'newest';
|
||||||
trackStatuses = TrackStatusEnum;
|
trackStatuses = TrackStatusEnum;
|
||||||
|
operationsSnapshot?: OperationsSnapshot;
|
||||||
|
operationsSnapshotError = '';
|
||||||
|
|
||||||
get activeWorkspaceTab(): SpootyWorkspaceTab {
|
get activeWorkspaceTab(): SpootyWorkspaceTab {
|
||||||
return this.workspaceTabsState.activeTab;
|
return this.workspaceTabsState.activeTab;
|
||||||
@@ -270,10 +366,18 @@ export class AppComponent implements OnDestroy {
|
|||||||
this.checkSpotifyStatus();
|
this.checkSpotifyStatus();
|
||||||
this.fetchPlaylists();
|
this.fetchPlaylists();
|
||||||
this.refreshArchive();
|
this.refreshArchive();
|
||||||
|
this.refreshOperationsSnapshot();
|
||||||
|
this.operationsRefreshTimer = window.setInterval(
|
||||||
|
() => this.refreshOperationsSnapshot(),
|
||||||
|
this.operationsRefreshMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.stopWorkspaceInteraction();
|
this.stopWorkspaceInteraction();
|
||||||
|
if (this.operationsRefreshTimer) {
|
||||||
|
window.clearInterval(this.operationsRefreshTimer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bootstrapAuthTokenFromUrl(): void {
|
private bootstrapAuthTokenFromUrl(): void {
|
||||||
@@ -284,7 +388,8 @@ export class AppComponent implements OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('spooty_auth_token', token);
|
setStorageItem(AUTH_TOKEN_STORAGE_KEY, token);
|
||||||
|
this.authTokenPresent = true;
|
||||||
url.searchParams.delete('token');
|
url.searchParams.delete('token');
|
||||||
window.history.replaceState({}, document.title, url.toString());
|
window.history.replaceState({}, document.title, url.toString());
|
||||||
}
|
}
|
||||||
@@ -292,7 +397,13 @@ export class AppComponent implements OnDestroy {
|
|||||||
checkSpotifyStatus(): void {
|
checkSpotifyStatus(): void {
|
||||||
this.http
|
this.http
|
||||||
.get<{ connected: boolean }>('/api/spotify/status')
|
.get<{ connected: boolean }>('/api/spotify/status')
|
||||||
.subscribe((status) => (this.spotifyConnected = status.connected));
|
.subscribe({
|
||||||
|
next: (status) => (this.spotifyConnected = status.connected),
|
||||||
|
error: (error) => {
|
||||||
|
console.error('Failed to check Spotify status', error);
|
||||||
|
this.spotifyConnected = false;
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connectSpotify(): void {
|
connectSpotify(): void {
|
||||||
@@ -318,7 +429,7 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveArchiveDestination(): void {
|
saveArchiveDestination(): void {
|
||||||
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
|
setStorageItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshArchive(): void {
|
refreshArchive(): void {
|
||||||
@@ -337,6 +448,18 @@ export class AppComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshOperationsSnapshot(): void {
|
||||||
|
this.http.get<OperationsSnapshot>('/api/operations/snapshot').subscribe({
|
||||||
|
next: (snapshot) => {
|
||||||
|
this.operationsSnapshot = snapshot;
|
||||||
|
this.operationsSnapshotError = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.operationsSnapshotError = 'Operations snapshot is unavailable.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
formatBytes(sizeBytes: number): string {
|
formatBytes(sizeBytes: number): string {
|
||||||
if (sizeBytes < 1024) {
|
if (sizeBytes < 1024) {
|
||||||
return `${sizeBytes} B`;
|
return `${sizeBytes} B`;
|
||||||
@@ -372,31 +495,56 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
selectTrack(track: Track): void {
|
||||||
this.trackService.select(track);
|
this.trackService.select(track);
|
||||||
|
this.refreshOperationsSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelectedTrack(): void {
|
clearSelectedTrack(): void {
|
||||||
this.trackService.clearSelection();
|
this.trackService.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
selectedTrackTruth(track: Track): TrackTruth | undefined {
|
||||||
|
return this.findTrackTruth(track.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCandidate(truth?: TrackTruth): SelectedCandidateTruth | undefined {
|
||||||
|
return truth?.selectedCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedCandidates(truth?: TrackTruth): RejectedCandidateTruth[] {
|
||||||
|
return truth?.rejectedCandidates || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedCandidateCount(truth?: TrackTruth): number {
|
||||||
|
return truth?.rejectedCandidateCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorClass(truth?: TrackTruth): string {
|
||||||
|
return truth?.errorClass || 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
errorSummary(truth?: TrackTruth): string {
|
||||||
|
return truth?.errorSummary || 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
errorDetail(truth?: TrackTruth): string {
|
||||||
|
return truth?.errorDetail || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTrackTruth(id: number): TrackTruth | undefined {
|
||||||
|
if (!this.operationsSnapshot) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...this.operationsSnapshot.active.searching,
|
||||||
|
...this.operationsSnapshot.active.downloading,
|
||||||
|
...this.operationsSnapshot.recentFailures,
|
||||||
|
...this.operationsSnapshot.recentTracks,
|
||||||
|
].find((track) => track.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
|
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
|
||||||
return WORKSPACE_TAB_LABELS[tab];
|
return WORKSPACE_TAB_LABELS[tab];
|
||||||
}
|
}
|
||||||
@@ -410,7 +558,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetLayout(): void {
|
resetLayout(): void {
|
||||||
this.setActiveWorkspace(defaultWorkspaceStateForTab(this.activeWorkspaceTab));
|
this.setActiveWorkspace(
|
||||||
|
defaultWorkspaceStateForTab(this.activeWorkspaceTab),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAllLayouts(): void {
|
resetAllLayouts(): void {
|
||||||
@@ -422,7 +572,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
const files = this.archiveListing?.files || [];
|
const files = this.archiveListing?.files || [];
|
||||||
const query = this.archiveSearch.trim().toLowerCase();
|
const query = this.archiveSearch.trim().toLowerCase();
|
||||||
const filtered = query
|
const filtered = query
|
||||||
? files.filter(file => `${file.name} ${file.path}`.toLowerCase().includes(query))
|
? files.filter((file) =>
|
||||||
|
`${file.name} ${file.path}`.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
: [...files];
|
: [...files];
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
return filtered.sort((a, b) => {
|
||||||
@@ -441,12 +593,12 @@ export class AppComponent implements OnDestroy {
|
|||||||
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
||||||
const groups = new Map<string, ArchiveFile[]>();
|
const groups = new Map<string, ArchiveFile[]>();
|
||||||
|
|
||||||
this.filteredArchiveFiles().forEach(file => {
|
this.filteredArchiveFiles().forEach((file) => {
|
||||||
const folder = this.topLevelFolder(file);
|
const folder = this.topLevelFolder(file);
|
||||||
groups.set(folder, [...(groups.get(folder) || []), file]);
|
groups.set(folder, [...(groups.get(folder) || []), file]);
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...groups.entries()].map(([folder, files]) => ({folder, files}));
|
return [...groups.entries()].map(([folder, files]) => ({ folder, files }));
|
||||||
}
|
}
|
||||||
|
|
||||||
archiveFileCount(): number {
|
archiveFileCount(): number {
|
||||||
@@ -454,7 +606,10 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
archiveTotalBytes(): number {
|
archiveTotalBytes(): number {
|
||||||
return this.filteredArchiveFiles().reduce((total, file) => total + file.sizeBytes, 0);
|
return this.filteredArchiveFiles().reduce(
|
||||||
|
(total, file) => total + file.sizeBytes,
|
||||||
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
topLevelFolder(file: ArchiveFile): string {
|
topLevelFolder(file: ArchiveFile): string {
|
||||||
@@ -490,7 +645,7 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setActiveWorkspace({
|
this.setActiveWorkspace({
|
||||||
panels: this.workspace.panels.filter(panel => panel.id !== panelId),
|
panels: this.workspace.panels.filter((panel) => panel.id !== panelId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,7 +738,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
|
|
||||||
private startWorkspaceInteraction(): void {
|
private startWorkspaceInteraction(): void {
|
||||||
document.addEventListener('pointermove', this.handlePointerMove);
|
document.addEventListener('pointermove', this.handlePointerMove);
|
||||||
document.addEventListener('pointerup', this.handlePointerUp, {once: true});
|
document.addEventListener('pointerup', this.handlePointerUp, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopWorkspaceInteraction(): void {
|
private stopWorkspaceInteraction(): void {
|
||||||
@@ -593,7 +750,7 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bringPanelToFront(panelId: string): void {
|
private bringPanelToFront(panelId: string): void {
|
||||||
const panel = this.workspace.panels.find(item => item.id === panelId);
|
const panel = this.workspace.panels.find((item) => item.id === panelId);
|
||||||
|
|
||||||
if (!panel) {
|
if (!panel) {
|
||||||
return;
|
return;
|
||||||
@@ -601,16 +758,19 @@ export class AppComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.setActiveWorkspace({
|
this.setActiveWorkspace({
|
||||||
panels: [
|
panels: [
|
||||||
...this.workspace.panels.filter(item => item.id !== panelId),
|
...this.workspace.panels.filter((item) => item.id !== panelId),
|
||||||
panel,
|
panel,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updatePanel(panelId: string, changes: Partial<SpootyPanelInstance>): void {
|
private updatePanel(
|
||||||
|
panelId: string,
|
||||||
|
changes: Partial<SpootyPanelInstance>,
|
||||||
|
): void {
|
||||||
this.setActiveWorkspace({
|
this.setActiveWorkspace({
|
||||||
panels: this.workspace.panels.map(panel =>
|
panels: this.workspace.panels.map((panel) =>
|
||||||
panel.id === panelId ? {...panel, ...changes} : panel
|
panel.id === panelId ? { ...panel, ...changes } : panel,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -627,6 +787,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private saveWorkspaceState(): void {
|
private saveWorkspaceState(): void {
|
||||||
localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState));
|
setStorageItem(
|
||||||
|
WORKSPACE_TABS_STORAGE_KEY,
|
||||||
|
JSON.stringify(this.workspaceTabsState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
export interface SelectedCandidateTruth {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
score?: number;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RejectedCandidateTruth {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
score?: number;
|
||||||
|
reason?: string;
|
||||||
|
rejectionClass?: string;
|
||||||
|
rejectionSummary?: string;
|
||||||
|
rejectedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackTruth {
|
||||||
|
id: number;
|
||||||
|
artist: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
spotifyUrl?: string;
|
||||||
|
youtubeUrl?: string;
|
||||||
|
selectedCandidate?: SelectedCandidateTruth;
|
||||||
|
downloadAttemptCount?: number;
|
||||||
|
errorClass?: string;
|
||||||
|
errorSummary?: string;
|
||||||
|
errorDetail?: string;
|
||||||
|
rejectedCandidateCount: number;
|
||||||
|
rejectedCandidates: RejectedCandidateTruth[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationsSnapshot {
|
||||||
|
generatedAt: string;
|
||||||
|
runtime: {
|
||||||
|
operation: string;
|
||||||
|
spotifyConnected: boolean;
|
||||||
|
searchQueueDepth: number;
|
||||||
|
downloadQueueDepth: number;
|
||||||
|
activeSearches: number;
|
||||||
|
activeDownloads: number;
|
||||||
|
};
|
||||||
|
counters: {
|
||||||
|
playlists: number;
|
||||||
|
singleSongs: number;
|
||||||
|
tracks: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
failed: number;
|
||||||
|
retries: number;
|
||||||
|
};
|
||||||
|
active: {
|
||||||
|
searching: TrackTruth[];
|
||||||
|
downloading: TrackTruth[];
|
||||||
|
};
|
||||||
|
recentFailures: TrackTruth[];
|
||||||
|
recentTracks: TrackTruth[];
|
||||||
|
}
|
||||||
@@ -3,7 +3,13 @@ import { HttpInterceptorFn } from '@angular/common/http';
|
|||||||
const TOKEN_STORAGE_KEY = 'spooty_auth_token';
|
const TOKEN_STORAGE_KEY = 'spooty_auth_token';
|
||||||
|
|
||||||
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
const token = localStorage.getItem(TOKEN_STORAGE_KEY);
|
let token: string | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
token = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read auth token from localStorage', error);
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return next(req);
|
return next(req);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
withUIEntities
|
withUIEntities
|
||||||
} from "@ngneat/elf-entities";
|
} from "@ngneat/elf-entities";
|
||||||
import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests";
|
import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests";
|
||||||
import {combineLatest, filter, first, map, Observable, of, switchMap, tap} from "rxjs";
|
import {catchError, combineLatest, filter, first, map, Observable, of, switchMap, tap} from "rxjs";
|
||||||
import {TrackService} from "./track.service";
|
import {TrackService} from "./track.service";
|
||||||
import {Socket} from "ngx-socket-io";
|
import {Socket} from "ngx-socket-io";
|
||||||
import {Playlist} from "../models/playlist";
|
import {Playlist} from "../models/playlist";
|
||||||
@@ -115,6 +115,14 @@ export class PlaylistService {
|
|||||||
|
|
||||||
fetch(): void {
|
fetch(): void {
|
||||||
this.http.get<Playlist[]>(ENDPOINT).pipe(
|
this.http.get<Playlist[]>(ENDPOINT).pipe(
|
||||||
|
catchError((error) => {
|
||||||
|
console.error('Failed to fetch playlists', error);
|
||||||
|
this.store.update(
|
||||||
|
setEntities([]),
|
||||||
|
setEntities([], { ref: UIEntitiesRef }),
|
||||||
|
);
|
||||||
|
return of([]);
|
||||||
|
}),
|
||||||
tap((data: Playlist[]) => this.store.update(
|
tap((data: Playlist[]) => this.store.update(
|
||||||
setEntities(data),
|
setEntities(data),
|
||||||
setEntities(data.map(item => ({id: item.id, collapsed: false})), {ref: UIEntitiesRef})
|
setEntities(data.map(item => ({id: item.id, collapsed: false})), {ref: UIEntitiesRef})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="boot-status">Loading Jarri Spooty...</div>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
|
import { isDevMode } from '@angular/core';
|
||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { AppComponent } from './app/app.component';
|
import { AppComponent } from './app/app.component';
|
||||||
import { devTools } from '@ngneat/elf-devtools';
|
import { devTools } from '@ngneat/elf-devtools';
|
||||||
|
|
||||||
|
function setBootStatus(message: string): void {
|
||||||
|
document.getElementById('boot-status')!.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBootStatus('Starting frontend bundle...');
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
setBootStatus(`Frontend error: ${event.message || 'unknown'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
setBootStatus(`Frontend rejection: ${String(event.reason || 'unknown')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootTimeout = window.setTimeout(() => {
|
||||||
|
setBootStatus('Bootstrap still running...');
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, appConfig)
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.then(() => {
|
||||||
devTools();
|
window.clearTimeout(bootTimeout);
|
||||||
|
document.getElementById('boot-status')?.remove();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
window.clearTimeout(bootTimeout);
|
||||||
|
console.error(err);
|
||||||
|
setBootStatus(`Bootstrap failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDevMode()) {
|
||||||
|
devTools();
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,62 @@ body {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.candidate-card {
|
||||||
|
display: block;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(126, 255, 180, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-card,
|
||||||
|
.candidate-card dl {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-card dt {
|
||||||
|
color: #f1fff5;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-card dd {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #bcd1c4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.candidate-card dd:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rejected-candidate-card {
|
||||||
|
border-color: rgba(255, 214, 102, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-detail summary {
|
||||||
|
color: #9fffc3;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.truth-detail pre {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid rgba(126, 255, 180, 0.14);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.22);
|
||||||
|
color: #dceee4;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state.compact {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.workspace-tabs {
|
.workspace-tabs {
|
||||||
position: static;
|
position: static;
|
||||||
|
|||||||
Reference in New Issue
Block a user