This commit is contained in:
AnidlSupport 2023-02-27 21:20:06 +01:00
parent 6707b7fdd4
commit cd62595518
47 changed files with 997 additions and 2283 deletions

View file

@ -13,9 +13,11 @@ export interface MessageHandler {
resolveItems: (data: ResolveItemsData) => Promise<ResponseBase<QueueItem[]>>,
listEpisodes: (id: string) => Promise<EpisodeListResponse>,
downloadItem: (data) => void,
isDownloading: () => boolean,
isDownloading: () => Promise<boolean>,
writeToClipboard: (text: string) => void,
openFolder: (path: FolderTypes) => void,
openFile: (data: [FolderTypes, string]) => void,
openURL: (data: string) => void;
}
export type FolderTypes = 'content' | 'config';

36
@types/ws.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
import { AuthResponse, CheckTokenResponse, EpisodeListResponse, FolderTypes, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from "./messageHandler"
export type WSMessage<T extends keyof MessageTypes, P extends 0|1 = 0> = {
name: T,
data: MessageTypes[T][P]
}
export type WSMessageWithID<T extends keyof MessageTypes, P extends 0|1 = 0> = WSMessage<T, P> & {
id: string
}
export type UnknownWSMessage = {
name: keyof MessageTypes,
data: MessageTypes[keyof MessageTypes][0],
id: string
}
export type MessageTypes = {
'auth': [AuthData, AuthResponse],
'checkToken': [undefined, CheckTokenResponse],
'search': [SearchData, SearchResponse],
'default': [string, unknown],
'availableDubCodes': [undefined, string[]],
'availableSubCodes': [undefined, string[]],
'resolveItems': [ResolveItemsData, ResponseBase<QueueItem[]>],
'listEpisodes': [string, EpisodeListResponse],
'downloadItem': [unknown, undefined],
'isDownloading': [undefined, boolean],
'writeToClipboard': [string, undefined],
'openFolder': [FolderTypes, undefined],
'changeProvider': [undefined, boolean],
'type': [undefined, 'funi'|'crunchy'|undefined],
'setup': ['funi'|'crunchy'|undefined, undefined],
'openFile': [[FolderTypes, string], undefined],
'openURL': [string, undefined]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -1 +0,0 @@
All credits go to KX-DAREKON#0420

2
config/gui.yml Normal file
View file

@ -0,0 +1,2 @@
port: 3000
password: "default"

View file

@ -455,13 +455,13 @@ export default class Crunchy implements ServiceClass {
// check title
item.title = item.title != '' ? item.title : 'NO_TITLE';
// static data
const oMetadata = [],
oBooleans = [],
const oMetadata: string[] = [],
oBooleans: string[] = [],
tMetadata = item.type + '_metadata',
iMetadata = (Object.prototype.hasOwnProperty.call(item, tMetadata) ? item[tMetadata as keyof ParseItem] : item) as Record<string, any>,
iTitle = [ item.title ];
const audio_languages = [];
const audio_languages: string[] = [];
// set object booleans
if(iMetadata.duration_ms){
@ -523,7 +523,7 @@ export default class Crunchy implements ServiceClass {
const showObjectMetadata = oMetadata.length > 0 && !iMetadata.hide_metadata ? true : false;
const showObjectBooleans = oBooleans.length > 0 && !iMetadata.hide_metadata ? true : false;
// make obj ids
const objects_ids = [];
const objects_ids: string[] = [];
objects_ids.push(oTypes[item.type as keyof typeof oTypes] + ':' + item.id);
if(item.seq_id){
objects_ids.unshift(item.seq_id);
@ -870,7 +870,7 @@ export default class Crunchy implements ServiceClass {
return objectInfo;
}
const selectedMedia = [];
const selectedMedia: Partial<CrunchyEpMeta>[] = [];
for(const item of objectInfo.data){
if(item.type != 'episode' && item.type != 'movie'){
@ -998,7 +998,7 @@ export default class Crunchy implements ServiceClass {
} as Variable;
}));
let streams = [];
let streams: any[] = [];
let hsLangs: string[] = [];
const pbStreams = pbData.data[0];

View file

@ -19,7 +19,7 @@ import * as yamlCfg from './modules/module.cfg-loader';
import vttConvert from './modules/module.vttconvert';
// types
import { Item } from './@types/items';
import type { Item } from './@types/items.js';
// params
@ -266,7 +266,7 @@ export default class Funi implements ServiceClass {
return showList;
const eps = showList.value;
const epSelList = parseSelect(data.e as string, data.but);
const fnSlug: FuniEpisodeData[] = [], epSelEpsTxt = []; let is_selected = false;
const fnSlug: FuniEpisodeData[] = [], epSelEpsTxt: string[] = []; let is_selected = false;
for(const e in eps){
@ -331,7 +331,7 @@ export default class Funi implements ServiceClass {
debug: this.debug,
});
if(!episodeData.ok || !episodeData.res){return { isOk: false, reason: new Error('Unable to get episodeData') }; }
const ep = JSON.parse(episodeData.res.body).items[0] as EpisodeData, streamIds = [];
const ep = JSON.parse(episodeData.res.body).items[0] as EpisodeData, streamIds: { id: number, lang: langsData.LanguageItem }[] = [];
// build fn
season = parseInt(ep.parent.seasonNumber);
if(ep.mediaCategory != 'Episode'){
@ -508,7 +508,7 @@ export default class Funi implements ServiceClass {
plStreams: Record<string|number, {
[key: string]: string
}> = {},
plLayersStr = [],
plLayersStr: string[] = [],
plLayersRes: Record<string|number, {
width: number,
height: number

1
gui.ts Normal file
View file

@ -0,0 +1 @@
import './gui/server/index'

View file

@ -1,2 +0,0 @@
USE_BROWSER=true
TEST=true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View file

@ -1,143 +0,0 @@
import { app, BrowserWindow, dialog, screen } from 'electron';
import path from 'path/posix';
import fs from 'fs';
import dotenv from 'dotenv';
import express from 'express';
import { Console } from 'console';
import json from '../../../package.json';
process.on('uncaughtException', (er, or) => {
console.error(er, or);
});
process.on('unhandledRejection', (er, pr) => {
console.log(er, pr);
});
const getDataDirectory = () => {
switch (process.platform) {
case 'darwin': {
if (!process.env.HOME) {
console.error('Unknown home directory');
process.exit(1);
}
return path.join(process.env.HOME, 'Library', 'Application Support', json.name);
}
case 'win32': {
if (!process.env.APPDATA) {
console.error('Unknown home directory');
process.exit(1);
}
console.log('Appdata', process.env.APPDATA);
return path.join(process.env.APPDATA, json.name);
}
case 'linux': {
if (!process.env.HOME) {
console.error('Unknown home directory');
process.exit(1);
}
return path.join(process.env.HOME, `.${json.name}`);
}
default: {
console.error('Unsupported platform!');
process.exit(1);
}
}
};
if (!fs.existsSync(getDataDirectory()))
fs.mkdirSync(getDataDirectory());
export { getDataDirectory };
process.env.contentDirectory = getDataDirectory();
import './menu';
if (fs.existsSync(path.join(__dirname, '.env')))
dotenv.config({ path: path.join(__dirname, '.env'), debug: true });
if (require('electron-squirrel-startup')) {
app.quit();
}
export const isWindows = process.platform === 'win32';
let mainWindow: BrowserWindow|undefined = undefined;
export { mainWindow };
const icon = path.join(__dirname, 'images', `Logo_Inverted.${isWindows ? 'ico' : 'png'}`);
// eslint-disable-next-line no-global-assign
console = (() => {
const logFolder = path.join(getDataDirectory(), 'logs');
if (!fs.existsSync(logFolder))
fs.mkdirSync(logFolder);
if (fs.existsSync(path.join(logFolder, 'latest.log')))
fs.renameSync(path.join(logFolder, 'latest.log'), path.join(logFolder, `${Date.now()}.log`));
return new Console(fs.createWriteStream(path.join(logFolder, 'latest.log')));
})();
const createWindow = async () => {
(await import('../../../modules/module.cfg-loader')).ensureConfig();
// Create the browser window.
mainWindow = new BrowserWindow({
width: screen.getPrimaryDisplay().bounds.width,
height: screen.getPrimaryDisplay().bounds.height,
title: `AniDL GUI v${json.version}`,
webPreferences: {
nodeIntegration: false,
preload: path.join(__dirname, 'preload.js')
},
icon,
});
mainWindow.webContents.on('crashed', (e) => console.log(e));
(await import('./messageHandler')).default(mainWindow);
if (!process.env.USE_BROWSER) {
const app = express();
// Path.sep seems to return / on windows with electron
// \\ in Filename on Linux is possible but I don't see another way rn
const sep = isWindows ? '\\' : '/';
const p = __dirname.split(sep);
p.pop();
p.push('build');
console.log(p.join(sep));
app.use(express.static(p.join(sep)));
await new Promise((resolve) => {
app.listen(3000, () => {
console.log('Express started');
resolve(undefined);
});
});
}
mainWindow.loadURL('http://localhost:3000');
if (process.env.TEST)
mainWindow.webContents.openDevTools();
};
app.on('ready', createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('quit', () => {
process.exit(0);
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});

View file

@ -1,120 +0,0 @@
import { Menu, MenuItem, MenuItemConstructorOptions, shell } from 'electron';
import path from 'path';
import { getDataDirectory } from '.';
import json from '../../../package.json';
const template: (MenuItemConstructorOptions | MenuItem)[] = [
{
label: 'Edit',
submenu: [
{
role: 'undo'
},
{
role: 'redo'
},
{
type: 'separator'
},
{
role: 'cut'
},
{
role: 'copy'
},
{
role: 'paste'
}
]
},
{
label: 'Debug',
submenu: [
{
role: 'toggleDevTools'
},
{
label: 'Open log folder',
click: () => {
shell.openPath(path.join(getDataDirectory(), 'logs'));
}
},
{
role: 'forceReload'
}
]
},
{
label: 'Settings',
submenu: [
{
label: 'Open settings folder',
click: () => {
shell.openPath(path.join(getDataDirectory(), 'config'));
}
},
{
label: 'Open settings file...',
submenu: [
{
label: 'FFmpeg/Mkvmerge path',
click: () => {
shell.openPath(path.join(getDataDirectory(), 'config', 'bin-path.yml'));
}
},
{
label: 'Advanced options',
sublabel: 'See the documention for the options you may enter here',
click: () => {
shell.openPath(path.join(getDataDirectory(), 'config', 'cli-defaults.yml'));
}
},
{
label: 'Output path',
click: () => {
shell.openPath(path.join(getDataDirectory(), 'config', 'dir-path.yml'));
}
}
]
}
]
},
{
label: 'Help',
submenu: [
{
label: 'Version',
sublabel: json.version
},
{
label: 'GitHub',
click: () => {
shell.openExternal('https://github.com/anidl/multi-downloader-nx');
}
},
{
label: 'Report a Bug',
click: () => {
shell.openExternal(`https://github.com/anidl/multi-downloader-nx/issues/new?assignees=AnimeDL,AnidlSupport&labels=bug&template=bug.yml&title=BUG&version=${json.version}`);
}
},
{
type: 'separator'
},
{
label: 'Contributors',
click: () => {
shell.openExternal('https://github.com/anidl/multi-downloader-nx/graphs/contributors');
}
},
{
label: 'Discord',
click: () => {
shell.openExternal('https://discord.gg/qEpbWen5vq');
}
}
]
}
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));

View file

@ -1,36 +0,0 @@
import { BrowserWindow, ipcMain } from 'electron';
import { MessageHandler } from '../../../@types/messageHandler';
import Crunchy from './serviceHandler/crunchyroll';
import Funimation from './serviceHandler/funimation';
export default (window: BrowserWindow) => {
let handler: MessageHandler|undefined;
ipcMain.handle('setup', (_, data) => {
if (data === 'funi') {
handler = new Funimation(window);
} else if (data === 'crunchy') {
handler = new Crunchy(window);
}
});
ipcMain.on('changeProvider', (ev) => {
if (handler?.isDownloading())
return ev.returnValue = false;
handler = undefined;
ev.returnValue = true;
});
ipcMain.handle('type', async () => handler === undefined ? undefined : handler instanceof Funimation ? 'funi' : 'crunchy');
ipcMain.handle('auth', async (_, data) => handler?.auth(data));
ipcMain.handle('checkToken', async () => handler?.checkToken());
ipcMain.handle('search', async (_, data) => handler?.search(data));
ipcMain.handle('default', async (_, data) => handler?.handleDefault(data));
ipcMain.handle('availableDubCodes', async () => handler?.availableDubCodes());
ipcMain.handle('availableSubCodes', async () => handler?.availableSubCodes());
ipcMain.handle('resolveItems', async (_, data) => handler?.resolveItems(data));
ipcMain.handle('listEpisodes', async (_, data) => handler?.listEpisodes(data));
ipcMain.handle('downloadItem', async (_, data) => handler?.downloadItem(data));
ipcMain.handle('writeToClipboard', async (_, data) => handler?.writeToClipboard(data));
ipcMain.handle('openFolder', async (_, data) => handler?.openFolder(data));
ipcMain.on('isDownloading', (ev) => ev.returnValue = handler?.isDownloading());
};

View file

@ -1,14 +0,0 @@
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('Electron', {
ipcRenderer: {
...ipcRenderer,
on: (name: string, handler: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
ipcRenderer.on(name, handler);
return ipcRenderer;
},
removeListener: (name: string, handler: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
ipcRenderer.removeListener(name, handler);
}
}
});

View file

@ -1,73 +0,0 @@
import { BrowserWindow, clipboard, dialog, shell } from 'electron';
import { DownloadInfo, FolderTypes, ProgressData } from '../../../../@types/messageHandler';
import { RandomEvent, RandomEvents } from '../../../../@types/randomEvents';
import { loadCfg } from '../../../../modules/module.cfg-loader';
export default class Base {
constructor(private window: BrowserWindow) {}
private downloading = false;
setDownloading(downloading: boolean) {
this.downloading = downloading;
}
getDownloading() {
return this.downloading;
}
alertError(error: Error) {
dialog.showMessageBoxSync(this.window, {
message: `${error.name ?? 'An error occured'}\n${error.message}`,
detail: error.stack,
title: 'Error',
type: 'error'
});
}
makeProgressHandler(videoInfo: DownloadInfo) {
return ((data: ProgressData) => {
const progress = (typeof data.percent === 'string' ?
parseFloat(data.percent) : data.percent) / 100;
this.window.setProgressBar(progress === 1 ? -1 : progress);
this.sendMessage({
name: 'progress',
data: {
downloadInfo: videoInfo,
progress: data
}
});
}).bind(this);
}
getWindow() {
return this.window;
}
sendMessage<T extends keyof RandomEvents>(data: RandomEvent<T>) {
this.window.webContents.send('randomEvent', data);
}
isDownloading() {
return this.downloading;
}
async writeToClipboard(text: string) {
clipboard.writeText(text, 'clipboard');
return true;
}
async openFolder(folderType: FolderTypes) {
const conf = loadCfg();
switch (folderType) {
case 'content':
shell.openPath(conf.dir.content);
break;
case 'config':
shell.openPath(conf.dir.config);
break;
}
}
}

1
gui/react/.env Normal file
View file

@ -0,0 +1 @@
PORT=3002

View file

@ -18,8 +18,11 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5"
"typescript": "^4.9.5",
"uuid": "^9.0.0",
"ws": "^8.12.1"
},
"proxy": "http://localhost:3000",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
@ -43,5 +46,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/uuid": "^9.0.1"
}
}

View file

@ -12,11 +12,14 @@ specifiers:
'@types/node': ^18.14.0
'@types/react': ^18.0.25
'@types/react-dom': ^18.0.11
'@types/uuid': ^9.0.1
notistack: ^2.0.8
react: ^18.2.0
react-dom: ^18.2.0
react-scripts: 5.0.1
typescript: ^4.9.5
uuid: ^9.0.0
ws: ^8.12.1
dependencies:
'@babel/core': 7.20.12
@ -35,6 +38,11 @@ dependencies:
react-dom: 18.2.0_react@18.2.0
react-scripts: 5.0.1_pegpel5nwbugtuutvxsiaw5kjq
typescript: 4.9.5
uuid: 9.0.0
ws: 8.12.1
devDependencies:
'@types/uuid': 9.0.1
packages:
@ -2753,6 +2761,10 @@ packages:
resolution: {integrity: sha512-NfQ4gyz38SL8sDNrSixxU2Os1a5xcdFxipAFxYEuLUlvU2uDwS4NUpsImcf1//SlWItCVMMLiylsxbmNMToV/g==}
dev: false
/@types/uuid/9.0.1:
resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==}
dev: true
/@types/ws/8.5.4:
resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==}
dependencies:
@ -9805,6 +9817,11 @@ packages:
hasBin: true
dev: false
/uuid/9.0.0:
resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==}
hasBin: true
dev: false
/v8-to-istanbul/8.1.1:
resolution: {integrity: sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==}
engines: {node: '>=10.12.0'}

View file

@ -8,14 +8,16 @@ import { messageChannelContext } from './provider/MessageChannel';
import { ClearAll, Folder } from "@mui/icons-material";
import useStore from "./hooks/useStore";
import StartQueueButton from "./components/StartQueue";
import MenuBar from "./components/MenuBar/MenuBar";
const Layout: React.FC = () => {
const messageHandler = React.useContext(messageChannelContext);
const [, dispatch] = useStore();
return <Box>
<Box sx={{ height: 50, mb: 4, display: 'flex', gap: 1 }}>
return <Box sx={{ display: 'flex', flexDirection: 'column' }}>
<MenuBar />
<Box sx={{ height: 50, mb: 4, display: 'flex', gap: 1, mt: 3 }}>
<LogoutButton />
<AuthButton />
<Box sx={{ display: 'flex', gap: 1, height: 36 }}>

View file

@ -11,7 +11,7 @@ const makeTheme = (mode: 'dark'|'light') : Partial<Theme> => {
const Style: FCWithChildren = ({children}) => {
return <ThemeProvider theme={makeTheme('dark')}>
<Container sx={{ mt: 3 }} maxWidth='xl'>
<Container maxWidth='xl'>
<Box sx={{ position: 'fixed', height: '100%', width: '100%', zIndex: -500, backgroundColor: 'rgb(0, 30, 60)', top: 0, left: 0 }}/>
{children}
</Container>

View file

@ -9,10 +9,10 @@ const LogoutButton: React.FC = () => {
const messageChannel = React.useContext(messageChannelContext);
const [, dispatch] = useStore();
const logout = () => {
if (messageChannel?.isDownloading())
const logout = async () => {
if (await messageChannel?.isDownloading())
return alert('You are currently downloading. Please finish the download first.');
if (messageChannel?.logout())
if (await messageChannel?.logout())
dispatch({
type: 'service',
payload: undefined

View file

@ -33,12 +33,14 @@ const useDownloadManager = () => {
}, [messageHandler, dispatch]);
React.useEffect(() => {
if (!currentDownload)
return;
if (messageHandler?.isDownloading())
return;
console.log('start download');
messageHandler?.downloadItem(currentDownload);
(async () => {
if (!currentDownload)
return;
if (await messageHandler?.isDownloading())
return;
console.log('start download');
messageHandler?.downloadItem(currentDownload);
})();
}, [currentDownload, messageHandler]);

View file

@ -0,0 +1,85 @@
import { Box, Button, Menu, MenuItem } from "@mui/material";
import React from "react"
import { messageChannelContext } from "../../provider/MessageChannel";
const MenuBar: React.FC = () => {
const [ openMenu, setMenuOpen ] = React.useState<'settings'|'help'|undefined>();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const msg = React.useContext(messageChannelContext);
const handleClick = (event: React.MouseEvent<HTMLElement>, n: 'settings'|'help') => {
setAnchorEl(event.currentTarget);
setMenuOpen(n);
};
const handleClose = () => {
setAnchorEl(null);
setMenuOpen(undefined);
};
if (!msg)
return <></>
return <Box sx={{ width: '100%', display: 'flex' }}>
<Button onClick={(e) => handleClick(e, 'settings')}>
Settings
</Button>
<Button onClick={(e) => handleClick(e, 'help')}>
Help
</Button>
<Menu open={openMenu === 'settings'} anchorEl={anchorEl} onClose={handleClose}>
<MenuItem onClick={() => {
msg.openFolder('config');
handleClose();
}}>
Open settings folder
</MenuItem>
<MenuItem onClick={() => {
msg.openFile(['config', 'bin-path.yml']);
handleClose();
}}>
Open FFmpeg/Mkvmerge file
</MenuItem>
<MenuItem onClick={() => {
msg.openFile(['config', 'cli-defaults.yml']);
handleClose();
}}>
Open advanced options
</MenuItem>
<MenuItem onClick={() => {
msg.openFolder('content');
handleClose();
}}>
Open output path
</MenuItem>
</Menu>
<Menu open={openMenu === 'help'} anchorEl={anchorEl} onClose={handleClose}>
<MenuItem onClick={() => {
msg.openURL('https://github.com/anidl/multi-downloader-nx');
handleClose();
}}>
GitHub
</MenuItem>
<MenuItem onClick={() => {
msg.openURL('https://github.com/anidl/multi-downloader-nx/issues/new?assignees=AnimeDL,AnidlSupport&labels=bug&template=bug.yml&title=BUG');
handleClose();
}}>
Report a bug
</MenuItem>
<MenuItem onClick={() => {
msg.openURL('https://github.com/anidl/multi-downloader-nx/graphs/contributors');
handleClose();
}}>
Contributors
</MenuItem>
<MenuItem onClick={() => {
msg.openURL('https://discord.gg/qEpbWen5vq');
handleClose();
}}>
Discord
</MenuItem>
</Menu>
</Box>;
}
export default MenuBar;

View file

@ -9,8 +9,8 @@ const StartQueueButton: React.FC = () => {
const messageChannel = React.useContext(messageChannelContext);
const [store, dispatch] = useStore();
const change = () => {
if (messageChannel?.isDownloading() && store.downloadQueue)
const change = async () => {
if (await messageChannel?.isDownloading() && store.downloadQueue)
alert("The current download will be finished before the queue stops")
dispatch({
type: 'downloadQueue',

View file

@ -19,26 +19,24 @@ const onClickDismiss = (key: SnackbarKey | undefined) => () => {
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(
<React.StrictMode>
<ErrorHandler>
<Store>
<SnackbarProvider
ref={notistackRef}
action={(key) => (
<IconButton onClick={onClickDismiss(key)} color="inherit">
<CloseOutlined />
</IconButton>
)}
>
<Style>
<MessageChannel>
<ServiceProvider>
<App />
</ServiceProvider>
</MessageChannel>
</Style>
</SnackbarProvider>
</Store>
</ErrorHandler>
</React.StrictMode>
<ErrorHandler>
<Store>
<SnackbarProvider
ref={notistackRef}
action={(key) => (
<IconButton onClick={onClickDismiss(key)} color="inherit">
<CloseOutlined />
</IconButton>
)}
>
<Style>
<MessageChannel>
<ServiceProvider>
<App />
</ServiceProvider>
</MessageChannel>
</Style>
</SnackbarProvider>
</Store>
</ErrorHandler>
);

View file

@ -1,13 +1,13 @@
import React from 'react';
import type { MessageHandler } from '../../../../@types/messageHandler';
import type { IpcRenderer, IpcRendererEvent } from "electron";
import type { AuthData, AuthResponse, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../../@types/messageHandler';
import useStore from '../hooks/useStore';
import type { MessageTypes, WSMessage, WSMessageWithID } from '../../../../@types/ws';
import type { Handler, RandomEvent, RandomEvents } from '../../../../@types/randomEvents';
import { Backdrop, Typography } from '@mui/material';
import { v4 } from "uuid";
export type FrontEndMessanges = (MessageHandler & { randomEvents: RandomEventHandler, logout: () => boolean });
export type FrontEndMessanges = (MessageHandler & { randomEvents: RandomEventHandler, logout: () => Promise<boolean> });
export class RandomEventHandler {
private handler: {
@ -36,53 +36,93 @@ export class RandomEventHandler {
export const messageChannelContext = React.createContext<FrontEndMessanges|undefined>(undefined);
async function messageAndResponse<T extends keyof MessageTypes>(socket: WebSocket, msg: WSMessage<T>): Promise<WSMessage<T, 1>> {
const id = v4();
const ret = new Promise<WSMessage<T, 1>>((resolve) => {
const handler = function({ data }: MessageEvent) {
const parsed = JSON.parse(data.toString()) as WSMessageWithID<T, 1>;
if (parsed.id === id) {
socket.removeEventListener('message', handler);
resolve(parsed);
}
}
socket.addEventListener('message', handler);
});
const toSend = msg as WSMessageWithID<T>;
toSend.id = id;
socket.send(JSON.stringify(toSend));
return ret;
}
const MessageChannelProvider: FCWithChildren = ({ children }) => {
const [store, dispatch] = useStore();
const [socket, setSocket] = React.useState<undefined|WebSocket|null>();
React.useEffect(() => {
const wws = new WebSocket(`ws://localhost:3000/ws?${new URLSearchParams({
password: prompt('This website requires a password') ?? ''
})}`, );
wws.addEventListener('open', () => {
console.log('[INFO] [WS] Connected');
setSocket(wws);
});
wws.addEventListener('error', (er) => {
console.error(`[ERROR] [WS]`, er);
setSocket(null);
})
}, []);
const { ipcRenderer } = (window as any).Electron as { ipcRenderer: IpcRenderer };
const randomEventHandler = React.useMemo(() => new RandomEventHandler(), []);
React.useEffect(() => {
(async () => {
const currentService = await ipcRenderer.invoke('type');
if (currentService !== undefined)
return dispatch({ type: 'service', payload: currentService });
if (store.service !== currentService)
ipcRenderer.invoke('setup', store.service)
if (!socket)
return;
const currentService = await messageAndResponse(socket, { name: 'type', data: undefined });
if (currentService.data !== undefined)
return dispatch({ type: 'service', payload: currentService.data });
if (store.service !== currentService.data)
messageAndResponse(socket, { name: 'setup', data: store.service });
})();
}, [store.service, dispatch, ipcRenderer])
}, [store.service, dispatch, socket])
React.useEffect(() => {
if (!socket)
return;
/* finish is a placeholder */
const listener = (_: IpcRendererEvent, initalData: RandomEvent<'finish'>) => {
const eventName = initalData.name as keyof RandomEvents;
const data = initalData as unknown as RandomEvent<typeof eventName>;
const listener = (initalData: MessageEvent<string>) => {
const data = JSON.parse(initalData.data) as RandomEvent<'finish'>;
randomEventHandler.emit(data.name, data);
}
ipcRenderer.on('randomEvent', listener);
socket.addEventListener('message', listener);
return () => {
ipcRenderer.removeListener('randomEvent', listener);
socket.removeEventListener('message', listener);
};
}, [ ipcRenderer ]);
}, [ socket ]);
if (socket === undefined || socket === null)
return <Typography color='primary'>{socket === undefined ? 'Loading...' : 'WebSocket Error. Please try to reload and make sure the password ist correct.'}</Typography>;
const messageHandler: FrontEndMessanges = {
auth: async (data) => await ipcRenderer.invoke('auth', data),
checkToken: async () => await ipcRenderer.invoke('checkToken'),
search: async (data) => await ipcRenderer.invoke('search', data),
handleDefault: async (data) => await ipcRenderer.invoke('default', data),
availableDubCodes: async () => await ipcRenderer.invoke('availableDubCodes'),
availableSubCodes: async () => await ipcRenderer.invoke('availableSubCodes'),
resolveItems: async (data) => await ipcRenderer.invoke('resolveItems', data),
listEpisodes: async (data) => await ipcRenderer.invoke('listEpisodes', data),
auth: async (data) => (await messageAndResponse(socket, { name: 'auth', data })).data,
checkToken: async () => (await messageAndResponse(socket, { name: 'checkToken', data: undefined })).data,
search: async (data) => (await messageAndResponse(socket, { name: 'search', data })).data,
handleDefault: async (data) => (await messageAndResponse(socket, { name: 'default', data })).data,
availableDubCodes: async () => (await messageAndResponse(socket, { name: 'availableDubCodes', data: undefined})).data,
availableSubCodes: async () => (await messageAndResponse(socket, { name: 'availableSubCodes', data: undefined })).data,
resolveItems: async (data) => (await messageAndResponse(socket, { name: 'resolveItems', data })).data,
listEpisodes: async (data) => (await messageAndResponse(socket, { name: 'listEpisodes', data })).data,
randomEvents: randomEventHandler,
downloadItem: (data) => ipcRenderer.invoke('downloadItem', data),
isDownloading: () => ipcRenderer.sendSync('isDownloading'),
writeToClipboard: async (data) => await ipcRenderer.invoke('writeToClipboard', data),
openFolder: async (data) => await ipcRenderer.invoke('openFolder', data),
logout: () => ipcRenderer.sendSync('changeProvider')
downloadItem: (data) => messageAndResponse(socket, { name: 'downloadItem', data }),
isDownloading: async () => (await messageAndResponse(socket, { name: 'isDownloading', data: undefined })).data,
writeToClipboard: async (data) => messageAndResponse(socket, { name: 'writeToClipboard', data }),
openFolder: async (data) => messageAndResponse(socket, { name: 'openFolder', data }),
logout: async () => (await messageAndResponse(socket, { name: 'changeProvider', data: undefined })).data,
openFile: async (data) => await messageAndResponse(socket, { name: 'openFile', data }),
openURL: async (data) => await messageAndResponse(socket, { name: 'openURL', data })
}
return <messageChannelContext.Provider value={messageHandler}>

29
gui/server/index.ts Normal file
View file

@ -0,0 +1,29 @@
import express from 'express';
import { ensureConfig, loadCfg, workingDir } from '../../modules/module.cfg-loader';
import cors from 'cors';
import ServiceHandler from './serviceHandler';
import open from 'open';
import path from 'path';
process.title = 'AniDL';
ensureConfig();
const cfg = loadCfg();
const app = express();
export { app, cfg };
app.use(express.json());
app.use(cors());
app.use(express.static(path.join(workingDir, 'gui', 'server', 'build'), { maxAge: 1000 * 60 * 20 }))
const server = app.listen(cfg.gui.port, () => {
console.log(`[INFO] GUI server started on port ${cfg.gui.port}`);
});
new ServiceHandler(server);
open(`http://localhost:${cfg.gui.port}`);

View file

@ -0,0 +1,100 @@
import { ServerResponse } from 'http';
import { Server } from 'http';
import { IncomingMessage } from 'http';
import { MessageHandler } from '../../@types/messageHandler';
import Funi from '../../funi';
import CrunchyHandler from './services/crunchyroll';
import FunimationHandler from './services/funimation';
import WebSocketHandler from './websocket';
export default class ServiceHandler {
private service: MessageHandler|undefined = undefined;
private ws: WebSocketHandler;
constructor(server: Server<typeof IncomingMessage, typeof ServerResponse>) {
this.ws = new WebSocketHandler(server);
this.handleMessanges();
}
private handleMessanges() {
this.ws.events.on('setup', ({ data }) => {
if (data === 'funi') {
this.service = new FunimationHandler(this.ws);
} else if (data === 'crunchy') {
this.service = new CrunchyHandler(this.ws);
}
});
this.ws.events.on('changeProvider', async (_, respond) => {
if (await this.service?.isDownloading())
return respond(false);
this.service = undefined;
respond(true);
})
this.ws.events.on('auth', async ({ data }, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });
respond(await this.service.auth(data));
});
this.ws.events.on('type', async ({}, respond) => respond(this.service === undefined ? undefined : this.service instanceof Funi ? 'funi' : 'crunchy'));
this.ws.events.on('checkToken', async ({}, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });
respond(await this.service.checkToken());
});
this.ws.events.on('search', async ({ data }, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });
respond(await this.service.search(data));
});
this.ws.events.on('default', async ({ data }, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });
respond(await this.service.handleDefault(data));
});
this.ws.events.on('availableDubCodes', async ({}, respond) => {
if (this.service === undefined)
return respond([]);
respond(await this.service.availableDubCodes());
});
this.ws.events.on('availableSubCodes', async ({}, respond) => {
if (this.service === undefined)
return respond([]);
respond(await this.service.availableSubCodes());
});
this.ws.events.on('resolveItems', async ({ data }, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });
respond(await this.service.resolveItems(data));
});
this.ws.events.on('listEpisodes', async ({ data }, respond) => {
if (this.service === undefined)
return respond({ isOk: false, reason: new Error('No service selected') });
respond(await this.service.listEpisodes(data));
});
this.ws.events.on('downloadItem', async ({ data }, respond) => {
this.service!.downloadItem(data);
respond(undefined);
});
this.ws.events.on('writeToClipboard', async ({ data }, respond) => {
this.service!.writeToClipboard(data);
respond(undefined);
});
this.ws.events.on('openFolder', async ({ data }, respond) => {
this.service!.openFolder(data);
respond(undefined);
});
this.ws.events.on('openFile', async ({ data }, respond) => {
this.service!.openFile(data);
respond(undefined);
});
this.ws.events.on('openURL', async ({ data }, respond) => {
this.service!.openURL(data);
respond(undefined);
});
this.ws.events.on('isDownloading', async ({}, respond) => respond(await this.service!.isDownloading()));
}
}

View file

@ -0,0 +1,77 @@
import { DownloadInfo, FolderTypes, ProgressData } from '../../../@types/messageHandler';
import { RandomEvent, RandomEvents } from '../../../@types/randomEvents';
import WebSocketHandler from '../websocket';
import copy from "copy-to-clipboard";
import open from 'open';
import { cfg } from '..';
import path from 'path';
export default class Base {
constructor(private ws: WebSocketHandler) {}
private downloading = false;
setDownloading(downloading: boolean) {
this.downloading = downloading;
}
getDownloading() {
return this.downloading;
}
alertError(error: Error) {
console.log(`[ERROR] ${error}`);
}
makeProgressHandler(videoInfo: DownloadInfo) {
return ((data: ProgressData) => {
this.sendMessage({
name: 'progress',
data: {
downloadInfo: videoInfo,
progress: data
}
});
}).bind(this);
}
sendMessage<T extends keyof RandomEvents>(data: RandomEvent<T>) {
this.ws.sendMessage(data);
}
async isDownloading() {
return this.downloading;
}
async writeToClipboard(text: string) {
copy(text);
return true;
}
async openFolder(folderType: FolderTypes) {
switch (folderType) {
case 'content':
open(cfg.dir.content);
break;
case 'config':
open(cfg.dir.config);
break;
}
}
async openFile(data: [FolderTypes, string]) {
switch (data[0]) {
case 'config':
open(path.join(cfg.dir.config, data[1]));
break;
case 'content':
throw new Error('No subfolders');
}
}
async openURL(data: string) {
open(data);
}
}

View file

@ -1,110 +1,110 @@
import { BrowserWindow } from 'electron';
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../../@types/messageHandler';
import Crunchy from '../../../../crunchy';
import { ArgvType } from '../../../../modules/module.app-args';
import { buildDefault, getDefault } from '../../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../../modules/module.langsData';
import Base from './base';
class CrunchyHandler extends Base implements MessageHandler {
private crunchy: Crunchy;
constructor(window: BrowserWindow) {
super(window);
this.crunchy = new Crunchy();
this.crunchy.refreshToken();
}
public async listEpisodes (id: string): Promise<EpisodeListResponse> {
await this.crunchy.refreshToken(true);
return { isOk: true, value: (await this.crunchy.listSeriesID(id)).list };
}
public async handleDefault(name: string) {
return getDefault(name, this.crunchy.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray = [];
for(const language of languages){
if (language.cr_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<ResponseBase<QueueItem[]>> {
await this.crunchy.refreshToken(true);
console.log(`[DEBUG] Got resolve options: ${JSON.stringify(data)}`);
const res = await this.crunchy.downloadFromSeriesID(data.id, data);
if (!res.isOk)
return res;
return { isOk: true, value: res.value.map(a => {
return {
...data,
ids: a.data.map(a => a.mediaId),
title: a.episodeTitle,
parent: {
title: a.seasonTitle,
season: a.season.toString()
},
e: a.e,
image: a.image,
episode: a.episodeNumber
};
}) };
}
public async search(data: SearchData): Promise<SearchResponse> {
await this.crunchy.refreshToken(true);
console.log(`[DEBUG] Got search options: ${JSON.stringify(data)}`);
const crunchySearch = await this.crunchy.doSearch(data);
if (!crunchySearch.isOk) {
this.crunchy.refreshToken();
return crunchySearch;
}
return { isOk: true, value: crunchySearch.value };
}
public async checkToken(): Promise<CheckTokenResponse> {
if (await this.crunchy.getProfile()) {
return { isOk: true, value: undefined };
} else {
return { isOk: false, reason: new Error('') };
}
}
public auth(data: AuthData) {
return this.crunchy.doAuth(data);
}
public async downloadItem(data: DownloadData) {
await this.crunchy.refreshToken(true);
console.log(`[DEBUG] Got download options: ${JSON.stringify(data)}`);
this.setDownloading(true);
const _default = buildDefault() as ArgvType;
const res = await this.crunchy.downloadFromSeriesID(data.id, {
dubLang: data.dubLang,
e: data.e
});
if (res.isOk) {
for (const select of res.value) {
if (!(await this.crunchy.downloadEpisode(select, {..._default, skipsubs: false, callbackMaker: this.makeProgressHandler.bind(this), q: data.q, fileName: data.fileName, dlsubs: data.dlsubs, dlVideoOnce: data.dlVideoOnce, force: 'y',
novids: data.novids }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(res.reason);
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
}
}
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../@types/messageHandler';
import Crunchy from '../../../crunchy';
import { ArgvType } from '../../../modules/module.app-args';
import { buildDefault, getDefault } from '../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../modules/module.langsData';
import WebSocketHandler from '../websocket';
import Base from './base';
class CrunchyHandler extends Base implements MessageHandler {
private crunchy: Crunchy;
constructor(ws: WebSocketHandler) {
super(ws);
this.crunchy = new Crunchy();
this.crunchy.refreshToken();
}
public async listEpisodes (id: string): Promise<EpisodeListResponse> {
await this.crunchy.refreshToken(true);
return { isOk: true, value: (await this.crunchy.listSeriesID(id)).list };
}
public async handleDefault(name: string) {
return getDefault(name, this.crunchy.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.cr_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<ResponseBase<QueueItem[]>> {
await this.crunchy.refreshToken(true);
console.log(`[DEBUG] Got resolve options: ${JSON.stringify(data)}`);
const res = await this.crunchy.downloadFromSeriesID(data.id, data);
if (!res.isOk)
return res;
return { isOk: true, value: res.value.map(a => {
return {
...data,
ids: a.data.map(a => a.mediaId),
title: a.episodeTitle,
parent: {
title: a.seasonTitle,
season: a.season.toString()
},
e: a.e,
image: a.image,
episode: a.episodeNumber
};
}) };
}
public async search(data: SearchData): Promise<SearchResponse> {
await this.crunchy.refreshToken(true);
console.log(`[DEBUG] Got search options: ${JSON.stringify(data)}`);
const crunchySearch = await this.crunchy.doSearch(data);
if (!crunchySearch.isOk) {
this.crunchy.refreshToken();
return crunchySearch;
}
return { isOk: true, value: crunchySearch.value };
}
public async checkToken(): Promise<CheckTokenResponse> {
if (await this.crunchy.getProfile()) {
return { isOk: true, value: undefined };
} else {
return { isOk: false, reason: new Error('') };
}
}
public auth(data: AuthData) {
return this.crunchy.doAuth(data);
}
public async downloadItem(data: DownloadData) {
await this.crunchy.refreshToken(true);
console.log(`[DEBUG] Got download options: ${JSON.stringify(data)}`);
this.setDownloading(true);
const _default = buildDefault() as ArgvType;
const res = await this.crunchy.downloadFromSeriesID(data.id, {
dubLang: data.dubLang,
e: data.e
});
if (res.isOk) {
for (const select of res.value) {
if (!(await this.crunchy.downloadEpisode(select, {..._default, skipsubs: false, callbackMaker: this.makeProgressHandler.bind(this), q: data.q, fileName: data.fileName, dlsubs: data.dlsubs, dlVideoOnce: data.dlVideoOnce, force: 'y',
novids: data.novids }))) {
const er = new Error(`Unable to download episode ${data.e} from ${data.id}`);
er.name = 'Download error';
this.alertError(er);
}
}
} else {
this.alertError(res.reason);
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
}
}
export default CrunchyHandler;

View file

@ -1,115 +1,115 @@
import { BrowserWindow } from 'electron';
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../../@types/messageHandler';
import Funimation from '../../../../funi';
import { ArgvType } from '../../../../modules/module.app-args';
import { buildDefault, getDefault } from '../../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../../modules/module.langsData';
import Base from './base';
class FunimationHandler extends Base implements MessageHandler {
private funi: Funimation;
constructor(window: BrowserWindow) {
super(window);
this.funi = new Funimation();
}
public async listEpisodes (id: string) : Promise<EpisodeListResponse> {
const parse = parseInt(id);
if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') };
const request = await this.funi.listShowItems(parse);
if (!request.isOk)
return request;
return { isOk: true, value: request.value.map(item => ({
e: item.id_split.join(''),
lang: item.audio ?? [],
name: item.title,
season: item.seasonNum ?? item.seasonTitle ?? item.item.seasonNum ?? item.item.seasonTitle,
seasonTitle: item.seasonTitle,
episode: item.episodeNum,
id: item.id,
img: item.thumb,
description: item.synopsis,
time: item.runtime ?? item.item.runtime
})) };
}
public async handleDefault(name: string) {
return getDefault(name, this.funi.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray = [];
for(const language of languages){
if (language.funi_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<ResponseBase<QueueItem[]>> {
console.log(`[DEBUG] Got resolve options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { ...data, id: parseInt(data.id) });
if (!res.isOk)
return res;
return { isOk: true, value: res.value.map(a => {
return {
...data,
ids: [a.episodeID],
title: a.title,
parent: {
title: a.seasonTitle,
season: a.seasonNumber
},
image: a.image,
e: a.episodeID,
episode: a.epsiodeNumber,
};
}) };
}
public async search(data: SearchData): Promise<SearchResponse> {
console.log(`[DEBUG] Got search options: ${JSON.stringify(data)}`);
const funiSearch = await this.funi.searchShow(false, data);
if (!funiSearch.isOk)
return funiSearch;
return { isOk: true, value: funiSearch.value.items.hits.map(a => ({
image: a.image.showThumbnail,
name: a.title,
desc: a.description,
id: a.id,
lang: a.languages,
rating: a.starRating
})) };
}
public async checkToken(): Promise<CheckTokenResponse> {
return this.funi.checkToken();
}
public auth(data: AuthData) {
return this.funi.auth(data);
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.log(`[DEBUG] Got download options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { all: false, but: false, id: parseInt(data.id), e: data.e });
const _default = buildDefault() as ArgvType;
if (!res.isOk)
return this.alertError(res.reason);
for (const ep of res.value) {
await this.funi.getEpisode(false, { dubLang: data.dubLang, fnSlug: ep, s: data.id, subs: { dlsubs: data.dlsubs, sub: false, ccTag: _default.ccTag } }, { ..._default, callbackMaker: this.makeProgressHandler.bind(this), ass: true, fileName: data.fileName, q: data.q, force: 'y',
noaudio: data.noaudio, novids: data.novids });
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
}
}
import { AuthData, CheckTokenResponse, DownloadData, EpisodeListResponse, MessageHandler, QueueItem, ResolveItemsData, ResponseBase, SearchData, SearchResponse } from '../../../@types/messageHandler';
import Funimation from '../../../funi';
import { ArgvType } from '../../../modules/module.app-args';
import { buildDefault, getDefault } from '../../../modules/module.args';
import { languages, subtitleLanguagesFilter } from '../../../modules/module.langsData';
import WebSocketHandler from '../websocket';
import Base from './base';
class FunimationHandler extends Base implements MessageHandler {
private funi: Funimation;
constructor(ws: WebSocketHandler) {
super(ws);
this.funi = new Funimation();
}
public async listEpisodes (id: string) : Promise<EpisodeListResponse> {
const parse = parseInt(id);
if (isNaN(parse) || parse <= 0)
return { isOk: false, reason: new Error('The ID is invalid') };
const request = await this.funi.listShowItems(parse);
if (!request.isOk)
return request;
return { isOk: true, value: request.value.map(item => ({
e: item.id_split.join(''),
lang: item.audio ?? [],
name: item.title,
season: item.seasonNum ?? item.seasonTitle ?? item.item.seasonNum ?? item.item.seasonTitle,
seasonTitle: item.seasonTitle,
episode: item.episodeNum,
id: item.id,
img: item.thumb,
description: item.synopsis,
time: item.runtime ?? item.item.runtime
})) };
}
public async handleDefault(name: string) {
return getDefault(name, this.funi.cfg.cli);
}
public async availableDubCodes(): Promise<string[]> {
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
if (language.funi_locale)
dubLanguageCodesArray.push(language.code);
}
return [...new Set(dubLanguageCodesArray)];
}
public async availableSubCodes(): Promise<string[]> {
return subtitleLanguagesFilter;
}
public async resolveItems(data: ResolveItemsData): Promise<ResponseBase<QueueItem[]>> {
console.log(`[DEBUG] Got resolve options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { ...data, id: parseInt(data.id) });
if (!res.isOk)
return res;
return { isOk: true, value: res.value.map(a => {
return {
...data,
ids: [a.episodeID],
title: a.title,
parent: {
title: a.seasonTitle,
season: a.seasonNumber
},
image: a.image,
e: a.episodeID,
episode: a.epsiodeNumber,
};
}) };
}
public async search(data: SearchData): Promise<SearchResponse> {
console.log(`[DEBUG] Got search options: ${JSON.stringify(data)}`);
const funiSearch = await this.funi.searchShow(false, data);
if (!funiSearch.isOk)
return funiSearch;
return { isOk: true, value: funiSearch.value.items.hits.map(a => ({
image: a.image.showThumbnail,
name: a.title,
desc: a.description,
id: a.id,
lang: a.languages,
rating: a.starRating
})) };
}
public async checkToken(): Promise<CheckTokenResponse> {
return this.funi.checkToken();
}
public auth(data: AuthData) {
return this.funi.auth(data);
}
public async downloadItem(data: DownloadData) {
this.setDownloading(true);
console.log(`[DEBUG] Got download options: ${JSON.stringify(data)}`);
const res = await this.funi.getShow(false, { all: false, but: false, id: parseInt(data.id), e: data.e });
const _default = buildDefault() as ArgvType;
if (!res.isOk)
return this.alertError(res.reason);
for (const ep of res.value) {
await this.funi.getEpisode(false, { dubLang: data.dubLang, fnSlug: ep, s: data.id, subs: { dlsubs: data.dlsubs, sub: false, ccTag: _default.ccTag } }, { ..._default, callbackMaker: this.makeProgressHandler.bind(this), ass: true, fileName: data.fileName, q: data.q, force: 'y',
noaudio: data.noaudio, novids: data.novids });
}
this.sendMessage({ name: 'finish', data: undefined });
this.setDownloading(false);
}
}
export default FunimationHandler;

74
gui/server/websocket.ts Normal file
View file

@ -0,0 +1,74 @@
import { IncomingMessage, Server } from "http";
import ws, { WebSocket } from 'ws';
import { RandomEvent, RandomEvents } from "../../@types/randomEvents";
import { MessageTypes, UnknownWSMessage, WSMessage } from "../../@types/ws";
import { EventEmitter } from "events";
import { cfg } from ".";
declare interface ExternalEvent {
on<T extends keyof MessageTypes>(event: T, listener: (msg: WSMessage<T>, respond: (data: MessageTypes[T][1]) => void) => void): this;
emit<T extends keyof MessageTypes>(event: T, msg: WSMessage<T>, respond: (data: MessageTypes[T][1]) => void): boolean;
}
class ExternalEvent extends EventEmitter {}
export default class WebSocketHandler {
private wsServer: ws.Server;
public events: ExternalEvent = new ExternalEvent();
constructor(server: Server) {
this.wsServer = new ws.WebSocketServer({ noServer: true, path: '/ws' });
this.wsServer.on('connection', (socket, req) => {
console.log(`[INFO] [WS] Connection from '${req.socket.remoteAddress}'`);
socket.on('error', (er) => console.log(`[ERROR] [WS] ${er}`));
socket.on('message', (data) => {
const json = JSON.parse(data.toString()) as UnknownWSMessage;
this.events.emit(json.name, json as any, (data) => {
this.wsServer.clients.forEach(client => {
if (client.readyState !== WebSocket.OPEN)
return;
client.send(JSON.stringify({
data,
id: json.id,
name: json.name
}), (er) => {
if (er)
console.log(`[ERROR] [WS] ${er}`)
});
})
});
});
});
server.on('upgrade', (request, socket, head) => {
if (!this.authenticate(request)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
this.wsServer.handleUpgrade(request, socket, head, socket => {
this.wsServer.emit('connection', socket, request);
});
});
}
public sendMessage<T extends keyof RandomEvents>(data: RandomEvent<T>) {
this.wsServer.clients.forEach(client => {
if (client.readyState !== WebSocket.OPEN)
return;
client.send(JSON.stringify(data), (er) => {
if (er)
console.log(`[ERROR] [WS] ${er}`);
});
})
}
private authenticate(request: IncomingMessage): boolean {
return cfg.gui.password === new URL(`http://${request.headers.host}${request.url}`).searchParams.get('password');
}
}

View file

@ -45,7 +45,7 @@ Object.entries(groups).forEach(([key, value]) => {
typeof argument.default === 'object'
? Array.isArray(argument.default)
? JSON.stringify(argument.default)
: argument.default.default
: (argument.default as any).default
: argument.default
}\`|` : ''}`
+ ` ${typeof argument.default === 'object' && !Array.isArray(argument.default)

View file

@ -15,61 +15,11 @@ type BuildTypes = `${'ubuntu'|'windows'|'macos'|'arm'}64`
const buildType = process.argv[2] as BuildTypes;
const isGUI = process.argv[3] === 'true';
if (isGUI) {
buildGUI(buildType);
} else {
buildBinary(buildType);
}
buildBinary(buildType, isGUI);
})();
async function buildGUI(buildType: BuildTypes) {
execSync(`npx electron-builder build --publish=never ${getCommand(buildType)}`, { stdio: [0,1,2] });
execSync(`7z a -t7z "../${buildsDir}/multi-downloader-nx-${buildType}-gui.7z" ${getOutputFileName(buildType).map(a => `"${a}"`).join(' ')}`,{
stdio:[0,1,2],
cwd: path.join('dist')
});
}
function getCommand(buildType: BuildTypes) {
switch (buildType) {
case 'arm64':
return '--linux --arm64';
case 'ubuntu64':
return '--linux --x64';
case 'windows64':
return '--win';
case 'macos64':
return '--mac dmg';
default:
return '--error';
}
}
function getOutputFileName(buildType: BuildTypes): string[] {
switch (buildType) {
case 'arm64':
return [
`${pkg.name}_${pkg.version}_arm64.deb`
];
case 'ubuntu64':
return [
`${pkg.name}_${pkg.version}_amd64.deb`
];
case 'windows64':
return [
`${pkg.name} Setup ${pkg.version}.exe`
];
case 'macos64':
return [
`${pkg.name}-${pkg.version}.dmg`
];
default:
throw new Error(`Unknown build type ${buildType}`);
}
}
// main
async function buildBinary(buildType: BuildTypes) {
async function buildBinary(buildType: BuildTypes, gui: boolean) {
const buildStr = 'multi-downloader-nx';
const acceptableBuilds = ['windows64','ubuntu64','macos64'];
if(!acceptableBuilds.includes(buildType)){
@ -87,7 +37,7 @@ async function buildBinary(buildType: BuildTypes) {
}
fs.mkdirSync(buildDir);
const buildConfig = [
pkg.main,
gui ? 'gui.js' : 'index.js',
'--target', nodeVer + getTarget(buildType),
'--output', `${buildDir}/${pkg.short_name}`,
];
@ -109,6 +59,10 @@ async function buildBinary(buildType: BuildTypes) {
fs.copySync('./package.json', `${buildDir}/package.json`);
fs.copySync('./docs/', `${buildDir}/docs/`);
fs.copySync('./LICENSE.md', `${buildDir}/docs/LICENSE.md`);
if (gui) {
fs.copySync('./gui', `${buildDir}/gui`)
fs.copySync('./node_modules/open/xdg-open', `${buildDir}/xdg-open`)
}
if(fs.existsSync(`${buildsDir}/${buildFull}.7z`)){
fs.removeSync(`${buildsDir}/${buildFull}.7z`);
}

View file

@ -2,7 +2,7 @@ import yargs, { Choices } from 'yargs';
import { args, AvailableMuxer, groups } from './module.args';
import { LanguageItem } from './module.langsData';
let argvC: { [x: string]: unknown; ccTag: string, defaultAudio: LanguageItem, defaultSub: LanguageItem, ffmpegOptions: string[], mkvmergeOptions: string[], force: 'Y'|'y'|'N'|'n'|'C'|'c', skipUpdate: boolean, videoTitle: string, override: string[], fsRetryTime: number, forceMuxer: AvailableMuxer|undefined; username: string|undefined, password: string|undefined, silentAuth: boolean, skipSubMux: boolean, downloadArchive: boolean, addArchive: boolean, but: boolean, auth: boolean | undefined; dlFonts: boolean | undefined; search: string | undefined; 'search-type': string; page: number | undefined; 'search-locale': string; new: boolean | undefined; 'movie-listing': string | undefined; series: string | undefined; s: string | undefined; e: string | undefined; q: number; x: number; kstream: number; partsize: number; hslang: string; dlsubs: string[]; novids: boolean | undefined; noaudio: boolean | undefined; nosubs: boolean | undefined; dubLang: string[]; all: boolean; fontSize: number; allDubs: boolean; timeout: number; simul: boolean; mp4: boolean; skipmux: boolean | undefined; fileName: string; numbers: number; nosess: string; debug: boolean | undefined; nocleanup: boolean; help: boolean | undefined; service: 'funi' | 'crunchy'; update: boolean; fontName: string | undefined; _: (string | number)[]; $0: string; dlVideoOnce: boolean; };
let argvC: { [x: string]: unknown; port: number, ccTag: string, defaultAudio: LanguageItem, defaultSub: LanguageItem, ffmpegOptions: string[], mkvmergeOptions: string[], force: 'Y'|'y'|'N'|'n'|'C'|'c', skipUpdate: boolean, videoTitle: string, override: string[], fsRetryTime: number, forceMuxer: AvailableMuxer|undefined; username: string|undefined, password: string|undefined, silentAuth: boolean, skipSubMux: boolean, downloadArchive: boolean, addArchive: boolean, but: boolean, auth: boolean | undefined; dlFonts: boolean | undefined; search: string | undefined; 'search-type': string; page: number | undefined; 'search-locale': string; new: boolean | undefined; 'movie-listing': string | undefined; series: string | undefined; s: string | undefined; e: string | undefined; q: number; x: number; kstream: number; partsize: number; hslang: string; dlsubs: string[]; novids: boolean | undefined; noaudio: boolean | undefined; nosubs: boolean | undefined; dubLang: string[]; all: boolean; fontSize: number; allDubs: boolean; timeout: number; simul: boolean; mp4: boolean; skipmux: boolean | undefined; fileName: string; numbers: number; nosess: string; debug: boolean | undefined; nocleanup: boolean; help: boolean | undefined; service: 'funi' | 'crunchy'; update: boolean; fontName: string | undefined; _: (string | number)[]; $0: string; dlVideoOnce: boolean; };
export type ArgvType = typeof argvC;
@ -53,7 +53,7 @@ const getArgv = (cfg: { [key:string]: unknown }) => {
...a,
group: groups[a.group],
default: typeof a.default === 'object' && !Array.isArray(a.default) ?
parseDefault(a.default.name || a.name, a.default.default) : a.default
parseDefault((a.default as any).name || a.name, (a.default as any).default) : a.default
};
});
for (const item of data)

View file

@ -9,7 +9,8 @@ const groups = {
'fileName': 'Filename Template:',
'debug': 'Debug:',
'util': 'Utilities:',
'help': 'Help:'
'help': 'Help:',
'gui': 'GUI:'
};
export type AvailableFilenameVars = 'title' | 'episode' | 'showTitle' | 'season' | 'width' | 'height' | 'service'
@ -703,6 +704,16 @@ const args: TAppArg<boolean|number|string|unknown[]>[] = [
default: {
default: 'cc'
}
},
{
name: 'port',
describe: 'Set the port for the GUI server',
docDescribe: true,
group: 'gui',
service: 'both',
default: 3000,
type: 'number',
usage: '${port}'
}
];
@ -715,10 +726,10 @@ const getDefault = <T extends boolean|string|number|unknown[]>(name: string, cfg
if (typeof option.default === 'object') {
if (Array.isArray(option.default))
return option.default as T;
if (Object.prototype.hasOwnProperty.call(cfg, option.default.name ?? option.name)) {
return cfg[option.default.name ?? option.name];
if (Object.prototype.hasOwnProperty.call(cfg, (option.default as any).name ?? option.name)) {
return cfg[(option.default as any).name ?? option.name];
} else {
return option.default.default as T;
return (option.default as any).default as T;
}
} else {
return option.default as T;
@ -733,7 +744,7 @@ const buildDefault = () => {
if (Array.isArray(item.default)) {
data[item.name] = item.default;
} else {
data[item.default.name ?? item.name] = item.default.default;
data[(item.default as any).name ?? item.name] = (item.default as any).default;
}
} else {
data[item.name] = item.default;

View file

@ -12,6 +12,7 @@ export { workingDir };
const binCfgFile = path.join(workingDir, 'config', 'bin-path');
const dirCfgFile = path.join(workingDir, 'config', 'dir-path');
const guiCfgFile = path.join(workingDir, 'config', 'gui')
const cliCfgFile = path.join(workingDir, 'config', 'cli-defaults');
const sessCfgFile = path.join(workingDir, 'config', 'session');
const tokenFile = {
@ -23,7 +24,7 @@ export const ensureConfig = () => {
if (!fs.existsSync(path.join(workingDir, 'config')))
fs.mkdirSync(path.join(workingDir, 'config'));
if (process.env.contentDirectory)
[binCfgFile, dirCfgFile, cliCfgFile].forEach(a => {
[binCfgFile, dirCfgFile, cliCfgFile, guiCfgFile].forEach(a => {
if (!fs.existsSync(`${a}.yml`))
fs.copyFileSync(path.join(__dirname, '..', 'config', `${path.basename(a)}.yml`), `${a}.yml`);
});
@ -59,6 +60,10 @@ export type ConfigObject = {
},
cli: {
[key: string]: any
},
gui: {
port: number,
password: string
}
}
@ -75,6 +80,10 @@ const loadCfg = () : ConfigObject => {
cli: loadYamlCfgFile<{
[key: string]: any
}>(cliCfgFile),
gui: loadYamlCfgFile<{
port: number,
password: string
}>(guiCfgFile)
};
const defaultDirs = {
fonts: '${wdir}/fonts/',

View file

@ -28,7 +28,7 @@ const fontFamilies = {
// collect styles from ass string
function assFonts(ass: string){
const strings = ass.replace(/\r/g,'').split('\n');
const styles = [];
const styles: string[] = [];
for(const s of strings){
if(s.match(/^Style: /)){
const addStyle = s.split(',');

View file

@ -44,7 +44,7 @@ const languages: LanguageItem[] = [
// construct dub language codes
const dubLanguageCodes = (() => {
const dubLanguageCodesArray = [];
const dubLanguageCodesArray: string[] = [];
for(const language of languages){
dubLanguageCodesArray.push(language.code);
}

View file

@ -56,8 +56,8 @@ class Merger {
}
public FFmpeg() : string {
const args = [];
const metaData = [];
const args: string[] = [];
const metaData: string[] = [];
let index = 0;
let audioIndex = 0;
@ -137,7 +137,7 @@ class Merger {
};
public MkvMerge = () => {
const args = [];
const args: string[] = [];
let hasVideo = false;
@ -272,7 +272,7 @@ class Merger {
language: LanguageItem,
fonts: Font[]
}[]) : ParsedFont[] {
let fontsNameList: Font[] = []; const fontsList = [], subsList = []; let isNstr = true;
let fontsNameList: Font[] = []; const fontsList: { name: string, path: string, mime: string }[] = [], subsList: string[] = []; let isNstr = true;
for(const s of subs){
fontsNameList.push(...s.fonts);
subsList.push(s.language.locale);

View file

@ -122,7 +122,7 @@ class Req {
}
}
setNewCookie(setCookie: Record<string, string>, isAuth: boolean, fileData?: string){
const cookieUpdated = []; let lastExp = 0;
const cookieUpdated: string[] = []; let lastExp = 0;
console.trace('Type of setCookie:', typeof setCookie, setCookie);
const parsedCookie = fileData ? cookieFile(fileData) : shlp.cookie.parse(setCookie);
for(const cookieName of Object.keys(parsedCookie)){

View file

@ -10,7 +10,7 @@ export type NullRecord = Record | null;
function loadVtt(vttStr: string) {
const rx = /^([\d:.]*) --> ([\d:.]*)\s?(.*?)\s*$/;
const lines = vttStr.replace(/\r?\n/g, '\n').split('\n');
const data = []; let lineBuf = [], record: NullRecord = null;
const data: Record[] = []; let lineBuf: string[] = [], record: NullRecord = null;
// check lines
for (const l of lines) {
const m = l.match(rx);
@ -142,7 +142,7 @@ function convertTime(time: string, srtFormat = false) {
function toSubsTime(str: string, srtFormat: boolean) : string {
const n = [], x: (string|number)[] = str.split(/[:.]/).map(x => Number(x)); let sx;
const n: string[] = [], x: (string|number)[] = str.split(/[:.]/).map(x => Number(x)); let sx;
const msLen = srtFormat ? 3 : 2;
const hLen = srtFormat ? 2 : 1;

View file

@ -35,14 +35,14 @@
"url": "https://github.com/anidl/multi-downloader-nx/issues"
},
"license": "MIT",
"main": "gui/electron/src/index.js",
"dependencies": {
"@babel/core": "^7.20.12",
"@babel/plugin-syntax-flow": "^7.18.6",
"@babel/plugin-transform-react-jsx": "^7.20.13",
"cheerio": "^1.0.0-rc.12",
"copy-to-clipboard": "^3.3.3",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"electron-squirrel-startup": "^1.0.0",
"eslint-plugin-import": "^2.27.5",
"express": "^4.18.2",
"form-data": "^4.0.0",
@ -52,39 +52,35 @@
"iso-639": "^0.2.2",
"lookpath": "^1.2.2",
"m3u8-parsed": "^1.3.0",
"open": "^8.4.2",
"sei-helper": "^3.3.0",
"typescript-eslint": "^0.0.1-alpha.0",
"webpack": "^5.75.0",
"ws": "^8.12.1",
"yaml": "^2.2.1",
"yargs": "^17.7.0"
},
"devDependencies": {
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/fs-extra": "^11.0.1",
"@types/node": "^18.14.0",
"@types/ws": "^8.5.4",
"@types/yargs": "^17.0.22",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@vercel/webpack-asset-relocator-loader": "^1.7.3",
"css-loader": "^6.7.3",
"electron": "23.1.0",
"electron-builder": "^23.6.0",
"eslint": "^8.34.0",
"eslint-config-react-app": "^7.0.1",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-react": "^7.32.2",
"fork-ts-checker-webpack-plugin": "^7.3.0",
"node-loader": "^2.0.0",
"pkg": "^5.8.0",
"removeNPMAbsolutePaths": "^3.0.1",
"style-loader": "^3.3.1",
"ts-loader": "^9.4.2",
"ts-node": "^10.9.1",
"typescript": "^4.9.5"
"typescript": "5.1.0-dev.20230227"
},
"scripts": {
"prestart": "pnpm run tsc test",
"start": "pnpm prestart && cd lib && npx electron .",
"start": "pnpm prestart && cd lib && node gui.js",
"docs": "ts-node modules/build-docs.ts",
"tsc": "ts-node tsc.ts",
"prebuild-cli": "pnpm run tsc false false",
@ -101,33 +97,5 @@
"eslint-fix": "eslint *.js modules --fix",
"pretest": "pnpm run tsc",
"test": "pnpm run pretest && cd lib && node modules/build windows64 && node modules/build ubuntu64 && node modules/build macos64"
},
"build": {
"appId": "github.com/anidl",
"mac": {
"category": "public.app-category.developer-tools",
"darkModeSupport": true
},
"dmg": {
"iconSize": 160,
"contents": [
{
"x": 180,
"y": 170
},
{
"x": 480,
"y": 170,
"type": "link",
"path": "/Applications"
}
]
},
"linux": {
"target": [
"deb"
],
"category": "Development"
}
}
}

File diff suppressed because it is too large Load diff

13
tsc.ts
View file

@ -18,7 +18,8 @@ if (!isTest)
if (!isGUI)
buildIgnore = buildIgnore.concat([
'./gui*',
'./build*'
'./build*',
'gui.ts'
]);
@ -84,7 +85,7 @@ export { ignore };
process.stdout.write('✓\nCopying files... ');
if (!isTest && isGUI) {
copyDir(path.join(__dirname, 'gui', 'react', 'build'), path.join(__dirname, 'lib', 'gui', 'electron', 'build'));
copyDir(path.join(__dirname, 'gui', 'react', 'build'), path.join(__dirname, 'lib', 'gui', 'server', 'build'));
}
const files = readDir(__dirname);
@ -99,9 +100,6 @@ export { ignore };
});
process.stdout.write('✓\nInstalling dependencies... ');
if (!isTest && !isGUI) {
alterJSON();
}
if (!isTest) {
const dependencies = exec(`pnpm install ${isGUI ? '' : '-P'}`, {
cwd: path.join(__dirname, 'lib')
@ -112,11 +110,6 @@ export { ignore };
process.stdout.write('✓\n');
})();
function alterJSON() {
packageJSON.main = 'index.js';
fs.writeFileSync(path.join('lib', 'package.json'), JSON.stringify(packageJSON, null, 4));
}
function readDir (dir: string): {
path: string,
stats: fs.Stats