Files
Tidal-DL/startup.mjs
2025-05-11 07:32:35 -04:00

427 lines
17 KiB
JavaScript

'use strict';
import readline from 'readline';
import path from 'path';
import { promises as fs } from 'fs';
import axios from 'axios';
import { authenticate } from './v2/login.mjs';
import { downloadMusicTrack } from './v2/music.mjs';
import { downloadVideo, fetchAvailableVideoStreams } from './v2/video.mjs';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const UI_TEXT = {
WELCOME_BANNER_TOP: "╔═════════════════════════════════════════════════╗",
WELCOME_BANNER_MID: "║ Welcome to Tidal Downloader! ║",
WELCOME_BANNER_BOT: "╚═════════════════════════════════════════════════╝",
ARIA2C_NOTICE: "\nMake sure you have 'aria2c' installed and in your system's PATH.",
DOWNLOAD_DIR_NOTICE: "Downloads will be saved in a './downloads' directory relative to this script.",
AUTHENTICATING_MSG: "\nAttempting to authenticate with Tidal...",
AUTH_SUCCESS_MSG: "\n✅ Successfully authenticated with Tidal!",
AUTH_FAILED_MSG: "\nAuthentication failed, or no valid session obtained. Cannot proceed.",
AUTH_RETRY_PROMPT: "Please ensure you complete the device authorization if prompted.",
SEPARATOR_LINE: "\n---------------------------------------------",
MAIN_MENU_PROMPT: "What would you like to do?",
EXIT_MESSAGE: "\nExiting. Goodbye! 👋",
INVALID_CHOICE: "Invalid choice. Please try again.",
DOWNLOAD_ANOTHER_PROMPT: "\nDo you want to download another item?",
};
const APP_CONFIG = {
OUTPUT_BASE_DIR: './downloads',
MUSIC_SUBDIR: 'music',
VIDEO_SUBDIR: 'videos',
DEFAULT_AXIOS_TIMEOUT: 15000,
MAX_FILENAME_LENGTH: 200,
};
const ITEM_TYPE = {
SONG: 'song',
VIDEO: 'video',
};
const TIDAL_URL_PATTERNS = {
TRACK: /\/(?:browse\/)?track\/(\d+)/,
VIDEO: /\/(?:browse\/)?video\/(\d+)/,
};
const AUDIO_QUALITIES = [
{ name: "Standard (AAC 96 kbps)", apiCode: "LOW" },
{ name: "High (AAC 320 kbps)", apiCode: "HIGH" },
{ name: "HiFi (CD Quality FLAC 16-bit/44.1kHz - Lossless)", apiCode: "LOSSLESS" },
{ name: "Max (HiRes FLAC up to 24-bit/192kHz - Lossless)", apiCode: "HI_RES_LOSSLESS" }
];
const MAIN_MENU_OPTIONS = [
{ id: 'DOWNLOAD_SONG', name: 'Download a Song' },
{ id: 'DOWNLOAD_VIDEO', name: 'Download a Music Video' },
{ id: 'EXIT', name: 'Exit' },
];
function askQuestion(query) {
return new Promise(resolve => rl.question(query, resolve));
}
async function promptUserForSelection(promptMessage, options, optionFormatter = (opt) => opt.name || opt) {
console.log(`\n${promptMessage}`);
options.forEach((option, index) => {
console.log(` ${index + 1}. ${optionFormatter(option)}`);
});
let choiceIndex = -1;
const maxChoice = options.length;
while (choiceIndex < 0 || choiceIndex >= maxChoice) {
const answer = await askQuestion(`Select an option (1-${maxChoice}): `);
const parsedAnswer = parseInt(answer, 10);
if (!isNaN(parsedAnswer) && parsedAnswer >= 1 && parsedAnswer <= maxChoice) {
choiceIndex = parsedAnswer - 1;
} else {
console.log(`Invalid selection. Please enter a number between 1 and ${maxChoice}.`);
}
}
return options[choiceIndex];
}
async function promptUserForConfirmation(promptMessage, defaultValue = true) {
const reminder = defaultValue ? '(Y/n)' : '(y/N)';
const validYes = ['yes', 'y'];
const validNo = ['no', 'n'];
while (true) {
const answer = (await askQuestion(`${promptMessage} ${reminder}: `)).toLowerCase().trim();
if (answer === '') return defaultValue;
if (validYes.includes(answer)) return true;
if (validNo.includes(answer)) return false;
console.log("Invalid input. Please enter 'yes' or 'no'.");
}
}
function extractIdFromTidalUrl(url, itemType) {
if (!url || typeof url !== 'string') return null;
const regex = itemType === ITEM_TYPE.SONG ? TIDAL_URL_PATTERNS.TRACK : TIDAL_URL_PATTERNS.VIDEO;
const match = url.match(regex);
return match && match[1] ? { type: itemType, id: match[1] } : null;
}
async function fetchHtmlContent(url) {
const urlObj = new URL(url);
const headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'Accept-Language': 'en-US,en;q=0.9',
'Host': urlObj.hostname,
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Connection': 'keep-alive',
};
try {
const response = await axios.get(url, { headers, timeout: APP_CONFIG.DEFAULT_AXIOS_TIMEOUT });
return response.data;
} catch (error) {
console.error(`Error fetching HTML from ${url}: ${error.message}`);
if (error.response) {
console.error(`Status: ${error.response.status}, Data (first 200 chars): ${String(error.response.data).substring(0, 200)}`);
} else if (error.request) {
console.error('No response received from server.');
}
throw error;
}
}
function parseOgMetaProperty(htmlContent, property) {
const regex = new RegExp(`<meta[^>]*property="og:${property}"[^>]*content="([^"]+)"`, 'i');
const match = htmlContent.match(regex);
return match ? match[1] : null;
}
function normalizeTidalTrackUrlForMetadata(inputUrlStr) {
try {
const url = new URL(inputUrlStr);
let path = url.pathname;
if (path.startsWith('/u/')) path = path.substring(2);
const segments = path.split('/').filter(Boolean);
let trackId = null;
if (segments[0] === 'track' && segments[1]) {
trackId = segments[1];
} else if (segments[0] === 'browse' && segments[1] === 'track' && segments[2]) {
trackId = segments[2];
}
if (trackId && /^\d+$/.test(trackId)) {
return `${url.protocol}//${url.host}/browse/track/${trackId}`;
}
return inputUrlStr;
} catch (e) {
return inputUrlStr;
}
}
async function fetchTrackMetadataForRenaming(trackUrl) {
const urlToFetch = normalizeTidalTrackUrlForMetadata(trackUrl);
if (urlToFetch !== trackUrl) {
console.log(`Normalized URL for metadata fetching: ${urlToFetch}`);
}
try {
console.log(`Fetching HTML from: ${urlToFetch}`);
const htmlContent = await fetchHtmlContent(urlToFetch);
let title = null;
let artist = null;
const pageTitleTagMatch = htmlContent.match(/<title>(.+?) by (.+?) on TIDAL<\/title>/i);
if (pageTitleTagMatch && pageTitleTagMatch[1] && pageTitleTagMatch[2]) {
title = pageTitleTagMatch[1].trim();
artist = pageTitleTagMatch[2].trim();
console.log(`Metadata from <title>: Title "${title}", Artist "${artist}"`);
} else {
const ogTitle = parseOgMetaProperty(htmlContent, 'title');
if (ogTitle) {
const parts = ogTitle.split(' - ');
if (parts.length >= 2) {
title = parts[0].trim();
artist = parts.slice(1).join(' - ').trim();
console.log(`Metadata from og:title (split): Title "${title}", Artist "${artist}"`);
} else {
title = ogTitle.trim();
console.log(`Metadata from og:title (no split): Title "${title}"`);
}
} else {
console.log("Could not extract title/artist from <title> or og:title tags.");
}
}
return { title, artist };
} catch (error) {
console.error(`Error fetching metadata from ${urlToFetch}: ${error.message}`);
return { title: null, artist: null };
}
}
function sanitizeFilenameSegment(name) {
if (!name || typeof name !== 'string') return '';
let sanitized = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
sanitized = sanitized.replace(/\s+/g, ' ').trim();
return (sanitized === '' || sanitized.match(/^\.+$/)) ? 'untitled' : sanitized;
}
async function renameDownloadedSong(originalFilePath, songUrl) {
console.log("Fetching metadata for potential renaming...");
const { title, artist } = await fetchTrackMetadataForRenaming(songUrl);
if (!title || !artist) {
console.log("Skipping renaming: insufficient metadata (title/artist missing).");
return originalFilePath;
}
const doRename = await promptUserForConfirmation("Rename file using Artist - Title?", true);
if (!doRename) {
console.log("Skipping renaming as per user choice.");
return originalFilePath;
}
const fileExt = path.extname(originalFilePath);
const outputDir = path.dirname(originalFilePath);
const newBaseName = `${sanitizeFilenameSegment(artist)} - ${sanitizeFilenameSegment(title)}`;
let newFilePath = path.join(outputDir, `${newBaseName}${fileExt}`);
if (newFilePath.length > APP_CONFIG.MAX_FILENAME_LENGTH) {
const excessLength = newFilePath.length - APP_CONFIG.MAX_FILENAME_LENGTH;
const baseNamePathLength = newBaseName.length + outputDir.length + fileExt.length + 1;
if (newBaseName.length > excessLength) {
const truncatedBaseName = newBaseName.substring(0, newBaseName.length - excessLength - 3) + "...";
newFilePath = path.join(outputDir, `${truncatedBaseName}${fileExt}`);
} else {
console.warn("Filename is too long even after attempting truncation. Using a generic short name.");
newFilePath = path.join(outputDir, `tidal_download_${Date.now()}${fileExt}`);
}
}
if (newFilePath === originalFilePath) {
console.log("Generated filename is same as original. No rename needed.");
return originalFilePath;
}
try {
console.log(`Renaming "${path.basename(originalFilePath)}" to "${path.basename(newFilePath)}"`);
await fs.rename(originalFilePath, newFilePath);
console.log(`✅ File renamed successfully to: ${newFilePath}`);
return newFilePath;
} catch (renameError) {
console.error(`❌ Failed to rename file: ${renameError.message}. Using original filename.`);
return originalFilePath;
}
}
async function selectAudioDownloadQuality() {
return await promptUserForSelection(
"Available Audio Qualities:",
AUDIO_QUALITIES,
(q) => `${q.name} (API Code: ${q.apiCode})`
);
}
async function selectVideoDownloadQuality(videoId, accessToken) {
console.log("\nFetching available video qualities...");
try {
const streams = await fetchAvailableVideoStreams(videoId, accessToken);
if (!streams || streams.length === 0) {
console.log("No video streams found or an error occurred during fetch.");
return null;
}
return await promptUserForSelection(
"Available Video Qualities (sorted best first by bandwidth):",
streams,
(s) => `Resolution: ${s.resolution}, Bandwidth: ${s.bandwidth} bps, Codecs: ${s.codecs}`
);
} catch (error) {
console.error("Error fetching video qualities:", error.message);
return null;
}
}
async function handleSongDownload(session, itemUrl, itemId) {
const selectedQuality = await selectAudioDownloadQuality();
if (!selectedQuality) {
console.log("No audio quality selected. Aborting song download.");
return;
}
console.log(`Selected audio quality: ${selectedQuality.name}`);
const outputDir = path.join(APP_CONFIG.OUTPUT_BASE_DIR, APP_CONFIG.MUSIC_SUBDIR);
await fs.mkdir(outputDir, { recursive: true });
console.log(`\n🎵 Starting download for song ID: ${itemId}`);
console.log(` Output directory: ${path.resolve(outputDir)}`);
const downloadResult = await downloadMusicTrack({
trackId: itemId,
audioQuality: selectedQuality.apiCode,
accessToken: session.accessToken,
outputDir: outputDir,
countryCode: session.countryCode
});
if (downloadResult && downloadResult.success && downloadResult.filePath) {
console.log(`\n✅ Song ${itemId} (${selectedQuality.apiCode}) download process finished. Original file: ${downloadResult.filePath}`);
const finalFilePath = await renameDownloadedSong(downloadResult.filePath, itemUrl);
console.log(` Final file location: ${finalFilePath}`);
} else {
const errorMsg = downloadResult ? downloadResult.error : 'Unknown download error';
console.error(`\n❌ Song ${itemId} download failed. ${errorMsg}`);
}
}
async function handleVideoDownload(session, itemUrl, itemId) {
const selectedStream = await selectVideoDownloadQuality(itemId, session.accessToken);
if (!selectedStream) {
console.log("No video quality selected or error fetching. Aborting video download.");
return;
}
console.log(`Selected video quality: ${selectedStream.resolution} @ ${selectedStream.bandwidth}bps`);
const outputDir = path.join(APP_CONFIG.OUTPUT_BASE_DIR, APP_CONFIG.VIDEO_SUBDIR);
await fs.mkdir(outputDir, { recursive: true });
console.log(`\n🎬 Starting download for music video ID: ${itemId}`);
console.log(` Output directory: ${path.resolve(outputDir)}`);
await downloadVideo({
videoId: itemId,
accessToken: session.accessToken,
selectedStreamUrl: selectedStream.url,
outputDir: outputDir,
tidalUrl: itemUrl
});
console.log(`\n✅ Music video ${itemId} (Res: ${selectedStream.resolution}) download finished.`);
}
async function main() {
console.log(UI_TEXT.WELCOME_BANNER_TOP);
console.log(UI_TEXT.WELCOME_BANNER_MID);
console.log(UI_TEXT.WELCOME_BANNER_BOT);
console.log(UI_TEXT.ARIA2C_NOTICE);
console.log(UI_TEXT.DOWNLOAD_DIR_NOTICE);
let session;
try {
console.log(UI_TEXT.AUTHENTICATING_MSG);
session = await authenticate();
} catch (error) {
console.error("\nFatal error during authentication:", error.message);
rl.close();
return;
}
if (!session || !session.isAccessTokenCurrentlyValid()) {
console.error(UI_TEXT.AUTH_FAILED_MSG);
console.log(UI_TEXT.AUTH_RETRY_PROMPT);
rl.close();
return;
}
console.log(UI_TEXT.AUTH_SUCCESS_MSG);
console.log(` User ID: ${session.userId}, Country: ${session.countryCode}`);
await fs.mkdir(APP_CONFIG.OUTPUT_BASE_DIR, { recursive: true });
while (true) {
console.log(UI_TEXT.SEPARATOR_LINE);
const choice = await promptUserForSelection(UI_TEXT.MAIN_MENU_PROMPT, MAIN_MENU_OPTIONS);
if (choice.id === 'EXIT') {
console.log(UI_TEXT.EXIT_MESSAGE);
break;
}
const currentItemType = choice.id === 'DOWNLOAD_SONG' ? ITEM_TYPE.SONG : ITEM_TYPE.VIDEO;
const exampleUrl = currentItemType === ITEM_TYPE.SONG ? 'https://tidal.com/browse/track/TRACK_ID' : 'https://tidal.com/browse/video/VIDEO_ID';
const itemUrl = await askQuestion(`\nPlease enter the Tidal URL for the ${currentItemType} (e.g., ${exampleUrl}): `);
const idInfo = extractIdFromTidalUrl(itemUrl, currentItemType);
if (!idInfo) {
console.error(`\n❌ Could not extract a ${currentItemType} ID from URL: ${itemUrl}`);
console.error(` Ensure URL format is like: ${exampleUrl}`);
continue;
}
console.log(`\n🆔 Extracted ${idInfo.type} ID: ${idInfo.id}`);
try {
if (currentItemType === ITEM_TYPE.SONG) {
await handleSongDownload(session, itemUrl, idInfo.id);
} else {
await handleVideoDownload(session, itemUrl, idInfo.id);
}
} catch (error) {
console.error(`\n❌ Error during download of ${currentItemType} ID ${idInfo.id}: ${error.message}`);
console.error(error.stack);
}
const Rerun = await promptUserForConfirmation(UI_TEXT.DOWNLOAD_ANOTHER_PROMPT, true);
if (!Rerun) {
console.log(UI_TEXT.EXIT_MESSAGE);
break;
}
}
rl.close();
}
main().catch(error => {
console.error("\n🚨 Unexpected critical error in application:", error.message);
console.error(error.stack);
if (rl && typeof rl.close === 'function') {
rl.close();
}
process.exit(1);
});