Added simple gui, bugfixes, error logging

- Removed large promise from Download, and turned it into a sole trigger
- Fixed a nasty bug that threw unresolved promises
- Added error callback for IDownload
This commit is contained in:
2026-01-10 05:23:35 +01:00
parent f3e2001cd9
commit 49be7a313b
8 changed files with 382 additions and 98 deletions

View File

@ -1,5 +1,6 @@
import type { IDownload } from "./download.ts";
import { Download } from "./download.js";
import path from "node:path";
import { Readable } from "node:stream";
import { v4 as v4uuid } from "uuid";
@ -20,6 +21,11 @@ interface DownloadOrder
__download: ()=>IDownload;
}
interface DownloadError extends DownloadStatus
{
reason: string;
}
interface DownloadEntity
{
dl: IDownload;
@ -54,7 +60,7 @@ export class DownloadManager
let id = uuid();
// Ensure we don't collide, just in that 0.0000000001% case :)
while (id in this.__downloads && this.__orders.some(o => o.id == id))
while (id in this.__errors || id in this.__downloads || this.__orders.some(o => o.id == id))
id = uuid();
let resolve: (en: DownloadEntity) => void;
@ -77,11 +83,29 @@ export class DownloadManager
return id;
}
getError(id: string): DownloadError
{
const err = this.__errors[id];
if (!err)
throw new Error("Error does not exist");
const out: DownloadError = {
reason: err.reason,
url: err.url,
finished: false,
total: 0,
received: 0,
speed: 0,
};
if (err.filename)
out.filename = err.filename;
return out;
}
getDownload(id: string): DownloadStatus
{
const en = this.__downloads[id];
if (!en)
throw "Download does not exist";
throw new Error("Download does not exist");
const out: DownloadStatus = {
url: en.dl.getUrl(),
finished: en.fin,
@ -91,7 +115,27 @@ export class DownloadManager
};
const filename = en.dl.getFilename();
if (filename)
out.filename = filename;
out.filename = path.basename(filename);
return out;
}
remove(id: string): void
{
if (id in this.__errors)
delete this.__errors[id];
else if (id in this.__downloads)
delete this.__downloads[id];
else if (this.__orders.some(o => o.id == id))
this.__orders = this.__orders.filter(o => o.id != id);
else
throw new Error("Download does not exist");
}
getErrors(): Record<string, DownloadError>
{
const out: Record<string, DownloadError> = {};
for (const id of Object.keys(this.__errors))
out[id] = this.getError(id);
return out;
}
@ -116,21 +160,36 @@ export class DownloadManager
speed: 0,
};
if (or.outfile)
out[or.id]!.filename = or.outfile;
out[or.id]!.filename = path.basename(or.outfile);
}
return out;
}
inQueue(id: string): boolean
isOrder(id: string): boolean
{
return this.__orders.some(item=>item.id==id);
}
existsDownload(id: string): boolean
isDownload(id: string): boolean
{
return id in this.__downloads;
}
isError(id: string): boolean
{
return id in this.__errors;
}
exists(id: string): boolean
{
return (
id in this.__errors ||
id in this.__downloads ||
this.__orders.some(o => o.id == id) ||
false
);
}
async getData(id: string): Promise<Readable>
{
let en: DownloadEntity | undefined = this.__downloads[id];
@ -142,7 +201,7 @@ export class DownloadManager
break;
}
if (!en)
throw "Download does not exist";
throw new Error("Download does not exist");
return await en.dl.getData();
}
@ -172,12 +231,30 @@ export class DownloadManager
this.__update();
});
dl.onError((error: Error)=>{
en.fin = true;
delete this.__downloads[or.id];
const err: DownloadError = {
reason: error.message,
url: or.url,
finished: false,
total: 0,
received: 0,
speed: 0,
};
if (or.outfile)
err.filename = path.basename(or.outfile);
this.__errors[or.id] = err;
this.__update();
});
or.__en(en);
this.__downloads[or.id] = en;
}
}
__max_downloads: number;
__errors: Record<string, DownloadError> = {};
__downloads: Record<string, DownloadEntity> = {};
__orders: DownloadOrder[] = [];
}

View File

