14 changed files with 658 additions and 16 deletions
+24
View File
@@ -0,0 +1,24 @@
# Spooty Operations Snapshot
`GET /api/operations/snapshot` exposes backend-owned operational truth for Workspace and other clients.
The MVP reports truth Spooty already owns without a schema migration:
- track state from `TrackEntity.status`
- selected YouTube URL from `TrackEntity.youtubeUrl`
- raw failure evidence from `TrackEntity.error`
- download retry count from `TrackEntity.downloadAttemptCount`
- rejected YouTube candidates from `TrackEntity.rejectedYoutubeUrls`
- playlist and single-song counts from `PlaylistEntity.isTrack`
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
- Spotify connection presence from the stored Spotify user token
The endpoint intentionally does not infer candidate scoring history. Today, selected candidate title, author, score, and reason are logged during search but are not persisted. Rejected candidates are persisted only as URL strings.
Future persistence work is required for:
- selected candidate title, author, score, and scoring reason
- full searched candidate history
- structured rejected candidate metadata beyond URL
- structured error class fields stored at write time instead of classified from the persisted raw error string
- operation attempt history across search and download phases
+3 -1
View File
@@ -15,6 +15,7 @@ import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './shared/auth.guard';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { ArchiveModule } from './archive/archive.module';
import { OperationsModule } from './operations/operations.module';
@Module({
imports: [
@@ -49,7 +50,7 @@ import { ArchiveModule } from './archive/archive.module';
ThrottlerModule.forRoot([
{
ttl: 60000,
limit: 60,
limit: Number(process.env.API_THROTTLE_LIMIT || 600),
},
]),
BullModule.forRootAsync({
@@ -81,6 +82,7 @@ import { ArchiveModule } from './archive/archive.module';
TrackModule,
PlaylistModule,
ArchiveModule,
OperationsModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
import { OperationsService } from './operations.service';
import { OperationsSnapshot } from './operations.types';
@Controller('operations')
export class OperationsController {
constructor(private readonly operationsService: OperationsService) {}
@Get('snapshot')
getSnapshot(): Promise<OperationsSnapshot> {
return this.operationsService.getSnapshot();
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bullmq';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SharedModule } from '../shared/shared.module';
import { TrackEntity } from '../track/track.entity';
import { OperationsController } from './operations.controller';
import { OperationsService } from './operations.service';
@Module({
imports: [
TypeOrmModule.forFeature([PlaylistEntity, TrackEntity]),
BullModule.registerQueue(
{ name: 'track-search-processor' },
{ name: 'track-download-processor' },
),
SharedModule,
],
controllers: [OperationsController],
providers: [OperationsService],
})
export class OperationsModule {}
@@ -0,0 +1,331 @@
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { InjectRepository } from '@nestjs/typeorm';
import { Job, Queue } from 'bullmq';
import { In, MoreThan, Repository } from 'typeorm';
import { PlaylistEntity } from '../playlist/playlist.entity';
import { SpotifyApiService } from '../shared/spotify-api.service';
import { TrackEntity, TrackStatusEnum } from '../track/track.entity';
import {
ErrorClass,
FailureTruth,
OperationsSnapshot,
SpootyOperation,
TrackTruth,
} from './operations.types';
interface ClassifiedError {
errorClass: ErrorClass;
errorSummary: string;
errorDetail: string;
}
@Injectable()
export class OperationsService {
constructor(
@InjectRepository(TrackEntity)
private readonly trackRepository: Repository<TrackEntity>,
@InjectRepository(PlaylistEntity)
private readonly playlistRepository: Repository<PlaylistEntity>,
@InjectQueue('track-download-processor')
private readonly trackDownloadQueue: Queue,
@InjectQueue('track-search-processor')
private readonly trackSearchQueue: Queue,
private readonly spotifyApiService: SpotifyApiService,
) {}
async getSnapshot(): Promise<OperationsSnapshot> {
const [
spotifyConnected,
searchQueueCounts,
downloadQueueCounts,
activeSearchJobs,
activeDownloadJobs,
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
searchingTracks,
downloadingTracks,
recentFailures,
recentTracks,
] = await Promise.all([
this.spotifyApiService.hasUserToken(),
this.trackSearchQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackDownloadQueue.getJobCounts('waiting', 'delayed', 'active'),
this.trackSearchQueue.getJobs('active', 0, 100, true),
this.trackDownloadQueue.getJobs('active', 0, 100, true),
this.playlistRepository.countBy({ isTrack: false }),
this.playlistRepository.countBy({ isTrack: true }),
this.trackRepository.count(),
this.trackRepository.countBy({
status: In([
TrackStatusEnum.Searching,
TrackStatusEnum.Queued,
TrackStatusEnum.Downloading,
]),
}),
this.trackRepository.countBy({ status: TrackStatusEnum.Completed }),
this.trackRepository.countBy({ status: TrackStatusEnum.Error }),
this.trackRepository.countBy({ downloadAttemptCount: MoreThan(0) }),
this.trackRepository.find({
where: { status: TrackStatusEnum.Searching },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Downloading },
order: { createdAt: 'DESC' },
take: 100,
}),
this.trackRepository.find({
where: { status: TrackStatusEnum.Error },
order: { createdAt: 'DESC' },
take: 20,
}),
this.trackRepository.find({
order: { createdAt: 'DESC' },
take: 20,
}),
]);
const activeSearchTracks = await this.mergeTracksFromActiveJobs(
searchingTracks,
activeSearchJobs,
);
const activeDownloadTracks = await this.mergeTracksFromActiveJobs(
downloadingTracks,
activeDownloadJobs,
);
const searchQueueDepth =
(searchQueueCounts.waiting || 0) + (searchQueueCounts.delayed || 0);
const downloadQueueDepth =
(downloadQueueCounts.waiting || 0) + (downloadQueueCounts.delayed || 0);
const activeSearches = activeSearchJobs.length;
const activeDownloads = activeDownloadJobs.length;
return {
generatedAt: new Date().toISOString(),
runtime: {
operation: this.getOperation({
activeSearches,
activeDownloads,
searchQueueDepth,
downloadQueueDepth,
failed,
retries,
}),
spotifyConnected,
searchQueueDepth,
downloadQueueDepth,
activeSearches,
activeDownloads,
},
counters: {
playlists,
singleSongs,
tracks,
active,
completed,
failed,
retries,
},
active: {
searching: activeSearchTracks.map((track) => this.toTrackTruth(track)),
downloading: activeDownloadTracks.map((track) =>
this.toTrackTruth(track),
),
},
recentFailures: recentFailures.map((track) => this.toTrackTruth(track)),
recentTracks: recentTracks.map((track) => this.toTrackTruth(track)),
};
}
private async mergeTracksFromActiveJobs(
tracks: TrackEntity[],
jobs: Job[],
): Promise<TrackEntity[]> {
const knownIds = new Set(tracks.map((track) => track.id).filter(Boolean));
const missingIds = jobs
.map((job) => Number(job.data?.id))
.filter((id) => Number.isInteger(id) && !knownIds.has(id));
if (!missingIds.length) {
return tracks;
}
const activeJobTracks = await this.trackRepository.find({
where: { id: In(missingIds) },
});
return [...tracks, ...activeJobTracks];
}
private toTrackTruth(track: TrackEntity): TrackTruth | FailureTruth {
const rejectedCandidates = this.parseRejectedCandidates(track);
const error = this.classifyError(track.error);
return {
id: track.id,
artist: track.artist,
name: track.name,
status: TrackStatusEnum[track.status] || String(track.status),
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
...(typeof track.downloadAttemptCount === 'number'
? { downloadAttemptCount: track.downloadAttemptCount }
: {}),
...(error || {}),
rejectedCandidateCount: rejectedCandidates.length,
rejectedCandidates,
};
}
private parseRejectedCandidates(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 classifyError(error?: string): ClassifiedError | undefined {
if (!error) {
return undefined;
}
const detail = this.trimDetail(error);
const normalized = error.toLowerCase();
if (normalized.includes('no acceptable youtube result found')) {
return this.error(
'NO_ACCEPTABLE_CANDIDATE',
'No acceptable YouTube candidate',
detail,
);
}
if (normalized.includes('sign in to confirm your age')) {
return this.error(
'YOUTUBE_AGE_GATED',
'YouTube video is age-gated',
detail,
);
}
if (normalized.includes('private video')) {
return this.error(
'YOUTUBE_PRIVATE_VIDEO',
'YouTube video is private',
detail,
);
}
if (
normalized.includes('only images are available') ||
normalized.includes('requested format is not available') ||
normalized.includes('no downloadable audio/video formats')
) {
return this.error(
'YOUTUBE_NO_FORMATS',
'No downloadable YouTube audio format',
detail,
);
}
if (
normalized.includes('video unavailable') ||
normalized.includes('this video is not available') ||
normalized.includes('selected video unavailable')
) {
return this.error(
'YOUTUBE_VIDEO_UNAVAILABLE',
'YouTube video is unavailable',
detail,
);
}
if (normalized.includes('unable to download webpage')) {
return this.error(
'YOUTUBE_WEBPAGE_UNAVAILABLE',
'YouTube webpage is unavailable',
detail,
);
}
if (
normalized.includes('failed to parse yt-dlp search output') ||
normalized.includes('yt-dlp exited with code') ||
normalized.includes('failed to start yt-dlp') ||
normalized.includes('yt-dlp exceeded maximum runtime')
) {
return this.error(
'YOUTUBE_EXTRACTION_FAILURE',
'YouTube extraction failed',
detail,
);
}
if (
normalized.includes('eacces') ||
normalized.includes('enoent') ||
normalized.includes('failed to write') ||
normalized.includes('permission denied')
) {
return this.error('FILE_WRITE_FAILED', 'File write failed', detail);
}
return this.error(
'UNKNOWN_DOWNLOAD_ERROR',
'Unknown download error',
detail,
);
}
private error(
errorClass: ErrorClass,
errorSummary: string,
errorDetail: string,
): ClassifiedError {
return { errorClass, errorSummary, errorDetail };
}
private trimDetail(error: string): string {
const trimmed = error.trim();
return trimmed.length > 1200 ? `${trimmed.slice(0, 1197)}...` : trimmed;
}
private getOperation(input: {
activeSearches: number;
activeDownloads: number;
searchQueueDepth: number;
downloadQueueDepth: number;
failed: number;
retries: number;
}): SpootyOperation {
if (input.activeDownloads > 0 || input.downloadQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'downloading';
}
if (input.activeSearches > 0 || input.searchQueueDepth > 0) {
return input.retries > 0 ? 'retrying' : 'searching';
}
if (input.failed > 0) {
return 'degraded';
}
return 'idle';
}
}
@@ -0,0 +1,61 @@
export type SpootyOperation =
| 'idle'
| 'searching'
| 'downloading'
| 'retrying'
| 'degraded';
export type ErrorClass =
| 'YOUTUBE_VIDEO_UNAVAILABLE'
| 'YOUTUBE_AGE_GATED'
| 'YOUTUBE_NO_FORMATS'
| 'YOUTUBE_PRIVATE_VIDEO'
| 'YOUTUBE_WEBPAGE_UNAVAILABLE'
| 'YOUTUBE_EXTRACTION_FAILURE'
| 'NO_ACCEPTABLE_CANDIDATE'
| 'FILE_WRITE_FAILED'
| 'UNKNOWN_DOWNLOAD_ERROR';
export interface TrackTruth {
id: number;
artist: string;
name: string;
status: string;
spotifyUrl?: string;
youtubeUrl?: string;
downloadAttemptCount?: number;
errorClass?: ErrorClass;
errorSummary?: string;
errorDetail?: string;
rejectedCandidateCount: number;
rejectedCandidates: string[];
}
export interface FailureTruth extends TrackTruth {}
export interface OperationsSnapshot {
generatedAt: string;
runtime: {
operation: SpootyOperation;
spotifyConnected: boolean;
searchQueueDepth: number;
downloadQueueDepth: number;
activeSearches: number;
activeDownloads: number;
};
counters: {
playlists: number;
singleSongs: number;
tracks: number;
active: number;
completed: number;
failed: number;
retries: number;
};
active: {
searching: TrackTruth[];
downloading: TrackTruth[];
};
recentFailures: FailureTruth[];
recentTracks: TrackTruth[];
}
@@ -0,0 +1,11 @@
import { IsString, MaxLength } from 'class-validator';
export class SearchTrackDto {
@IsString()
@MaxLength(512)
artist: string;
@IsString()
@MaxLength(512)
title: string;
}
@@ -12,6 +12,7 @@ import { PlaylistService } from './playlist.service';
import { PlaylistEntity } from './playlist.entity';
import { CreatePlaylistDto } from './dto/create-playlist.dto';
import { UpdatePlaylistDto } from './dto/update-playlist.dto';
import { SearchTrackDto } from './dto/search-track.dto';
@Controller('playlist')
export class PlaylistController {
@@ -27,6 +28,13 @@ export class PlaylistController {
await this.service.create(playlist as PlaylistEntity);
}
@Post('search-track')
async searchTrack(
@Body() body: SearchTrackDto,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
return this.service.createFromSearch(body.artist, body.title);
}
@Put(':id')
update(
@Param('id', ParseIntPipe) id: number,
+23 -1
View File
@@ -48,6 +48,27 @@ export class PlaylistService {
this.io.emit(WsPlaylistOperation.Delete, { id });
}
async createFromSearch(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
const track = await this.spotifyService.searchTrack(artist, title);
await this.create({
spotifyUrl: track.spotifyUrl,
name: track.name,
active: false,
isTrack: true,
} as PlaylistEntity);
return {
spotifyUrl: track.spotifyUrl,
name: track.name,
artist: track.artist,
};
}
async create(playlist: PlaylistEntity): Promise<void> {
// Detect if URL is for a single track or a playlist and route accordingly
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
@@ -63,7 +84,7 @@ export class PlaylistService {
}
private async createSingleTrack(playlist: PlaylistEntity): Promise<void> {
let trackDetail: { name: string; artist: string; image: string };
let trackDetail: { name: string; artist: string; image: string; durationMs?: number };
let playlist2Save: PlaylistEntity;
try {
trackDetail = await this.spotifyService.getTrackDetail(
@@ -97,6 +118,7 @@ export class PlaylistService {
name: trackDetail.name,
spotifyUrl: playlist.spotifyUrl,
coverUrl: trackDetail.image,
durationMs: trackDetail.durationMs,
},
savedPlaylist,
);
@@ -12,27 +12,57 @@ export class SpotifyAuthController {
}
@Get('login')
login(@Res() res: Response): void {
res.redirect(this.spotifyApiService.getAuthorizationUrl());
login(
@Query('returnTo') returnTo: string | undefined,
@Res() res: Response,
): void {
const state =
returnTo && /^https?:\/\/localhost:\d+\/?$/.test(returnTo)
? Buffer.from(JSON.stringify({ returnTo }), 'utf8').toString('base64url')
: undefined;
res.redirect(this.spotifyApiService.getAuthorizationUrl(state));
}
@Get('callback')
async callback(
@Query('code') code: string | undefined,
@Query('error') error: string | undefined,
@Query('state') state: string | undefined,
@Res() res: Response,
): Promise<void> {
const returnTo = this.parseReturnTo(state);
if (error) {
res.redirect(`/?spotify_error=${encodeURIComponent(error)}`);
res.redirect(`${returnTo}?spotify_error=${encodeURIComponent(error)}`);
return;
}
if (!code) {
res.redirect('/?spotify_error=missing_code');
res.redirect(`${returnTo}?spotify_error=missing_code`);
return;
}
await this.spotifyApiService.exchangeAuthorizationCode(code);
res.redirect('/?spotify=connected');
res.redirect(`${returnTo}?spotify=connected`);
}
private parseReturnTo(state: string | undefined): string {
if (!state) {
return '/';
}
try {
const parsed = JSON.parse(Buffer.from(state, 'base64url').toString('utf8'));
const returnTo = parsed?.returnTo;
if (typeof returnTo === 'string' && /^https?:\/\/localhost:\d+\/?$/.test(returnTo)) {
return returnTo;
}
} catch {
return '/';
}
return '/';
}
}
+84 -1
View File
@@ -151,7 +151,7 @@ export class SpotifyApiService {
return !!this.readUserToken();
}
getAuthorizationUrl(): string {
getAuthorizationUrl(state?: string): string {
const url = new URL('https://accounts.spotify.com/authorize');
url.searchParams.set('response_type', 'code');
@@ -167,6 +167,10 @@ export class SpotifyApiService {
url.searchParams.set('redirect_uri', this.getRedirectUri());
url.searchParams.set('show_dialog', 'true');
if (state) {
url.searchParams.set('state', state);
}
return url.toString();
}
@@ -336,6 +340,85 @@ export class SpotifyApiService {
}
}
async searchTrack(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
const accessToken = await this.getClientCredentialsAccessToken();
const plainQuery = `${artist} ${title}`.trim();
const structuredQuery = `track:"${title}" artist:"${artist}"`;
const url = new URL('https://api.spotify.com/v1/search');
url.searchParams.set('q', structuredQuery || plainQuery);
url.searchParams.set('type', 'track');
url.searchParams.set('limit', '10');
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Spotify track search failed: ${response.status} ${errorText}`);
}
const data = await response.json();
const tracks = data?.tracks?.items || [];
if (!tracks.length) {
throw new Error(`No Spotify track found for ${artist} - ${title}`);
}
const normalize = (value: string) =>
(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const wantedArtist = normalize(artist);
const wantedTitle = normalize(title);
const scoreTrack = (track: any): number => {
const trackName = normalize(track.name);
const artistNames = (track.artists || []).map((item: any) => normalize(item.name));
const allArtists = artistNames.join(' ');
let score = 0;
if (trackName === wantedTitle) score += 70;
else if (trackName.includes(wantedTitle) || wantedTitle.includes(trackName)) score += 40;
if (artistNames.some((name: string) => name === wantedArtist)) score += 50;
else if (allArtists.includes(wantedArtist) || wantedArtist.includes(allArtists)) score += 25;
if (track.explicit !== undefined) score += 1;
if (track.popularity) score += Math.min(20, Math.floor(track.popularity / 5));
return score;
};
const ranked = [...tracks]
.map((track: any) => ({ track, score: scoreTrack(track) }))
.sort((a, b) => b.score - a.score);
const best = ranked[0]?.track || tracks[0];
this.logger.debug(
`Spotify search "${plainQuery}" selected "${best?.artists?.map((item: any) => item.name).join(', ')} - ${best?.name}" score=${ranked[0]?.score ?? 0}`,
);
return {
spotifyUrl: best.external_urls.spotify,
name: best.name,
artist: (best.artists || []).map((item: any) => item.name).join(', '),
image: best.album?.images?.[0]?.url || '',
durationMs: best.duration_ms,
};
}
async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> {
try {
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
@@ -72,6 +72,14 @@ export class SpotifyService {
return this.spotifyApiService.getArtistLibrary(spotifyUrl);
}
async searchTrack(
artist: string,
title: string,
): Promise<{ spotifyUrl: string; name: string; artist: string; image: string; durationMs?: number }> {
return this.spotifyApiService.searchTrack(artist, title);
}
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
try {
+33 -7
View File
@@ -38,6 +38,19 @@ const BAD_MATCH_TERMS = [
'reaction',
'tutorial',
'instrumental',
'reaction',
'review',
'analysis',
'breakdown',
'explained',
'secret message',
'meaning',
'interview',
'podcast',
'news',
'documentary',
'fifa world cup',
'song style',
];
const OFFICIALISH_TERMS = [
@@ -123,11 +136,12 @@ export class YoutubeService {
.join(' | '),
);
const accepted = candidates.find((candidate) => !candidate.rejected);
const minimumScore = Number(process.env.YT_MIN_CANDIDATE_SCORE || 60);
const accepted = candidates.find((candidate) => !candidate.rejected && candidate.score >= minimumScore);
if (!accepted) {
throw new Error(
`No acceptable YouTube result found for: ${query}` +
`No acceptable YouTube result found for: ${query} with minimum score ${minimumScore}` +
(excludedUrls.length ? ` after excluding ${excludedUrls.length} failed candidate(s)` : ''),
);
}
@@ -143,11 +157,12 @@ export class YoutubeService {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--flat-playlist',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--extractor-args',
'youtube:player_client=android_vr',
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
searchTarget,
@@ -198,6 +213,7 @@ export class YoutubeService {
const author = String(video.author?.name || video.author || '');
const url = String(video.url || '');
const seconds = this.getVideoSeconds(video);
this.logger.debug(`YT candidate raw duration title="${title}" duration=${video.duration} seconds=${video.seconds} parsed=${seconds}`);
const normalizedTitle = this.normalize(title);
const normalizedAuthor = this.normalize(author);
@@ -213,14 +229,21 @@ export class YoutubeService {
reasons.push('title=45');
}
let hasArtistSignal = false;
for (const artistPart of this.artistParts(normalizedArtist)) {
if (artistPart.length >= 3 && (normalizedTitle.includes(artistPart) || normalizedAuthor.includes(artistPart))) {
score += 20;
reasons.push(`artist=${artistPart}:20`);
hasArtistSignal = true;
break;
}
}
if (!hasArtistSignal) {
score -= 35;
reasons.push('artist=missing:-35');
}
const officialish = OFFICIALISH_TERMS.find((term) =>
`${normalizedTitle} ${normalizedAuthor}`.includes(this.normalize(term)),
);
@@ -233,8 +256,9 @@ export class YoutubeService {
normalizedTitle.includes(this.normalize(term)),
);
if (badTerm) {
score -= 25;
reasons.push(`bad=${badTerm}:-25`);
score -= 80;
rejected = true;
reasons.push(`bad=${badTerm}:-80 reject`);
}
if (durationMs && seconds) {
@@ -380,8 +404,8 @@ export class YoutubeService {
return 'YouTube unavailable: private video';
}
if (text.includes('Video unavailable')) {
return 'YouTube unavailable';
if (text.includes('Video unavailable') || text.includes('This video is not available')) {
return 'YouTube unavailable: selected video unavailable';
}
if (text.includes('Unable to download webpage')) {
@@ -495,6 +519,8 @@ export class YoutubeService {
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'-f',
'bestaudio/best',
'--extract-audio',
'--audio-format',
format,
+2 -1
View File
@@ -320,7 +320,8 @@ export class TrackService {
const safeArtist = track.artist || 'unknown_artist';
const safeName = track.name || 'unknown_track';
const fileName = `${safeArtist} - ${safeName}`;
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
return `${this.utilsService.stripFileIllegalChars(fileName)}.${format}`;
}
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {