Compare commits
4 commits
212c544eb1
...
15091444be
Author | SHA1 | Date | |
---|---|---|---|
15091444be | |||
f9a729f22c | |||
b5dcaaa515 | |||
81b18df1f4 |
10 changed files with 1071 additions and 794 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.idea
|
||||||
|
node_modules
|
||||||
|
backend/Dockerfile
|
||||||
|
dist
|
||||||
|
.dockerignore
|
||||||
|
test
|
||||||
|
.gitignore
|
||||||
|
.git
|
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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"]
|
1678
package-lock.json → backend/package-lock.json
generated
1678
package-lock.json → backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
|
@ -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);
|
||||||
|
|
|
@ -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.'
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
30
docker-compose.yml
Normal 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
7
frontend/Dockerfile
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY . /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
Reference in a new issue