fix(downloader): private playlist download

This commit is contained in:
JellyBrick
2023-10-12 00:41:58 +09:00
parent 572a023aaa
commit 1d5b2997bd

View File

@ -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) {