feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads

This commit is contained in:
AlanCarrer
2025-10-17 20:54:44 +00:00
committed by Filip Gulán
parent de55e42fcd
commit 4c051782fd
12 changed files with 84 additions and 148 deletions
+11
View File
@@ -112,6 +112,17 @@ Some behaviour and settings of Spooty can be configured using environment variab
RUN_REDIS | false | Whenever Redis server should be started from backend (recommended for Docker environment) |
SPOTIFY_CLIENT_ID | your_client_id | Client ID of your Spotify application (required) |
SPOTIFY_CLIENT_SECRET| your_client_secret | Client Secret of your Spotify application (required) |
YT_DOWNLOADS_PER_MINUTE | 3 | Set the maximum number of YouTube downloads started per minute |
YT_COOKIES | | Allows you to pass your YouTube cookies to bypass some download restrictions. See [below](#how-to-get-your-youtube-cookies) for instructions. |
How to get your YouTube cookies (using browser dev tools):
1. Go to https://www.youtube.com and log in if needed.
2. Open the browser developer tools (F12 or right click > Inspect).
3. Go to the "Application" tab (in Chrome) or "Storage" (in Firefox).
4. In the left menu, find "Cookies" and select https://www.youtube.com.
5. Copy all the cookies (name=value) and join them with a semicolon and a space, like:
VISITOR_INFO1_LIVE=xxxx; YSC=xxxx; SID=xxxx; ...
6. Paste this string into the YT_COOKIES environment variable (in your .env or Docker config).
# ⚖️ License
[MIT](https://choosealicense.com/licenses/mit/)
-128
View File
@@ -383,44 +383,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": {
"version": "9.2.15",
"dev": true,
@@ -481,32 +443,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@angular-devkit/schematics-cli/node_modules/run-async": {
"version": "3.0.0",
"dev": true,
@@ -4074,44 +4010,6 @@
}
}
},
"node_modules/@nestjs/schematics/node_modules/chokidar": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/@nestjs/schematics/node_modules/glob-parent": {
"version": "5.1.2",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@nestjs/schematics/node_modules/magic-string": {
"version": "0.30.8",
"dev": true,
@@ -4134,32 +4032,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@nestjs/schematics/node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/@nestjs/schematics/node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.1",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@nestjs/serve-static": {
"version": "4.0.2",
"license": "MIT",
+3
View File
@@ -10,3 +10,6 @@ REDIS_RUN=false
# Credentials for Spotify API
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
YT_COOKIES="AEC=xxx; APISID=xxx; APISID=xxx; HSID=xxx; HSID=xxx; LOGIN_INFO=xxx; NID=xxx; PREF=xxx; SAPISID=xxx; SAPISID=Nxxx; SEARCH_SAMESITE=xxx; SID=xxx; SID=xxx; SIDCC=xxx; SIDCC=xxx; SSID=xxx; SSID=xxx; STRP=xxx; VISITOR_PRIVACY_METADATA=xxx; YSC=xxx"
YT_DOWNLOADS_PER_MINUTE=3
+3
View File
@@ -10,3 +10,6 @@ REDIS_RUN=true
# Credentials for Spotify API
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
YT_COOKIES=
YT_DOWNLOADS_PER_MINUTE=3
+1 -1
View File
@@ -14,7 +14,7 @@ import { BullModule } from '@nestjs/bullmq';
@Module({
imports: [
ConfigModule.forRoot(),
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
+41 -7
View File
@@ -10,21 +10,39 @@ import * as ytdl from '@distube/ytdl-core';
import { Readable } from 'stream';
const NodeID3 = require('node-id3');
const YT_SETTINGS: ytdl.downloadOptions = {
quality: 'highestaudio',
filter: 'audioonly',
};
enum StreamStates {
Finish = 'finish',
Error = 'error',
}
function parseCookies(
cookieString?: string,
): { name: string; value: string }[] | undefined {
if (!cookieString) return undefined;
return cookieString
.split(';')
.map((c) => {
const [name, ...rest] = c.split('=');
return { name: name.trim(), value: rest.join('=').trim() };
})
.filter((c) => c.name && c.value);
}
@Injectable()
export class YoutubeService {
private readonly logger = new Logger(TrackService.name);
private readonly ytCookies: string | undefined;
private readonly ytAgent: any | undefined;
constructor(private readonly configService: ConfigService) {}
constructor(private readonly configService: ConfigService) {
this.ytCookies = this.configService.get<string>('YT_COOKIES');
if (this.ytCookies) {
const cookiesArr = parseCookies(this.ytCookies);
if (cookiesArr && cookiesArr.length > 0) {
this.ytAgent = ytdl.createAgent(cookiesArr);
}
}
}
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
this.logger.debug(`Searching ${artist} - ${name} on YT`);
@@ -86,7 +104,23 @@ export class YoutubeService {
youtubeUrl: string,
reject: (reason: any) => void,
): Readable {
return ytdl(youtubeUrl, YT_SETTINGS).on(StreamStates.Error, (err) =>
if (!youtubeUrl) {
this.logger.error('youtubeUrl is null or undefined');
reject('youtubeUrl is null or undefined');
return null;
}
const options: ytdl.downloadOptions = {
quality: 'highestaudio',
filter: 'audioonly',
agent: this.ytAgent,
requestOptions: {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
},
};
return ytdl(youtubeUrl, options).on(StreamStates.Error, (err) =>
reject(err),
);
}
@@ -9,7 +9,10 @@ export class TrackDownloadProcessor extends WorkerHost {
super();
}
async process(job: Job<TrackEntity, void, string>): Promise<void> {
async process(job: Job<TrackEntity, void>): Promise<void> {
const maxPerMinute = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3);
const sleepMs = Math.floor(60000 / maxPerMinute);
await new Promise((res) => setTimeout(res, sleepMs));
await this.trackService.downloadFromYoutube(job.data);
}
}
+2 -6
View File
@@ -6,12 +6,8 @@ import { TrackController } from './track.controller';
import { ConfigModule } from '@nestjs/config';
import { SharedModule } from '../shared/shared.module';
import { BullModule } from '@nestjs/bullmq';
import {
TrackDownloadProcessor,
} from './track-download.processor';
import {
TrackSearchProcessor,
} from './track-search.processor';
import { TrackDownloadProcessor } from './track-download.processor';
import { TrackSearchProcessor } from './track-search.processor';
@Module({
imports: [
+16 -2
View File
@@ -110,6 +110,17 @@ export class TrackService {
if (!(await this.get(track.id))) {
return;
}
if (
!track.name ||
!track.artist ||
!track.playlist ||
!track.playlist.coverUrl
) {
this.logger.error(
`Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}, coverUrl=${track.playlist?.coverUrl}`,
);
return;
}
await this.update(track.id, {
...track,
status: TrackStatusEnum.Downloading,
@@ -137,13 +148,16 @@ export class TrackService {
}
getTrackFileName(track: TrackEntity): string {
const fileName = `${track.artist} - ${track.name.replace('/', '')}`;
const safeArtist = track.artist || 'unknown_artist';
const safeName = (track.name || 'unknown_track').replace('/', '');
const fileName = `${safeArtist} - ${safeName}`;
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
}
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
const safePlaylistName = playlist?.name || 'unknown_playlist';
return resolve(
this.utilsService.getPlaylistFolderPath(playlist.name),
this.utilsService.getPlaylistFolderPath(safePlaylistName),
this.getTrackFileName(track),
);
}