From 48517a5a851aeed14fe6310427c967daa1630bfd Mon Sep 17 00:00:00 2001 From: Tor Wingalen Date: Fri, 15 May 2026 20:47:25 +0200 Subject: [PATCH] Use direct yt-dlp CLI with deterministic fallback handling --- .dockerignore | 13 ++ .gitignore | 1 + Dockerfile | 2 +- src/backend/src/playlist/playlist.service.ts | 105 +++++++++ src/backend/src/shared/spotify-api.service.ts | 216 ++++++++++++++++++ src/backend/src/shared/spotify.service.ts | 11 + src/backend/src/shared/youtube.service.ts | 164 ++++++++++--- src/backend/src/track/track.entity.ts | 6 + src/backend/src/track/track.service.ts | 82 ++++++- src/frontend/src/app/app.component.html | 144 ++++++------ src/frontend/src/app/app.component.scss | 129 +++++++++++ .../playlist-box/playlist-box.component.html | 80 +++++-- .../playlist-box/playlist-box.component.scss | 72 +++++- .../playlist-box/playlist-box.component.ts | 31 ++- 14 files changed, 913 insertions(+), 143 deletions(-) diff --git a/.dockerignore b/.dockerignore index db9b566..7617d12 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,16 @@ config .env.* README.md assets + +# Local runtime/download artifacts +test-downloads +downloads +config +*.sqlite +*.sqlite3 +*.db +*.mp3 +*.m4a +*.webm +*.part +spooty-config diff --git a/.gitignore b/.gitignore index 555f5e6..a0c1e3f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ .netlify test-downloads/ +spooty-config diff --git a/Dockerfile b/Dockerfile index b896b27..09868ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN npm run build FROM node:20.20.0-alpine WORKDIR /spooty -RUN apk add --no-cache ffmpeg redis python3 py3-pip curl \ +RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \ && addgroup -S spooty \ && adduser -S spooty -G spooty diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index 35942f2..ac973c3 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -51,9 +51,12 @@ export class PlaylistService { async create(playlist: PlaylistEntity): Promise { // Detect if URL is for a single track or a playlist and route accordingly const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl); + const isArtist = this.spotifyService.isArtistUrl(playlist.spotifyUrl); if (isTrack) { await this.createSingleTrack(playlist); + } else if (isArtist) { + await this.createArtistLibrary(playlist); } else { await this.createPlaylist(playlist); } @@ -105,6 +108,107 @@ export class PlaylistService { } } + + private async createArtistLibrary(playlist: PlaylistEntity): Promise { + let detail: { tracks: any[]; name: string; image: string }; + let playlist2Save: PlaylistEntity; + + try { + detail = await this.spotifyService.getArtistLibrary(playlist.spotifyUrl); + this.logger.debug( + `Artist library retrieved with ${detail.tracks?.length || 0} tracks`, + ); + + playlist2Save = { + ...playlist, + name: detail.name, + coverUrl: detail.image, + active: false, + }; + this.createPlaylistFolderStructure(playlist2Save.name); + } catch (err) { + this.logger.error(`Error getting artist library details: ${err}`); + playlist2Save = { + ...playlist, + name: 'Failed artist library import', + error: toSafeErrorMessage(err), + active: false, + }; + } + + const savedPlaylist = await this.save(playlist2Save); + + if (detail?.tracks && detail.tracks.length > 0) { + await this.createTracksForSavedPlaylist(detail.tracks, savedPlaylist); + } else { + this.logger.warn(`No tracks found for artist library ${savedPlaylist.name}`); + } + } + + private async createTracksForSavedPlaylist( + tracks: any[], + savedPlaylist: PlaylistEntity, + ): Promise { + this.logger.debug( + `Starting to process ${tracks.length} tracks for ${savedPlaylist.name}`, + ); + + let processedCount = 0; + let skippedCount = 0; + let errorCount = 0; + + for (const track of tracks) { + try { + if (!track.artist || !track.name) { + this.logger.warn( + `Skipping track ${processedCount + skippedCount + 1}: Missing artist or name information`, + ); + skippedCount++; + continue; + } + + if (track.unavailable === true) { + this.logger.warn( + `Skipping unavailable track ${processedCount + skippedCount + 1}: ${track.artist} - ${track.name}`, + ); + skippedCount++; + continue; + } + + await this.trackService.create( + { + artist: track.artist, + name: track.name, + spotifyUrl: track.previewUrl || null, + coverUrl: track.coverUrl || savedPlaylist.coverUrl, + durationMs: track.durationMs, + }, + savedPlaylist, + ); + + processedCount++; + + if (processedCount % 100 === 0) { + this.logger.debug( + `Processed ${processedCount} tracks so far for ${savedPlaylist.name}`, + ); + } + } catch (error) { + this.logger.error( + `Error creating track "${ + track?.artist || 'Unknown' + } - ${track?.name || 'Unknown'}": ${error.message}`, + ); + errorCount++; + } + } + + this.logger.debug( + `Finished processing ${savedPlaylist.name}: ` + + `${processedCount} tracks processed, ${skippedCount} skipped, ${errorCount} errors`, + ); + } + private async createPlaylist(playlist: PlaylistEntity): Promise { let detail: { tracks: any; name: any; image: any }; let playlist2Save: PlaylistEntity; @@ -162,6 +266,7 @@ export class PlaylistService { name: track.name, spotifyUrl: track.previewUrl || null, coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover + durationMs: track.durationMs, }, savedPlaylist, ); diff --git a/src/backend/src/shared/spotify-api.service.ts b/src/backend/src/shared/spotify-api.service.ts index 3ff818e..cfd50f3 100644 --- a/src/backend/src/shared/spotify-api.service.ts +++ b/src/backend/src/shared/spotify-api.service.ts @@ -60,6 +60,30 @@ export class SpotifyApiService { } } + isArtistUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return urlObj.pathname.includes('/artist/'); + } catch { + return false; + } + } + + private getArtistId(url: string): string { + try { + const urlObj = new URL(url); + const pathParts = urlObj.pathname.split('/'); + const artistIndex = pathParts.findIndex((part) => part === 'artist'); + if (artistIndex >= 0 && pathParts.length > artistIndex + 1) { + return pathParts[artistIndex + 1].split('?')[0]; + } + throw new Error('Invalid Spotify artist URL'); + } catch (error) { + this.logger.error(`Failed to extract artist ID: ${error.message}`); + throw error; + } + } + private getClientId(): string { const clientId = process.env.SPOTIFY_CLIENT_ID; if (!clientId) { @@ -412,4 +436,196 @@ export class SpotifyApiService { throw error; } } + + async getArtistLibrary( + spotifyUrl: string, + ): Promise<{ name: string; image: string; tracks: any[] }> { + try { + this.logger.debug(`Getting artist library for ${spotifyUrl}`); + const artistId = this.getArtistId(spotifyUrl); + const accessToken = await this.getUserAccessToken(); + + const artistResponse = await fetch( + `https://api.spotify.com/v1/artists/${artistId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!artistResponse.ok) { + const errorText = await artistResponse.text(); + throw new Error(`Failed to fetch artist metadata: ${artistResponse.status} ${errorText}`); + } + + const artist = await artistResponse.json(); + const albums = await this.getAllArtistAlbums(artistId, accessToken); + const tracks = await this.getTracksForAlbums(albums, accessToken, artist.name); + + return { + name: `Artist Library: ${artist.name}`, + image: artist.images?.[0]?.url || '', + tracks, + }; + } catch (error) { + this.logger.error(`Failed to get artist library: ${error.message}`); + throw error; + } + } + + private async getAllArtistAlbums(artistId: string, accessToken: string): Promise { + const includeGroups = process.env.SPOTIFY_ARTIST_INCLUDE_GROUPS || 'album,single'; + const albumByKey = new Map(); + const artistAlbumPageLimit = 20; + let offset = 0; + let hasMoreAlbums = true; + + while (hasMoreAlbums) { + this.logger.debug( + `Fetching artist albums from Spotify API with offset ${offset}`, + ); + + const albumUrl = new URL( + `https://api.spotify.com/v1/artists/${artistId}/albums`, + ); + albumUrl.searchParams.set('include_groups', includeGroups); + albumUrl.searchParams.set('offset', String(offset)); + + this.logger.debug(`Spotify artist albums URL: ${albumUrl.toString()}`); + + const response = await fetch(albumUrl.toString(), { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch artist albums: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + for (const album of data.items || []) { + const key = this.normalizeLibraryKey(`${album.name}|${album.release_date}|${album.total_tracks}`); + if (!albumByKey.has(key)) { + albumByKey.set(key, album); + } + } + + if (!data.next) { + hasMoreAlbums = false; + } else { + offset += artistAlbumPageLimit; + } + } + + const albums = [...albumByKey.values()]; + this.logger.debug(`Retrieved ${albums.length} deduplicated artist albums/singles`); + return albums; + } + + private async getTracksForAlbums( + albums: any[], + accessToken: string, + artistName: string, + ): Promise { + const trackByKey = new Map(); + const maxTracks = Number(process.env.SPOTIFY_ARTIST_LIBRARY_MAX_TRACKS || 1000); + + for (const album of albums) { + let offset = 0; + let hasMoreTracks = true; + + while (hasMoreTracks) { + const response = await fetch( + `https://api.spotify.com/v1/albums/${album.id}/tracks?limit=50&offset=${offset}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch album tracks: ${response.status} ${errorText}`); + } + + const data = await response.json(); + + for (const item of data.items || []) { + if (!item?.name || !item?.artists?.length) { + continue; + } + + const fullTrack = await this.getTrackById(item.id, accessToken); + const isrc = fullTrack?.external_ids?.isrc; + const durationMs = fullTrack?.duration_ms || item.duration_ms; + const artist = item.artists.map((a) => a.name).join(', '); + const key = isrc + ? `isrc:${isrc}` + : this.normalizeLibraryKey(`${artist}|${item.name}|${Math.round((durationMs || 0) / 2000)}`); + + if (!trackByKey.has(key)) { + trackByKey.set(key, { + id: item.id, + name: item.name, + artist, + previewUrl: item.external_urls?.spotify || null, + coverUrl: album.images?.[0]?.url || null, + durationMs, + albumName: album.name, + primaryArtist: artistName, + }); + } + + if (trackByKey.size >= maxTracks) { + this.logger.warn(`Artist library max track limit reached: ${maxTracks}`); + return [...trackByKey.values()]; + } + } + + if (!data.next) { + hasMoreTracks = false; + } else { + offset += 50; + } + } + } + + const tracks = [...trackByKey.values()]; + this.logger.debug(`Retrieved ${tracks.length} deduplicated artist library tracks`); + return tracks; + } + + private async getTrackById(trackId: string, accessToken: string): Promise { + const response = await fetch( + `https://api.spotify.com/v1/tracks/${trackId}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + return null; + } + + return response.json(); + } + + private normalizeLibraryKey(value: string): string { + return value + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + } diff --git a/src/backend/src/shared/spotify.service.ts b/src/backend/src/shared/spotify.service.ts index c029f0e..b1a1b14 100644 --- a/src/backend/src/shared/spotify.service.ts +++ b/src/backend/src/shared/spotify.service.ts @@ -16,6 +16,10 @@ export class SpotifyService { return this.spotifyApiService.isTrackUrl(url); } + isArtistUrl(url: string): boolean { + return this.spotifyApiService.isArtistUrl(url); + } + async getTrackDetail( spotifyUrl: string, ): Promise<{ name: string; artist: string; image: string }> { @@ -61,6 +65,13 @@ export class SpotifyService { } } + async getArtistLibrary( + spotifyUrl: string, + ): Promise<{ name: string; tracks: any[]; image: string }> { + this.logger.debug(`Get artist library ${spotifyUrl} on Spotify`); + return this.spotifyApiService.getArtistLibrary(spotifyUrl); + } + async getPlaylistTracks(spotifyUrl: string): Promise { this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); try { diff --git a/src/backend/src/shared/youtube.service.ts b/src/backend/src/shared/youtube.service.ts index 5d6a1dd..69d83c2 100644 --- a/src/backend/src/shared/youtube.service.ts +++ b/src/backend/src/shared/youtube.service.ts @@ -3,9 +3,9 @@ import { TrackEntity } from '../track/track.entity'; import { EnvironmentEnum } from '../environmentEnum'; import { TrackService } from '../track/track.service'; import { ConfigService } from '@nestjs/config'; -import { YtDlp } from 'ytdlp-nodejs'; import * as yts from 'yt-search'; import * as fs from 'fs'; +import { spawn } from 'child_process'; const NodeID3 = require('node-id3'); const HEADERS = { @@ -73,13 +73,24 @@ export class YoutubeService { artist: string, name: string, durationMs?: number, + excludedUrls: string[] = [], ): Promise { const query = `${artist} - ${name}`; this.logger.debug(`Searching ${query} on YT`); const result = await yts(query); + const excludedVideoIds = new Set( + excludedUrls + .map((url) => this.getYoutubeVideoId(url)) + .filter((id): id is string => !!id), + ); + const candidates = (result.videos || []) .filter((video: any) => !!video?.url && !!video?.title) + .filter((video: any) => { + const videoId = this.getYoutubeVideoId(String(video.url)); + return !videoId || !excludedVideoIds.has(videoId); + }) .slice(0, 15) .map((video: any) => this.scoreCandidate(video, artist, name, durationMs)) .filter((match: CandidateScore) => { @@ -106,7 +117,10 @@ export class YoutubeService { const accepted = candidates.find((candidate) => !candidate.rejected); if (!accepted) { - throw new Error(`No acceptable YouTube result found for: ${query}`); + throw new Error( + `No acceptable YouTube result found for: ${query}` + + (excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''), + ); } this.logger.debug( @@ -244,6 +258,20 @@ export class YoutubeService { return undefined; } + private getYoutubeVideoId(url: string): string | null { + try { + const parsed = new URL(url); + + if (parsed.hostname === 'youtu.be') { + return parsed.pathname.replace(/^\//, '') || null; + } + + return parsed.searchParams.get('v'); + } catch { + return null; + } + } + assertValidYoutubeUrl(url: string): void { let parsed: URL; @@ -262,25 +290,93 @@ export class YoutubeService { } } - private getCookiesOptions(): { - cookiesFromBrowser?: string; - cookies?: string; - } { - const cookiesBrowser = this.configService.get( - EnvironmentEnum.YT_COOKIES, - ); - if (cookiesBrowser) { - this.logger.debug(`Using cookies from browser: ${cookiesBrowser}`); - return { cookiesFromBrowser: cookiesBrowser }; - } + private getCookiesFile(): string | null { const cookiesFile = this.configService.get( EnvironmentEnum.YT_COOKIES_FILE, ); + if (cookiesFile && fs.existsSync(cookiesFile)) { this.logger.debug(`Using cookies file: ${cookiesFile}`); - return { cookies: cookiesFile }; + return cookiesFile; } - return {}; + + return null; + } + + private classifyYtDlpError(output: string): string { + const text = output || ''; + + if (text.includes('Sign in to confirm your age')) { + return 'YouTube age-gated: cookies required or invalid'; + } + + if (text.includes('Only images are available')) { + return 'YouTube unavailable: no downloadable audio/video formats'; + } + + if (text.includes('Requested format is not available')) { + return 'YouTube unavailable: requested audio format is not available'; + } + + if (text.includes('Private video')) { + return 'YouTube unavailable: private video'; + } + + if (text.includes('Video unavailable')) { + return 'YouTube unavailable'; + } + + if (text.includes('Unable to download webpage')) { + return 'YouTube unavailable: unable to download webpage'; + } + + return text.trim().slice(0, 1200) || 'Unknown yt-dlp error'; + } + + private prepareWritableCookiesFile(cookiesFile: string | null): string | null { + if (!cookiesFile) { + return null; + } + + const tempCookiesFile = `/tmp/jarri-spooty-youtube-${process.pid}.cookies.txt`; + fs.copyFileSync(cookiesFile, tempCookiesFile); + return tempCookiesFile; + } + + private runYtDlp(args: string[]): Promise { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn('yt-dlp', args, { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + child.on('error', (error) => { + rejectPromise(new Error(`Failed to start yt-dlp: ${error.message}`)); + }); + + child.on('close', (code) => { + if (code === 0) { + resolvePromise(); + return; + } + + rejectPromise( + new Error( + `yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`, + ), + ); + }); + }); } async downloadAndFormat(track: TrackEntity, output: string): Promise { @@ -294,18 +390,32 @@ export class YoutubeService { this.assertValidYoutubeUrl(track.youtubeUrl); - const ytdlp = new YtDlp(); - await ytdlp.downloadAudio( - track.youtubeUrl, - this.configService.get<'m4a'>(EnvironmentEnum.FORMAT), - { - output, - ...this.getCookiesOptions(), - headers: HEADERS, - jsRuntime: 'node', - audioQuality: this.configService.get('QUALITY'), - }, - ); + const format = this.configService.get(EnvironmentEnum.FORMAT) || 'mp3'; + const quality = this.configService.get('QUALITY') || '0'; + const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile()); + + const args = [ + '--no-playlist', + '--no-cache-dir', + '--no-cookies-from-browser', + '--extract-audio', + '--audio-format', + format, + '--audio-quality', + quality, + '--add-header', + `User-Agent:${HEADERS['User-Agent']}`, + '-o', + output, + ]; + + if (cookiesFile) { + args.push('--cookies', cookiesFile); + } + + args.push(track.youtubeUrl); + + await this.runYtDlp(args); this.logger.debug( `Downloaded ${track.artist} - ${track.name} to ${output}`, diff --git a/src/backend/src/track/track.entity.ts b/src/backend/src/track/track.entity.ts index ab17da9..62bb437 100644 --- a/src/backend/src/track/track.entity.ts +++ b/src/backend/src/track/track.entity.ts @@ -27,6 +27,12 @@ export class TrackEntity { @Column({ nullable: true }) youtubeUrl?: string; + @Column({ nullable: true, type: 'text' }) + rejectedYoutubeUrls?: string; + + @Column({ default: 0 }) + downloadAttemptCount?: number; + @Column({ default: TrackStatusEnum.New }) status?: TrackStatusEnum; diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index cb63820..45cbe8f 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -70,6 +70,23 @@ export class TrackService { this.io.emit(WsTrackOperation.Update, track); } + private parseRejectedYoutubeUrls(track: TrackEntity): string[] { + if (!track.rejectedYoutubeUrls) { + return []; + } + + try { + const parsed = JSON.parse(track.rejectedYoutubeUrls); + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []; + } catch { + return []; + } + } + + private stringifyRejectedYoutubeUrls(urls: string[]): string { + return JSON.stringify([...new Set(urls)].slice(0, 20)); + } + async retry(id: number): Promise { const track = await this.get(id); @@ -78,12 +95,22 @@ export class TrackService { return; } - await this.enqueueSearch(id); + const rejectedUrls = this.parseRejectedYoutubeUrls(track); + + if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) { + rejectedUrls.push(track.youtubeUrl); + } + await this.update(id, { ...track, + youtubeUrl: null, + rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls), + downloadAttemptCount: 0, error: null, status: TrackStatusEnum.New, }); + + await this.enqueueSearch(id); } async findOnYoutube(track: TrackEntity): Promise { @@ -102,9 +129,13 @@ export class TrackService { }); let updatedTrack: TrackEntity; try { + const rejectedUrls = this.parseRejectedYoutubeUrls(track); + const youtubeMatch = await this.youtubeService.findBestYoutubeMatch( track.artist, track.name, + track.durationMs, + rejectedUrls, ); updatedTrack = { ...track, @@ -141,8 +172,8 @@ export class TrackService { const existingJob = await this.trackSearchQueue.getJob(jobId); if (existingJob) { - this.logger.warn(`Search job already exists for track ${id}`); - return; + this.logger.warn(`Removing existing search job for track ${id} before enqueue`); + await existingJob.remove(); } await this.trackSearchQueue.add('search-track', { id }, { jobId }); @@ -160,13 +191,23 @@ export class TrackService { const existingJob = await this.trackDownloadQueue.getJob(jobId); if (existingJob) { - this.logger.warn(`Download job already exists for track ${id}`); - return; + this.logger.warn(`Removing existing download job for track ${id} before enqueue`); + await existingJob.remove(); } await this.trackDownloadQueue.add('download-track', { id }, { jobId }); } + private getMaxDownloadAttempts(): number { + const parsed = Number(process.env.YT_DOWNLOAD_FALLBACK_ATTEMPTS || 3); + + if (!Number.isFinite(parsed) || parsed < 1) { + return 3; + } + + return Math.max(1, Math.min(10, Math.floor(parsed))); + } + async downloadFromYoutube(track: TrackEntity): Promise { const dbTrack = await this.get(track.id); if (!dbTrack) { @@ -217,10 +258,39 @@ export class TrackService { error = toSafeErrorMessage(err); } + const rejectedUrls = this.parseRejectedYoutubeUrls(track); + const nextAttemptCount = (track.downloadAttemptCount || 0) + 1; + const maxAttempts = this.getMaxDownloadAttempts(); + + if (error && track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) { + rejectedUrls.push(track.youtubeUrl); + } + + if (error && nextAttemptCount < maxAttempts) { + this.logger.warn( + `Download failed for track ${track.id}; rejecting current YouTube candidate and retrying search ` + + `(${nextAttemptCount}/${maxAttempts})`, + ); + + await this.update(track.id, { + ...track, + youtubeUrl: null, + rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls), + downloadAttemptCount: nextAttemptCount, + error: null, + status: TrackStatusEnum.New, + }); + + await this.enqueueSearch(track.id); + return; + } + const updatedTrack = { ...track, status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, - ...(error ? { error } : {}), + rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls), + downloadAttemptCount: error ? nextAttemptCount : 0, + ...(error ? { error } : { error: null }), }; await this.update(track.id, updatedTrack); } diff --git a/src/frontend/src/app/app.component.html b/src/frontend/src/app/app.component.html index 3dc769c..2e77ef7 100644 --- a/src/frontend/src/app/app.component.html +++ b/src/frontend/src/app/app.component.html @@ -1,80 +1,94 @@ -
-
-

