feat(youtube): use youtube cookies to bypass limitation & add timeout between each downloads
This commit is contained in:
@@ -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) |
|
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_ID | your_client_id | Client ID of your Spotify application (required) |
|
||||||
SPOTIFY_CLIENT_SECRET| your_client_secret | Client Secret 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
|
# ⚖️ License
|
||||||
[MIT](https://choosealicense.com/licenses/mit/)
|
[MIT](https://choosealicense.com/licenses/mit/)
|
||||||
|
|||||||
Generated
-128
@@ -383,44 +383,6 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"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": {
|
"node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": {
|
||||||
"version": "9.2.15",
|
"version": "9.2.15",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -481,32 +443,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/@angular-devkit/schematics-cli/node_modules/run-async": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"dev": true,
|
"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": {
|
"node_modules/@nestjs/schematics/node_modules/magic-string": {
|
||||||
"version": "0.30.8",
|
"version": "0.30.8",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
@@ -4134,32 +4032,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/@nestjs/serve-static": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
+1
-1
@@ -31,4 +31,4 @@
|
|||||||
"release-it-docker-plugin": "^2.0.0",
|
"release-it-docker-plugin": "^2.0.0",
|
||||||
"rimraf": "^6.0.1"
|
"rimraf": "^6.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,3 +10,6 @@ REDIS_RUN=false
|
|||||||
# Credentials for Spotify API
|
# Credentials for Spotify API
|
||||||
SPOTIFY_CLIENT_ID=your_client_id
|
SPOTIFY_CLIENT_ID=your_client_id
|
||||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
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
|
||||||
@@ -10,3 +10,6 @@ REDIS_RUN=true
|
|||||||
# Credentials for Spotify API
|
# Credentials for Spotify API
|
||||||
SPOTIFY_CLIENT_ID=your_client_id
|
SPOTIFY_CLIENT_ID=your_client_id
|
||||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||||
|
|
||||||
|
YT_COOKIES=
|
||||||
|
YT_DOWNLOADS_PER_MINUTE=3
|
||||||
@@ -84,4 +84,4 @@
|
|||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"testEnvironment": "node"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,7 +14,7 @@ import { BullModule } from '@nestjs/bullmq';
|
|||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
ScheduleModule.forRoot(),
|
ScheduleModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|||||||
@@ -10,21 +10,39 @@ import * as ytdl from '@distube/ytdl-core';
|
|||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
const NodeID3 = require('node-id3');
|
const NodeID3 = require('node-id3');
|
||||||
|
|
||||||
const YT_SETTINGS: ytdl.downloadOptions = {
|
|
||||||
quality: 'highestaudio',
|
|
||||||
filter: 'audioonly',
|
|
||||||
};
|
|
||||||
|
|
||||||
enum StreamStates {
|
enum StreamStates {
|
||||||
Finish = 'finish',
|
Finish = 'finish',
|
||||||
Error = 'error',
|
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()
|
@Injectable()
|
||||||
export class YoutubeService {
|
export class YoutubeService {
|
||||||
private readonly logger = new Logger(TrackService.name);
|
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> {
|
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
||||||
this.logger.debug(`Searching ${artist} - ${name} on YT`);
|
this.logger.debug(`Searching ${artist} - ${name} on YT`);
|
||||||
@@ -86,7 +104,23 @@ export class YoutubeService {
|
|||||||
youtubeUrl: string,
|
youtubeUrl: string,
|
||||||
reject: (reason: any) => void,
|
reject: (reason: any) => void,
|
||||||
): Readable {
|
): 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),
|
reject(err),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ export class TrackDownloadProcessor extends WorkerHost {
|
|||||||
super();
|
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);
|
await this.trackService.downloadFromYoutube(job.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,8 @@ import { TrackController } from './track.controller';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { SharedModule } from '../shared/shared.module';
|
import { SharedModule } from '../shared/shared.module';
|
||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import {
|
import { TrackDownloadProcessor } from './track-download.processor';
|
||||||
TrackDownloadProcessor,
|
import { TrackSearchProcessor } from './track-search.processor';
|
||||||
} from './track-download.processor';
|
|
||||||
import {
|
|
||||||
TrackSearchProcessor,
|
|
||||||
} from './track-search.processor';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -110,6 +110,17 @@ export class TrackService {
|
|||||||
if (!(await this.get(track.id))) {
|
if (!(await this.get(track.id))) {
|
||||||
return;
|
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, {
|
await this.update(track.id, {
|
||||||
...track,
|
...track,
|
||||||
status: TrackStatusEnum.Downloading,
|
status: TrackStatusEnum.Downloading,
|
||||||
@@ -137,13 +148,16 @@ export class TrackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTrackFileName(track: TrackEntity): string {
|
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)}`;
|
return `${this.utilsService.stripFileIllegalChars(fileName)}.${this.configService.get<string>(EnvironmentEnum.FORMAT)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
|
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
|
||||||
|
const safePlaylistName = playlist?.name || 'unknown_playlist';
|
||||||
return resolve(
|
return resolve(
|
||||||
this.utilsService.getPlaylistFolderPath(playlist.name),
|
this.utilsService.getPlaylistFolderPath(safePlaylistName),
|
||||||
this.getTrackFileName(track),
|
this.getTrackFileName(track),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,4 +48,4 @@
|
|||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
"typescript": "~5.6.3"
|
"typescript": "~5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user