Compare commits

...

4 commits

10 changed files with 1071 additions and 794 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.idea
node_modules
backend/Dockerfile
dist
.dockerignore
test
.gitignore
.git

15
backend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM node:22-bookworm
WORKDIR /app
COPY package*.json ./
RUN npm install
RUN npx playwright install --with-deps
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/app.js", "--", "--port", "3000", "--proxy", "socks5://tor:9050"]

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{ {
"name": "lucida-queue", "name": "lucida-queue",
"version": "1.0.0", "version": "0.1.0",
"description": "Queue for downloading from lucida.to", "description": "Queue for downloading from lucida.to",
"main": "backend/app.ts", "main": "app.ts",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",

View file

@ -1,10 +1,13 @@
import {fetchMetadata} from "./services/metadata"; import {fetchMetadata} from "./services/metadata";
import {DEFAULT_LUCIDA_OPTIONS, DownloadResult, LucidaOptions, ProcessingQueue, Queue, QueueItem} from "./types/queue"; import {DEFAULT_LUCIDA_OPTIONS, DownloadResult, LucidaOptions, ProcessingQueue, Queue, QueueItem} from "./types/queue";
import Lucida from "./services/lucida"; import Lucida from "./services/lucida";
import * as fs from "node:fs";
const TIMEOUT: number = 120000; const TIMEOUT: number = 120000;
const RETRIES: number = 5; const RETRIES: number = 5;
const STATE_FILE: string = '/data/state.json';
class QueueManager { class QueueManager {
private readonly queue: Queue; private readonly queue: Queue;
private readonly processing: ProcessingQueue; private readonly processing: ProcessingQueue;
@ -20,6 +23,42 @@ class QueueManager {
this.lucida = null; this.lucida = null;
this.lucidaOptions = DEFAULT_LUCIDA_OPTIONS; this.lucidaOptions = DEFAULT_LUCIDA_OPTIONS;
this.loadState();
}
private saveState(): void {
const state = {
queue: this.processing.map(pi => pi.item).concat(this.queue),
history: this.history
};
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 4));
}
private loadState(): void {
if (!fs.existsSync(STATE_FILE)) {
return;
}
const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
console.log('QueueManager: Restoring state:', state);
this.queue.push(...state.queue);
state.history.forEach((item: QueueItem) => {
if (!this.queue.some(q => q.id === item.id)) {
if (item.result?.success) {
// Add successful items to the history
this.history.push(item);
} else {
// Add failed items to the queue
this.queue.push(item);
}
}
});
this.processQueue();
} }
public setLucidaOptions(options: LucidaOptions): void { public setLucidaOptions(options: LucidaOptions): void {
@ -58,6 +97,7 @@ class QueueManager {
song: await fetchMetadata(song) song: await fetchMetadata(song)
}; };
this.queue.push(item); this.queue.push(item);
this.saveState();
this.processQueue(); this.processQueue();
return item; return item;
@ -74,6 +114,7 @@ class QueueManager {
song: await fetchMetadata(song) song: await fetchMetadata(song)
}; };
this.queue.splice(index, 0, item); this.queue.splice(index, 0, item);
this.saveState();
this.processQueue(); this.processQueue();
return item; return item;
@ -87,11 +128,13 @@ class QueueManager {
const item: QueueItem = this.queue[index]; const item: QueueItem = this.queue[index];
this.queue.splice(index, 1); this.queue.splice(index, 1);
this.saveState();
return item; return item;
} }
public clear(): void { public clear(): void {
this.queue.length = 0; this.queue.length = 0;
this.saveState();
} }
public move(song: string, to: number): QueueItem { public move(song: string, to: number): QueueItem {
@ -102,6 +145,7 @@ class QueueManager {
const item: QueueItem = this.queue.splice(index, 1)[0]; const item: QueueItem = this.queue.splice(index, 1)[0];
this.queue.splice(to, 0, item); this.queue.splice(to, 0, item);
this.saveState();
return item; return item;
} }
@ -114,6 +158,7 @@ class QueueManager {
const item: QueueItem = this.history.splice(index, 1)[0]; const item: QueueItem = this.history.splice(index, 1)[0];
item.retries = 0; item.retries = 0;
this.queue.push(item); this.queue.push(item);
this.saveState();
this.processQueue(); this.processQueue();
return item; return item;
@ -149,9 +194,11 @@ class QueueManager {
item.timeout = (item.timeout ?? TIMEOUT) * 2; item.timeout = (item.timeout ?? TIMEOUT) * 2;
item.result = result; item.result = result;
this.queue.push(item); this.queue.push(item);
this.saveState();
} else { } else {
item.result = result; item.result = result;
this.history.push(item); this.history.push(item);
this.saveState();
} }
} catch (err) { } catch (err) {
item.result = { item.result = {
@ -159,6 +206,7 @@ class QueueManager {
error: err instanceof Error ? err.message : 'Unknown error' error: err instanceof Error ? err.message : 'Unknown error'
}; };
this.history.push(item); this.history.push(item);
this.saveState();
} }
this.processing.splice(this.processing.indexOf(current), 1); this.processing.splice(this.processing.indexOf(current), 1);

