Use direct yt-dlp CLI with deterministic fallback handling
This commit is contained in:
@@ -11,3 +11,16 @@ config
|
|||||||
.env.*
|
.env.*
|
||||||
README.md
|
README.md
|
||||||
assets
|
assets
|
||||||
|
|
||||||
|
# Local runtime/download artifacts
|
||||||
|
test-downloads
|
||||||
|
downloads
|
||||||
|
config
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.db
|
||||||
|
*.mp3
|
||||||
|
*.m4a
|
||||||
|
*.webm
|
||||||
|
*.part
|
||||||
|
spooty-config
|
||||||
|
|||||||
@@ -6,3 +6,4 @@
|
|||||||
.netlify
|
.netlify
|
||||||
|
|
||||||
test-downloads/
|
test-downloads/
|
||||||
|
spooty-config
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ 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 \
|
RUN apk add --no-cache ffmpeg redis python3 py3-pip curl yt-dlp \
|
||||||
&& addgroup -S spooty \
|
&& addgroup -S spooty \
|
||||||
&& adduser -S spooty -G spooty
|
&& adduser -S spooty -G spooty
|
||||||
|
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ export class PlaylistService {
|
|||||||
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);
|
||||||
|
const isArtist = this.spotifyService.isArtistUrl(playlist.spotifyUrl);
|
||||||
|
|
||||||
if (isTrack) {
|
if (isTrack) {
|
||||||
await this.createSingleTrack(playlist);
|
await this.createSingleTrack(playlist);
|
||||||
|
} else if (isArtist) {
|
||||||
|
await this.createArtistLibrary(playlist);
|
||||||
} else {
|
} else {
|
||||||
await this.createPlaylist(playlist);
|
await this.createPlaylist(playlist);
|
||||||
}
|
}
|
||||||
@@ -105,6 +108,107 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async createArtistLibrary(playlist: PlaylistEntity): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
private async createPlaylist(playlist: PlaylistEntity): Promise<void> {
|
||||||
let detail: { tracks: any; name: any; image: any };
|
let detail: { tracks: any; name: any; image: any };
|
||||||
let playlist2Save: PlaylistEntity;
|
let playlist2Save: PlaylistEntity;
|
||||||
@@ -162,6 +266,7 @@ export class PlaylistService {
|
|||||||
name: track.name,
|
name: track.name,
|
||||||
spotifyUrl: track.previewUrl || null,
|
spotifyUrl: track.previewUrl || null,
|
||||||
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
||||||
|
durationMs: track.durationMs,
|
||||||
},
|
},
|
||||||
savedPlaylist,
|
savedPlaylist,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
private getClientId(): string {
|
||||||
const clientId = process.env.SPOTIFY_CLIENT_ID;
|
const clientId = process.env.SPOTIFY_CLIENT_ID;
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@@ -412,4 +436,196 @@ export class SpotifyApiService {
|
|||||||
throw error;
|
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<any[]> {
|
||||||
|
const includeGroups = process.env.SPOTIFY_ARTIST_INCLUDE_GROUPS || 'album,single';
|
||||||
|
const albumByKey = new Map<string, any>();
|
||||||
|
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<any[]> {
|
||||||
|
const trackByKey = new Map<string, any>();
|
||||||
|
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<any> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export class SpotifyService {
|
|||||||
return this.spotifyApiService.isTrackUrl(url);
|
return this.spotifyApiService.isTrackUrl(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isArtistUrl(url: string): boolean {
|
||||||
|
return this.spotifyApiService.isArtistUrl(url);
|
||||||
|
}
|
||||||
|
|
||||||
async getTrackDetail(
|
async getTrackDetail(
|
||||||
spotifyUrl: string,
|
spotifyUrl: string,
|
||||||
): Promise<{ name: string; artist: string; image: 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<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 {
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { TrackEntity } from '../track/track.entity';
|
|||||||
import { EnvironmentEnum } from '../environmentEnum';
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
import { TrackService } from '../track/track.service';
|
import { TrackService } from '../track/track.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { YtDlp } from 'ytdlp-nodejs';
|
|
||||||
import * as yts from 'yt-search';
|
import * as yts from 'yt-search';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
const NodeID3 = require('node-id3');
|
const NodeID3 = require('node-id3');
|
||||||
|
|
||||||
const HEADERS = {
|
const HEADERS = {
|
||||||
@@ -73,13 +73,24 @@ export class YoutubeService {
|
|||||||
artist: string,
|
artist: string,
|
||||||
name: string,
|
name: string,
|
||||||
durationMs?: number,
|
durationMs?: number,
|
||||||
|
excludedUrls: string[] = [],
|
||||||
): Promise<YoutubeMatch> {
|
): Promise<YoutubeMatch> {
|
||||||
const query = `${artist} - ${name}`;
|
const query = `${artist} - ${name}`;
|
||||||
this.logger.debug(`Searching ${query} on YT`);
|
this.logger.debug(`Searching ${query} on YT`);
|
||||||
|
|
||||||
const result = await yts(query);
|
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 || [])
|
const candidates = (result.videos || [])
|
||||||
.filter((video: any) => !!video?.url && !!video?.title)
|
.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)
|
.slice(0, 15)
|
||||||
.map((video: any) => this.scoreCandidate(video, artist, name, durationMs))
|
.map((video: any) => this.scoreCandidate(video, artist, name, durationMs))
|
||||||
.filter((match: CandidateScore) => {
|
.filter((match: CandidateScore) => {
|
||||||
@@ -106,7 +117,10 @@ export class YoutubeService {
|
|||||||
const accepted = candidates.find((candidate) => !candidate.rejected);
|
const accepted = candidates.find((candidate) => !candidate.rejected);
|
||||||
|
|
||||||
if (!accepted) {
|
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(
|
this.logger.debug(
|
||||||
@@ -244,6 +258,20 @@ export class YoutubeService {
|
|||||||
return undefined;
|
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 {
|
assertValidYoutubeUrl(url: string): void {
|
||||||
let parsed: URL;
|
let parsed: URL;
|
||||||
|
|
||||||
@@ -262,25 +290,93 @@ export class YoutubeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCookiesOptions(): {
|
private getCookiesFile(): string | null {
|
||||||
cookiesFromBrowser?: string;
|
|
||||||
cookies?: string;
|
|
||||||
} {
|
|
||||||
const cookiesBrowser = this.configService.get<string>(
|
|
||||||
EnvironmentEnum.YT_COOKIES,
|
|
||||||
);
|
|
||||||
if (cookiesBrowser) {
|
|
||||||
this.logger.debug(`Using cookies from browser: ${cookiesBrowser}`);
|
|
||||||
return { cookiesFromBrowser: cookiesBrowser };
|
|
||||||
}
|
|
||||||
const cookiesFile = this.configService.get<string>(
|
const cookiesFile = this.configService.get<string>(
|
||||||
EnvironmentEnum.YT_COOKIES_FILE,
|
EnvironmentEnum.YT_COOKIES_FILE,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
if (cookiesFile && fs.existsSync(cookiesFile)) {
|
||||||
this.logger.debug(`Using cookies file: ${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<void> {
|
||||||
|
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<void> {
|
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
|
||||||
@@ -294,18 +390,32 @@ export class YoutubeService {
|
|||||||
|
|
||||||
this.assertValidYoutubeUrl(track.youtubeUrl);
|
this.assertValidYoutubeUrl(track.youtubeUrl);
|
||||||
|
|
||||||
const ytdlp = new YtDlp();
|
const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
|
||||||
await ytdlp.downloadAudio(
|
const quality = this.configService.get<string>('QUALITY') || '0';
|
||||||
track.youtubeUrl,
|
const cookiesFile = this.prepareWritableCookiesFile(this.getCookiesFile());
|
||||||
this.configService.get<'m4a'>(EnvironmentEnum.FORMAT),
|
|
||||||
{
|
const args = [
|
||||||
output,
|
'--no-playlist',
|
||||||
...this.getCookiesOptions(),
|
'--no-cache-dir',
|
||||||
headers: HEADERS,
|
'--no-cookies-from-browser',
|
||||||
jsRuntime: 'node',
|
'--extract-audio',
|
||||||
audioQuality: this.configService.get<string>('QUALITY'),
|
'--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(
|
this.logger.debug(
|
||||||
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
rejectedYoutubeUrls?: string;
|
||||||
|
|
||||||
|
@Column({ default: 0 })
|
||||||
|
downloadAttemptCount?: number;
|
||||||
|
|
||||||
@Column({ default: TrackStatusEnum.New })
|
@Column({ default: TrackStatusEnum.New })
|
||||||
status?: TrackStatusEnum;
|
status?: TrackStatusEnum;
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,23 @@ export class TrackService {
|
|||||||
this.io.emit(WsTrackOperation.Update, track);
|
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<void> {
|
async retry(id: number): Promise<void> {
|
||||||
const track = await this.get(id);
|
const track = await this.get(id);
|
||||||
|
|
||||||
@@ -78,12 +95,22 @@ export class TrackService {
|
|||||||
return;
|
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, {
|
await this.update(id, {
|
||||||
...track,
|
...track,
|
||||||
|
youtubeUrl: null,
|
||||||
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||||
|
downloadAttemptCount: 0,
|
||||||
error: null,
|
error: null,
|
||||||
status: TrackStatusEnum.New,
|
status: TrackStatusEnum.New,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.enqueueSearch(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOnYoutube(track: TrackEntity): Promise<void> {
|
async findOnYoutube(track: TrackEntity): Promise<void> {
|
||||||
@@ -102,9 +129,13 @@ export class TrackService {
|
|||||||
});
|
});
|
||||||
let updatedTrack: TrackEntity;
|
let updatedTrack: TrackEntity;
|
||||||
try {
|
try {
|
||||||
|
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||||
|
|
||||||
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
|
const youtubeMatch = await this.youtubeService.findBestYoutubeMatch(
|
||||||
track.artist,
|
track.artist,
|
||||||
track.name,
|
track.name,
|
||||||
|
track.durationMs,
|
||||||
|
rejectedUrls,
|
||||||
);
|
);
|
||||||
updatedTrack = {
|
updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
@@ -141,8 +172,8 @@ export class TrackService {
|
|||||||
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
||||||
|
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
this.logger.warn(`Search job already exists for track ${id}`);
|
this.logger.warn(`Removing existing search job for track ${id} before enqueue`);
|
||||||
return;
|
await existingJob.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.trackSearchQueue.add('search-track', { id }, { jobId });
|
await this.trackSearchQueue.add('search-track', { id }, { jobId });
|
||||||
@@ -160,13 +191,23 @@ export class TrackService {
|
|||||||
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
||||||
|
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
this.logger.warn(`Download job already exists for track ${id}`);
|
this.logger.warn(`Removing existing download job for track ${id} before enqueue`);
|
||||||
return;
|
await existingJob.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.trackDownloadQueue.add('download-track', { id }, { jobId });
|
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<void> {
|
async downloadFromYoutube(track: TrackEntity): Promise<void> {
|
||||||
const dbTrack = await this.get(track.id);
|
const dbTrack = await this.get(track.id);
|
||||||
if (!dbTrack) {
|
if (!dbTrack) {
|
||||||
@@ -217,10 +258,39 @@ export class TrackService {
|
|||||||
error = toSafeErrorMessage(err);
|
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 = {
|
const updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
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);
|
await this.update(track.id, updatedTrack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,94 @@
|
|||||||
<section class="hero is-primary">
|
<section class="jarri-hero">
|
||||||
<div class="hero-body">
|
<div class="hero-left">
|
||||||
<p class="title">
|
<p class="eyebrow">Jarri subsystem · deterministic media ingestion</p>
|
||||||
|
<h1>
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
<span>Spooty</span>
|
Jarri Spooty
|
||||||
<span class="tag is-dark is-size-7">v{{version}}</span>
|
<span class="tag version-tag">v{{version}}</span>
|
||||||
</p>
|
</h1>
|
||||||
<p class="subtitle">Self-hosted spotify downloader</p>
|
<p class="hero-subtitle">Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-right">
|
||||||
<button
|
<button
|
||||||
class="button is-light is-small"
|
class="button auth-button"
|
||||||
[class.is-success]="spotifyConnected"
|
[class.connected]="spotifyConnected"
|
||||||
(click)="connectSpotify()"
|
(click)="connectSpotify()"
|
||||||
>
|
>
|
||||||
|
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
||||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="jarri-body">
|
||||||
<div class="hero-body">
|
<div class="intelligence-grid">
|
||||||
<div class="box">
|
<div class="intel-card">
|
||||||
<p class="subtitle">Download</p>
|
<span class="intel-label">Spotify</span>
|
||||||
<div>
|
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
|
||||||
<div class="is-flex">
|
|
||||||
<input class="input"
|
|
||||||
[class.is-danger]="url && !isValidSpotifyUrl"
|
|
||||||
type="text"
|
|
||||||
[(ngModel)]="url"
|
|
||||||
placeholder="Paste playlist/song/artist url"/>
|
|
||||||
<button class="button is-primary"
|
|
||||||
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
|
||||||
(click)="download()"
|
|
||||||
[disabled]="!url || !isValidSpotifyUrl"
|
|
||||||
>
|
|
||||||
<i class="fa-solid fa-download"></i> Download
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">Please enter a valid Spotify URL (e.g. https://open.spotify.com/...)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<div class="intel-card">
|
||||||
<div class="box">
|
<span class="intel-label">Matching</span>
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<strong>Duration-aware</strong>
|
||||||
<p class="subtitle">Playlists</p>
|
|
||||||
<div class="buttons has-addons">
|
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
|
||||||
<i class="fa-solid fa-check"></i>
|
|
||||||
</button>
|
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="playlists$ | async as playlists">
|
<div class="intel-card">
|
||||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
<span class="intel-label">Queue</span>
|
||||||
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
<strong>Paced</strong>
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<div class="intel-card">
|
||||||
<div class="box">
|
<span class="intel-label">Mode</span>
|
||||||
<div class="is-flex is-justify-content-space-between">
|
<strong>Archive run</strong>
|
||||||
<p class="subtitle">Songs</p>
|
|
||||||
<div class="buttons has-addons">
|
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
|
||||||
<i class="fa-solid fa-check"></i>
|
|
||||||
</button>
|
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ng-container *ngIf="songs$ | async as songs">
|
|
||||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
|
||||||
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="box jarri-box">
|
||||||
|
<p class="subtitle">Acquire music</p>
|
||||||
|
<div class="is-flex">
|
||||||
|
<input class="input"
|
||||||
|
[class.is-danger]="url && !isValidSpotifyUrl"
|
||||||
|
type="text"
|
||||||
|
[(ngModel)]="url"
|
||||||
|
placeholder="Paste Spotify playlist or track URL"/>
|
||||||
|
<button class="button is-primary"
|
||||||
|
[class.is-loading]="(createLoading$ | async)?.isLoading"
|
||||||
|
(click)="download()"
|
||||||
|
[disabled]="!url || !isValidSpotifyUrl"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-download"></i> Queue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p *ngIf="url && !isValidSpotifyUrl" class="help is-danger">
|
||||||
|
Please enter a valid Spotify URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box jarri-box">
|
||||||
|
<div class="is-flex is-justify-content-space-between">
|
||||||
|
<div>
|
||||||
|
<p class="subtitle">Playlist History</p>
|
||||||
|
<p class="has-text-grey is-size-7">One collapsed row per playlist run.</p>
|
||||||
|
</div>
|
||||||
|
<div class="buttons has-addons">
|
||||||
|
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
||||||
|
<i class="fa-solid fa-check"></i>
|
||||||
|
</button>
|
||||||
|
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="playlists$ | async as playlists">
|
||||||
|
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
||||||
|
<p *ngIf="playlists?.length === 0" class="has-text-grey has-text-centered">No playlists</p>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box jarri-box">
|
||||||
|
<p class="subtitle">Single Songs</p>
|
||||||
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
|
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
||||||
|
<p *ngIf="songs?.length === 0" class="has-text-grey has-text-centered">No songs</p>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,65 @@
|
|||||||
<article class="panel" [ngClass]="statusClass$ | async">
|
<article class="panel playlist-card" [ngClass]="statusClass$ | async">
|
||||||
<p class="panel-heading is-flex is-justify-content-space-between">
|
<p class="panel-heading playlist-heading is-flex is-justify-content-space-between" (click)="toggleCollapse(playlist.id)">
|
||||||
<span>
|
<span class="playlist-title">
|
||||||
<i class="is-clickable fa-solid"
|
<i class="is-clickable fa-solid"
|
||||||
[ngClass]="playlist.collapsed ? 'fa-caret-up' : 'fa-caret-down'"
|
[ngClass]="playlist.collapsed ? 'fa-caret-down' : 'fa-caret-right'"></i>
|
||||||
(click)="toggleCollapse(playlist.id)"
|
|
||||||
></i>
|
<span class="history-label">{{ playlist.isTrack ? 'track' : 'playlist' }}</span>
|
||||||
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">{{playlist.error}}</span>
|
|
||||||
<ng-template #noErrorTemplate>
|
<span *ngIf="playlist.error; else noErrorTemplate" class="is-size-6 has-text-weight-medium">
|
||||||
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
{{playlist.error}}
|
||||||
</ng-template>
|
</span>
|
||||||
<a [href]="playlist.spotifyUrl"
|
|
||||||
target="_blank"
|
<ng-template #noErrorTemplate>
|
||||||
class="has-text-black" title="Link to Spotify url to be download from">
|
<span class="is-size-6 has-text-weight-semibold">{{playlist.name}}</span>
|
||||||
<i class="fa-brands fa-spotify"></i>
|
</ng-template>
|
||||||
</a>
|
</span>
|
||||||
</span>
|
|
||||||
<span>
|
<span class="playlist-actions" (click)="$event.stopPropagation()">
|
||||||
|
<span *ngIf="!playlist.error" class="progress-text">
|
||||||
|
{{trackCompletedCount$ | async}}/{{trackCount$ | async}}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span *ngIf="(trackErrorCount$ | async) as errors" class="error-count" [class.is-hidden]="errors === 0">
|
||||||
|
{{errors}} failed
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a [href]="playlist.spotifyUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="has-text-black"
|
||||||
|
title="Open Spotify URL">
|
||||||
|
<i class="fa-brands fa-spotify"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
<ng-container *ngIf="!playlist.isTrack">
|
<ng-container *ngIf="!playlist.isTrack">
|
||||||
<i class="fa-solid is-clickable hover-icon"
|
<i class="fa-solid is-clickable hover-icon"
|
||||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||||
(click)="toggleActive(playlist.id, playlist.active)"
|
(click)="toggleActive(playlist.id, playlist.active)">
|
||||||
></i>
|
</i>
|
||||||
<i> </i>
|
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>
|
|
||||||
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>
|
<i class="fa-solid fa-repeat is-clickable hover-icon"
|
||||||
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
title="Retry failed tracks"
|
||||||
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>
|
(click)="retryFailed(playlist.id)">
|
||||||
|
</i>
|
||||||
|
|
||||||
|
<i class="fa-solid fa-xmark is-clickable hover-icon"
|
||||||
|
title="Remove playlist from list"
|
||||||
|
(click)="delete(playlist.id)">
|
||||||
|
</i>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="progress-shell">
|
||||||
|
<div class="progress-fill" [style.width.%]="progressPercent$ | async"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="progress-caption">
|
||||||
|
<span>{{progressPercent$ | async}}% complete</span>
|
||||||
|
<span *ngIf="playlist.active">subscribed</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ng-container *ngIf="playlist.collapsed">
|
<ng-container *ngIf="playlist.collapsed">
|
||||||
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
<app-track-list [playlistId]="playlist.id"></app-track-list>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -2,19 +2,73 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-heading {
|
.playlist-card {
|
||||||
padding: 5px 10px;
|
margin: 8px 0;
|
||||||
border-radius: 10px;
|
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 {
|
&:hover .hover-icon {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover .hover-icon-reversed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.playlist-title {
|
||||||
margin: 5px 0;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {Component, Input} from '@angular/core';
|
|||||||
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
||||||
import {TrackListComponent} from "../track-list/track-list.component";
|
import {TrackListComponent} from "../track-list/track-list.component";
|
||||||
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
||||||
import {Observable, map} from "rxjs";
|
import {Observable, combineLatest, map} from "rxjs";
|
||||||
import {Playlist} from "../../models/playlist";
|
import {Playlist} from "../../models/playlist";
|
||||||
|
|
||||||
const STATUS2CLASS = {
|
const STATUS2CLASS = {
|
||||||
@@ -14,15 +14,15 @@ const STATUS2CLASS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-playlist-box',
|
selector: 'app-playlist-box',
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgIf,
|
NgIf,
|
||||||
TrackListComponent
|
TrackListComponent
|
||||||
],
|
],
|
||||||
templateUrl: './playlist-box.component.html',
|
templateUrl: './playlist-box.component.html',
|
||||||
styleUrl: './playlist-box.component.scss',
|
styleUrl: './playlist-box.component.scss',
|
||||||
standalone: true
|
standalone: true
|
||||||
})
|
})
|
||||||
export class PlaylistBoxComponent {
|
export class PlaylistBoxComponent {
|
||||||
@@ -31,16 +31,27 @@ export class PlaylistBoxComponent {
|
|||||||
this._playlist = val;
|
this._playlist = val;
|
||||||
this.trackCount$ = this.service.getTrackCount(val.id);
|
this.trackCount$ = this.service.getTrackCount(val.id);
|
||||||
this.trackCompletedCount$ = this.service.getCompletedTrackCount(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(
|
this.statusClass$ = this.service.getStatus$(val.id).pipe(
|
||||||
map(status => STATUS2CLASS[status])
|
map(status => STATUS2CLASS[status])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get playlist(): Playlist & PlaylistUi {
|
get playlist(): Playlist & PlaylistUi {
|
||||||
return this._playlist;
|
return this._playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
_playlist!: Playlist & PlaylistUi;
|
_playlist!: Playlist & PlaylistUi;
|
||||||
trackCount$!: Observable<number>;
|
trackCount$!: Observable<number>;
|
||||||
trackCompletedCount$!: Observable<number>;
|
trackCompletedCount$!: Observable<number>;
|
||||||
|
trackErrorCount$!: Observable<number>;
|
||||||
|
progressPercent$!: Observable<number>;
|
||||||
statusClass$!: Observable<string>;
|
statusClass$!: Observable<string>;
|
||||||
|
|
||||||
constructor(private readonly service: PlaylistService) { }
|
constructor(private readonly service: PlaylistService) { }
|
||||||
|
|||||||
Reference in New Issue
Block a user