Compare commits

...

10 Commits

Author SHA1 Message Date
49be7a313b 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
2026-01-10 05:23:35 +01:00
f3e2001cd9 Ignore python cache 2026-01-10 04:01:40 +01:00
410efad4ac Remove python cache 2026-01-10 04:00:24 +01:00
2d8b1485a0 Don't redownload urls 2026-01-09 04:01:25 +01:00
79c238b912 Remove debug message 2026-01-09 03:48:31 +01:00
c718ebe07f Lengthen update checks from 10 seconds to 10 minutes 2026-01-09 03:47:46 +01:00
ec8a0cd84e Fix directory resolution for older nodejs 2026-01-09 03:46:12 +01:00
c4fce38086 Added requiremets.txt 2026-01-09 03:35:22 +01:00
3afe3cd1a6 Fix daemon url 2026-01-09 03:26:27 +01:00
048077722e Fix typo 2026-01-09 03:24:12 +01:00
13 changed files with 396 additions and 95 deletions

2
.gitignore vendored
View File

@ -5,6 +5,8 @@ tsconfig.tsbuildinfo
# Python
env/
__pycache__/
*.pyc
# Output
dist/

View File

@ -7,7 +7,7 @@ require 'json'
current = `git rev-parse HEAD`.strip
latest = current
api_url = URI.parse 'https://lesbian.ddns.net/api/v1/repos/tomas/freestuff/commits?limit=1'
api_url = URI.parse 'https://lesbian.ddns.net/api/v1/repos/tomas/webdl/commits?limit=1'
puts '[Daemon] '.red + 'Running WebDL daemon'
while true do
@ -27,7 +27,7 @@ while true do
end
while true do
sleep 10
sleep 600
print '[Daemon] '.red + 'Checking for updates...'
res = Net::HTTP.get_response api_url
@ -39,7 +39,6 @@ while true do
else
puts ' Update available!'.cyan
puts '[Daemon] '.red + 'Signaled server to shutdown'
puts 'Signal'
io.puts 'shutdown'
io.flush
break

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
certifi==2026.1.4
charset-normalizer==3.4.4
cloudscraper==1.2.71
idna==3.11
pyparsing==3.3.1
requests==2.32.5
requests-toolbelt==1.0.0
urllib3==2.6.2

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,9 +39,10 @@ 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)
{
this.__trigger = new Promise((resolve: (a?: undefined)=>any)=>{
this.on("end", resolve);
});
let proc;
if (!outfile)
proc = child_process.spawn("python", ["-m", "util.dlp", url, "-"]);
@ -68,16 +70,17 @@ export class Download extends EventEmitter implements IDownload
case "success":
stderr_fin = true;
if (stdout_fin)
resolve()
this.emit("end");
break;
case "failure":
dlp.error = msg.message!;
fail(msg.message!);
this.error = msg.message!;
proc.stderr.removeAllListeners();
this.emit("error", new Error(msg.message!));
break;
case "progress":
dlp.total = msg.total!;
dlp.received = msg.received!;
dlp.speed = msg.speed!;
this.total = msg.total!;
this.received = msg.received!;
this.speed = msg.speed!;
break;
}
}
@ -87,16 +90,10 @@ export class Download extends EventEmitter implements IDownload
proc.stdout.on("data", (chunk=>{stdout.push(chunk)}));
proc.stdout.on("end", ()=>{
if (!outfile)
dlp.__buffer = Buffer.concat(stdout);
this.__buffer = Buffer.concat(stdout);
stdout_fin = true;
if (stderr_fin)
resolve()
})
}
this.__trigger = new Promise(main);
this.__trigger.then(()=>{
dlp.emit("end");
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,23 +22,6 @@ interface API_PostDownload
filename: string,
}
app.get("/download/:filename", (req,res)=>{
if (req.params.filename.includes("/"))
return res.status(400).send({error:"filename can not contain directories"});
const filename = path.join(config.download.path, req.params.filename);
const parts = req.url.split("?");
if (parts.length == 1)
return res.status(400).send("Missing download query");
parts.shift();
const url = parts.join("?");
const id = dlmgr.download(url, filename);
return res.status(303).header("Location", "/#"+id).send(id);
});
app.use("/api/", express.json());
app.post("/api/download", (req,res)=>{
@ -58,28 +37,92 @@ app.post("/api/download", (req,res)=>{
});
app.get("/api/download/:id", (req, res)=>{
if (!dlmgr.existsDownload(req.params.id))
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.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");
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("Download has not finished");
return res.status(400).send({error:"Download has not finished"});
const data = await dlmgr.getData(req.params.id);
const head = data.read(2048);
const mime = await detectBufferMime(head);
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);
data.pipe(res);
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"});
const filename = path.join(config.download.path, req.params.filename);
const parts = req.url.split("?");
if (parts.length == 1)
return res.status(400).send("Missing download query");
parts.shift();
const url = parts.join("?");
for (const dl of Object.entries(dlmgr.getDownloads()))
if (dl[1].url == url)
return res.status(303).header("Location", "/#"+dl[0]).send(dl[0]);
for (const dl of Object.entries(dlmgr.getQueue()))
if (dl[1].url == url)
return res.status(303).header("Location", "/#"+dl[0]).send(dl[0]);
const id = dlmgr.download(url, filename);
return res.status(303).header("Location", "/#"+id).send(id);
});
app.use(express.static("www"));
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

@ -9,7 +9,7 @@ export type Driver = (url: string) => ()=>IDownload;
const __promises: Promise<any>[] = [];
const __drivers: Record<string, Driver> = {};
for (const ent of fs.readdirSync(path.join(import.meta.dirname, "movies")))
for (const ent of fs.readdirSync(path.join(path.dirname(new URL(import.meta.url).pathname), "movies")))
{
if (!ent.endsWith(".js"))
continue;

View File

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

Binary file not shown.

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;
}