7 Commits
24 changed files with 1630 additions and 206 deletions
+97 -50
View File
@@ -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
~~~
--- ---
+24
View File
@@ -0,0 +1,24 @@
# Spooty Operations Snapshot
`GET /api/operations/snapshot` exposes backend-owned operational truth for Workspace and other clients.
The MVP reports truth Spooty already owns without a schema migration:
- track state from `TrackEntity.status`
- selected YouTube URL from `TrackEntity.youtubeUrl`
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
- raw failure evidence from `TrackEntity.error`
- download retry count from `TrackEntity.downloadAttemptCount`
- rejected YouTube candidate explanations from `TrackEntity.rejectedYoutubeCandidatesJson`
- legacy rejected YouTube candidate URLs from `TrackEntity.rejectedYoutubeUrls`
- playlist and single-song counts from `PlaylistEntity.isTrack`
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
- Spotify connection presence from the stored Spotify user token
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidate metadata is persisted when a selected candidate is rejected by manual retry or download failure. Older rows and URL-only cases fall back to rejected candidate objects containing only `url`.
Future persistence work is required for:
- full searched candidate history
- structured error class fields stored at write time instead of classified from the persisted raw error string
- operation attempt history across search and download phases
+4 -4
View File
@@ -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
View File
@@ -1,5 +1,5 @@
{ {
"version": "3.0.0", "version": "3.1.0",
"name": "spooty", "name": "spooty",
"workspaces": [ "workspaces": [
"src/backend", "src/backend",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backend", "name": "backend",
"version": "3.0.0", "version": "3.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+3 -1
View File
@@ -15,6 +15,7 @@ import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './shared/auth.guard'; import { AuthGuard } from './shared/auth.guard';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ArchiveModule } from './archive/archive.module'; import { ArchiveModule } from './archive/archive.module';
import { OperationsModule } from './operations/operations.module';
@Module({ @Module({
imports: [ imports: [
@@ -49,7 +50,7 @@ import { ArchiveModule } from './archive/archive.module';
ThrottlerModule.forRoot([ ThrottlerModule.forRoot([
{ {
ttl: 60000, ttl: 60000,
limit: 60, limit: Number(process.env.API_THROTTLE_LIMIT || 600),
}, },
]), ]),
BullModule.forRootAsync({ BullModule.forRootAsync({
@@ -81,6 +82,7 @@ import { ArchiveModule } from './archive/archive.module';
TrackModule, TrackModule,
PlaylistModule, PlaylistModule,
ArchiveModule, ArchiveModule,
OperationsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { OperationsService } from './operations.service';
import { OperationsSnapshot } from './operations.types';
@Controller('operations')
export class OperationsController {
constructor(private readonly operationsService: OperationsService) {}
@Get('snapshot')
getSnapshot(): Promise<OperationsSnapshot> {
return this.operationsService.getSnapshot();
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SharedModule } from '../shared/shared.module';
import { TrackEntity } from '../track/track.entity';
import { OperationsController } from './operations.controller';
import { OperationsService } from './operations.service';
@Module({
imports: [
TypeOrmModule.forFeature([PlaylistEntity, TrackEntity]),
BullModule.registerQueue(
{ name: 'track-search-processor' },
{ name: 'track-download-processor' },
),
SharedModule,
],
controllers: [OperationsController],
providers: [OperationsService],
})
export class OperationsModule {}
@@ -0,0 +1,404 @@
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bullmq';
import { In, MoreThan, Repository } from 'typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SpotifyApiService } from '../shared/spotify-api.service';
import { TrackEntity, TrackStatusEnum } from '../track/track.entity';
import {
ErrorClass,
FailureTruth,
OperationsSnapshot,
RejectedCandidateTruth,
SpootyOperation,
TrackTruth,
} from './operations.types';
interface ClassifiedError {
errorClass: ErrorClass;
errorSummary: string;
errorDetail: string;
}
@Injectable()
export class OperationsService {
constructor(
@InjectRepository(TrackEntity)
private readonly trackRepository: Repository<TrackEntity>,
@InjectRepository(PlaylistEntity)
private readonly playlistRepository: Repository<PlaylistEntity>,
@InjectQueue('track-download-processor')
private readonly trackDownloadQueue: Queue,
@InjectQueue('track-search-processor')
private readonly trackSearchQueue: Queue,
private readonly spotifyApiService: SpotifyApiService,
) {}
async getSnapshot(): Promise<OperationsSnapshot> {
const [
spotifyConnected,
searchQueueCounts,
downloadQueueCounts,
activeSearchJobs,
activeDownloadJobs,
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
searchingTracks,
downloadingTracks,
recentFailures,
recentTracks,
] = await Promise.all([
this.spotifyApiService.hasUserToken(),
this.trackSearchQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackDownloadQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackSearchQueue.getJobs('active', 0, 100, true),
this.trackDownloadQueue.getJobs('active', 0, 100, true),
this.playlistRepository.countBy({ isTrack: false }),
this.playlistRepository.countBy({ isTrack: true }),
this.trackRepository.count(),
this.trackRepository.countBy({
status: In([
TrackStatusEnum.Searching,
TrackStatusEnum.Queued,
TrackStatusEnum.Downloading,
]),
}),
this.trackRepository.countBy({ status: TrackStatusEnum.Completed }),
this.trackRepository.countBy({ status: TrackStatusEnum.Error }),
this.trackRepository.countBy({ downloadAttemptCount: MoreThan(0) }),
this.trackRepository.find({
where: { status: TrackStatusEnum.Searching },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Downloading },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Error },
order: { createdAt: 'DESC' },
take: 20,
}),
this.trackRepository.find({
order: { createdAt: 'DESC' },
take: 20,
}),
]);
const activeSearchTracks = await this.mergeTracksFromActiveJobs(
searchingTracks,
activeSearchJobs,
);
const activeDownloadTracks = await this.mergeTracksFromActiveJobs(
downloadingTracks,
activeDownloadJobs,
);
const searchQueueDepth =
(searchQueueCounts.waiting || 0) + (searchQueueCounts.delayed || 0);
const downloadQueueDepth =
(downloadQueueCounts.waiting || 0) + (downloadQueueCounts.delayed || 0);
const activeSearches = activeSearchJobs.length;
const activeDownloads = activeDownloadJobs.length;
return {
generatedAt: new Date().toISOString(),
runtime: {
operation: this.getOperation({
activeSearches,
activeDownloads,
searchQueueDepth,
downloadQueueDepth,
failed,
retries,
}),
spotifyConnected,
searchQueueDepth,
downloadQueueDepth,
activeSearches,
activeDownloads,
},
counters: {
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
},
active: {
searching: activeSearchTracks.map((track) => this.toTrackTruth(track)),
downloading: activeDownloadTracks.map((track) =>
this.toTrackTruth(track),
),
},
recentFailures: recentFailures.map((track) => this.toTrackTruth(track)),
recentTracks: recentTracks.map((track) => this.toTrackTruth(track)),
};
}
private async mergeTracksFromActiveJobs(
tracks: TrackEntity[],
jobs: Job[],
): Promise<TrackEntity[]> {
const knownIds = new Set(tracks.map((track) => track.id).filter(Boolean));
const missingIds = jobs
.map((job) => Number(job.data?.id))
.filter((id) => Number.isInteger(id) && !knownIds.has(id));
if (!missingIds.length) {
return tracks;
}
const activeJobTracks = await this.trackRepository.find({
where: { id: In(missingIds) },
});
return [...tracks, ...activeJobTracks];
}
private toTrackTruth(track: TrackEntity): TrackTruth | FailureTruth {
const rejectedCandidates = this.parseRejectedCandidates(track);
const error = this.classifyError(track.error);
return {
id: track.id,
artist: track.artist,
name: track.name,
status: TrackStatusEnum[track.status] || String(track.status),
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
...(track.youtubeUrl
? {
selectedCandidate: {
url: track.youtubeUrl,
...(track.selectedYoutubeTitle
? { title: track.selectedYoutubeTitle }
: {}),
...(track.selectedYoutubeAuthor
? { author: track.selectedYoutubeAuthor }
: {}),
...(typeof track.selectedYoutubeScore === 'number'
? { score: track.selectedYoutubeScore }
: {}),
...(track.selectedYoutubeReason
? { reason: track.selectedYoutubeReason }
: {}),
},
}
: {}),
...(typeof track.downloadAttemptCount === 'number'
? { downloadAttemptCount: track.downloadAttemptCount }
: {}),
...(error || {}),
rejectedCandidateCount: rejectedCandidates.length,
rejectedCandidates,
};
}
private parseRejectedCandidates(
track: TrackEntity,
): RejectedCandidateTruth[] {
const structured = this.parseRejectedCandidateJson(track);
if (structured.length) {
return structured;
}
if (!track.rejectedYoutubeUrls) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeUrls);
return Array.isArray(parsed)
? parsed
.filter((item) => typeof item === 'string')
.map((url) => ({ url }))
: [];
} catch {
return [];
}
}
private parseRejectedCandidateJson(
track: TrackEntity,
): RejectedCandidateTruth[] {
if (!track.rejectedYoutubeCandidatesJson) {
return [];
}
try {
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
return Array.isArray(parsed)
? parsed
.filter(
(item) =>
!!item &&
typeof item === 'object' &&
typeof item.url === 'string',
)
.map((item) => ({
url: item.url,
...(typeof item.title === 'string' ? { title: item.title } : {}),
...(typeof item.author === 'string'
? { author: item.author }
: {}),
...(typeof item.score === 'number' ? { score: item.score } : {}),
...(typeof item.reason === 'string'
? { reason: item.reason }
: {}),
...(typeof item.rejectionClass === 'string'
? { rejectionClass: item.rejectionClass }
: {}),
...(typeof item.rejectionSummary === 'string'
? { rejectionSummary: item.rejectionSummary }
: {}),
...(typeof item.rejectedAt === 'string'
? { rejectedAt: item.rejectedAt }
: {}),
}))
: [];
} catch {
return [];
}
}
private classifyError(error?: string): ClassifiedError | undefined {
if (!error) {
return undefined;
}
const detail = this.trimDetail(error);
const normalized = error.toLowerCase();
if (normalized.includes('no acceptable youtube result found')) {
return this.error(
'NO_ACCEPTABLE_CANDIDATE',
'No acceptable YouTube candidate',
detail,
);
}
if (normalized.includes('sign in to confirm your age')) {
return this.error(
'YOUTUBE_AGE_GATED',
'YouTube video is age-gated',
detail,
);
}
if (normalized.includes('private video')) {
return this.error(
'YOUTUBE_PRIVATE_VIDEO',
'YouTube video is private',
detail,
);
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return this.error(
'YOUTUBE_NO_FORMATS',
'No downloadable YouTube audio format',
detail,
);
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return this.error(
'YOUTUBE_VIDEO_UNAVAILABLE',
'YouTube video is unavailable',
detail,
);
}
if (normalized.includes('unable to download webpage')) {
return this.error(
'YOUTUBE_WEBPAGE_UNAVAILABLE',
'YouTube webpage is unavailable',
detail,
);
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime')
) {
return this.error(
'YOUTUBE_EXTRACTION_FAILURE',
'YouTube extraction failed',
detail,
);
}
if (
normalized.includes('eacces') ||
normalized.includes('enoent') ||
normalized.includes('failed to write') ||
normalized.includes('permission denied')
) {
return this.error('FILE_WRITE_FAILED', 'File write failed', detail);
}
return this.error(
'UNKNOWN_DOWNLOAD_ERROR',
'Unknown download error',
detail,
);
}
private error(
errorClass: ErrorClass,
errorSummary: string,
errorDetail: string,
): ClassifiedError {
return { errorClass, errorSummary, errorDetail };
}
private trimDetail(error: string): string {
const trimmed = error.trim();
return trimmed.length > 1200 ? `${trimmed.slice(0, 1197)}...` : trimmed;
}
private getOperation(input: {
activeSearches: number;
activeDownloads: number;
searchQueueDepth: number;
downloadQueueDepth: number;
failed: number;
retries: number;
}): SpootyOperation {
if (input.activeDownloads > 0 || input.downloadQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'downloading';
}
if (input.activeSearches > 0 || input.searchQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'searching';
}
if (input.failed > 0) {
return 'degraded';
}
return 'idle';
}
}
@@ -0,0 +1,89 @@
export type SpootyOperation =
| 'idle'
| 'searching'
| 'downloading'
| 'retrying'
| 'degraded';
export type ErrorClass =
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_WEBPAGE_UNAVAILABLE'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'NO_ACCEPTABLE_CANDIDATE'
| 'FILE_WRITE_FAILED'
| 'UNKNOWN_DOWNLOAD_ERROR';
export type RejectionClass =
| 'DOWNLOAD_FAILED'
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'UNKNOWN_DOWNLOAD_ERROR'
| 'MANUAL_RETRY';
export interface RejectedCandidateTruth {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
rejectionClass?: RejectionClass;
rejectionSummary?: string;
rejectedAt?: string;
}
export interface TrackTruth {
id: number;
artist: string;
name: string;
status: string;
spotifyUrl?: string;
youtubeUrl?: string;
selectedCandidate?: {
url: string;
title?: string;
author?: string;
score?: number;
reason?: string;
};
downloadAttemptCount?: number;
errorClass?: ErrorClass;
errorSummary?: string;
errorDetail?: string;
rejectedCandidateCount: number;
rejectedCandidates: RejectedCandidateTruth[];
}
export interface FailureTruth extends TrackTruth {}
export interface OperationsSnapshot {
generatedAt: string;
runtime: {
operation: SpootyOperation;
spotifyConnected: boolean;
searchQueueDepth: number;
downloadQueueDepth: number;
activeSearches: number;
activeDownloads: number;
};
counters: {
playlists: number;
singleSongs: number;
tracks: number;
active: number;
completed: number;
failed: number;
retries: number;
};
active: {
searching: TrackTruth[];
downloading: TrackTruth[];
};
recentFailures: FailureTruth[];
recentTracks: TrackTruth[];
}
@@ -0,0 +1,11 @@
import { IsString, MaxLength } from 'class-validator';
export class SearchTrackDto {
@IsString()
@MaxLength(512)
artist: string;
@IsString()
@MaxLength(512)
title: string;
}
@@ -12,6 +12,7 @@ import { PlaylistService } from './playlist.service';
import { PlaylistEntity } from './playlist.entity'; import { PlaylistEntity } from './playlist.entity';
import { CreatePlaylistDto } from './dto/create-playlist.dto'; import { CreatePlaylistDto } from './dto/create-playlist.dto';
import { UpdatePlaylistDto } from './dto/update-playlist.dto'; import { UpdatePlaylistDto } from './dto/update-playlist.dto';
import { SearchTrackDto } from './dto/search-track.dto';
@Controller('playlist') @Controller('playlist')
export class PlaylistController { export class PlaylistController {
@@ -27,6 +28,13 @@ export class PlaylistController {
await this.service.create(playlist as PlaylistEntity); await this.service.create(playlist as PlaylistEntity);
} }
@Post('search-track')
async searchTrack(
@Body() body: SearchTrackDto,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
return this.service.createFromSearch(body.artist, body.title);
}
@Put(':id') @Put(':id')
update( update(
@Param('id', ParseIntPipe) id: number, @Param('id', ParseIntPipe) id: number,
+23 -1
View File
@@ -48,6 +48,27 @@ export class PlaylistService {
this.io.emit(WsPlaylistOperation.Delete, { id }); this.io.emit(WsPlaylistOperation.Delete, { id });
} }
async createFromSearch(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
const track = await this.spotifyService.searchTrack(artist, title);
await this.create({
spotifyUrl: track.spotifyUrl,
name: track.name,
active: false,
isTrack: true,
} as PlaylistEntity);
return {
spotifyUrl: track.spotifyUrl,
name: track.name,
artist: track.artist,
};
}
async create(playlist: PlaylistEntity): Promise<void> { async create(playlist: PlaylistEntity): Promise<void> {
// Detect if URL is for a single track or a playlist and route accordingly // Detect if URL is for a single track or a playlist and route accordingly
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl); const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
@@ -63,7 +84,7 @@ export class PlaylistService {
} }
private async createSingleTrack(playlist: PlaylistEntity): Promise<void> { private async createSingleTrack(playlist: PlaylistEntity): Promise<void> {
let trackDetail: { name: string; artist: string; image: string }; let trackDetail: { name: string; artist: string; image: string; durationMs?: number };
let playlist2Save: PlaylistEntity; let playlist2Save: PlaylistEntity;
try { try {
trackDetail = await this.spotifyService.getTrackDetail( trackDetail = await this.spotifyService.getTrackDetail(
@@ -97,6 +118,7 @@ export class PlaylistService {
name: trackDetail.name, name: trackDetail.name,
spotifyUrl: playlist.spotifyUrl, spotifyUrl: playlist.spotifyUrl,
coverUrl: trackDetail.image, coverUrl: trackDetail.image,
durationMs: trackDetail.durationMs,
}, },
savedPlaylist, savedPlaylist,
); );
@@ -12,27 +12,57 @@ export class SpotifyAuthController {
} }
@Get('login') @Get('login')
login(@Res() res: Response): void { login(
res.redirect(this.spotifyApiService.getAuthorizationUrl()); @Query('returnTo') returnTo: string | undefined,
@Res() res: Response,
): void {
const state =
returnTo && /^https?:\/\/localhost:\d+\/?$/.test(returnTo)
? Buffer.from(JSON.stringify({ returnTo }), 'utf8').toString('base64url')
: undefined;
res.redirect(this.spotifyApiService.getAuthorizationUrl(state));
} }
@Get('callback') @Get('callback')
async callback( async callback(
@Query('code') code: string | undefined, @Query('code') code: string | undefined,
@Query('error') error: string | undefined, @Query('error') error: string | undefined,
@Query('state') state: string | undefined,
@Res() res: Response, @Res() res: Response,
): Promise<void> { ): Promise<void> {
const returnTo = this.parseReturnTo(state);
if (error) { if (error) {
res.redirect(`/?spotify_error=${encodeURIComponent(error)}`); res.redirect(`${returnTo}?spotify_error=${encodeURIComponent(error)}`);
return; return;
} }
if (!code) { if (!code) {
res.redirect('/?spotify_error=missing_code'); res.redirect(`${returnTo}?spotify_error=missing_code`);
return; return;
} }
await this.spotifyApiService.exchangeAuthorizationCode(code); await this.spotifyApiService.exchangeAuthorizationCode(code);
res.redirect('/?spotify=connected'); res.redirect(`${returnTo}?spotify=connected`);
}
private parseReturnTo(state: string | undefined): string {
if (!state) {
return '/';
}
try {
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8'));
const returnTo = parsed?.returnTo;
if (typeof returnTo === 'string' && /^https?:\/\/localhost:\d+\/?$/.test(returnTo)) {
return returnTo;
}
} catch {
return '/';
}
return '/';
} }
} }
+84 -1
View File
@@ -151,7 +151,7 @@ export class SpotifyApiService {
return !!this.readUserToken(); return !!this.readUserToken();
} }
getAuthorizationUrl(): string { getAuthorizationUrl(state?: string): string {
const url = new URL('https://accounts.spotify.com/authorize'); const url = new URL('https://accounts.spotify.com/authorize');
url.searchParams.set('response_type', 'code'); url.searchParams.set('response_type', 'code');
@@ -167,6 +167,10 @@ export class SpotifyApiService {
url.searchParams.set('redirect_uri', this.getRedirectUri()); url.searchParams.set('redirect_uri', this.getRedirectUri());
url.searchParams.set('show_dialog', 'true'); url.searchParams.set('show_dialog', 'true');
if (state) {
url.searchParams.set('state', state);
}
return url.toString(); return url.toString();
} }
@@ -336,6 +340,85 @@ export class SpotifyApiService {
} }
} }
async searchTrack(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
const accessToken = await this.getClientCredentialsAccessToken();
const plainQuery = `${artist} ${title}`.trim();
const structuredQuery = `track:"${title}" artist:"${artist}"`;
const url = new URL('https://api.spotify.com/v1/search');
url.searchParams.set('q', structuredQuery || plainQuery);
url.searchParams.set('type', 'track');
url.searchParams.set('limit', '10');
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Spotify track search failed: ${response.status} ${errorText}`);
}
const data = await response.json();
const tracks = data?.tracks?.items || [];
if (!tracks.length) {
throw new Error(`No Spotify track found for ${artist} - ${title}`);
}
const normalize = (value: string) =>
(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const wantedArtist = normalize(artist);
const wantedTitle = normalize(title);
const scoreTrack = (track: any): number => {
const trackName = normalize(track.name);
const artistNames = (track.artists || []).map((item: any) => normalize(item.name));
const allArtists = artistNames.join(' ');
let score = 0;
if (trackName === wantedTitle) score += 70;
else if (trackName.includes(wantedTitle) || wantedTitle.includes(trackName)) score += 40;
if (artistNames.some((name: string) => name === wantedArtist)) score += 50;
else if (allArtists.includes(wantedArtist) || wantedArtist.includes(allArtists)) score += 25;
if (track.explicit !== undefined) score += 1;
if (track.popularity) score += Math.min(20, Math.floor(track.popularity / 5));
return score;
};
const ranked = [...tracks]
.map((track: any) => ({ track, score: scoreTrack(track) }))
.sort((a, b) => b.score - a.score);
const best = ranked[0]?.track || tracks[0];
this.logger.debug(
`Spotify search "${plainQuery}" selected "${best?.artists?.map((item: any) => item.name).join(', ')} - ${best?.name}" score=${ranked[0]?.score ?? 0}`,
);
return {
spotifyUrl: best.external_urls.spotify,
name: best.name,
artist: (best.artists || []).map((item: any) => item.name).join(', '),
image: best.album?.images?.[0]?.url || '',
durationMs: best.duration_ms,
};
}
async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> { async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> {
try { try {
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`); this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
@@ -72,6 +72,14 @@ export class SpotifyService {
return this.spotifyApiService.getArtistLibrary(spotifyUrl); return this.spotifyApiService.getArtistLibrary(spotifyUrl);
} }
async searchTrack(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
return this.spotifyApiService.searchTrack(artist, title);
}
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> { async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
try { try {
+33 -7
View File
@@ -38,6 +38,19 @@ const BAD_MATCH_TERMS = [
'reaction', 'reaction',
'tutorial', 'tutorial',
'instrumental', 'instrumental',
'reaction',
'review',
'analysis',
'breakdown',
'explained',
'secret message',
'meaning',
'interview',
'podcast',
'news',
'documentary',
'fifa world cup',
'song style',
]; ];
const OFFICIALISH_TERMS = [ const OFFICIALISH_TERMS = [
@@ -123,11 +136,12 @@ export class YoutubeService {
.join(' | '), .join(' | '),
); );
const accepted = candidates.find((candidate) => !candidate.rejected); const minimumScore = Number(process.env.YT_MIN_CANDIDATE_SCORE || 60);
const accepted = candidates.find((candidate) => !candidate.rejected && candidate.score >= minimumScore);
if (!accepted) { if (!accepted) {
throw new Error( throw new Error(
`No acceptable YouTube result found for: ${query}` + `No acceptable YouTube result found for: ${query} with minimum score ${minimumScore}` +
(excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''), (excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''),
); );
} }
@@ -143,11 +157,12 @@ export class YoutubeService {
const searchTarget = `ytsearch15:${query}`; const searchTarget = `ytsearch15:${query}`;
const args = [ const args = [
'--dump-single-json', '--dump-single-json',
'--flat-playlist',
'--skip-download', '--skip-download',
'--no-playlist', '--no-playlist',
'--no-cache-dir', '--no-cache-dir',
'--no-cookies-from-browser', '--no-cookies-from-browser',
'--extractor-args',
'youtube:player_client=android_vr',
'--add-header', '--add-header',
`User-Agent:${HEADERS['User-Agent']}`, `User-Agent:${HEADERS['User-Agent']}`,
searchTarget, searchTarget,
@@ -198,6 +213,7 @@ export class YoutubeService {
const author = String(video.author?.name || video.author || ''); const author = String(video.author?.name || video.author || '');
const url = String(video.url || ''); const url = String(video.url || '');
const seconds = this.getVideoSeconds(video); const seconds = this.getVideoSeconds(video);
this.logger.debug(`YT candidate raw duration title="${title}" duration=${video.duration} seconds=${video.seconds} parsed=${seconds}`);
const normalizedTitle = this.normalize(title); const normalizedTitle = this.normalize(title);
const normalizedAuthor = this.normalize(author); const normalizedAuthor = this.normalize(author);
@@ -213,14 +229,21 @@ export class YoutubeService {
reasons.push('title=45'); reasons.push('title=45');
} }
let hasArtistSignal = false;
for (const artistPart of this.artistParts(normalizedArtist)) { for (const artistPart of this.artistParts(normalizedArtist)) {
if (artistPart.length >= 3 && (normalizedTitle.includes(artistPart) || normalizedAuthor.includes(artistPart))) { if (artistPart.length >= 3 && (normalizedTitle.includes(artistPart) || normalizedAuthor.includes(artistPart))) {
score += 20; score += 20;
reasons.push(`artist=${artistPart}:20`); reasons.push(`artist=${artistPart}:20`);
hasArtistSignal = true;
break; break;
} }
} }
if (!hasArtistSignal) {
score -= 35;
reasons.push('artist=missing:-35');
}
const officialish = OFFICIALISH_TERMS.find((term) => const officialish = OFFICIALISH_TERMS.find((term) =>
`${normalizedTitle} ${normalizedAuthor}`.includes(this.normalize(term)), `${normalizedTitle} ${normalizedAuthor}`.includes(this.normalize(term)),
); );
@@ -233,8 +256,9 @@ export class YoutubeService {
normalizedTitle.includes(this.normalize(term)), normalizedTitle.includes(this.normalize(term)),
); );
if (badTerm) { if (badTerm) {
score -= 25; score -= 80;
reasons.push(`bad=${badTerm}:-25`); rejected = true;
reasons.push(`bad=${badTerm}:-80 reject`);
} }
if (durationMs && seconds) { if (durationMs && seconds) {
@@ -380,8 +404,8 @@ export class YoutubeService {
return 'YouTube unavailable: private video'; return 'YouTube unavailable: private video';
} }
if (text.includes('Video unavailable')) { if (text.includes('Video unavailable') || text.includes('This video is not available')) {
return 'YouTube unavailable'; return 'YouTube unavailable: selected video unavailable';
} }
if (text.includes('Unable to download webpage')) { if (text.includes('Unable to download webpage')) {
@@ -495,6 +519,8 @@ export class YoutubeService {
'--no-playlist', '--no-playlist',
'--no-cache-dir', '--no-cache-dir',
'--no-cookies-from-browser', '--no-cookies-from-browser',
'-f',
'bestaudio/best',
'--extract-audio', '--extract-audio',
'--audio-format', '--audio-format',
format, format,
+15
View File
@@ -27,9 +27,24 @@ export class TrackEntity {
@Column({ nullable: true }) @Column({ nullable: true })
youtubeUrl?: string; youtubeUrl?: string;
@Column({ nullable: true })
selectedYoutubeTitle?: string;
@Column({ nullable: true })
selectedYoutubeAuthor?: string;
@Column({ nullable: true })
selectedYoutubeScore?: number;
@Column({ nullable: true, type: 'text' })
selectedYoutubeReason?: string;
@Column({ nullable: true, type: 'text' }) @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;
+221 -17
View File
@@ -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 {
@@ -98,7 +124,9 @@ export class TrackService {
try { try {
const parsed = JSON.parse(track.rejectedYoutubeUrls); const parsed = JSON.parse(track.rejectedYoutubeUrls);
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []; return Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string')
: [];
} catch { } catch {
return []; return [];
} }
@@ -108,6 +136,161 @@ 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 rejectCurrentYoutubeCandidate(
track: TrackEntity,
reason: RejectionReason,
): {
rejectedYoutubeUrls: string;
rejectedYoutubeCandidatesJson: string;
} {
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
const rejectedCandidates = this.parseRejectedYoutubeCandidates(track);
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
if (rejectedCandidate) {
if (!rejectedUrls.includes(rejectedCandidate.url)) {
rejectedUrls.push(rejectedCandidate.url);
}
rejectedCandidates.push(rejectedCandidate);
}
return {
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
rejectedYoutubeCandidatesJson:
this.stringifyRejectedYoutubeCandidates(rejectedCandidates),
};
}
private classifyRejection(error?: string): RejectionReason {
if (!error) {
return {
rejectionClass: 'UNKNOWN_DOWNLOAD_ERROR',
rejectionSummary: 'Unknown download error',
};
}
const normalized = error.toLowerCase();
if (normalized.includes('sign in to confirm your age')) {
return {
rejectionClass: 'YOUTUBE_AGE_GATED',
rejectionSummary: 'YouTube video is age-gated',
};
}
if (normalized.includes('private video')) {
return {
rejectionClass: 'YOUTUBE_PRIVATE_VIDEO',
rejectionSummary: 'YouTube video is private',
};
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return {
rejectionClass: 'YOUTUBE_NO_FORMATS',
rejectionSummary: 'No downloadable YouTube audio format',
};
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return {
rejectionClass: 'YOUTUBE_VIDEO_UNAVAILABLE',
rejectionSummary: 'YouTube video is unavailable',
};
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime') ||
normalized.includes('unable to download webpage')
) {
return {
rejectionClass: 'YOUTUBE_EXTRACTION_FAILURE',
rejectionSummary: 'YouTube extraction failed',
};
}
return {
rejectionClass: 'DOWNLOAD_FAILED',
rejectionSummary: 'Download failed',
};
}
async retry(id: number): Promise<void> { async retry(id: number): Promise<void> {
const track = await this.get(id); const track = await this.get(id);
@@ -116,16 +299,19 @@ export class TrackService {
return; return;
} }
const rejectedUrls = this.parseRejectedYoutubeUrls(track); const rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
rejectionClass: 'MANUAL_RETRY',
if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) { rejectionSummary: 'Rejected by manual retry',
rejectedUrls.push(track.youtubeUrl); });
}
await this.update(id, { await this.update(id, {
...track, ...track,
youtubeUrl: null, youtubeUrl: null,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls), selectedYoutubeTitle: null,
selectedYoutubeAuthor: null,
selectedYoutubeScore: null,
selectedYoutubeReason: null,
...rejectedCandidateFields,
downloadAttemptCount: 0, downloadAttemptCount: 0,
error: null, error: null,
status: TrackStatusEnum.New, status: TrackStatusEnum.New,
@@ -161,6 +347,10 @@ export class TrackService {
updatedTrack = { updatedTrack = {
...track, ...track,
youtubeUrl: youtubeMatch.url, youtubeUrl: youtubeMatch.url,
selectedYoutubeTitle: youtubeMatch.title,
selectedYoutubeAuthor: youtubeMatch.author,
selectedYoutubeScore: youtubeMatch.score,
selectedYoutubeReason: youtubeMatch.reason,
status: TrackStatusEnum.Queued, status: TrackStatusEnum.Queued,
}; };
this.logger.debug( this.logger.debug(
@@ -193,7 +383,9 @@ export class TrackService {
const existingJob = await this.trackSearchQueue.getJob(jobId); const existingJob = await this.trackSearchQueue.getJob(jobId);
if (existingJob) { if (existingJob) {
this.logger.warn(`Removing existing search job for track ${id} before enqueue`); this.logger.warn(
`Removing existing search job for track ${id} before enqueue`,
);
await existingJob.remove(); await existingJob.remove();
} }
@@ -212,7 +404,9 @@ export class TrackService {
const existingJob = await this.trackDownloadQueue.getJob(jobId); const existingJob = await this.trackDownloadQueue.getJob(jobId);
if (existingJob) { if (existingJob) {
this.logger.warn(`Removing existing download job for track ${id} before enqueue`); this.logger.warn(
`Removing existing download job for track ${id} before enqueue`,
);
await existingJob.remove(); await existingJob.remove();
} }
@@ -279,13 +473,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.rejectCurrentYoutubeCandidate(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(
@@ -296,7 +494,11 @@ export class TrackService {
await this.update(track.id, { await this.update(track.id, {
...track, ...track,
youtubeUrl: null, youtubeUrl: null,
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls), selectedYoutubeTitle: null,
selectedYoutubeAuthor: null,
selectedYoutubeScore: null,
selectedYoutubeReason: null,
...rejectedCandidateFields,
downloadAttemptCount: nextAttemptCount, downloadAttemptCount: nextAttemptCount,
error: null, error: null,
status: TrackStatusEnum.New, status: TrackStatusEnum.New,
@@ -309,7 +511,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 }),
}; };
@@ -320,7 +522,9 @@ export class TrackService {
const safeArtist = track.artist || 'unknown_artist'; const safeArtist = track.artist || 'unknown_artist';
const safeName = track.name || 'unknown_track'; const safeName = track.name || 'unknown_track';
const fileName = `${safeArtist} - ${safeName}`; const fileName = `${safeArtist} - ${safeName}`;
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`; const format =
this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
return `${this.utilsService.stripFileIllegalChars(fileName)}.${format}`;
} }
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
+1 -1
View File
@@ -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",
+207 -45
View File
@@ -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>
@@ -49,7 +52,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 +95,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 +131,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 +148,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 +175,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 +212,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 +224,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 +262,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 +308,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
+209 -72
View File
@@ -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'
@@ -66,20 +75,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 +157,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 })),
}; };
} }
@@ -133,8 +186,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 +196,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();
} }
@@ -156,11 +209,23 @@ export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
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 +247,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 +257,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 +265,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 +285,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 +295,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,8 +308,12 @@ 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();
@@ -250,6 +325,8 @@ export class AppComponent implements OnDestroy {
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 +347,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 {
@@ -337,6 +422,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 +469,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 +532,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 +546,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 +567,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 +580,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 +619,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 +712,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 +724,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 +732,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 +761,9 @@ export class AppComponent implements OnDestroy {
} }
private saveWorkspaceState(): void { private saveWorkspaceState(): void {
localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState)); localStorage.setItem(
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[];
}
+56
View File
@@ -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;