QOL: Move source code under the src directory. (#1318)
5
.prettierrc
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
@ -18,7 +18,7 @@ export default defineConfig({
|
|||||||
nodeResolvePlugin({
|
nodeResolvePlugin({
|
||||||
browser: false,
|
browser: false,
|
||||||
preferBuiltins: true,
|
preferBuiltins: true,
|
||||||
exportConditions: ['node', 'default', 'module', 'import'] ,
|
exportConditions: ['node', 'default', 'module', 'import'],
|
||||||
}),
|
}),
|
||||||
commonjs({
|
commonjs({
|
||||||
ignoreDynamicRequires: true,
|
ignoreDynamicRequires: true,
|
||||||
@ -34,7 +34,7 @@ export default defineConfig({
|
|||||||
css(),
|
css(),
|
||||||
copy({
|
copy({
|
||||||
targets: [
|
targets: [
|
||||||
{ src: 'error.html', dest: 'dist/' },
|
{ src: 'src/error.html', dest: 'dist/' },
|
||||||
{ src: 'assets', dest: 'dist/' },
|
{ src: 'assets', dest: 'dist/' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@ -47,18 +47,14 @@ export default defineConfig({
|
|||||||
setTimeout(() => process.exit(0));
|
setTimeout(() => process.exit(0));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
name: 'force-close'
|
name: 'force-close',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
input: './index.ts',
|
input: './src/index.ts',
|
||||||
output: {
|
output: {
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
name: '[name].js',
|
name: '[name].js',
|
||||||
dir: './dist',
|
dir: './dist',
|
||||||
},
|
},
|
||||||
external: [
|
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
||||||
'electron',
|
|
||||||
'custom-electron-prompt',
|
|
||||||
...builtinModules,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -41,18 +41,14 @@ export default defineConfig({
|
|||||||
setTimeout(() => process.exit(0));
|
setTimeout(() => process.exit(0));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
name: 'force-close'
|
name: 'force-close',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
input: './preload.ts',
|
input: './src/preload.ts',
|
||||||
output: {
|
output: {
|
||||||
format: 'cjs',
|
format: 'cjs',
|
||||||
name: '[name].js',
|
name: '[name].js',
|
||||||
dir: './dist',
|
dir: './dist',
|
||||||
},
|
},
|
||||||
external: [
|
external: ['electron', 'custom-electron-prompt', ...builtinModules],
|
||||||
'electron',
|
|
||||||
'custom-electron-prompt',
|
|
||||||
...builtinModules,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
0
navigation.d.ts → src/navigation.d.ts
vendored
@ -1,16 +1,32 @@
|
|||||||
import { createWriteStream, existsSync, mkdirSync, writeFileSync, } from 'node:fs';
|
import {
|
||||||
|
createWriteStream,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs';
|
||||||
import { join } from 'node:path';
|
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, YTNodes } from 'youtubei.js';
|
import {
|
||||||
|
ClientType,
|
||||||
|
Innertube,
|
||||||
|
UniversalCache,
|
||||||
|
Utils,
|
||||||
|
YTNodes,
|
||||||
|
} from 'youtubei.js';
|
||||||
import is from 'electron-is';
|
import is from 'electron-is';
|
||||||
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 { cropMaxWidth, getFolder, sendFeedback as sendFeedback_, setBadge } from './utils';
|
import {
|
||||||
|
cropMaxWidth,
|
||||||
|
getFolder,
|
||||||
|
sendFeedback as sendFeedback_,
|
||||||
|
setBadge,
|
||||||
|
} from './utils';
|
||||||
import config from './config';
|
import config from './config';
|
||||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
|
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
|
||||||
|
|
||||||
@ -34,10 +50,8 @@ type CustomSongInfo = SongInfo & { trackId?: string };
|
|||||||
|
|
||||||
const ffmpeg = createFFmpeg({
|
const ffmpeg = createFFmpeg({
|
||||||
log: false,
|
log: false,
|
||||||
logger() {
|
logger() {}, // Console.log,
|
||||||
}, // Console.log,
|
progress() {}, // Console.log,
|
||||||
progress() {
|
|
||||||
}, // Console.log,
|
|
||||||
});
|
});
|
||||||
const ffmpegMutex = new Mutex();
|
const ffmpegMutex = new Mutex();
|
||||||
|
|
||||||
@ -65,9 +79,13 @@ const sendError = (error: Error, source?: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCookieFromWindow = async (win: BrowserWindow) => {
|
export const getCookieFromWindow = async (win: BrowserWindow) => {
|
||||||
return (await win.webContents.session.cookies.get({ url: 'https://music.youtube.com' })).map((it) =>
|
return (
|
||||||
it.name + '=' + it.value + ';'
|
await win.webContents.session.cookies.get({
|
||||||
).join('');
|
url: 'https://music.youtube.com',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.map((it) => it.name + '=' + it.value + ';')
|
||||||
|
.join('');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async (win_: BrowserWindow) => {
|
export default async (win_: BrowserWindow) => {
|
||||||
@ -78,12 +96,13 @@ export default async (win_: BrowserWindow) => {
|
|||||||
cache: new UniversalCache(false),
|
cache: new UniversalCache(false),
|
||||||
cookie: await getCookieFromWindow(win),
|
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 =
|
||||||
typeof input === 'string' ?
|
typeof input === 'string'
|
||||||
new URL(input) :
|
? new URL(input)
|
||||||
input instanceof URL ?
|
: input instanceof URL
|
||||||
input : new URL(input.url);
|
? input
|
||||||
|
: new URL(input.url);
|
||||||
|
|
||||||
if (init?.body && !init.method) {
|
if (init?.body && !init.method) {
|
||||||
init.method = 'POST';
|
init.method = 'POST';
|
||||||
@ -95,7 +114,7 @@ export default async (win_: BrowserWindow) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return net.fetch(request, init);
|
return net.fetch(request, init);
|
||||||
}
|
}) as typeof fetch,
|
||||||
});
|
});
|
||||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
||||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
||||||
@ -110,15 +129,14 @@ export async function downloadSong(
|
|||||||
url: string,
|
url: string,
|
||||||
playlistFolder: string | undefined = undefined,
|
playlistFolder: string | undefined = undefined,
|
||||||
trackId: string | undefined = undefined,
|
trackId: string | undefined = undefined,
|
||||||
increasePlaylistProgress: (value: number) => void = () => {
|
increasePlaylistProgress: (value: number) => void = () => {},
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
let resolvedName;
|
let resolvedName;
|
||||||
try {
|
try {
|
||||||
await downloadSongUnsafe(
|
await downloadSongUnsafe(
|
||||||
false,
|
false,
|
||||||
url,
|
url,
|
||||||
(name: string) => resolvedName = name,
|
(name: string) => (resolvedName = name),
|
||||||
playlistFolder,
|
playlistFolder,
|
||||||
trackId,
|
trackId,
|
||||||
increasePlaylistProgress,
|
increasePlaylistProgress,
|
||||||
@ -132,15 +150,14 @@ export async function downloadSongFromId(
|
|||||||
id: string,
|
id: string,
|
||||||
playlistFolder: string | undefined = undefined,
|
playlistFolder: string | undefined = undefined,
|
||||||
trackId: string | undefined = undefined,
|
trackId: string | undefined = undefined,
|
||||||
increasePlaylistProgress: (value: number) => void = () => {
|
increasePlaylistProgress: (value: number) => void = () => {},
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
let resolvedName;
|
let resolvedName;
|
||||||
try {
|
try {
|
||||||
await downloadSongUnsafe(
|
await downloadSongUnsafe(
|
||||||
true,
|
true,
|
||||||
id,
|
id,
|
||||||
(name: string) => resolvedName = name,
|
(name: string) => (resolvedName = name),
|
||||||
playlistFolder,
|
playlistFolder,
|
||||||
trackId,
|
trackId,
|
||||||
increasePlaylistProgress,
|
increasePlaylistProgress,
|
||||||
@ -190,8 +207,8 @@ async function downloadSongUnsafe(
|
|||||||
|
|
||||||
metadata.trackId = trackId;
|
metadata.trackId = trackId;
|
||||||
|
|
||||||
const dir
|
const dir =
|
||||||
= playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
|
||||||
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
|
||||||
metadata.title
|
metadata.title
|
||||||
}`;
|
}`;
|
||||||
@ -214,7 +231,8 @@ async function downloadSongUnsafe(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (playabilityStatus.status === 'UNPLAYABLE') {
|
if (playabilityStatus.status === 'UNPLAYABLE') {
|
||||||
const errorScreen = playabilityStatus.error_screen as PlayerErrorMessage | null;
|
const errorScreen =
|
||||||
|
playabilityStatus.error_screen as PlayerErrorMessage | null;
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
|
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
|
||||||
);
|
);
|
||||||
@ -223,7 +241,8 @@ async function downloadSongUnsafe(
|
|||||||
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)';
|
const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)';
|
||||||
let presetSetting: Preset;
|
let presetSetting: Preset;
|
||||||
if (selectedPreset === 'Custom') {
|
if (selectedPreset === 'Custom') {
|
||||||
presetSetting = config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
|
presetSetting =
|
||||||
|
config.get('customPresetSetting') ?? DefaultPresetList['Custom'];
|
||||||
} else if (selectedPreset === 'Source') {
|
} else if (selectedPreset === 'Source') {
|
||||||
presetSetting = DefaultPresetList['Source'];
|
presetSetting = DefaultPresetList['Source'];
|
||||||
} else {
|
} else {
|
||||||
@ -240,7 +259,9 @@ async function downloadSongUnsafe(
|
|||||||
|
|
||||||
let targetFileExtension: string;
|
let targetFileExtension: string;
|
||||||
if (!presetSetting?.extension) {
|
if (!presetSetting?.extension) {
|
||||||
targetFileExtension = YoutubeFormatList.find((it) => it.itag === format.itag)?.container ?? 'mp3';
|
targetFileExtension =
|
||||||
|
YoutubeFormatList.find((it) => it.itag === format.itag)?.container ??
|
||||||
|
'mp3';
|
||||||
} else {
|
} else {
|
||||||
targetFileExtension = presetSetting?.extension ?? 'mp3';
|
targetFileExtension = presetSetting?.extension ?? 'mp3';
|
||||||
}
|
}
|
||||||
@ -285,7 +306,11 @@ async function downloadSongUnsafe(
|
|||||||
if (targetFileExtension !== 'mp3') {
|
if (targetFileExtension !== 'mp3') {
|
||||||
createWriteStream(filePath).write(fileBuffer);
|
createWriteStream(filePath).write(fileBuffer);
|
||||||
} else {
|
} else {
|
||||||
const buffer = await writeID3(Buffer.from(fileBuffer), metadata, sendFeedback);
|
const buffer = await writeID3(
|
||||||
|
Buffer.from(fileBuffer),
|
||||||
|
metadata,
|
||||||
|
sendFeedback,
|
||||||
|
);
|
||||||
if (buffer) {
|
if (buffer) {
|
||||||
writeFileSync(filePath, buffer);
|
writeFileSync(filePath, buffer);
|
||||||
}
|
}
|
||||||
@ -303,8 +328,7 @@ async function iterableStreamToTargetFile(
|
|||||||
presetFfmpegArgs: string[],
|
presetFfmpegArgs: string[],
|
||||||
contentLength: number,
|
contentLength: number,
|
||||||
sendFeedback: (str: string, value?: number) => void,
|
sendFeedback: (str: string, value?: number) => void,
|
||||||
increasePlaylistProgress: (value: number) => void = () => {
|
increasePlaylistProgress: (value: number) => void = () => {},
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
let downloaded = 0;
|
let downloaded = 0;
|
||||||
@ -337,7 +361,7 @@ async function iterableStreamToTargetFile(
|
|||||||
|
|
||||||
ffmpeg.setProgress(({ ratio }) => {
|
ffmpeg.setProgress(({ ratio }) => {
|
||||||
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
|
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
|
||||||
increasePlaylistProgress(0.15 + (ratio * 0.85));
|
increasePlaylistProgress(0.15 + ratio * 0.85);
|
||||||
});
|
});
|
||||||
|
|
||||||
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
|
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
|
||||||
@ -372,7 +396,11 @@ const getCoverBuffer = cache(async (url: string) => {
|
|||||||
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
|
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
async function writeID3(buffer: Buffer, metadata: CustomSongInfo, sendFeedback: (str: string, value?: number) => void) {
|
async function writeID3(
|
||||||
|
buffer: Buffer,
|
||||||
|
metadata: CustomSongInfo,
|
||||||
|
sendFeedback: (str: string, value?: number) => void,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
sendFeedback('Writing ID3 tags...');
|
sendFeedback('Writing ID3 tags...');
|
||||||
const tags: NodeID3.Tags = {};
|
const tags: NodeID3.Tags = {};
|
||||||
@ -425,10 +453,10 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const playlistId
|
const playlistId =
|
||||||
= getPlaylistID(givenUrl)
|
getPlaylistID(givenUrl) ||
|
||||||
|| getPlaylistID(new URL(win.webContents.getURL()))
|
getPlaylistID(new URL(win.webContents.getURL())) ||
|
||||||
|| getPlaylistID(new URL(playingUrl));
|
getPlaylistID(new URL(playingUrl));
|
||||||
|
|
||||||
if (!playlistId) {
|
if (!playlistId) {
|
||||||
sendError(new Error('No playlist ID found'));
|
sendError(new Error('No playlist ID found'));
|
||||||
@ -444,7 +472,11 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
playlist = await yt.music.getPlaylist(playlistId);
|
playlist = await yt.music.getPlaylist(playlistId);
|
||||||
} 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,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -461,15 +493,12 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalPlaylistTitle = playlist.header?.title?.text;
|
const normalPlaylistTitle = playlist.header?.title?.text;
|
||||||
const playlistTitle = normalPlaylistTitle ??
|
const playlistTitle =
|
||||||
playlist
|
normalPlaylistTitle ??
|
||||||
.page
|
playlist.page.contents_memo
|
||||||
.contents_memo
|
|
||||||
?.get('MusicResponsiveListItemFlexColumn')
|
?.get('MusicResponsiveListItemFlexColumn')
|
||||||
?.at(2)
|
?.at(2)
|
||||||
?.as(YTNodes.MusicResponsiveListItemFlexColumn)
|
?.as(YTNodes.MusicResponsiveListItemFlexColumn)?.title?.text ??
|
||||||
?.title
|
|
||||||
?.text ??
|
|
||||||
'NO_TITLE';
|
'NO_TITLE';
|
||||||
const isAlbum = !normalPlaylistTitle;
|
const isAlbum = !normalPlaylistTitle;
|
||||||
|
|
||||||
@ -513,7 +542,7 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
|
|
||||||
const increaseProgress = (itemPercentage: number) => {
|
const increaseProgress = (itemPercentage: number) => {
|
||||||
const currentProgress = (counter - 1) / (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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -528,7 +557,11 @@ export async function downloadPlaylist(givenUrl?: string | URL) {
|
|||||||
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}`,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -562,8 +595,8 @@ function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
|
|||||||
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
|
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
|
||||||
|
|
||||||
const getPlaylistID = (aURL: URL) => {
|
const getPlaylistID = (aURL: URL) => {
|
||||||
const result
|
const result =
|
||||||
= aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
|
aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
|
||||||
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
|
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
|
||||||
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
|
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
|
||||||
}
|
}
|
||||||
@ -572,15 +605,18 @@ const getPlaylistID = (aURL: URL) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getVideoId = (url: URL | string): string | null => {
|
const getVideoId = (url: URL | string): string | null => {
|
||||||
return (new URL(url)).searchParams.get('v');
|
return new URL(url).searchParams.get('v');
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
|
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
|
||||||
videoId: info.basic_info.id!,
|
videoId: info.basic_info.id!,
|
||||||
title: cleanupName(info.basic_info.title!),
|
title: cleanupName(info.basic_info.title!),
|
||||||
artist: cleanupName(info.basic_info.author!),
|
artist: cleanupName(info.basic_info.author!),
|
||||||
album: info.player_overlays?.browser_media_session?.as(YTNodes.BrowserMediaSession).album?.text,
|
album: info.player_overlays?.browser_media_session?.as(
|
||||||
imageSrc: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url,
|
YTNodes.BrowserMediaSession,
|
||||||
|
).album?.text,
|
||||||
|
imageSrc: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))
|
||||||
|
?.url,
|
||||||
views: info.basic_info.view_count!,
|
views: info.basic_info.view_count!,
|
||||||
songDuration: info.basic_info.duration!,
|
songDuration: info.basic_info.duration!,
|
||||||
});
|
});
|
||||||
|
Before Width: | Height: | Size: 392 B After Width: | Height: | Size: 392 B |
|
Before Width: | Height: | Size: 252 B After Width: | Height: | Size: 252 B |
|
Before Width: | Height: | Size: 338 B After Width: | Height: | Size: 338 B |
|
Before Width: | Height: | Size: 174 B After Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 546 B After Width: | Height: | Size: 546 B |