diff --git a/plugins/downloader/back.ts b/plugins/downloader/back.ts index 0cc19400..69a3d173 100644 --- a/plugins/downloader/back.ts +++ b/plugins/downloader/back.ts @@ -3,23 +3,14 @@ import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; 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 ytpl from 'ytpl'; -// REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid import filenamify from 'filenamify'; import { Mutex } from 'async-mutex'; import { createFFmpeg } from '@ffmpeg.wasm/main'; 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 config from './config'; @@ -32,8 +23,13 @@ import { cleanupName, getImage, SongInfo } from '../../providers/song-info'; import { injectCSS } from '../utils'; 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 }; @@ -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) => { win = win_; 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({ cache: new UniversalCache(false), - cookie, + cookie: await getCookieFromWindow(win), generate_session_locally: true, fetch: async (input: RequestInfo | URL, init?: RequestInit) => { const url = @@ -118,6 +117,7 @@ export async function downloadSong( let resolvedName; try { await downloadSongUnsafe( + false, url, (name: string) => resolvedName = name, 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( - url: string, + isId: boolean, + idOrUrl: string, setName: (name: string) => void, playlistFolder: string | undefined = undefined, trackId: string | undefined = undefined, @@ -147,8 +170,13 @@ async function downloadSongUnsafe( sendFeedback('Downloading...', 2); - const id = getVideoId(url); - if (typeof id !== 'string') throw new Error('Video not found'); + let id: string | null; + 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); @@ -417,11 +445,9 @@ export async function downloadPlaylist(givenUrl?: string | URL) { console.log(`trying to get playlist ID: '${playlistId}'`); sendFeedback('Getting playlist info…'); - let playlist: ytpl.Result; + let playlist: Playlist; try { - playlist = await ytpl(playlistId, { - limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY, - }); + playlist = await yt.music.getPlaylist(playlistId); } catch (error: unknown) { sendError( 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; } - if (playlist.items.length === 0) { + if (!playlist || !playlist.items || playlist.items.length === 0) { 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'); - await downloadSong(playlist.items[0].url); + await downloadSongFromId(items.at(0)!.id!); return; } - const isAlbum = playlist.title.startsWith('Album - '); + let playlistTitle = playlist.header?.title?.text ?? ''; + const isAlbum = playlistTitle?.startsWith('Album - '); 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 playlistFolder = join(folder, safePlaylistTitle); @@ -461,47 +492,47 @@ export async function downloadPlaylist(givenUrl?: string | URL) { type: 'info', buttons: ['OK'], title: 'Started Download', - message: `Downloading Playlist "${playlist.title}"`, - detail: `(${playlist.items.length} songs)`, + message: `Downloading Playlist "${playlistTitle}"`, + detail: `(${items.length} songs)`, }); if (is.dev()) { 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 - setBadge(playlist.items.length); + setBadge(items.length); let counter = 1; - const progressStep = 1 / playlist.items.length; + const progressStep = 1 / items.length; const increaseProgress = (itemPercentage: number) => { - const currentProgress = (counter - 1) / (playlist.items.length ?? 1); + const currentProgress = (counter - 1) / (items.length ?? 1); const newProgress = currentProgress + (progressStep * itemPercentage); win.setProgressBar(newProgress); }; try { - for (const song of playlist.items) { - sendFeedback(`Downloading ${counter}/${playlist.items.length}...`); + for (const song of items) { + sendFeedback(`Downloading ${counter}/${items.length}...`); const trackId = isAlbum ? counter : undefined; - await downloadSong( - song.url, + await downloadSongFromId( + song.id!, playlistFolder, trackId?.toString(), increaseProgress, ).catch((error) => 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); - setBadge(playlist.items.length - counter); + win.setProgressBar(counter / items.length); + setBadge(items.length - counter); counter++; } } catch (error: unknown) {