mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-16 12:42:06 +00:00
fix(downloader): private playlist download
This commit is contained in:
@ -3,23 +3,14 @@ import { join } from 'node:path';
|
|||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
||||||
import { ClientType, Innertube, UniversalCache, Utils } from 'youtubei.js';
|
import { ClientType, Innertube, UniversalCache, Utils, YTNodes } from 'youtubei.js';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
import ytpl from 'ytpl';
|
|
||||||
// REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
|
|
||||||
import filenamify from 'filenamify';
|
import filenamify from 'filenamify';
|
||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
import { createFFmpeg } from '@ffmpeg.wasm/main';
|
import { createFFmpeg } from '@ffmpeg.wasm/main';
|
||||||
|
|
||||||
import NodeID3, { TagConstants } from 'node-id3';
|
import NodeID3, { TagConstants } from 'node-id3';
|
||||||
|
|
||||||
import PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
|
||||||
import { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
|
||||||
|
|
||||||
import TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
|
||||||
|
|
||||||
import { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
|
||||||
|
|
||||||
import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBadge } from './utils';
|
import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBadge } from './utils';
|
||||||
|
|
||||||
import config from './config';
|
import config from './config';
|
||||||
@ -32,8 +23,13 @@ import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
|||||||
import { injectCSS } from '../utils';
|
import { injectCSS } from '../utils';
|
||||||
import { cache } from '../../providers/decorators';
|
import { cache } from '../../providers/decorators';
|
||||||
|
|
||||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||||
|
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||||
|
import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
|
||||||
|
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
||||||
|
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
||||||
|
|
||||||
|
import type { GetPlayerResponse } from '../../types/get-player-response';
|
||||||
|
|
||||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||||
|
|
||||||
@ -69,16 +65,19 @@ const sendError = (error: Error, source?: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCookieFromWindow = async (win: BrowserWindow) => {
|
||||||
|
return (await win.webContents.session.cookies.get({ url: 'https://music.youtube.com' })).map((it) =>
|
||||||
|
it.name + '=' + it.value + ';'
|
||||||
|
).join('');
|
||||||
|
};
|
||||||
|
|
||||||
export default async (win_: BrowserWindow) => {
|
export default async (win_: BrowserWindow) => {
|
||||||
win = win_;
|
win = win_;
|
||||||
injectCSS(win.webContents, style);
|
injectCSS(win.webContents, style);
|
||||||
|
|
||||||
const cookie = (await win.webContents.session.cookies.get({ url: 'https://music.youtube.com' })).map((it) =>
|
|
||||||
it.name + '=' + it.value + ';'
|
|
||||||
).join('');
|
|
||||||
yt = await Innertube.create({
|
yt = await Innertube.create({
|
||||||
cache: new UniversalCache(false),
|
cache: new UniversalCache(false),
|
||||||
cookie,
|
cookie: await getCookieFromWindow(win),
|
||||||
generate_session_locally: true,
|
generate_session_locally: true,
|
||||||
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const url =
|
const url =
|
||||||
@ -118,6 +117,7 @@ export async function downloadSong(
|
|||||||
let resolvedName;
|
let resolvedName;
|
||||||
try {
|
try {
|
||||||
await downloadSongUnsafe(
|
await downloadSongUnsafe(
|
||||||
|
false,
|
||||||
url,
|
url,
|
||||||
(name: string) => resolvedName = name,
|
(name: string) => resolvedName = name,
|
||||||
playlistFolder,
|
playlistFolder,
|
||||||
@ -129,8 +129,31 @@ export async function downloadSong(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function downloadSongFromId(
|
||||||
|
id: string,
|
||||||
|
playlistFolder: string | undefined = undefined,
|
||||||
|
trackId: string | undefined = undefined,
|
||||||
|
increasePlaylistProgress: (value: number) => void = () => {
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let resolvedName;
|
||||||
|
try {
|
||||||
|
await downloadSongUnsafe(
|
||||||
|
true,
|
||||||
|
id,
|
||||||
|
(name: string) => resolvedName = name,
|
||||||
|
playlistFolder,
|
||||||
|
trackId,
|
||||||
|
increasePlaylistProgress,
|
||||||
|
);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
sendError(error as Error, resolvedName || id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadSongUnsafe(
|
async function downloadSongUnsafe(
|
||||||
url: string,
|
isId: boolean,
|
||||||
|
idOrUrl: string,
|
||||||
setName: (name: string) => void,
|
setName: (name: string) => void,
|
||||||
playlistFolder: string | undefined = undefined,
|
playlistFolder: string | undefined = undefined,
|
||||||
trackId: string | undefined = undefined,
|
trackId: string | undefined = undefined,
|
||||||
@ -147,8 +170,13 @@ async function downloadSongUnsafe(
|
|||||||
|
|
||||||
sendFeedback('Downloading...', 2);
|
sendFeedback('Downloading...', 2);
|
||||||
|
|
||||||
const id = getVideoId(url);
|
let id: string | null;
|
||||||
if (typeof id !== 'string') throw new Error('Video not found');
|
if (isId) {
|
||||||
|
id = idOrUrl;
|
||||||
|
} else {
|
||||||
|
id = getVideoId(idOrUrl);
|
||||||
|
if (typeof id !== 'string') throw new Error('Video not found');
|
||||||
|
}
|
||||||
|
|
||||||
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
|
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
|
||||||
|
|
||||||
@ -417,11 +445,9 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
|
|
||||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
console.log(`trying to get playlist ID: '${playlistId}'`);
|
||||||
sendFeedback('Getting playlist info…');
|
sendFeedback('Getting playlist info…');
|
||||||
let playlist: ytpl.Result;
|
let playlist: Playlist;
|
||||||
try {
|
try {
|
||||||
playlist = await ytpl(playlistId, {
|
playlist = await yt.music.getPlaylist(playlistId);
|
||||||
limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
sendError(
|
sendError(
|
||||||
Error(`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(error)}`),
|
Error(`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(error)}`),
|
||||||
@ -429,22 +455,27 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.items.length === 0) {
|
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||||
sendError(new Error('Playlist is empty'));
|
sendError(new Error('Playlist is empty'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.items.length === 1) {
|
const items = playlist.items!.as(YTNodes.MusicResponsiveListItem);
|
||||||
|
if (items.length === 1) {
|
||||||
sendFeedback('Playlist has only one item, downloading it directly');
|
sendFeedback('Playlist has only one item, downloading it directly');
|
||||||
await downloadSong(playlist.items[0].url);
|
await downloadSongFromId(items.at(0)!.id!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlbum = playlist.title.startsWith('Album - ');
|
let playlistTitle = playlist.header?.title?.text ?? '';
|
||||||
|
const isAlbum = playlistTitle?.startsWith('Album - ');
|
||||||
if (isAlbum) {
|
if (isAlbum) {
|
||||||
playlist.title = playlist.title.slice(8);
|
playlistTitle = playlistTitle.slice(8);
|
||||||
}
|
}
|
||||||
|
|
||||||
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
|
let safePlaylistTitle = filenamify(playlistTitle, { replacement: ' ' });
|
||||||
|
if (!is.macOS()) {
|
||||||
|
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
||||||
|
}
|
||||||
|
|
||||||
const folder = getFolder(config.get('downloadFolder') ?? '');
|
const folder = getFolder(config.get('downloadFolder') ?? '');
|
||||||
const playlistFolder = join(folder, safePlaylistTitle);
|
const playlistFolder = join(folder, safePlaylistTitle);
|
||||||
@ -461,47 +492,47 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
type: 'info',
|
type: 'info',
|
||||||
buttons: ['OK'],
|
buttons: ['OK'],
|
||||||
title: 'Started Download',
|
title: 'Started Download',
|
||||||
message: `Downloading Playlist "${playlist.title}"`,
|
message: `Downloading Playlist "${playlistTitle}"`,
|
||||||
detail: `(${playlist.items.length} songs)`,
|
detail: `(${items.length} songs)`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (is.dev()) {
|
if (is.dev()) {
|
||||||
console.log(
|
console.log(
|
||||||
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`,
|
`Downloading playlist "${playlistTitle}" - ${items.length} songs (${playlistId})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
win.setProgressBar(2); // Starts with indefinite bar
|
win.setProgressBar(2); // Starts with indefinite bar
|
||||||
|
|
||||||
setBadge(playlist.items.length);
|
setBadge(items.length);
|
||||||
|
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
|
|
||||||
const progressStep = 1 / playlist.items.length;
|
const progressStep = 1 / items.length;
|
||||||
|
|
||||||
const increaseProgress = (itemPercentage: number) => {
|
const increaseProgress = (itemPercentage: number) => {
|
||||||
const currentProgress = (counter - 1) / (playlist.items.length ?? 1);
|
const currentProgress = (counter - 1) / (items.length ?? 1);
|
||||||
const newProgress = currentProgress + (progressStep * itemPercentage);
|
const newProgress = currentProgress + (progressStep * itemPercentage);
|
||||||
win.setProgressBar(newProgress);
|
win.setProgressBar(newProgress);
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const song of playlist.items) {
|
for (const song of items) {
|
||||||
sendFeedback(`Downloading ${counter}/${playlist.items.length}...`);
|
sendFeedback(`Downloading ${counter}/${items.length}...`);
|
||||||
const trackId = isAlbum ? counter : undefined;
|
const trackId = isAlbum ? counter : undefined;
|
||||||
await downloadSong(
|
await downloadSongFromId(
|
||||||
song.url,
|
song.id!,
|
||||||
playlistFolder,
|
playlistFolder,
|
||||||
trackId?.toString(),
|
trackId?.toString(),
|
||||||
increaseProgress,
|
increaseProgress,
|
||||||
).catch((error) =>
|
).catch((error) =>
|
||||||
sendError(
|
sendError(
|
||||||
new Error(`Error downloading "${song.author.name} - ${song.title}":\n ${error}`)
|
new Error(`Error downloading "${song.author!.name} - ${song.title!}":\n ${error}`)
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
win.setProgressBar(counter / playlist.items.length);
|
win.setProgressBar(counter / items.length);
|
||||||
setBadge(playlist.items.length - counter);
|
setBadge(items.length - counter);
|
||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
|||||||
Reference in New Issue
Block a user