diff --git a/src/dlmanager.ts b/src/dlmanager.ts index 1f09ee3..d99c3d2 100644 --- a/src/dlmanager.ts +++ b/src/dlmanager.ts @@ -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 + { + const out: Record = {}; + 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 { 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 = {}; __downloads: Record = {}; __orders: DownloadOrder[] = []; } diff --git a/src/download.ts b/src/download.ts index d5e333d..77f8127 100644 --- a/src/download.ts +++ b/src/download.ts @@ -25,6 +25,7 @@ export interface IDownload getData: ()=>Promise; 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 { await this.__trigger; diff --git a/src/index.ts b/src/index.ts index b69cf48..10bcc9d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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); diff --git a/src/movies/kinogo.biz.ts b/src/movies/kinogo.biz.ts index 448a29d..a030adc 100644 --- a/src/movies/kinogo.biz.ts +++ b/src/movies/kinogo.biz.ts @@ -53,6 +53,10 @@ class KinogoDownload implements IDownload onFinish(callback: ()=>any) { } + + onError(callback: (err: Error)=>any) + { + } } diff --git a/www/404.html b/www/404.html new file mode 100644 index 0000000..8b4b34b --- /dev/null +++ b/www/404.html @@ -0,0 +1,9 @@ + + + + 404 - Not Found + + +

404 - Not Found

+ + diff --git a/www/api.js b/www/api.js new file mode 100644 index 0000000..b4368ea --- /dev/null +++ b/www/api.js @@ -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(); + }, +} diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..c41b850 --- /dev/null +++ b/www/index.html @@ -0,0 +1,53 @@ + + + + + + + WebDL + + +
+ +
+ + diff --git a/www/style.css b/www/style.css new file mode 100644 index 0000000..f2fb7ff --- /dev/null +++ b/www/style.css @@ -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; +}