+

+
+

Jarri subsystem · deterministic media ingestion

+

- Spooty - v{{version}} -

-

Self-hosted spotify downloader

+ Jarri Spooty + v{{version}} +

+

Spotify metadata → YouTube candidate scoring → Local archive

+
+ +
-
-
-
-

Download

-
-
- - -
-

Please enter a valid Spotify URL (e.g. https://open.spotify.com/...)

-
+
+
+
+ Spotify + {{ spotifyConnected ? 'Connected' : 'Disconnected' }}
-
-
-
-

Playlists

-
- - -
+
+ Matching + Duration-aware
- - -

No playlists

-
+
+ Queue + Paced
-
-
-
-

Songs

-
- - -
-
- - -

No songs

-
+
+ Mode + Archive run
+ +
+

Acquire music

+
+ + +
+

+ Please enter a valid Spotify URL. +

+
+ +
+
+
+

Playlist History

+

One collapsed row per playlist run.

+
+
+ + +
+
+ + + +

No playlists

+
+
+ +
+

Single Songs

+ + +

No songs

+
+
- - diff --git a/src/frontend/src/app/app.component.scss b/src/frontend/src/app/app.component.scss index e69de29..0ab9ed7 100644 --- a/src/frontend/src/app/app.component.scss +++ b/src/frontend/src/app/app.component.scss @@ -0,0 +1,129 @@ +:host { + display: block; + min-height: 100vh; + background: + radial-gradient(circle at top left, rgba(29, 185, 84, 0.18), transparent 30rem), + linear-gradient(135deg, #06110c 0%, #0a1210 45%, #020403 100%); + color: #eafff0; +} + +.jarri-hero { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; + padding: 34px 44px; + background: + linear-gradient(90deg, rgba(3, 12, 7, 0.95), rgba(29, 185, 84, 0.92)); + border-bottom: 1px solid rgba(126, 255, 180, 0.25); +} + +.eyebrow { + margin: 0 0 6px; + color: #9fffc3; + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.72rem; + font-weight: 800; +} + +h1 { + margin: 0; + color: #f3fff7; + font-size: clamp(2.2rem, 4vw, 4.6rem); + font-weight: 900; + letter-spacing: -0.06em; + text-shadow: 0 2px 24px rgba(0, 0, 0, 0.45); +} + +.hero-subtitle { + margin-top: 8px; + color: #d8ffe5; + font-size: 1.05rem; +} + +.version-tag { + background: #07110c; + color: #9fffc3; + vertical-align: middle; +} + +.auth-button { + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.28); + background: rgba(255, 255, 255, 0.92); + color: #07110c; + font-weight: 800; +} + +.auth-button.connected { + background: #9fffc3; +} + +.jarri-body { + padding: 34px 44px; +} + +.intelligence-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 22px; +} + +.intel-card { + border: 1px solid rgba(126, 255, 180, 0.18); + border-radius: 16px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.06); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.22); +} + +.intel-label { + display: block; + color: #8ea99a; + text-transform: uppercase; + letter-spacing: 0.13em; + font-size: 0.68rem; + font-weight: 800; +} + +.intel-card strong { + display: block; + margin-top: 4px; + color: #f1fff5; + font-size: 1.1rem; +} + +.intel-card strong.online { + color: #9fffc3; +} + +.jarri-box { + border-radius: 18px; + margin-bottom: 20px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24); +} + +.button { + border-radius: 10px; +} + +@media (max-width: 900px) { + .jarri-hero, + .jarri-body { + padding: 18px; + } + + .jarri-hero { + display: block; + } + + .hero-right { + margin-top: 14px; + } + + .intelligence-grid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/src/frontend/src/app/components/playlist-box/playlist-box.component.html b/src/frontend/src/app/components/playlist-box/playlist-box.component.html index 6b8e2b8..79db7ac 100644 --- a/src/frontend/src/app/components/playlist-box/playlist-box.component.html +++ b/src/frontend/src/app/components/playlist-box/playlist-box.component.html @@ -1,35 +1,65 @@ -
-

- -   - {{playlist.error}} - - {{playlist.name}} -   - - - - - +

+

+ +   + + {{ playlist.isTrack ? 'track' : 'playlist' }}  + + + {{playlist.error}} + + + + {{playlist.name}} + + + + + + {{trackCompletedCount$ | async}}/{{trackCount$ | async}} + + + + {{errors}} failed + + + + + + -   + (click)="toggleActive(playlist.id, playlist.active)"> + -   -   -   - {{trackCompletedCount$ | async}}/{{trackCount$ | async}}  + + + + + +

+ +
+
+
+ +

+ {{progressPercent$ | async}}% complete + subscribed +

+ diff --git a/src/frontend/src/app/components/playlist-box/playlist-box.component.scss b/src/frontend/src/app/components/playlist-box/playlist-box.component.scss index 9272c18..bfccf69 100644 --- a/src/frontend/src/app/components/playlist-box/playlist-box.component.scss +++ b/src/frontend/src/app/components/playlist-box/playlist-box.component.scss @@ -2,19 +2,73 @@ display: none; } -.panel-heading { - padding: 5px 10px; - border-radius: 10px; +.playlist-card { + margin: 8px 0; + border-radius: 14px; + overflow: hidden; + background: rgba(255, 255, 255, 0.96); +} + +.playlist-heading { + padding: 8px 12px; + border-radius: 0; + cursor: pointer; &:hover .hover-icon { display: inline; } - - &:hover .hover-icon-reversed { - display: none; - } } -.panel { - margin: 5px 0; +.playlist-title { + min-width: 0; + overflow-wrap: anywhere; +} + +.history-label { + border-radius: 999px; + background: rgba(0, 0, 0, 0.16); + padding: 2px 7px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.playlist-actions { + display: inline-flex; + align-items: center; + gap: 9px; + white-space: nowrap; +} + +.progress-text { + font-weight: 700; +} + +.error-count { + border-radius: 999px; + padding: 2px 7px; + background: #ff3860; + color: white; + font-size: 0.72rem; + font-weight: 700; +} + +.progress-shell { + height: 7px; + background: rgba(0, 0, 0, 0.08); +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #1db954, #48f59b); + transition: width 180ms ease-out; +} + +.progress-caption { + display: flex; + justify-content: space-between; + margin: 0; + padding: 5px 12px 8px; + color: #667; + font-size: 0.78rem; } diff --git a/src/frontend/src/app/components/playlist-box/playlist-box.component.ts b/src/frontend/src/app/components/playlist-box/playlist-box.component.ts index 50a072c..d25673e 100644 --- a/src/frontend/src/app/components/playlist-box/playlist-box.component.ts +++ b/src/frontend/src/app/components/playlist-box/playlist-box.component.ts @@ -2,7 +2,7 @@ import {Component, Input} from '@angular/core'; import {AsyncPipe, CommonModule, NgIf} from "@angular/common"; import {TrackListComponent} from "../track-list/track-list.component"; import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service"; -import {Observable, map} from "rxjs"; +import {Observable, combineLatest, map} from "rxjs"; import {Playlist} from "../../models/playlist"; const STATUS2CLASS = { @@ -14,15 +14,15 @@ const STATUS2CLASS = { } @Component({ - selector: 'app-playlist-box', - imports: [ - CommonModule, - AsyncPipe, - NgIf, - TrackListComponent - ], - templateUrl: './playlist-box.component.html', - styleUrl: './playlist-box.component.scss', + selector: 'app-playlist-box', + imports: [ + CommonModule, + AsyncPipe, + NgIf, + TrackListComponent + ], + templateUrl: './playlist-box.component.html', + styleUrl: './playlist-box.component.scss', standalone: true }) export class PlaylistBoxComponent { @@ -31,16 +31,27 @@ export class PlaylistBoxComponent { this._playlist = val; this.trackCount$ = this.service.getTrackCount(val.id); this.trackCompletedCount$ = this.service.getCompletedTrackCount(val.id); + this.trackErrorCount$ = this.service.getErrorTrackCount(val.id); + this.progressPercent$ = combineLatest([ + this.trackCompletedCount$, + this.trackCount$, + ]).pipe( + map(([completed, total]) => total ? Math.round((completed / total) * 100) : 0) + ); this.statusClass$ = this.service.getStatus$(val.id).pipe( map(status => STATUS2CLASS[status]) ); } + get playlist(): Playlist & PlaylistUi { return this._playlist; } + _playlist!: Playlist & PlaylistUi; trackCount$!: Observable; trackCompletedCount$!: Observable; + trackErrorCount$!: Observable; + progressPercent$!: Observable; statusClass$!: Observable; constructor(private readonly service: PlaylistService) { }