11 Commits
12 changed files with 167 additions and 29 deletions
+4 -1
View File
@@ -7,7 +7,10 @@ RUN npm run build
FROM node:20.20.0-alpine FROM node:20.20.0-alpine
WORKDIR /spooty WORKDIR /spooty
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \ RUN apk add --no-cache ffmpeg redis python3 py3-pip curl \
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
&& chmod +x /usr/local/bin/yt-dlp \
&& yt-dlp --version \
&& addgroup -S spooty \ && addgroup -S spooty \
&& adduser -S spooty -G spooty && adduser -S spooty -G spooty
+16 -3
View File
@@ -9,7 +9,10 @@ TOKEN="${SPOOTY_AUTH_TOKEN:-test-token}"
DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}" DOWNLOADS="${DOWNLOADS:-$PROJECT/test-downloads}"
CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}" CONFIG_DIR="${CONFIG_DIR:-$PROJECT/spooty-config}"
ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}" ENV_FILE="${ENV_FILE:-/etc/tokens/spotify.env}"
YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}" YT_COOKIES_FILE_HOST="${YT_COOKIES_FILE_HOST:-/etc/tokens/youtube.cookies.txt}"
RUNTIME_SECRETS_DIR="${RUNTIME_SECRETS_DIR:-$PROJECT/spooty-runtime-secrets}"
YT_COOKIES_FILE_STAGED="${YT_COOKIES_FILE_STAGED:-$RUNTIME_SECRETS_DIR/youtube.cookies.txt}"
YT_COOKIES_FILE_CONTAINER="${YT_COOKIES_FILE_CONTAINER:-/spooty/config/youtube.cookies.txt}" YT_COOKIES_FILE_CONTAINER="${YT_COOKIES_FILE_CONTAINER:-/spooty/config/youtube.cookies.txt}"
cd "$PROJECT" cd "$PROJECT"
@@ -28,6 +31,18 @@ if [[ "${1:-}" == "--build" ]]; then
$DOCKER build -t "$IMAGE" . $DOCKER build -t "$IMAGE" .
fi fi
echo "== Preparing runtime directories =="
mkdir -p "$DOWNLOADS" "$CONFIG_DIR" "$RUNTIME_SECRETS_DIR"
if [[ -f "$YT_COOKIES_FILE_HOST" ]]; then
echo "== Staging YouTube cookies =="
cp "$YT_COOKIES_FILE_HOST" "$YT_COOKIES_FILE_STAGED"
chmod 0666 "$YT_COOKIES_FILE_STAGED"
else
echo "ERROR: YouTube cookies file not found: $YT_COOKIES_FILE_HOST" >&2
exit 1
fi
echo "== Removing named container if present ==" echo "== Removing named container if present =="
$DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true $DOCKER rm -f "$CONTAINER" >/dev/null 2>&1 || true
@@ -37,8 +52,6 @@ if [[ -n "$PORT_CONTAINERS" ]]; then
$DOCKER rm -f $PORT_CONTAINERS $DOCKER rm -f $PORT_CONTAINERS
fi fi
mkdir -p "$DOWNLOADS" "$CONFIG_DIR"
echo "== Starting Jarri Spooty ==" echo "== Starting Jarri Spooty =="
$DOCKER run -d \ $DOCKER run -d \
--name "$CONTAINER" \ --name "$CONTAINER" \
@@ -52,7 +65,7 @@ $DOCKER run -d \
-e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \ -e YT_DOWNLOAD_FALLBACK_ATTEMPTS=3 \
-e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \ -e YT_COOKIES_FILE="$YT_COOKIES_FILE_CONTAINER" \
-v "$DOWNLOADS:/spooty/backend/downloads" \ -v "$DOWNLOADS:/spooty/backend/downloads" \
-v "$YT_COOKIES_FILE_HOST:$YT_COOKIES_FILE_CONTAINER:ro" \ -v "$YT_COOKIES_FILE_STAGED:$YT_COOKIES_FILE_CONTAINER" \
-v "$CONFIG_DIR:/spooty/backend/config" \ -v "$CONFIG_DIR:/spooty/backend/config" \
"$IMAGE" "$IMAGE"
+19 -2
View File
@@ -16,6 +16,24 @@ import { AuthGuard } from './shared/auth.guard';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ArchiveModule } from './archive/archive.module'; import { ArchiveModule } from './archive/archive.module';
import { OperationsModule } from './operations/operations.module'; import { OperationsModule } from './operations/operations.module';
import * as fs from 'fs';
function resolveFrontendRoot(fePath: string | undefined): string {
const configuredRoot = resolve(__dirname, fePath || '../frontend/browser');
const candidateRoots = [
configuredRoot,
resolve(configuredRoot, 'browser'),
resolve(configuredRoot, '..'),
];
for (const candidateRoot of candidateRoots) {
if (fs.existsSync(resolve(candidateRoot, 'index.html'))) {
return candidateRoot;
}
}
return configuredRoot;
}
@Module({ @Module({
imports: [ imports: [
@@ -38,8 +56,7 @@ import { OperationsModule } from './operations/operations.module';
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => [ useFactory: async (configService: ConfigService) => [
{ {
rootPath: resolve( rootPath: resolveFrontendRoot(
__dirname,
configService.get<string>(EnvironmentEnum.FE_PATH), configService.get<string>(EnvironmentEnum.FE_PATH),
), ),
exclude: ['/api/(.*)'], exclude: ['/api/(.*)'],
+14 -3
View File
@@ -3,7 +3,7 @@ import { ValidationPipe } from '@nestjs/common';
import { HttpExceptionFilter } from './shared/filters/http-exception.filter'; import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import * as fs from 'fs'; import * as fs from 'fs';
import { resolve } from 'path'; import { dirname, resolve } from 'path';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import helmet from 'helmet'; import helmet from 'helmet';
import { EnvironmentEnum } from './environmentEnum'; import { EnvironmentEnum } from './environmentEnum';
@@ -17,11 +17,17 @@ async function bootstrap() {
throw new Error('DOWNLOADS_PATH environment variable is missing'); throw new Error('DOWNLOADS_PATH environment variable is missing');
} }
const folderName = resolve( const downloadsPath = resolve(
__dirname, __dirname,
process.env[EnvironmentEnum.DOWNLOADS_PATH], process.env[EnvironmentEnum.DOWNLOADS_PATH],
); );
fs.mkdirSync(folderName, { recursive: true }); fs.mkdirSync(downloadsPath, { recursive: true });
const dbPath = resolve(
__dirname,
process.env[EnvironmentEnum.DB_PATH] || './config/db.sqlite',
);
fs.mkdirSync(dirname(dbPath), { recursive: true });
if (envFlag(EnvironmentEnum.REDIS_RUN)) { if (envFlag(EnvironmentEnum.REDIS_RUN)) {
try { try {
@@ -60,6 +66,11 @@ async function bootstrap() {
app.use( app.use(
helmet({ helmet({
crossOriginResourcePolicy: false, crossOriginResourcePolicy: false,
contentSecurityPolicy: {
directives: {
upgradeInsecureRequests: null,
},
},
}), }),
); );
@@ -155,6 +155,7 @@ export class YoutubeService {
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> { private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`; const searchTarget = `ytsearch15:${query}`;
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
const args = [ const args = [
'--dump-single-json', '--dump-single-json',
'--skip-download', '--skip-download',
@@ -168,6 +169,10 @@ export class YoutubeService {
searchTarget, searchTarget,
]; ];
if (cookiesFile) {
args.splice(args.length - 1, 0, '--cookies', cookiesFile);
}
const output = await this.runYtDlpForOutput(args); const output = await this.runYtDlpForOutput(args);
let parsed: any; let parsed: any;
+20 -9
View File
@@ -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, track: TrackEntity,
reason: RejectionReason, reason: RejectionReason,
): { ): {
@@ -212,7 +223,10 @@ export class TrackService {
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason); const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
if (rejectedCandidate) { if (rejectedCandidate) {
if (!rejectedUrls.includes(rejectedCandidate.url)) { if (
this.isPermanentYoutubeCandidateRejection(reason) &&
!rejectedUrls.includes(rejectedCandidate.url)
) {
rejectedUrls.push(rejectedCandidate.url); rejectedUrls.push(rejectedCandidate.url);
} }
@@ -299,11 +313,6 @@ export class TrackService {
return; return;
} }
const rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
rejectionClass: 'MANUAL_RETRY',
rejectionSummary: 'Rejected by manual retry',
});
await this.update(id, { await this.update(id, {
...track, ...track,
youtubeUrl: null, youtubeUrl: null,
@@ -311,7 +320,9 @@ export class TrackService {
selectedYoutubeAuthor: null, selectedYoutubeAuthor: null,
selectedYoutubeScore: null, selectedYoutubeScore: null,
selectedYoutubeReason: null, selectedYoutubeReason: null,
...rejectedCandidateFields, rejectedYoutubeUrls: track.rejectedYoutubeUrls || JSON.stringify([]),
rejectedYoutubeCandidatesJson:
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
downloadAttemptCount: 0, downloadAttemptCount: 0,
error: null, error: null,
status: TrackStatusEnum.New, status: TrackStatusEnum.New,
@@ -476,7 +487,7 @@ export class TrackService {
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1; const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
const maxAttempts = this.getMaxDownloadAttempts(); const maxAttempts = this.getMaxDownloadAttempts();
const rejectedCandidateFields = error const rejectedCandidateFields = error
? this.rejectCurrentYoutubeCandidate(track, this.classifyRejection(error)) ? this.recordCurrentYoutubeCandidateFailure(track, this.classifyRejection(error))
: { : {
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls( rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
this.parseRejectedYoutubeUrls(track), this.parseRejectedYoutubeUrls(track),
+7
View File
@@ -32,6 +32,13 @@
</button> </button>
</nav> </nav>
<section *ngIf="!authTokenPresent" class="workspace-controls">
<div class="truth-pill">
Auth token required. Open once with `?token=your_token_here` and it will
be stored in your browser.
</div>
</section>
<section class="workspace-controls"> <section class="workspace-controls">
<div class="panel-picker"> <div class="panel-picker">
<div class="select"> <div class="select">
+33 -7
View File
@@ -53,6 +53,7 @@ export interface SpootyWorkspaceTabsState {
const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1'; const WORKSPACE_STORAGE_KEY = 'jarri_spooty_workspace_state_v1';
const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1'; const WORKSPACE_TABS_STORAGE_KEY = 'jarri_spooty_workspace_tabs_v1';
const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1'; const ARCHIVE_DESTINATION_KEY = 'jarri_spooty_archive_destination_v1';
const AUTH_TOKEN_STORAGE_KEY = 'spooty_auth_token';
const GRID_SIZE = 12; const GRID_SIZE = 12;
const MIN_PANEL_WIDTH = 260; const MIN_PANEL_WIDTH = 260;
const MIN_PANEL_HEIGHT = 160; const MIN_PANEL_HEIGHT = 160;
@@ -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 { export function loadWorkspaceState(): SpootyWorkspaceState {
try { try {
const raw = localStorage.getItem(WORKSPACE_STORAGE_KEY); const raw = getStorageItem(WORKSPACE_STORAGE_KEY);
if (!raw) { if (!raw) {
return defaultWorkspaceState(); return defaultWorkspaceState();
@@ -204,7 +222,7 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState { export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
try { try {
const raw = localStorage.getItem(WORKSPACE_TABS_STORAGE_KEY); const raw = getStorageItem(WORKSPACE_TABS_STORAGE_KEY);
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState; const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
@@ -318,7 +336,8 @@ export class AppComponent implements OnDestroy {
selectedTrack$ = this.trackService.selectedTrack$; selectedTrack$ = this.trackService.selectedTrack$;
version = this.versionService.getVersion(); version = this.versionService.getVersion();
spotifyConnected = false; spotifyConnected = false;
archiveDestination = localStorage.getItem(ARCHIVE_DESTINATION_KEY) || ''; authTokenPresent = Boolean(getStorageItem(AUTH_TOKEN_STORAGE_KEY));
archiveDestination = getStorageItem(ARCHIVE_DESTINATION_KEY) || '';
archiveListing?: ArchiveListing; archiveListing?: ArchiveListing;
archiveLoading = false; archiveLoading = false;
archiveError = ''; archiveError = '';
@@ -369,7 +388,8 @@ export class AppComponent implements OnDestroy {
return; return;
} }
localStorage.setItem('spooty_auth_token', token); setStorageItem(AUTH_TOKEN_STORAGE_KEY, token);
this.authTokenPresent = true;
url.searchParams.delete('token'); url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.toString()); window.history.replaceState({}, document.title, url.toString());
} }
@@ -377,7 +397,13 @@ export class AppComponent implements OnDestroy {
checkSpotifyStatus(): void { checkSpotifyStatus(): void {
this.http this.http
.get<{ connected: boolean }>('/api/spotify/status') .get<{ connected: boolean }>('/api/spotify/status')
.subscribe((status) => (this.spotifyConnected = status.connected)); .subscribe({
next: (status) => (this.spotifyConnected = status.connected),
error: (error) => {
console.error('Failed to check Spotify status', error);
this.spotifyConnected = false;
},
});
} }
connectSpotify(): void { connectSpotify(): void {
@@ -403,7 +429,7 @@ export class AppComponent implements OnDestroy {
} }
saveArchiveDestination(): void { saveArchiveDestination(): void {
localStorage.setItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination); setStorageItem(ARCHIVE_DESTINATION_KEY, this.archiveDestination);
} }
refreshArchive(): void { refreshArchive(): void {
@@ -761,7 +787,7 @@ export class AppComponent implements OnDestroy {
} }
private saveWorkspaceState(): void { private saveWorkspaceState(): void {
localStorage.setItem( setStorageItem(
WORKSPACE_TABS_STORAGE_KEY, WORKSPACE_TABS_STORAGE_KEY,
JSON.stringify(this.workspaceTabsState), JSON.stringify(this.workspaceTabsState),
); );
@@ -3,7 +3,13 @@ import { HttpInterceptorFn } from '@angular/common/http';
const TOKEN_STORAGE_KEY = 'spooty_auth_token'; const TOKEN_STORAGE_KEY = 'spooty_auth_token';
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => { export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = localStorage.getItem(TOKEN_STORAGE_KEY); let token: string | null = null;
try {
token = localStorage.getItem(TOKEN_STORAGE_KEY);
} catch (error) {
console.error('Failed to read auth token from localStorage', error);
}
if (!token) { if (!token) {
return next(req); return next(req);
@@ -11,7 +11,7 @@ import {
withUIEntities withUIEntities
} from "@ngneat/elf-entities"; } from "@ngneat/elf-entities";
import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests"; import {joinRequestResult, trackRequestResult} from "@ngneat/elf-requests";
import {combineLatest, filter, first, map, Observable, of, switchMap, tap} from "rxjs"; import {catchError, combineLatest, filter, first, map, Observable, of, switchMap, tap} from "rxjs";
import {TrackService} from "./track.service"; import {TrackService} from "./track.service";
import {Socket} from "ngx-socket-io"; import {Socket} from "ngx-socket-io";
import {Playlist} from "../models/playlist"; import {Playlist} from "../models/playlist";
@@ -115,6 +115,14 @@ export class PlaylistService {
fetch(): void { fetch(): void {
this.http.get<Playlist[]>(ENDPOINT).pipe( this.http.get<Playlist[]>(ENDPOINT).pipe(
catchError((error) => {
console.error('Failed to fetch playlists', error);
this.store.update(
setEntities([]),
setEntities([], { ref: UIEntitiesRef }),
);
return of([]);
}),
tap((data: Playlist[]) => this.store.update( tap((data: Playlist[]) => this.store.update(
setEntities(data), setEntities(data),
setEntities(data.map(item => ({id: item.id, collapsed: false})), {ref: UIEntitiesRef}) setEntities(data.map(item => ({id: item.id, collapsed: false})), {ref: UIEntitiesRef})
+1
View File
@@ -8,6 +8,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico"> <link rel="icon" type="image/x-icon" href="favicon.ico">
</head> </head>
<body> <body>
<div id="boot-status">Loading Jarri Spooty...</div>
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>
+32 -2
View File
@@ -1,8 +1,38 @@
import { isDevMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config'; import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
import { devTools } from '@ngneat/elf-devtools'; import { devTools } from '@ngneat/elf-devtools';
function setBootStatus(message: string): void {
document.getElementById('boot-status')!.textContent = message;
}
setBootStatus('Starting frontend bundle...');
window.addEventListener('error', (event) => {
setBootStatus(`Frontend error: ${event.message || 'unknown'}`);
});
window.addEventListener('unhandledrejection', (event) => {
setBootStatus(`Frontend rejection: ${String(event.reason || 'unknown')}`);
});
const bootTimeout = window.setTimeout(() => {
setBootStatus('Bootstrap still running...');
}, 3000);
bootstrapApplication(AppComponent, appConfig) bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err)); .then(() => {
devTools(); window.clearTimeout(bootTimeout);
document.getElementById('boot-status')?.remove();
})
.catch((err) => {
window.clearTimeout(bootTimeout);
console.error(err);
setBootStatus(`Bootstrap failed: ${String(err)}`);
});
if (isDevMode()) {
devTools();
}