(Proper) Initial commit
This commit is contained in:
parent
48c0059860
commit
32796e4026
19 changed files with 6094 additions and 97 deletions
41
backend/services/booklet.ts
Normal file
41
backend/services/booklet.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {BrowserContext, Download, firefox, Locator} from "playwright";
|
||||
import path from "node:path";
|
||||
|
||||
export async function fetchBooklet(url: string, downloadPath: string, context: BrowserContext | null): Promise<string | null> {
|
||||
const browser = await firefox.launch({
|
||||
headless: true
|
||||
});
|
||||
context = await browser.newContext({
|
||||
acceptDownloads: true
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const bookletPage: string = 'http://audiofil.hostronavt.ru/booklet.php?name=' + encodeURIComponent(url);
|
||||
await page.goto(bookletPage);
|
||||
|
||||
// Find link with goodies
|
||||
const link: Locator = page.locator('a').filter({hasText: '/goodies/'});
|
||||
const linkCount: number = await link.count();
|
||||
if (0 <= linkCount) {
|
||||
await page.close();
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Booklet: Found goodies: ${await link.innerHTML()}`);
|
||||
|
||||
let filename: string | null = null;
|
||||
try {
|
||||
const downloadPromise: Promise<Download> = page.waitForEvent('download');
|
||||
await link.dispatchEvent('click');
|
||||
const download: Download = await downloadPromise;
|
||||
|
||||
filename = download.suggestedFilename();
|
||||
await download.saveAs(path.join(downloadPath, filename));
|
||||
} catch (err) {
|
||||
console.log('Booklet: Could not download booklet:', err instanceof Error ? err.message : 'Unknown error');
|
||||
filename = null;
|
||||
}
|
||||
|
||||
await page.close();
|
||||
return filename;
|
||||
}
|
201
backend/services/lucida.ts
Normal file
201
backend/services/lucida.ts
Normal file
|
@ -0,0 +1,201 @@
|
|||
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";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | undefined> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const pathName: string = path.join(downloadPath, download.suggestedFilename());
|
||||
// await download.saveAs(pathName);
|
||||
// return 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<DownloadResult> {
|
||||
let finished: boolean = false;
|
||||
|
||||
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) {
|
||||
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) => {
|
||||
await this.saveDownload(download, downloadPath);
|
||||
finished = true;
|
||||
await fetchBooklet(url, downloadPath, this.context);
|
||||
|
||||
await downloadPage.close();
|
||||
if (close) {
|
||||
await this.destruct();
|
||||
}
|
||||
|
||||
process.stdout.clearLine(0);
|
||||
return {success: true};
|
||||
});
|
||||
// Start download
|
||||
await downloadPage.getByText('download full album').click();
|
||||
|
||||
// Retry file download if it fails
|
||||
const progressElement: Locator = downloadPage.locator('div[class="loader svelte-1ipdo3f"]');
|
||||
while (!finished && retryCount < 10 && (Date.now() - start) < timeout) {
|
||||
const retry: Locator = downloadPage.getByText('Retry');
|
||||
const error: string | undefined = finished ? undefined : await this.getError(downloadPage);
|
||||
|
||||
if (error !== undefined) {
|
||||
console.log('Retrying download because of error:', error);
|
||||
await retry.click();
|
||||
retryCount++;
|
||||
} else if (!finished && await progressElement.count() !== 0 && await progressElement.isVisible()) {
|
||||
const progress: string = (await progressElement.locator('p').innerText()).trim();
|
||||
status.status = progress;
|
||||
process.stdout.clearLine(0);
|
||||
process.stdout.write(`\r${progress}`);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.clearLine(0);
|
||||
|
||||
if (!finished) {
|
||||
// Cleanup
|
||||
await downloadPage.close();
|
||||
if (close) {
|
||||
await this.destruct();
|
||||
}
|
||||
|
||||
let errorMessage: string;
|
||||
if (timeout <= (Date.now() - start)) {
|
||||
errorMessage = 'Download timed out';
|
||||
} else if (retryCount >= 10) {
|
||||
errorMessage = 'Download failed after 10 retries';
|
||||
} else {
|
||||
errorMessage = 'Unknown error';
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
return {success: true};
|
||||
}
|
||||
}
|
||||
|
||||
export default Lucida;
|
50
backend/services/metadata.ts
Normal file
50
backend/services/metadata.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import {JSDOM} from 'jsdom';
|
||||
import {Song, SongSource} from '../types/queue';
|
||||
|
||||
const TITLE_SELECTOR: string = 'h1[class="svelte-6pt9ji"]';
|
||||
const ARTIST_SELECTOR: string = 'h2[class="svelte-6pt9ji"]';
|
||||
const TRACK_COUNT_SELECTOR: string = 'h3[class="svelte-6pt9ji"]';
|
||||
const COVER_SELECTOR: string = 'a[title="Click for full quality cover (unproxied)."]';
|
||||
|
||||
export async function fetchMetadata(url: string): Promise<Song> {
|
||||
const resp: Response = await fetch('https://lucida.to/?url=' + encodeURIComponent(url));
|
||||
if (!resp.ok) {
|
||||
return {
|
||||
title: '<unknown>',
|
||||
artist: '<unknown>',
|
||||
trackCount: -1,
|
||||
url: url,
|
||||
source: getSource(url)
|
||||
}
|
||||
}
|
||||
|
||||
const doc: Document = new JSDOM(await resp.text()).window.document;
|
||||
const title: string = doc.querySelector(TITLE_SELECTOR)?.textContent?.trim() ?? '<unknown>';
|
||||
const artist: string = doc.querySelector(ARTIST_SELECTOR)?.textContent?.trim() ?? '<unknown>';
|
||||
const trackCount: number = parseInt(doc.querySelector(TRACK_COUNT_SELECTOR)?.innerHTML.trim().split(' ')[0] ?? '-1');
|
||||
const cover: string | undefined = doc.querySelector(COVER_SELECTOR)?.attributes.getNamedItem('href')?.value ?? undefined;
|
||||
|
||||
return {
|
||||
title: title,
|
||||
artist: artist.substring(artist.indexOf(' ') + 1),
|
||||
trackCount: trackCount,
|
||||
url: url,
|
||||
source: getSource(url),
|
||||
cover: cover
|
||||
}
|
||||
}
|
||||
|
||||
function getSource(url: string): SongSource {
|
||||
const urlToSource: Map<string, SongSource> = new Map([
|
||||
['qobuz.com', SongSource.Qobuz],
|
||||
['spotify.com', SongSource.Spotify]
|
||||
]);
|
||||
|
||||
for (const [key, value] of urlToSource) {
|
||||
if (url.includes(key)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return SongSource.Unknown;
|
||||
}
|
Reference in a new issue