Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd060cbc9 | ||
|
|
1c332edde8 | ||
|
|
b76a7d5586 | ||
|
|
609ed65f4f | ||
|
|
b52510df67 | ||
|
|
e5d09fe02b | ||
|
|
1fd3eff5b3 | ||
|
|
09c420ab46 | ||
|
|
bf09a151a8 | ||
|
|
f467836046 | ||
|
|
75129db86c | ||
|
|
8b79371d36 | ||
|
|
dca82a1c54 |
+4
-1
@@ -7,7 +7,10 @@ RUN npm run build
|
||||
FROM node:20.20.0-alpine
|
||||
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 \
|
||||
&& 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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -31,7 +48,10 @@ This hardened branch focuses on:
|
||||
- Reduced unused dependency surface
|
||||
- Hardened filename, cover-art, subprocess, and websocket boundaries
|
||||
- Workspace Archive Browser MVP with scoped read-only archive listing
|
||||
- Workspace Candidate Inspector MVP for selected track details
|
||||
- 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 Workspace tabs for Intake, Archive, and Diagnostics layouts
|
||||
|
||||
@@ -53,11 +73,15 @@ This hardened branch focuses on:
|
||||
- Automatic YouTube retry/fallback handling
|
||||
- Failed YouTube candidate rejection memory
|
||||
- Deterministic yt-dlp error classification
|
||||
- Candidate scoring visibility
|
||||
- Candidate selection reasoning
|
||||
- Candidate provenance persistence
|
||||
- Hardened cover-art validation and embedding
|
||||
- Explicit client-facing websocket payload shaping
|
||||
- Draggable/resizable Workspace UI
|
||||
- Archive Browser panel for downloaded file inspection
|
||||
- Candidate Inspector panel for selected track diagnostics
|
||||
- Operations Snapshot diagnostics API
|
||||
- Frontend-persisted archive destination input
|
||||
- Per-tab workspace layout presets
|
||||
- Read-only archive search, sort, and grouped file inspection
|
||||
@@ -66,7 +90,7 @@ This hardened branch focuses on:
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -79,11 +103,11 @@ Each tab has its own persisted panel layout in browser localStorage. The old sin
|
||||
Workspace panels:
|
||||
|
||||
- Source Intake: Spotify connection, URL queueing, and a frontend-persisted Archive destination field
|
||||
- Queue Observatory: Spotify, playlist, single-song, and archive-run metrics
|
||||
- Queue Observatory: Spotify, playlist, single-song, archive, and runtime metrics
|
||||
- Playlist History: existing playlist rows and delete completed/delete failed controls
|
||||
- Single Songs: existing single-track queue rows
|
||||
- Archive Browser: read-only browser-style listing of downloaded files inside the configured downloads root, with search, sort, file count, total size, and top-level folder grouping
|
||||
- Candidate Inspector: selected track details including Spotify URL, selected YouTube candidate, status, error, retry attempts, rejected candidates when available, and a Back/Clear selection action
|
||||
- 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -144,23 +201,21 @@ Copy:
|
||||
|
||||
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'
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
EOT
|
||||
sudo tee /etc/tokens/spotify.env > /dev/null <<'EOT'
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
EOT
|
||||
|
||||
sudo chown root:$USER /etc/tokens/spotify.env
|
||||
sudo chmod 640 /etc/tokens/spotify.env
|
||||
~~~
|
||||
sudo chown root:$USER /etc/tokens/spotify.env
|
||||
sudo chmod 640 /etc/tokens/spotify.env
|
||||
|
||||
Never commit this file.
|
||||
|
||||
---
|
||||
|
||||
## 3. Export YouTube Cookies (Recommended)
|
||||
## 3. Export YouTube Cookies Recommended
|
||||
|
||||
YouTube increasingly rate-limits or age-gates anonymous downloads.
|
||||
|
||||
@@ -168,18 +223,15 @@ Export a Netscape-format `cookies.txt` from a logged-in browser session.
|
||||
|
||||
Recommended storage:
|
||||
|
||||
~~~bash
|
||||
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
|
||||
sudo chown root:$USER /etc/tokens/youtube.cookies.txt
|
||||
sudo chmod 640 /etc/tokens/youtube.cookies.txt
|
||||
~~~
|
||||
sudo cp cookies.txt /etc/tokens/youtube.cookies.txt
|
||||
sudo chown root:$USER /etc/tokens/youtube.cookies.txt
|
||||
sudo chmod 640 /etc/tokens/youtube.cookies.txt
|
||||
|
||||
---
|
||||
|
||||
# Docker Run
|
||||
|
||||
~~~bash
|
||||
docker run --rm -p 3000:3000 \
|
||||
docker run --rm -p 3000:3000 \
|
||||
--env-file /etc/tokens/spotify.env \
|
||||
-e SPOTIFY_REDIRECT_URI='http://127.0.0.1:3000/api/spotify/callback' \
|
||||
-e AUTH_ENABLED=true \
|
||||
@@ -191,7 +243,6 @@ docker run --rm -p 3000:3000 \
|
||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||
jarri-spooty:local
|
||||
~~~
|
||||
|
||||
Open:
|
||||
|
||||
@@ -209,7 +260,7 @@ Then:
|
||||
|
||||
# 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:
|
||||
|
||||
@@ -242,10 +293,8 @@ Aggressive YouTube access can trigger:
|
||||
|
||||
Recommended safe pacing:
|
||||
|
||||
~~~bash
|
||||
-e YT_SEARCH_DELAY_MS=7000
|
||||
-e YT_DOWNLOADS_PER_MINUTE=6
|
||||
~~~
|
||||
-e YT_SEARCH_DELAY_MS=7000
|
||||
-e YT_DOWNLOADS_PER_MINUTE=6
|
||||
|
||||
The hardened branch also includes additional internal pacing and retry coordination to reduce:
|
||||
|
||||
@@ -269,15 +318,13 @@ Never commit:
|
||||
|
||||
Recommended `.gitignore` additions:
|
||||
|
||||
~~~gitignore
|
||||
downloads/
|
||||
config/
|
||||
spooty-config/
|
||||
*.sqlite
|
||||
cookies.txt
|
||||
.env
|
||||
.env.local
|
||||
~~~
|
||||
downloads/
|
||||
config/
|
||||
spooty-config/
|
||||
*.sqlite
|
||||
cookies.txt
|
||||
.env
|
||||
.env.local
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "spooty",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spooty",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"workspaces": [
|
||||
"src/backend",
|
||||
"src/frontend"
|
||||
@@ -25508,7 +25508,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"src/backend": {
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@nestjs/bullmq": "^10.2.3",
|
||||
@@ -25571,7 +25571,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"src/frontend": {
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^19.2.22",
|
||||
"@angular/common": "^19.2.22",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"name": "spooty",
|
||||
"workspaces": [
|
||||
"src/backend",
|
||||
|
||||
@@ -9,7 +9,10 @@ TOKEN="${SPOOTY_AUTH_TOKEN:-test-token}"
|
||||
DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}"
|
||||
CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}"
|
||||
ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}"
|
||||
|
||||
YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}"
|
||||
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}"
|
||||
|
||||
cd "$PROJECT"
|
||||
@@ -28,6 +31,18 @@ if [[ "${1:-}" == "--build" ]]; then
|
||||
$DOCKER build -t "$IMAGE" .
|
||||
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 =="
|
||||
$DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
|
||||
@@ -37,8 +52,6 @@ if [[ -n "$PORT_CONTAINERS" ]]; then
|
||||
$DOCKER rm -f $PORT_CONTAINERS
|
||||
fi
|
||||
|
||||
mkdir -p "$DOWNLOADS" "$CONFIG_DIR"
|
||||
|
||||
echo "== Starting Jarri Spooty =="
|
||||
$DOCKER run -d \
|
||||
--name "$CONTAINER" \
|
||||
@@ -52,7 +65,7 @@ $DOCKER run -d \
|
||||
-e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \
|
||||
-e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \
|
||||
-v "$DOWNLOADS:/spooty/backend/downloads" \
|
||||
-v "$YT_COOKIES_FILE_HOST:$YT_COOKIES_FILE_CONTAINER:ro" \
|
||||
-v "$YT_COOKIES_FILE_STAGED:$YT_COOKIES_FILE_CONTAINER" \
|
||||
-v "$CONFIG_DIR:/spooty/backend/config" \
|
||||
"$IMAGE"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -16,6 +16,24 @@ import { AuthGuard } from './shared/auth.guard';
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||
import { ArchiveModule } from './archive/archive.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({
|
||||
imports: [
|
||||
@@ -38,8 +56,7 @@ import { OperationsModule } from './operations/operations.module';
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => [
|
||||
{
|
||||
rootPath: resolve(
|
||||
__dirname,
|
||||
rootPath: resolveFrontendRoot(
|
||||
configService.get<string>(EnvironmentEnum.FE_PATH),
|
||||
),
|
||||
exclude: ['/api/(.*)'],
|
||||
|
||||
+14
-3
@@ -3,7 +3,7 @@ import { ValidationPipe } from '@nestjs/common';
|
||||
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||
import { AppModule } from './app.module';
|
||||
import * as fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import helmet from 'helmet';
|
||||
import { EnvironmentEnum } from './environmentEnum';
|
||||
@@ -17,11 +17,17 @@ async function bootstrap() {
|
||||
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
||||
}
|
||||
|
||||
const folderName = resolve(
|
||||
const downloadsPath = resolve(
|
||||
__dirname,
|
||||
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)) {
|
||||
try {
|
||||
@@ -60,6 +66,11 @@ async function bootstrap() {
|
||||
app.use(
|
||||
helmet({
|
||||
crossOriginResourcePolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
upgradeInsecureRequests: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ export class YoutubeService {
|
||||
|
||||
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
|
||||
const searchTarget = `ytsearch15:${query}`;
|
||||
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
|
||||
const args = [
|
||||
'--dump-single-json',
|
||||
'--skip-download',
|
||||
@@ -168,6 +169,10 @@ export class YoutubeService {
|
||||
searchTarget,
|
||||
];
|
||||
|
||||
if (cookiesFile) {
|
||||
args.splice(args.length - 1, 0, '--cookies', cookiesFile);
|
||||
}
|
||||
|
||||
const output = await this.runYtDlpForOutput(args);
|
||||
let parsed: any;
|
||||
|
||||
|
||||
@@ -200,7 +200,18 @@ export class TrackService {
|
||||
};
|
||||
}
|
||||
|
||||
private rejectCurrentYoutubeCandidate(
|
||||
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,
|
||||
): {
|
||||
@@ -212,7 +223,10 @@ export class TrackService {
|
||||
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
|
||||
|
||||
if (rejectedCandidate) {
|
||||
if (!rejectedUrls.includes(rejectedCandidate.url)) {
|
||||
if (
|
||||
this.isPermanentYoutubeCandidateRejection(reason) &&
|
||||
!rejectedUrls.includes(rejectedCandidate.url)
|
||||
) {
|
||||
rejectedUrls.push(rejectedCandidate.url);
|
||||
}
|
||||
|
||||
@@ -299,11 +313,6 @@ export class TrackService {
|
||||
return;
|
||||
}
|
||||
|
||||
const rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
|
||||
rejectionClass: 'MANUAL_RETRY',
|
||||
rejectionSummary: 'Rejected by manual retry',
|
||||
});
|
||||
|
||||
await this.update(id, {
|
||||
...track,
|
||||
youtubeUrl: null,
|
||||
@@ -311,7 +320,9 @@ export class TrackService {
|
||||
selectedYoutubeAuthor: null,
|
||||
selectedYoutubeScore: null,
|
||||
selectedYoutubeReason: null,
|
||||
...rejectedCandidateFields,
|
||||
rejectedYoutubeUrls: track.rejectedYoutubeUrls || JSON.stringify([]),
|
||||
rejectedYoutubeCandidatesJson:
|
||||
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
|
||||
downloadAttemptCount: 0,
|
||||
error: null,
|
||||
status: TrackStatusEnum.New,
|
||||
@@ -476,7 +487,7 @@ export class TrackService {
|
||||
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
||||
const maxAttempts = this.getMaxDownloadAttempts();
|
||||
const rejectedCandidateFields = error
|
||||
? this.rejectCurrentYoutubeCandidate(track, this.classifyRejection(error))
|
||||
? this.recordCurrentYoutubeCandidateFailure(track, this.classifyRejection(error))
|
||||
: {
|
||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
|
||||
this.parseRejectedYoutubeUrls(track),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -32,6 +32,13 @@
|
||||
</button>
|
||||
</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">
|
||||
<div class="panel-picker">
|
||||
<div class="select">
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface SpootyWorkspaceTabsState {
|
||||
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
|
||||
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
|
||||
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
|
||||
const AUTH_TOKEN_STORAGE_KEY = 'spooty_auth_token';
|
||||
const GRID_SIZE = 12;
|
||||
const MIN_PANEL_WIDTH = 260;
|
||||
const MIN_PANEL_HEIGHT = 160;
|
||||
@@ -176,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 {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY);
|
||||
const raw = getStorageItem(WORKSPACE_STORAGE_KEY);
|
||||
|
||||
if (!raw) {
|
||||
return defaultWorkspaceState();
|
||||
@@ -204,7 +222,7 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
|
||||
|
||||
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
||||
try {
|
||||
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY);
|
||||
const raw = getStorageItem(WORKSPACE_TABS_STORAGE_KEY);
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
||||
@@ -318,7 +336,8 @@ export class AppComponent implements OnDestroy {
|
||||
selectedTrack$ = this.trackService.selectedTrack$;
|
||||
version = this.versionService.getVersion();
|
||||
spotifyConnected = false;
|
||||
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || '';
|
||||
authTokenPresent = Boolean(getStorageItem(AUTH_TOKEN_STORAGE_KEY));
|
||||
archiveDestination = getStorageItem(ARCHIVE_DESTINATION_KEY) || '';
|
||||
archiveListing?: ArchiveListing;
|
||||
archiveLoading = false;
|
||||
archiveError = '';
|
||||
@@ -369,7 +388,8 @@ export class AppComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('spooty_auth_token', token);
|
||||
setStorageItem(AUTH_TOKEN_STORAGE_KEY, token);
|
||||
this.authTokenPresent = true;
|
||||
url.searchParams.delete('token');
|
||||
window.history.replaceState({}, document.title, url.toString());
|
||||
}
|
||||
@@ -377,7 +397,13 @@ export class AppComponent implements OnDestroy {
|
||||
checkSpotifyStatus(): void {
|
||||
this.http
|
||||
.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 {
|
||||
@@ -403,7 +429,7 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
saveArchiveDestination(): void {
|
||||
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
|
||||
setStorageItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
|
||||
}
|
||||
|
||||
refreshArchive(): void {
|
||||
@@ -761,7 +787,7 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private saveWorkspaceState(): void {
|
||||
localStorage.setItem(
|
||||
setStorageItem(
|
||||
WORKSPACE_TABS_STORAGE_KEY,
|
||||
JSON.stringify(this.workspaceTabsState),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,13 @@ import { HttpInterceptorFn } from '@angular/common/http';
|
||||
const TOKEN_STORAGE_KEY = 'spooty_auth_token';
|
||||
|
||||
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) {
|
||||
return next(req);
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
withUIEntities
|
||||
} from "@ngneat/elf-entities";
|
||||
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 {Socket} from "ngx-socket-io";
|
||||
import {Playlist} from "../models/playlist";
|
||||
@@ -115,6 +115,14 @@ export class PlaylistService {
|
||||
|
||||
fetch(): void {
|
||||
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(
|
||||
setEntities(data),
|
||||
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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="boot-status">Loading Jarri Spooty...</div>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,38 @@
|
||||
import { isDevMode } from '@angular/core';
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { AppComponent } from './app/app.component';
|
||||
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)
|
||||
.catch((err) => console.error(err));
|
||||
devTools();
|
||||
.then(() => {
|
||||
window.clearTimeout(bootTimeout);
|
||||
document.getElementById('boot-status')?.remove();
|
||||
})
|
||||
.catch((err) => {
|
||||
window.clearTimeout(bootTimeout);
|
||||
console.error(err);
|
||||
setBootStatus(`Bootstrap failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
if (isDevMode()) {
|
||||
devTools();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user