qobuz-dl-cli
Version:
CLI downloader for Qobuz-DL instances
138 lines (112 loc) • 4.9 kB
JavaScript
import os from 'node:os';
import fs from 'node:fs';
import { Logger, LOGLEVEL } from '@ckcr4lyf/logger'
import { QobuzDlAPI } from "./api.mjs"
import { muxToDisk } from './ffmpeg.mjs';
import path from 'node:path';
export const getLogger = () => {
return new Logger({ loglevel: LOGLEVEL.INFO });
}
/**
* 27 -> FLAC 24bit 192kHZ
* 7 -> FLAC 24bit 96kHz
* 6 -> FLAC 16bit 44.1kHZ
* 5 -> MP3 320kbps
*/
const QUALITY = 6;
/**
*
* @param {string} albumId
* @param {'ALAC' | 'FLAC'}
* @param {QobuzDlAPI} api
*/
export const downloadAlbum = async (albumId, format, api) => {
const logger = getLogger();
logger.info(`Going to get album info from Qobuz-DL instance (Album ID: ${albumId})`);
const albumInfo = await api.getAlbumInfo(albumId);
logger.info(`Going to download album art from Qobuz`);
const albumArt = await api.downloadAlbumArt(albumInfo);
const albumArtFilename = getTempFolderFilename(`${albumId}_cover.jpg`);
fs.writeFileSync(albumArtFilename, Buffer.from(albumArt));
logger.info(`Wrote album art to ${albumArtFilename}`);
const albumFolder = createAlbumDirectory(albumInfo);
logger.info(`going to download tracks in parallel... (to ${format})`)
// although node.js is single threaded, the track downloading is I/O bound (on internet) so can be done concurrently
// for the muxing to m4a, we use spawn, so the underlying ffmpeg processes can adapt to the number of threads
const trackPromises = await Promise.all(albumInfo.data.tracks.items.map(track => downloadTrack(albumInfo, albumArtFilename, albumFolder, format, track, api)));
logger.info(`we cooked!`);
}
/**
*
* @param {import('./api').Track} track
* @param {QobuzDlAPI} api
*/
export const downloadTrack = async(albumInfo, albumArtFilename, albumFolder, format, track, api) => {
const logger = getLogger();
logger.info(`[${track.id}] Going to get download URL for track #${track.track_number} (${track.title})`);
const trackDownloadUrl = await api.getTrackDownloadLink(track.id, QUALITY);
logger.info(`[${track.id}] Downloading track...`);
const trackResponse = await fetch(trackDownloadUrl.data.url);
const trackData = await trackResponse.arrayBuffer();
const trackFilename = getTempFolderFilename(`${track.id}_${QUALITY}.flac`);
fs.writeFileSync(trackFilename, Buffer.from(trackData));
logger.info(`[${track.id}] Downloaded file to ${trackFilename}`);
const metadataFilename = getTempFolderFilename(`${track.id}.txt`);
const metadata = generateFfmpegMetadata(albumInfo, track);
fs.writeFileSync(metadataFilename, metadata)
logger.info(`[${track.id}] Wrote metadata to ${metadataFilename}`);
const finalFilename = await muxToDisk({
coverArt: albumArtFilename,
flac: trackFilename,
metadata: metadataFilename,
trackName: track.title,
trackNumber: track.track_number,
albumFolder: albumFolder,
format: format,
});
logger.info(`[${track.id}] saved to disk at ${finalFilename}`);
}
/**
*
* @param {import('./api').AlbumInfo} albumInfo
* @param {import('./api').Track} trackInfo
*/
export const generateFfmpegMetadata = (albumInfo, trackInfo) => {
let metadata = `;FFMETADATA1`
metadata += `\ntitle=${trackInfo.title}`;
if (albumInfo.data.artists.length > 0) {
metadata += `\nartist=${trackInfo.performer.name}`;
metadata += `\nalbum_artist=${albumInfo.data.artist.name}`
} else {
metadata += `\nartist=Various Artists`;
metadata += `\nalbum_artist=Various Artists`;
}
metadata += `\nalbum=${albumInfo.data.title}`
metadata += `\ndate=${albumInfo.data.release_date_original}`
metadata += `\nyear=${new Date(albumInfo.data.release_date_original).getFullYear()}`
metadata += `\nlabel=${albumInfo.data.label.name}`
metadata += `\ncopyright=${albumInfo.data.copyright}`
if (trackInfo.isrc) metadata += `\nisrc=${trackInfo.isrc}`;
if (albumInfo.data.upc) metadata += `\nbarcode=${albumInfo.data.upc}`;
if (trackInfo.track_number) metadata += `\ntrack=${trackInfo.track_number}`;
return metadata;
}
/**
*
* @param {import('./api').AlbumInfo} albumInfo
* @returns {string} The path to the created directory
*/
export const createAlbumDirectory = (albumInfo) => {
const artistName = albumInfo.data.artist.name;
const albumName = albumInfo.data.title;
const dirName = `${artistName} - ${albumName}`;
// Remove invalid characters for directory names
const sanitizedDirName = path.join(process.cwd(), dirName.replace(/[<>:"/\\|?*]/g, ''));
if (!fs.existsSync(sanitizedDirName)) {
fs.mkdirSync(sanitizedDirName, { recursive: true });
}
return sanitizedDirName;
}
export const getTempFolderFilename = (filename) => {
return path.join(os.tmpdir(), filename);
}