@ -25,6 +25,7 @@ export interface IDownload
getData: ()=>Promise<Readable>;
onFinish: (callback: ()=>any)=>void;
onError: (callback: (err: Error)=>any)=>void;
}
@ -38,65 +39,61 @@ export class Download extends EventEmitter implements IDownload
if (outfile)
this.filename = outfile
let dlp = this;
function main(resolve: (a?: undefined)=>void, fail: (reason: string)=>void)
{
let proc;
if (!outfile)
proc = child_process.spawn("python", ["-m", "util.dlp", url, "-"]);
else
proc = child_process.spawn("python", ["-m", "util.dlp", url, outfile]);
this.__trigger = new Promise((resolve: (a?: undefined)=>any)=>{
this.on("end", resolve);
});
let stdout: Buffer[] = [];
let stderr = "";
let proc;
if (!outfile)
proc = child_process.spawn("python", ["-m", "util.dlp", url, "-"]);
else
proc = child_process.spawn("python", ["-m", "util.dlp", url, outfile]);
let stdout_fin = false;
let stderr_fin = false;
let stdout: Buffer[] = [];
let stderr = "";
proc.stderr.on("data", (chunk=>{
stderr += chunk;
if (stderr.includes("\n"))
let stdout_fin = false;
let stderr_fin = false;
proc.stderr.on("data", (chunk=>{
stderr += chunk;
if (stderr.includes("\n"))
{
let lines = stderr.split("\n");
stderr = lines.pop()!;
for (const line of lines)
{
let lines = stderr.split("\n");
stderr = lines.pop()!;
for (const line of lines)
let msg = JSON.parse(line) as DLStatus;
switch (msg.status)
{
let msg = JSON.parse(line) as DLStatus;
switch (msg.status)
{
case "success":
stderr_fin = true;
if (stdout_fin)
resolve()
break;
case "failure":
dlp.error = msg.message!;
fail(msg.message!);
break;
case "progress":
dlp.total = msg.total!;
dlp.received = msg.received!;
dlp.speed = msg.speed!;
break;
}
case "success":
stderr_fin = true;
if (stdout_fin)
this.emit("end");
break;
case "failure":
this.error = msg.message!;
proc.stderr.removeAllListeners();
this.emit("error", new Error(msg.message!));
break;
case "progress":
this.total = msg.total!;
this.received = msg.received!;
this.speed = msg.speed!;
break;
}
}
}));
}
}));
proc.stdout.on("data", (chunk=>{stdout.push(chunk)}));
proc.stdout.on("end", ()=>{
if (!outfile)
dlp.__buffer = Buffer.concat(stdout);
stdout_fin = true;
if (stderr_fin)
resolve()
})
}
this.__trigger = new Promise(main);
this.__trigger.then(()=>{
dlp.emit("end");
proc.stdout.on("data", (chunk=>{stdout.push(chunk)}));
proc.stdout.on("end", ()=>{
if (!outfile)
this.__buffer = Buffer.concat(stdout);
stdout_fin = true;
if (stderr_fin)
this.emit("end");
});
}
@ -121,6 +118,11 @@ export class Download extends EventEmitter implements IDownload
this.on("end", callback);
}
onError(callback: (err: Error)=>any)
{
this.on("error", callback);
}
async getData(): Promise<Readable>
{
await this.__trigger;

View File

@ -1,11 +1,9 @@
import { DownloadManager } from "./dlmanager.js";
import express from "express";
import { detectBufferMime } from "mime-detect";
import { downloadMovie } from "./movies.js";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import { Readable } from "node:stream";
import toml from "toml";
@ -16,8 +14,6 @@ if (!fs.existsSync(config.download.path))
const dlmgr = new DownloadManager(config.download.parallel);
const app = express();
// const mov_url = "https://kinogo.biz/122738-zveropolis-2.html";
// dlmgr.enqueue(await downloadMovie(mov_url), mov_url);
interface API_PostDownload
@ -26,6 +22,77 @@ interface API_PostDownload
filename: string,
}
app.use("/api/", express.json());
app.post("/api/download", (req,res)=>{
const params: API_PostDownload = req.body;
if (params.filename.includes("/"))
return res.status(400).send({error:"filename can not contain directories"});
const filename = path.join(config.download.path, params.filename);
const id = dlmgr.download(params.url, filename);
res.send({id});
});
app.get("/api/download/:id", (req, res)=>{
if (!dlmgr.isDownload(req.params.id))
return res.status(404).send({error:"Download does not exist"});
res.send(dlmgr.getDownload(req.params.id));
});
app.get("/api/errors", (_req, res)=>{
res.send(dlmgr.getErrors());
});
app.get("/api/downloads", (_req, res)=>{
res.send(dlmgr.getDownloads());
});
app.get("/api/queue", (_req, res)=>{
res.send(dlmgr.getQueue());
});
app.get("/api/data/:id", async (req, res)=>{
if (dlmgr.isError(req.params.id))
return res.status(400).send({error:"Download has failed", reason:dlmgr.getError(req.params.id)});
if (dlmgr.isOrder(req.params.id))
return res.status(400).send({error:"Download has not yet started"});
if (!dlmgr.isDownload(req.params.id))
return res.status(404).send({error:"Download does not exist"});
const dl = dlmgr.getDownload(req.params.id)
if (!dl.finished)
return res.status(400).send({error:"Download has not finished"});
const stream = await dlmgr.getData(req.params.id);
let head_size: number = 0;
const head_chunks: Buffer[] = [];
function tee(chunk: Buffer)
{
head_size += chunk.length;
head_chunks.push(chunk);
if (head_size >= 2048)
{
stream.off("data", tee);
stream.pause();
const head = Buffer.concat(head_chunks);
detectBufferMime(head).then((mime)=>{
res.contentType(mime);
res.write(head);
stream.pipe(res);
stream.resume();
});
}
}
stream.on("data", tee);
});
app.use("/api/", (_req,res)=>{
res.status(404).send({"error": "Invalid API"});
});
app.get("/download/:filename", (req,res)=>{
if (req.params.filename.includes("/"))
return res.status(400).send({error:"filename can not contain directories"});
@ -51,43 +118,11 @@ app.get("/download/:filename", (req,res)=>{
return res.status(303).header("Location", "/#"+id).send(id);
});
app.use("/api/", express.json());
app.use(express.static("www"));
app.post("/api/download", (req,res)=>{
const params: API_PostDownload = req.body;
if (params.filename.includes("/"))
return res.status(400).send({error:"filename can not contain directories"});
const filename = path.join(config.download.path, params.filename);
const id = dlmgr.download(params.url, filename);
res.send({id});
});
app.get("/api/download/:id", (req, res)=>{
if (!dlmgr.existsDownload(req.params.id))
return res.status(404).send({error:"Download does not exist"});
res.send(dlmgr.getDownload(req.params.id));
});
app.get("/api/data/:id", async (req, res)=>{
if (dlmgr.inQueue(req.params.id))
return res.status(400).send("Download has not yet started");
if (!dlmgr.existsDownload(req.params.id))
return res.status(404).send("Download does not exist");
const dl = dlmgr.getDownload(req.params.id)
if (!dl.finished)
return res.status(400).send("Download has not finished");
const data = await dlmgr.getData(req.params.id);
const head = data.read(2048);
const mime = await detectBufferMime(head);
res.contentType(mime);
res.write(head);
data.pipe(res);
});
app.use((_req,res)=>{
res.status(404).sendFile(path.resolve("www/404.html"));
})
app.listen(config.server.port);
console.log("Server running on :"+config.server.port);

View File

@ -53,6 +53,10 @@ class KinogoDownload implements IDownload
onFinish(callback: ()=>any)
{
}
onError(callback: (err: Error)=>any)
{
}
}

9
www/404.html Normal file
View File

@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>404 - Not Found</title>
</head>
<body>
<h1>404 - Not Found</h1>
</body>
</html>

33
www/api.js Normal file
View File

@ -0,0 +1,33 @@
API = {
__get: async function(url)
{
const res = await fetch(url);
if (res.status != 200)
throw new Error(await res.json().error);
return res;
},
getDownload: async function(id)
{
const res = await this.__get("/api/download/"+id);
return await res.json();
},
getErrors: async function()
{
const res = await this.__get("/api/errors");
return await res.json();
},
getDownloads: async function()
{
const res = await this.__get("/api/downloads");
return await res.json();
},
getQueue: async function()
{
const res = await this.__get("/api/queue");
return await res.json();
},
}

53
www/index.html Normal file
View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/style.css" />
<script src="/api.js"></script>
<script src="https://lesbian.ddns.net/Tomas/-/files/lib/web/alpine.js" defer></script>
<title>WebDL</title>
</head>
<body>
<main>
<div id="menu" x-data="{errs: API.getErrors(), dls: API.getDownloads(), que: API.getQueue()}">
<div id="errors" class="section" x-init="setInterval(async ()=>{errs = await API.getErrors();}, 500);">
<template x-for="(err, id) in errs">
<div class="entry">
<div>
<span x-text="(err.filename || err.url) + ' - ' + err.reason"></span>
</div>
</div>
</template>
</div>
<hr>
<div id="downloads" class="section" x-init="setInterval(async ()=>{dls = await API.getDownloads();}, 500);">
<template x-for="(dl, id) in dls">
<div class="entry">
<template x-if="dl.received == dl.total">
<div>
<a x-bind:href="'/api/data/'+id" x-text="dl.filename || dl.url"></a>
</div>
</template>
<template x-if="dl.received != dl.total">
<div>
<span style="flex:1" x-text="dl.filename || dl.url"></span>
<progress style="flex:1" x-bind:max="dl.total" x-bind:value="dl.received"></progress>
<span class="progress-text" x-text="`${(dl.received/dl.total*100).toFixed(1)}%`"></span>
</div>
</template>
</div>
</template>
</div>
<hr>
<div id="queue" class="section" x-init="setInterval(async ()=>{que = await API.getQueue();}, 500);">
<template x-for="qu in que">
<div class="entry">
<div>
<span x-text="qu.filename || qu.url"></span>
</div>
</div>
</template>
</div>
</div>
</main>
</body>
</html>

71
www/style.css Normal file
View File

@ -0,0 +1,71 @@
:root
{
--fg1: black;
--fg2: gray;
--bg1: white;
--bg2: lightgray;
--fge: red;
--bge: yellow;
}
main
{
top: 0;
left: 0;
position: absolute;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
min-width: 100vw;
min-height: 100vh;
max-width: 100vw;
max-height: 100vh;
}
#menu
{
flex: 1;
margin: 1em;
padding: 1em;
border-radius: 1em;
background-color: var(--bg2);
}
.entry
{
border-radius: 1em;
background-color: var(--bg1);
margin-bottom: .5em;
}
.entry > div
{
padding: 1em;
display: flex;
flex-direction: row;
}
#errors > div
{
color: var(--fge);
background-color: var(--bge);
}
/*
#downloads
{
}
*/
#queue
{
flex: 1;
}
.progress-text
{
text-align: right;
min-width: 4em;
}