import {DownloadResult, LucidaOptions} from "../types/queue"; import {Browser, BrowserContext, Download, firefox, Locator, Page, Response as PResponse} from "playwright"; import path from "node:path"; import {fetchBooklet} from "./booklet"; class Lucida { private browser: Browser | null; private context: BrowserContext | null; private options: LucidaOptions; public constructor(lucidaOptions: LucidaOptions) { this.browser = null; this.context = null; this.options = lucidaOptions } public async construct(): Promise { console.log('Lucida: Launching browser'); const proxy: {server: string} | undefined = this.options.proxy ? {server: this.options.proxy} : undefined; this.browser = await firefox.launch({ headless: this.options.headless, proxy: proxy, args: ['--no-sandbox', '--no-gpu'] }); this.context = await this.browser.newContext({ acceptDownloads: true, baseURL: this.options.baseUrl }); } public async destruct(reason: string | undefined = undefined): Promise { if (this.browser === null) { return; } console.log('Lucida: Closing browser'); if (this.context !== null) { await this.context.close({reason: reason}); } await this.browser.close(); } private async getError(page: Page): Promise { const errorElement: Locator = page.locator('div[class="error svelte-40348f"]'); const hasError: boolean = !page.isClosed() && await errorElement.count() !== 0 && await errorElement.isVisible(); if (!hasError) { return undefined; } let errorText: string = (await errorElement.innerText()).trim(); errorText += (await errorElement.locator('details').locator('p').allTextContents()).map(e => e.trim()); return errorText; } /** * Check the 'Hide my download from recently downloaded' checkbox * @param page The page to check * @private */ private async hideDownload(page: Page): Promise { const hideCheckbox: Locator = page.locator('input[id="hide-from-ticker"]'); if (await hideCheckbox.count() === 0 || !await hideCheckbox.isVisible()) { console.error('Lucida: No hide checkbox found!'); return; } try { await hideCheckbox.setChecked(true, {force: true}); } catch (err) { console.error('Lucida: Could not check hide checkbox:', err instanceof Error ? err.message : 'Unknown error'); } } private async saveDownload(download: Download, downloadPath: string): Promise { const pathName: string = path.join(downloadPath, download.suggestedFilename()); await download.saveAs(pathName); // const stream = fs.createWriteStream(pathName); // const response = await download.createReadStream(); // if (response) { // response.pipe(stream); // await new Promise((resolve, reject) => { // stream.on('finish', resolve); // stream.on('error', reject); // }); // } return pathName; } public async download(url: string, downloadPath: string, timeout: number, status: { status: string }): Promise { let finished: boolean = false; let started: boolean = true; let close: boolean = false; if (this.context === null) { await this.construct(); close = true; } if (this.context === null) { return { success: false, error: 'Could not create browser context' }; } const downloadPage: Page = await this.context.newPage(); const downloadPageUrl: string = this.options.baseUrl + '?url=' + encodeURIComponent(url); const downloadPageResp: PResponse | null = await downloadPage.goto(downloadPageUrl); if (downloadPageResp === null || !downloadPageResp.ok()) { return { success: false, error: 'Could not load page' }; } // Check for errors const error: string | undefined = await this.getError(downloadPage); if (error !== undefined) { await downloadPage.close(); return { success: false, error: error }; } await this.hideDownload(downloadPage); const start: number = Date.now(); let retryCount: number = 0; // Listen for download downloadPage.once('download', async (download) => { started = true; await this.saveDownload(download, downloadPath); await downloadPage.close(); await fetchBooklet(url, downloadPath, this.context); finished = true; if (close) { await this.destruct(); } return {success: true}; }); // Start download await downloadPage.getByText('download full album').click(); // Retry file download if it fails const maxRetries: number = 10; const progressElement: Locator = downloadPage.locator('div[class="loader svelte-1ipdo3f"]'); while (!started && retryCount < maxRetries && (Date.now() - start) < timeout) { const retry: Locator = downloadPage.getByText('Retry'); const error: string | undefined = started ? undefined : await this.getError(downloadPage); if (error !== undefined) { console.log('Retrying download because of error:', error); await retry.click(); retryCount++; } else if (!started && await progressElement.count() !== 0 && await progressElement.isVisible()) { status.status = (await progressElement.locator('p').innerText()).trim(); } } if (!started) { // Cleanup await downloadPage.close(); if (close) { await this.destruct(); } let errorMessage: string; if (timeout <= (Date.now() - start)) { errorMessage = 'Download timed out'; } else if (retryCount >= maxRetries) { errorMessage = `Download failed after ${maxRetries} retries`; } else { errorMessage = 'Unknown error'; } return { success: false, error: errorMessage }; } // Wait for download to finish await new Promise(resolve => setTimeout(resolve, 30000)); return { success: finished, error: finished ? undefined : 'Save to disk did not finish.' }; } } export default Lucida;