View file

@ -2,7 +2,6 @@ import {DownloadResult, LucidaOptions} from "../types/queue";
import {Browser, BrowserContext, Download, firefox, Locator, Page, Response as PResponse} from "playwright"; import {Browser, BrowserContext, Download, firefox, Locator, Page, Response as PResponse} from "playwright";
import path from "node:path"; import path from "node:path";
import {fetchBooklet} from "./booklet"; import {fetchBooklet} from "./booklet";
import * as fs from "node:fs";
class Lucida { class Lucida {
private browser: Browser | null; private browser: Browser | null;
@ -77,18 +76,17 @@ class Lucida {
private async saveDownload(download: Download, downloadPath: string): Promise<string> { private async saveDownload(download: Download, downloadPath: string): Promise<string> {
const pathName: string = path.join(downloadPath, download.suggestedFilename()); const pathName: string = path.join(downloadPath, download.suggestedFilename());
// await download.saveAs(pathName); await download.saveAs(pathName);
// return pathName;
const stream = fs.createWriteStream(pathName); // const stream = fs.createWriteStream(pathName);
const response = await download.createReadStream(); // const response = await download.createReadStream();
if (response) { // if (response) {
response.pipe(stream); // response.pipe(stream);
await new Promise((resolve, reject) => { // await new Promise((resolve, reject) => {
stream.on('finish', resolve); // stream.on('finish', resolve);
stream.on('error', reject); // stream.on('error', reject);
}); // });
} // }
return pathName; return pathName;
} }
@ -97,6 +95,7 @@ class Lucida {
status: string status: string
}): Promise<DownloadResult> { }): Promise<DownloadResult> {
let finished: boolean = false; let finished: boolean = false;
let started: boolean = true;
let close: boolean = false; let close: boolean = false;
if (this.context === null) { if (this.context === null) {
@ -123,7 +122,7 @@ class Lucida {
// Check for errors // Check for errors
const error: string | undefined = await this.getError(downloadPage); const error: string | undefined = await this.getError(downloadPage);
if (error) { if (error !== undefined) {
await downloadPage.close(); await downloadPage.close();
return { return {
success: false, success: false,
@ -138,42 +137,38 @@ class Lucida {
// Listen for download // Listen for download
downloadPage.once('download', async (download) => { downloadPage.once('download', async (download) => {
started = true;
await this.saveDownload(download, downloadPath); await this.saveDownload(download, downloadPath);
finished = true;
await fetchBooklet(url, downloadPath, this.context);
await downloadPage.close(); await downloadPage.close();
await fetchBooklet(url, downloadPath, this.context);
finished = true;
if (close) { if (close) {
await this.destruct(); await this.destruct();
} }
process.stdout.clearLine(0);
return {success: true}; return {success: true};
}); });
// Start download // Start download
await downloadPage.getByText('download full album').click(); await downloadPage.getByText('download full album').click();
// Retry file download if it fails // Retry file download if it fails
const maxRetries: number = 10;
const progressElement: Locator = downloadPage.locator('div[class="loader svelte-1ipdo3f"]'); const progressElement: Locator = downloadPage.locator('div[class="loader svelte-1ipdo3f"]');
while (!finished && retryCount < 10 && (Date.now() - start) < timeout) { while (!started && retryCount < maxRetries && (Date.now() - start) < timeout) {
const retry: Locator = downloadPage.getByText('Retry'); const retry: Locator = downloadPage.getByText('Retry');
const error: string | undefined = finished ? undefined : await this.getError(downloadPage); const error: string | undefined = started ? undefined : await this.getError(downloadPage);
if (error !== undefined) { if (error !== undefined) {
console.log('Retrying download because of error:', error); console.log('Retrying download because of error:', error);
await retry.click(); await retry.click();
retryCount++; retryCount++;
} else if (!finished && await progressElement.count() !== 0 && await progressElement.isVisible()) { } else if (!started && await progressElement.count() !== 0 && await progressElement.isVisible()) {
const progress: string = (await progressElement.locator('p').innerText()).trim(); status.status = (await progressElement.locator('p').innerText()).trim();
status.status = progress;
process.stdout.clearLine(0);
process.stdout.write(`\r${progress}`);
} }
} }
process.stdout.clearLine(0); if (!started) {
if (!finished) {
// Cleanup // Cleanup
await downloadPage.close(); await downloadPage.close();
if (close) { if (close) {
@ -183,8 +178,8 @@ class Lucida {
let errorMessage: string; let errorMessage: string;
if (timeout <= (Date.now() - start)) { if (timeout <= (Date.now() - start)) {
errorMessage = 'Download timed out'; errorMessage = 'Download timed out';
} else if (retryCount >= 10) { } else if (retryCount >= maxRetries) {
errorMessage = 'Download failed after 10 retries'; errorMessage = `Download failed after ${maxRetries} retries`;
} else { } else {
errorMessage = 'Unknown error'; errorMessage = 'Unknown error';
} }
@ -194,7 +189,12 @@ class Lucida {
}; };
} }
return {success: true}; // Wait for download to finish
await new Promise(resolve => setTimeout(resolve, 30000));
return {
success: finished,
error: finished ? undefined : 'Save to disk did not finish.'
};
} }
} }

View file

@ -58,7 +58,7 @@
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */ // "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */ "outDir": "dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */ // "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
@ -109,12 +109,9 @@
"skipLibCheck": true /* Skip type checking all .d.ts files. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */
}, },
"include": [ "include": [
"backend/**/*.ts", "./**/*.ts",
"backend/**/*.ts",
"frontend/**/*.ts",
"backend/services/**/*.ts",
"backend/types/**/*.ts",
"backend/app.ts"
], ],
"exclude": ["node_modules"] "exclude": [
"node_modules"
]
} }

30
docker-compose.yml Normal file
View file

@ -0,0 +1,30 @@
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "8080:80"
depends_on:
- backend
restart: always
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3000:3000"
volumes:
- "/srv/lucida-queue:/data"
depends_on:
- tor
restart: always
tor:
container_name: tor
image: osminogin/tor-simple
ports:
- "9050:9050"
stop_grace_period: 1m
restart: always

7
frontend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM nginx:alpine
COPY . /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -19,7 +19,7 @@ function setApiUrl() {
if (url === null || url.value === '') { if (url === null || url.value === '') {
console.log('Using default API URL'); console.log('Using default API URL');
apiBaseUrl = 'http://127.0.0.1:3000/api/v1'; apiBaseUrl = `http://${window.location.hostname}:3000/api/v1`;
} else { } else {
apiBaseUrl = url.value; apiBaseUrl = url.value;
} }