Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd060cbc9 | ||
|
|
1c332edde8 | ||
|
|
b76a7d5586 | ||
|
|
609ed65f4f | ||
|
|
b52510df67 | ||
|
|
e5d09fe02b | ||
|
|
1fd3eff5b3 | ||
|
|
09c420ab46 | ||
|
|
bf09a151a8 | ||
|
|
f467836046 |
+4
-1
@@ -7,7 +7,10 @@ RUN npm run build
|
|||||||
FROM node:20.20.0-alpine
|
FROM node:20.20.0-alpine
|
||||||
WORKDIR /spooty
|
WORKDIR /spooty
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
|
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl \
|
||||||
|
&& curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
|
||||||
|
&& chmod +x /usr/local/bin/yt-dlp \
|
||||||
|
&& yt-dlp --version \
|
||||||
&& addgroup -S spooty \
|
&& addgroup -S spooty \
|
||||||
&& adduser -S spooty -G spooty
|
&& adduser -S spooty -G spooty
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ import { AuthGuard } from './shared/auth.guard';
|
|||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { ArchiveModule } from './archive/archive.module';
|
import { ArchiveModule } from './archive/archive.module';
|
||||||
import { OperationsModule } from './operations/operations.module';
|
import { OperationsModule } from './operations/operations.module';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
|
||||||
|
function resolveFrontendRoot(fePath: string | undefined): string {
|
||||||
|
const configuredRoot = resolve(__dirname, fePath || '../frontend/browser');
|
||||||
|
const candidateRoots = [
|
||||||
|
configuredRoot,
|
||||||
|
resolve(configuredRoot, 'browser'),
|
||||||
|
resolve(configuredRoot, '..'),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidateRoot of candidateRoots) {
|
||||||
|
if (fs.existsSync(resolve(candidateRoot, 'index.html'))) {
|
||||||
|
return candidateRoot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return configuredRoot;
|
||||||
|
}
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -38,8 +56,7 @@ import { OperationsModule } from './operations/operations.module';
|
|||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => [
|
useFactory: async (configService: ConfigService) => [
|
||||||
{
|
{
|
||||||
rootPath: resolve(
|
rootPath: resolveFrontendRoot(
|
||||||
__dirname,
|
|
||||||
configService.get<string>(EnvironmentEnum.FE_PATH),
|
configService.get<string>(EnvironmentEnum.FE_PATH),
|
||||||
),
|
),
|
||||||
exclude: ['/api/(.*)'],
|
exclude: ['/api/(.*)'],
|
||||||
|
|||||||
+14
-3
@@ -3,7 +3,7 @@ import { ValidationPipe } from '@nestjs/common';
|
|||||||
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { resolve } from 'path';
|
import { dirname, resolve } from 'path';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
@@ -17,11 +17,17 @@ async function bootstrap() {
|
|||||||
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderName = resolve(
|
const downloadsPath = resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
||||||
);
|
);
|
||||||
fs.mkdirSync(folderName, { recursive: true });
|
fs.mkdirSync(downloadsPath, { recursive: true });
|
||||||
|
|
||||||
|
const dbPath = resolve(
|
||||||
|
__dirname,
|
||||||
|
process.env[EnvironmentEnum.DB_PATH] || './config/db.sqlite',
|
||||||
|
);
|
||||||
|
fs.mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
if (envFlag(EnvironmentEnum.REDIS_RUN)) {
|
if (envFlag(EnvironmentEnum.REDIS_RUN)) {
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +66,11 @@ async function bootstrap() {
|
|||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
crossOriginResourcePolicy: false,
|
crossOriginResourcePolicy: false,
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
upgradeInsecureRequests: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="boot-status">Loading Jarri Spooty...</div>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,38 @@
|
|||||||
|
import { isDevMode } from '@angular/core';
|
||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { AppComponent } from './app/app.component';
|
import { AppComponent } from './app/app.component';
|
||||||
import { devTools } from '@ngneat/elf-devtools';
|
import { devTools } from '@ngneat/elf-devtools';
|
||||||
|
|
||||||
|
function setBootStatus(message: string): void {
|
||||||
|
document.getElementById('boot-status')!.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBootStatus('Starting frontend bundle...');
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
setBootStatus(`Frontend error: ${event.message || 'unknown'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
setBootStatus(`Frontend rejection: ${String(event.reason || 'unknown')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootTimeout = window.setTimeout(() => {
|
||||||
|
setBootStatus('Bootstrap still running...');
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, appConfig)
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.then(() => {
|
||||||
devTools();
|
window.clearTimeout(bootTimeout);
|
||||||
|
document.getElementById('boot-status')?.remove();
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
window.clearTimeout(bootTimeout);
|
||||||
|
console.error(err);
|
||||||
|
setBootStatus(`Bootstrap failed: ${String(err)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDevMode()) {
|
||||||
|
devTools();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user